diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml index 09e1af5c18174f5b1024e223f4a0cd5dac256c6e..b31b79a59b262a5cc18cf1d2b32124a97bab4fc7 100644 --- a/.cargo/ci-config.toml +++ b/.cargo/ci-config.toml @@ -5,8 +5,24 @@ # Arrays are merged together though. See: https://doc.rust-lang.org/cargo/reference/config.html#hierarchical-structure # The intent for this file is to configure CI build process with a divergance from Zed developers experience; for example, in this config file # we use `-D warnings` for rustflags (which makes compilation fail in presence of warnings during build process). Placing that in developers `config.toml` -# would be incovenient. +# would be inconvenient. # The reason for not using the RUSTFLAGS environment variable is that doing so would override all the settings in the config.toml file, even if the contents of the latter are completely nonsensical. See: https://github.com/rust-lang/cargo/issues/5376 # Here, we opted to use `[target.'cfg(all())']` instead of `[build]` because `[target.'**']` is guaranteed to be cumulative. [target.'cfg(all())'] rustflags = ["-D", "warnings"] + +# We don't need fullest debug information for dev stuff (tests etc.) in CI. +[profile.dev] +debug = "limited" + +# Use Mold on Linux, because it's faster than GNU ld and LLD. +# +# We no longer set this in the default `config.toml` so that developers can opt in to Wild, which +# is faster than Mold, in their own ~/.cargo/config.toml. +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] + +[target.aarch64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.cargo/config.toml b/.cargo/config.toml index 8db58d238003c29df6dbc9fa733c6d5521340103..9b2e6f51c96e3ae98a54bbb11524210911d0e262 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,14 +4,9 @@ rustflags = ["-C", "symbol-mangling-version=v0", "--cfg", "tokio_unstable"] [alias] xtask = "run --package xtask --" - -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] - -[target.aarch64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] +perf-test = ["test", "--profile", "release-fast", "--lib", "--bins", "--tests", "--all-features", "--config", "target.'cfg(true)'.runner='cargo run -p perf --release'", "--config", "target.'cfg(true)'.rustflags=[\"--cfg\", \"perf_enabled\"]"] +# Keep similar flags here to share some ccache +perf-compare = ["run", "--profile", "release-fast", "-p", "perf", "--config", "target.'cfg(true)'.rustflags=[\"--cfg\", \"perf_enabled\"]", "--", "compare"] [target.'cfg(target_os = "windows")'] rustflags = [ @@ -19,8 +14,6 @@ rustflags = [ "windows_slim_errors", # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes "-C", "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows - "-C", - "link-arg=-fuse-ld=lld", ] [env] diff --git a/.config/hakari.toml b/.config/hakari.toml deleted file mode 100644 index 8ce0b77490482ab5ff2d781fb78fd86b56959a6a..0000000000000000000000000000000000000000 --- a/.config/hakari.toml +++ /dev/null @@ -1,45 +0,0 @@ -# This file contains settings for `cargo hakari`. -# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options. - -hakari-package = "workspace-hack" - -resolver = "2" -dep-format-version = "4" -workspace-hack-line-style = "workspace-dotted" - -# this should be the same list as "targets" in ../rust-toolchain.toml -platforms = [ - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-unknown-linux-musl", # remote server -] - -[traversal-excludes] -workspace-members = [ - "remote_server", -] -third-party = [ - { name = "reqwest", version = "0.11.27" }, - # build of remote_server should not include scap / its x11 dependency - { name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" }, - # build of remote_server should not need to include on libalsa through rodio - { name = "rodio" }, -] - -[final-excludes] -workspace-members = [ - "zed_extension_api", - - # exclude all extensions - "zed_glsl", - "zed_html", - "zed_proto", - "zed_ruff", - "slash_commands_example", - "zed_snippets", - "zed_test_extension", - "zed_toml", -] diff --git a/.config/nextest.toml b/.config/nextest.toml index b05d68911fb5f50afaa623649fd426f7eb1e7bbe..49fb4d01f794613e430953e4565923a784368836 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -4,3 +4,17 @@ sequential-db-tests = { max-threads = 1 } [[profile.default.overrides]] filter = 'package(db)' test-group = 'sequential-db-tests' + +# Run slowest tests first. +# +[[profile.default.overrides]] +filter = 'package(worktree) and test(test_random_worktree_changes)' +priority = 100 + +[[profile.default.overrides]] +filter = 'package(collab) and (test(random_project_collaboration_tests) or test(random_channel_buffer_tests) or test(test_contact_requests) or test(test_basic_following))' +priority = 99 + +[[profile.default.overrides]] +filter = 'package(extension_host) and test(test_extension_store_with_test_extension)' +priority = 99 diff --git a/.gitattributes b/.gitattributes index 9973cfb4db9ce8e9c79e84b9861a946f2f1c2f15..57afd4ea6942bd3985fb7395101800706d7b4ae6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Prevent GitHub from displaying comments within JSON files as errors. *.json linguist-language=JSON-with-Comments + +# Ensure the WSL script always has LF line endings, even on Windows +crates/zed/resources/windows/zed.sh text eol=lf diff --git a/.github/ISSUE_TEMPLATE/06_bug_git.yml b/.github/ISSUE_TEMPLATE/06_bug_git.yml new file mode 100644 index 0000000000000000000000000000000000000000..7a01a728cd4592fb74144087110d475c9dd347a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/06_bug_git.yml @@ -0,0 +1,35 @@ +name: Bug Report (Git) +description: Zed Git Related Bugs +type: "Bug" +labels: ["git"] +title: "Git: " +body: + - type: textarea + attributes: + label: Summary + description: Describe the bug with a one-line summary, and provide detailed reproduction steps + value: | + + SUMMARY_SENTENCE_HERE + + ### Description + + Steps to trigger the problem: + 1. + 2. + 3. + + **Expected Behavior**: + **Actual Behavior**: + + validations: + required: true + - type: textarea + id: environment + attributes: + label: Zed Version and System Specs + description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"' + placeholder: | + Output of "zed: copy system specs into clipboard" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml b/.github/ISSUE_TEMPLATE/07_bug_windows.yml similarity index 86% rename from .github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml rename to .github/ISSUE_TEMPLATE/07_bug_windows.yml index 826c2b8027144d4b658108e09c79e40490c3005d..4e90c15dbde1b2727b9508cee69b5c7fd9304fc3 100644 --- a/.github/ISSUE_TEMPLATE/07_bug_windows_alpha.yml +++ b/.github/ISSUE_TEMPLATE/07_bug_windows.yml @@ -1,8 +1,8 @@ -name: Bug Report (Windows Alpha) -description: Zed Windows Alpha Related Bugs +name: Bug Report (Windows) +description: Zed Windows Related Bugs type: "Bug" labels: ["windows"] -title: "Windows Alpha: " +title: "Windows: " body: - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index e132eca1e52bc617f35fc2ec6e4e34fe3c796b11..1bf6c80e4073dafa90e736f995053c570f0ba2da 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -14,7 +14,7 @@ body: ### Description diff --git a/.github/ISSUE_TEMPLATE/11_crash_report.yml b/.github/ISSUE_TEMPLATE/11_crash_report.yml index aa736c75341512442720c202a4cadbf51bf253c8..97979308ae5ab4037c32db2660544c1299f2c750 100644 --- a/.github/ISSUE_TEMPLATE/11_crash_report.yml +++ b/.github/ISSUE_TEMPLATE/11_crash_report.yml @@ -33,11 +33,10 @@ body: required: true - type: textarea attributes: - label: If applicable, attach your `~/Library/Logs/Zed/Zed.log` file to this issue. + label: If applicable, attach your `Zed.log` file to this issue. description: | - macOS: `~/Library/Logs/Zed/Zed.log` - Linux: `~/.local/share/zed/logs/Zed.log` or $XDG_DATA_HOME - If you only need the most recent lines, you can run the `zed: open log` command palette action to see the last 1000. + From the command palette, run `zed: open log` to see the last 1000 lines. + Or run `zed: reveal log in file manager` to reveal the log file itself. value: |
Zed.log diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 0ee6af8a1d38e005f66b79f6c548d9f79396ea35..6d8e0107e9b42e71bb7266c0629393b9057e05bc 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -19,14 +19,27 @@ self-hosted-runner: - namespace-profile-16x32-ubuntu-2004-arm - namespace-profile-32x64-ubuntu-2004-arm # Namespace Ubuntu 22.04 (Everything else) - - namespace-profile-2x4-ubuntu-2204 - namespace-profile-4x8-ubuntu-2204 - namespace-profile-8x16-ubuntu-2204 - namespace-profile-16x32-ubuntu-2204 - namespace-profile-32x64-ubuntu-2204 + # Namespace Ubuntu 24.04 (like ubuntu-latest) + - namespace-profile-2x4-ubuntu-2404 # Namespace Limited Preview - namespace-profile-8x16-ubuntu-2004-arm-m4 - namespace-profile-8x32-ubuntu-2004-arm-m4 # Self Hosted Runners - self-mini-macos - self-32vcpu-windows-2022 + +# Disable shellcheck because it doesn't like powershell +# This should have been triggered with initial rollout of actionlint +# but https://github.com/zed-industries/zed/pull/36693 +# somehow caused actionlint to actually check those windows jobs +# where previously they were being skipped. Likely caused by an +# unknown bug in actionlint where parsing of `runs-on: [ ]` +# breaks something else. (yuck) +paths: + .github/workflows/{ci,release_nightly}.yml: + ignore: + - "shellcheck" diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index e46bc26945e4b5f94ad9f98a882aaa51fc6189af..3bc28249f3b8b2a08a48be040177530c5ecfd407 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -15,9 +15,12 @@ runs: node-version: "18" - name: Limit target directory size + env: + MAX_SIZE: ${{ runner.os == 'macOS' && 300 || 100 }} shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 + # Use the variable in the run command + run: script/clear-target-dir-if-larger-than ${{ env.MAX_SIZE }} - name: Run tests shell: bash -euxo pipefail {0} - run: cargo nextest run --workspace --no-fail-fast + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index e3e3b7142e2223e2b5a7524205dbe21fb963ed86..d85d47cb969e22ca3c73c9ab8caca279a9b5ba88 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -20,168 +20,8 @@ runs: with: node-version: "18" - - name: Configure crash dumps - shell: powershell - run: | - # Record the start time for this CI run - $runStartTime = Get-Date - $runStartTimeStr = $runStartTime.ToString("yyyy-MM-dd HH:mm:ss") - Write-Host "CI run started at: $runStartTimeStr" - - # Save the timestamp for later use - echo "CI_RUN_START_TIME=$($runStartTime.Ticks)" >> $env:GITHUB_ENV - - # Create crash dump directory in workspace (non-persistent) - $dumpPath = "$env:GITHUB_WORKSPACE\crash_dumps" - New-Item -ItemType Directory -Force -Path $dumpPath | Out-Null - - Write-Host "Setting up crash dump detection..." - Write-Host "Workspace dump path: $dumpPath" - - # Note: We're NOT modifying registry on stateful runners - # Instead, we'll check default Windows crash locations after tests - - name: Run tests shell: powershell working-directory: ${{ inputs.working-directory }} run: | - $env:RUST_BACKTRACE = "full" - - # Enable Windows debugging features - $env:_NT_SYMBOL_PATH = "srv*https://msdl.microsoft.com/download/symbols" - - # .NET crash dump environment variables (ephemeral) - $env:COMPlus_DbgEnableMiniDump = "1" - $env:COMPlus_DbgMiniDumpType = "4" - $env:COMPlus_CreateDumpDiagnostics = "1" - - cargo nextest run --workspace --no-fail-fast - continue-on-error: true - - - name: Analyze crash dumps - if: always() - shell: powershell - run: | - Write-Host "Checking for crash dumps..." - - # Get the CI run start time from the environment - $runStartTime = [DateTime]::new([long]$env:CI_RUN_START_TIME) - Write-Host "Only analyzing dumps created after: $($runStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" - - # Check all possible crash dump locations - $searchPaths = @( - "$env:GITHUB_WORKSPACE\crash_dumps", - "$env:LOCALAPPDATA\CrashDumps", - "$env:TEMP", - "$env:GITHUB_WORKSPACE", - "$env:USERPROFILE\AppData\Local\CrashDumps", - "C:\Windows\System32\config\systemprofile\AppData\Local\CrashDumps" - ) - - $dumps = @() - foreach ($path in $searchPaths) { - if (Test-Path $path) { - Write-Host "Searching in: $path" - $found = Get-ChildItem "$path\*.dmp" -ErrorAction SilentlyContinue | Where-Object { - $_.CreationTime -gt $runStartTime - } - if ($found) { - $dumps += $found - Write-Host " Found $($found.Count) dump(s) from this CI run" - } - } - } - - if ($dumps) { - Write-Host "Found $($dumps.Count) crash dump(s)" - - # Install debugging tools if not present - $cdbPath = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe" - if (-not (Test-Path $cdbPath)) { - Write-Host "Installing Windows Debugging Tools..." - $url = "https://go.microsoft.com/fwlink/?linkid=2237387" - Invoke-WebRequest -Uri $url -OutFile winsdksetup.exe - Start-Process -Wait winsdksetup.exe -ArgumentList "/features OptionId.WindowsDesktopDebuggers /quiet" - } - - foreach ($dump in $dumps) { - Write-Host "`n==================================" - Write-Host "Analyzing crash dump: $($dump.Name)" - Write-Host "Size: $([math]::Round($dump.Length / 1MB, 2)) MB" - Write-Host "Time: $($dump.CreationTime)" - Write-Host "==================================" - - # Set symbol path - $env:_NT_SYMBOL_PATH = "srv*C:\symbols*https://msdl.microsoft.com/download/symbols" - - # Run analysis - $analysisOutput = & $cdbPath -z $dump.FullName -c "!analyze -v; ~*k; lm; q" 2>&1 | Out-String - - # Extract key information - if ($analysisOutput -match "ExceptionCode:\s*([\w]+)") { - Write-Host "Exception Code: $($Matches[1])" - if ($Matches[1] -eq "c0000005") { - Write-Host "Exception Type: ACCESS VIOLATION" - } - } - - if ($analysisOutput -match "EXCEPTION_RECORD:\s*(.+)") { - Write-Host "Exception Record: $($Matches[1])" - } - - if ($analysisOutput -match "FAULTING_IP:\s*\n(.+)") { - Write-Host "Faulting Instruction: $($Matches[1])" - } - - # Save full analysis - $analysisFile = "$($dump.FullName).analysis.txt" - $analysisOutput | Out-File -FilePath $analysisFile - Write-Host "`nFull analysis saved to: $analysisFile" - - # Print stack trace section - Write-Host "`n--- Stack Trace Preview ---" - $stackSection = $analysisOutput -split "STACK_TEXT:" | Select-Object -Last 1 - $stackLines = $stackSection -split "`n" | Select-Object -First 20 - $stackLines | ForEach-Object { Write-Host $_ } - Write-Host "--- End Stack Trace Preview ---" - } - - Write-Host "`n⚠️ Crash dumps detected! Download the 'crash-dumps' artifact for detailed analysis." - - # Copy dumps to workspace for artifact upload - $artifactPath = "$env:GITHUB_WORKSPACE\crash_dumps_collected" - New-Item -ItemType Directory -Force -Path $artifactPath | Out-Null - - foreach ($dump in $dumps) { - $destName = "$($dump.Directory.Name)_$($dump.Name)" - Copy-Item $dump.FullName -Destination "$artifactPath\$destName" - if (Test-Path "$($dump.FullName).analysis.txt") { - Copy-Item "$($dump.FullName).analysis.txt" -Destination "$artifactPath\$destName.analysis.txt" - } - } - - Write-Host "Copied $($dumps.Count) dump(s) to artifact directory" - } else { - Write-Host "No crash dumps from this CI run found" - } - - - name: Upload crash dumps - if: always() - uses: actions/upload-artifact@v4 - with: - name: crash-dumps-${{ github.run_id }}-${{ github.run_attempt }} - path: | - crash_dumps_collected/*.dmp - crash_dumps_collected/*.txt - if-no-files-found: ignore - retention-days: 7 - - - name: Check test results - shell: powershell - working-directory: ${{ inputs.working-directory }} - run: | - # Re-check test results to fail the job if tests failed - if ($LASTEXITCODE -ne 0) { - Write-Host "Tests failed with exit code: $LASTEXITCODE" - exit $LASTEXITCODE - } + cargo nextest run --workspace --no-fail-fast --failure-output immediate-final diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index d8eaa6019ec29b5dd908564d05f430d3e7f01909..d400905b4da3304a8b916d3a38ae9d8a2855dbf5 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -8,7 +8,7 @@ on: jobs: update-collab-staging-tag: if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest + runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4ba227168fb9cec10e1b5e23223b48e7a4ca222..4e1d5d59c551976c94272b682250e100ed3957ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,9 @@ name: CI on: push: - branches: - - main - - "v[0-9]+.[0-9]+.x" tags: - "v*" - pull_request: - branches: - - "**" - concurrency: # Allow only one workflow per any non-`main` branch. group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} @@ -37,7 +30,7 @@ jobs: run_nix: ${{ steps.filter.outputs.run_nix }} run_actionlint: ${{ steps.filter.outputs.run_actionlint }} runs-on: - - ubuntu-latest + - namespace-profile-2x4-ubuntu-2404 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -81,6 +74,7 @@ jobs: echo "run_license=false" >> "$GITHUB_OUTPUT" echo "$CHANGED_FILES" | grep -qP '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' && \ + echo "$GITHUB_REF_NAME" | grep -qvP '^v[0-9]+\.[0-9]+\.[0-9x](-pre)?$' && \ echo "run_nix=true" >> "$GITHUB_OUTPUT" || \ echo "run_nix=false" >> "$GITHUB_OUTPUT" @@ -129,39 +123,6 @@ jobs: input: "crates/proto/proto/" against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/" - workspace_hack: - timeout-minutes: 60 - name: Check workspace-hack crate - needs: [job_spec] - if: | - github.repository_owner == 'zed-industries' && - needs.job_spec.outputs.run_tests == 'true' - runs-on: - - namespace-profile-8x16-ubuntu-2204 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Install cargo-hakari - uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2 - with: - command: install - args: cargo-hakari@0.9.35 - - - name: Check workspace-hack Cargo.toml is up-to-date - run: | - cargo hakari generate --diff || { - echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1"; - false - } - - name: Check all crates depend on workspace-hack - run: | - cargo hakari manage-deps --dry-run || { - echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1" - false - } - style: timeout-minutes: 60 name: Check formatting and spelling @@ -209,7 +170,7 @@ jobs: uses: ./.github/actions/check_style - name: Check for typos - uses: crate-ci/typos@8e6a4285bcbde632c5d79900a7779746e8b7ea3f # v1.24.6 + uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 # v1.38.1 with: config: ./typos.toml @@ -237,7 +198,7 @@ jobs: uses: ./.github/actions/build_docs actionlint: - runs-on: ubuntu-latest + runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_actionlint == 'true' needs: [job_spec] steps: @@ -305,15 +266,12 @@ jobs: uses: ./.github/actions/run_tests - name: Build collab + # we should do this on a linux x86 machinge run: cargo build -p collab - name: Build other binaries and features run: | - cargo build --workspace --bins --all-features - cargo check -p gpui --features "macos-blade" - cargo check -p workspace - cargo build -p remote_server - cargo check -p gpui --examples + cargo build --workspace --bins --examples # Since the macOS runners are stateful, so we need to remove the config file to prevent potential bug. - name: Clean CI config file @@ -372,6 +330,46 @@ jobs: if: always() run: rm -rf ./../.cargo + doctests: + # Nextest currently doesn't support doctests, so run them separately and in parallel. + timeout-minutes: 60 + name: (Linux) Run doctests + needs: [job_spec] + if: | + github.repository_owner == 'zed-industries' && + needs.job_spec.outputs.run_tests == 'true' + runs-on: + - namespace-profile-16x32-ubuntu-2204 + steps: + - name: Add Rust to the PATH + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + - name: Cache dependencies + uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + # cache-provider: "buildjet" + + - name: Install Linux dependencies + run: ./script/linux + + - name: Configure CI + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + + - name: Run doctests + run: cargo test --workspace --doc --no-fail-fast + + - name: Clean CI config file + if: always() + run: rm -rf ./../.cargo + build_remote_server: timeout-minutes: 60 name: (Linux) Build Remote Server @@ -418,7 +416,7 @@ jobs: if: | github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' - runs-on: [self-hosted, Windows, X64] + runs-on: [self-32vcpu-windows-2022] steps: - name: Environment Setup run: | @@ -458,7 +456,7 @@ jobs: tests_pass: name: Tests Pass - runs-on: ubuntu-latest + runs-on: namespace-profile-2x4-ubuntu-2404 needs: - job_spec - style @@ -466,7 +464,6 @@ jobs: - actionlint - migration_checks # run_tests: If adding required tests, add them here and to script below. - - workspace_hack - linux_tests - build_remote_server - macos_tests @@ -492,7 +489,6 @@ jobs: # Only check test jobs if they were supposed to run if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then - [[ "${{ needs.workspace_hack.result }}" != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; } [[ "${{ needs.macos_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "macOS tests failed"; } [[ "${{ needs.linux_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Linux tests failed"; } [[ "${{ needs.windows_tests.result }}" != 'success' ]] && { RET_CODE=1; echo "Windows tests failed"; } @@ -510,9 +506,7 @@ jobs: name: Create a macOS bundle runs-on: - self-mini-macos - if: | - ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) + if: startsWith(github.ref, 'refs/tags/v') needs: [macos_tests] env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} @@ -543,16 +537,14 @@ jobs: ref: ${{ github.ref }} - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 + run: script/clear-target-dir-if-larger-than 300 - name: Determine version and release channel - if: ${{ startsWith(github.ref, 'refs/tags/v') }} run: | # This exports RELEASE_CHANNEL into env (GITHUB_ENV) script/determine-release-channel - name: Draft release notes - if: ${{ startsWith(github.ref, 'refs/tags/v') }} run: | mkdir -p target/ # Ignore any errors that occur while drafting release notes to not fail the build. @@ -561,29 +553,17 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create macOS app bundle - run: script/bundle-mac + - name: Create macOS app bundle (aarch64) + run: script/bundle-mac aarch64-apple-darwin + + - name: Create macOS app bundle (x64) + run: script/bundle-mac x86_64-apple-darwin - name: Rename binaries - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} run: | mv target/aarch64-apple-darwin/release/Zed.dmg target/aarch64-apple-darwin/release/Zed-aarch64.dmg mv target/x86_64-apple-darwin/release/Zed.dmg target/x86_64-apple-darwin/release/Zed-x86_64.dmg - - name: Upload app bundle (aarch64) to workflow run if main branch or specific label - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} - with: - name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.dmg - path: target/aarch64-apple-darwin/release/Zed-aarch64.dmg - - - name: Upload app bundle (x86_64) to workflow run if main branch or specific label - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} - with: - name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.dmg - path: target/x86_64-apple-darwin/release/Zed-x86_64.dmg - - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 name: Upload app bundle to release if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} @@ -604,8 +584,7 @@ jobs: runs-on: - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | - ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) + ( startsWith(github.ref, 'refs/tags/v') ) needs: [linux_tests] steps: - name: Checkout repo @@ -622,7 +601,6 @@ jobs: token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - name: Determine version and release channel - if: startsWith(github.ref, 'refs/tags/v') run: | # This exports RELEASE_CHANNEL into env (GITHUB_ENV) script/determine-release-channel @@ -630,23 +608,8 @@ jobs: - name: Create Linux .tar.gz bundle run: script/bundle-linux - - name: Upload Artifact to Workflow - zed (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz - path: target/release/zed-*.tar.gz - - - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.gz - path: target/zed-remote-server-linux-x86_64.gz - - name: Upload Artifacts to release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} @@ -663,7 +626,6 @@ jobs: - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') needs: [linux_tests] steps: - name: Checkout repo @@ -680,7 +642,6 @@ jobs: token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - name: Determine version and release channel - if: startsWith(github.ref, 'refs/tags/v') run: | # This exports RELEASE_CHANNEL into env (GITHUB_ENV) script/determine-release-channel @@ -688,23 +649,8 @@ jobs: - name: Create and upload Linux .tar.gz bundles run: script/bundle-linux - - name: Upload Artifact to Workflow - zed (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz - path: target/release/zed-*.tar.gz - - - name: Upload Artifact to Workflow - zed-remote-server (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - with: - name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.gz - path: target/zed-remote-server-linux-aarch64.gz - - name: Upload Artifacts to release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} @@ -718,8 +664,7 @@ jobs: timeout-minutes: 60 runs-on: github-8vcpu-ubuntu-2404 if: | - false && ( startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) + false && ( startsWith(github.ref, 'refs/tags/v') ) needs: [linux_tests] name: Build Zed on FreeBSD steps: @@ -770,23 +715,19 @@ jobs: nix-build: name: Build with Nix - uses: ./.github/workflows/nix.yml + uses: ./.github/workflows/nix_build.yml needs: [job_spec] if: github.repository_owner == 'zed-industries' && (contains(github.event.pull_request.labels.*.name, 'run-nix') || needs.job_spec.outputs.run_nix == 'true') secrets: inherit - with: - flake-output: debug - # excludes the final package to only cache dependencies - cachix-filter: "-zed-editor-[0-9.]*-nightly" bundle-windows-x64: timeout-minutes: 120 - name: Create a Windows installer - runs-on: [self-hosted, Windows, X64] - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') - # if: (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling')) + name: Create a Windows installer for x86_64 + runs-on: [self-32vcpu-windows-2022] + if: | + ( startsWith(github.ref, 'refs/tags/v') ) needs: [windows_tests] env: AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} @@ -811,7 +752,6 @@ jobs: - name: Determine version and release channel working-directory: ${{ env.ZED_WORKSPACE }} - if: ${{ startsWith(github.ref, 'refs/tags/v') }} run: | # This exports RELEASE_CHANNEL into env (GITHUB_ENV) script/determine-release-channel.ps1 @@ -820,17 +760,55 @@ jobs: working-directory: ${{ env.ZED_WORKSPACE }} run: script/bundle-windows.ps1 - - name: Upload installer (x86_64) to Workflow - zed (run-bundling) - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: contains(github.event.pull_request.labels.*.name, 'run-bundling') + - name: Upload Artifacts to release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + with: + draft: true + prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} + files: ${{ env.SETUP_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + bundle-windows-aarch64: + timeout-minutes: 120 + name: Create a Windows installer for aarch64 + runs-on: [self-32vcpu-windows-2022] + if: | + ( startsWith(github.ref, 'refs/tags/v') ) + needs: [windows_tests] + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com" + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: - name: ZedEditorUserSetup-x64-${{ github.event.pull_request.head.sha || github.sha }}.exe - path: ${{ env.SETUP_PATH }} + clean: false + + - name: Setup Sentry CLI + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 + with: + token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} + + - name: Determine version and release channel + working-directory: ${{ env.ZED_WORKSPACE }} + run: | + # This exports RELEASE_CHANNEL into env (GITHUB_ENV) + script/determine-release-channel.ps1 + + - name: Build Zed installer + working-directory: ${{ env.ZED_WORKSPACE }} + run: script/bundle-windows.ps1 -Architecture aarch64 - name: Upload Artifacts to release uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 - # Re-enable when we are ready to publish windows preview releases - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} @@ -841,9 +819,10 @@ jobs: auto-release-preview: name: Auto release preview if: | - startsWith(github.ref, 'refs/tags/v') + false + && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') - needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64] + needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, bundle-windows-aarch64] runs-on: - self-mini-macos steps: diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml new file mode 100644 index 0000000000000000000000000000000000000000..c525bf4738f888b5ca84230982ff1f4f5da2db2f --- /dev/null +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -0,0 +1,48 @@ +name: Community Champion Auto Labeler + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +jobs: + label_community_champion: + if: github.repository_owner == 'zed-industries' + runs-on: ubuntu-latest + steps: + - name: Check if author is a community champion and apply label + uses: actions/github-script@v7 + with: + script: | + const communityChampionBody = `${{ secrets.COMMUNITY_CHAMPIONS }}`; + + const communityChampions = communityChampionBody + .split('\n') + .map(handle => handle.trim().toLowerCase()); + + let author; + if (context.eventName === 'issues') { + author = context.payload.issue.user.login; + } else if (context.eventName === 'pull_request_target') { + author = context.payload.pull_request.user.login; + } + + if (!author || !communityChampions.includes(author.toLowerCase())) { + return; + } + + const issueNumber = context.payload.issue?.number || context.payload.pull_request?.number; + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['community champion'] + }); + + console.log(`Applied 'community champion' label to #${issueNumber} by ${author}`); + } catch (error) { + console.error(`Failed to apply label: ${error.message}`); + } diff --git a/.github/workflows/community_release_actions.yml b/.github/workflows/community_release_actions.yml index 31dda1fa6d005ee16eb9d13aec6277ebf9a3ab94..7724aa2096cfa31c0586c9a43678a805443b259a 100644 --- a/.github/workflows/community_release_actions.yml +++ b/.github/workflows/community_release_actions.yml @@ -1,3 +1,6 @@ +# IF YOU UPDATE THE NAME OF ANY GITHUB SECRET, YOU MUST CHERRY PICK THE COMMIT +# TO BOTH STABLE AND PREVIEW CHANNELS + name: Release Actions on: @@ -13,9 +16,9 @@ jobs: id: get-release-url run: | if [ "${{ github.event.release.prerelease }}" == "true" ]; then - URL="https://zed.dev/releases/preview/latest" + URL="https://zed.dev/releases/preview" else - URL="https://zed.dev/releases/stable/latest" + URL="https://zed.dev/releases/stable" fi echo "URL=$URL" >> "$GITHUB_OUTPUT" @@ -32,11 +35,31 @@ jobs: - name: Discord Webhook Action uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0 with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + webhook-url: ${{ secrets.DISCORD_WEBHOOK_RELEASE_NOTES }} content: ${{ steps.get-content.outputs.string }} + publish-winget: + runs-on: + - ubuntu-latest + steps: + - name: Set Package Name + id: set-package-name + run: | + if [ "${{ github.event.release.prerelease }}" == "true" ]; then + PACKAGE_NAME=ZedIndustries.Zed.Preview + else + PACKAGE_NAME=ZedIndustries.Zed + fi + + echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" + - uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f # v2 + with: + identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }} + max-versions-to-keep: 5 + token: ${{ secrets.WINGET_TOKEN }} + send_release_notes_email: - if: github.repository_owner == 'zed-industries' && !github.event.release.prerelease + if: false && github.repository_owner == 'zed-industries' && !github.event.release.prerelease runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml new file mode 100644 index 0000000000000000000000000000000000000000..efd9812d8070f48fb64c78440a9e1c934ee8cbde --- /dev/null +++ b/.github/workflows/congrats.yml @@ -0,0 +1,57 @@ +name: Congratsbot + +on: + push: + branches: [main] + +jobs: + check-author: + if: ${{ github.repository_owner == 'zed-industries' }} + runs-on: ubuntu-latest + outputs: + should_congratulate: ${{ steps.check.outputs.should_congratulate }} + steps: + - name: Get PR info and check if author is external + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }} + script: | + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha + }); + + if (prs.length === 0) { + core.setOutput('should_congratulate', 'false'); + return; + } + + const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0]; + const prAuthor = mergedPR.user.login; + + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: 'zed-industries', + team_slug: 'staff', + username: prAuthor + }); + core.setOutput('should_congratulate', 'false'); + } catch (error) { + if (error.status === 404) { + core.setOutput('should_congratulate', 'true'); + } else { + console.error(`Error checking team membership: ${error.message}`); + core.setOutput('should_congratulate', 'false'); + } + } + + congrats: + needs: check-author + if: needs.check-author.outputs.should_congratulate == 'true' + uses: withastro/automation/.github/workflows/congratsbot.yml@main + with: + EMOJIS: 🎉,🎊,🧑‍🚀,🥳,🙌,🚀,🦀,🔥,🚢 + secrets: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_CONGRATS }} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 15c82643aef1e14c85daaaf2c8c3c61f62f1b3aa..1134167e05e29ffebfcf176b4f8c6cfc1b9e862d 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -1,42 +1,40 @@ -name: Danger - +# Generated from xtask::workflows::danger +# Rebuild with `cargo xtask workflows`. +name: danger on: pull_request: - branches: [main] types: - - opened - - synchronize - - reopened - - edited - + - opened + - synchronize + - reopened + - edited + branches: + - main jobs: danger: if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - + runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 - with: - version: 9 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "20" - cache: "pnpm" - cache-dependency-path: "script/danger/pnpm-lock.yaml" - - - run: pnpm install --dir script/danger - - - name: Run Danger - run: pnpm run --dir script/danger danger ci - env: - # This GitHub token is not used, but the value needs to be here to prevent - # Danger from throwing an error. - GITHUB_TOKEN: "not_a_real_token" - # All requests are instead proxied through an instance of - # https://github.com/maxdeviant/danger-proxy that allows Danger to securely - # authenticate with GitHub while still being able to run on PRs from forks. - DANGER_GITHUB_API_BASE_URL: "https://danger-proxy.fly.dev/github" + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: script/danger/pnpm-lock.yaml + - name: danger::install_deps + run: pnpm install --dir script/danger + shell: bash -euxo pipefail {0} + - name: danger::run + run: pnpm run --dir script/danger danger ci + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: not_a_real_token + DANGER_GITHUB_API_BASE_URL: https://danger-proxy.fly.dev/github diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index df35d44ca9ceb00a0503e941110c472c0b418fa2..2650cce1406b16e691565077b95d07730845664b 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -22,6 +22,8 @@ jobs: - name: Build docs uses: ./.github/actions/build_docs + env: + DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }} - name: Deploy Docs uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3 diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index ff2a3589e4c5482089536919618f1bbff982c63c..c61879faa8cd0a5dbdbed03a140f8e558f13322b 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -49,7 +49,7 @@ jobs: - name: Limit target directory size shell: bash -euxo pipefail {0} - run: script/clear-target-dir-if-larger-than 100 + run: script/clear-target-dir-if-larger-than 300 - name: Run tests shell: bash -euxo pipefail {0} diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml new file mode 100644 index 0000000000000000000000000000000000000000..1db992502beb1256da065a54a30e146e3efa48b5 --- /dev/null +++ b/.github/workflows/good_first_issue_notifier.yml @@ -0,0 +1,36 @@ +name: Good First Issue Notifier + +on: + issues: + types: [labeled] + +jobs: + handle-good-first-issue: + if: github.event.label.name == 'good first issue' && github.repository_owner == 'zed-industries' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Prepare Discord message + id: prepare-message + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + run: | + MESSAGE="[${ISSUE_TITLE} (#${ISSUE_NUMBER})](<${ISSUE_URL}>)" + + { + echo "message<> "$GITHUB_OUTPUT" + + - name: Discord Webhook Action + uses: tsickert/discord-webhook@c840d45a03a323fbc3f7507ac7769dbd91bfb164 # v5.3.0 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_GOOD_FIRST_ISSUE }} + content: ${{ steps.prepare-message.outputs.message }} diff --git a/.github/workflows/issue_response.yml b/.github/workflows/issue_response.yml deleted file mode 100644 index f084b9ba9dfc4aba46a766986bafcffa3e9fd0ce..0000000000000000000000000000000000000000 --- a/.github/workflows/issue_response.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Issue Response - -on: - schedule: - - cron: "0 12 * * 2" - workflow_dispatch: - -jobs: - issue-response: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 - with: - version: 9 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "20" - cache: "pnpm" - cache-dependency-path: "script/issue_response/pnpm-lock.yaml" - - - run: pnpm install --dir script/issue_response - - - name: Run Issue Response - run: pnpm run --dir script/issue_response start - env: - ISSUE_RESPONSE_GITHUB_TOKEN: ${{ secrets.ISSUE_RESPONSE_GITHUB_TOKEN }} - SLACK_ISSUE_RESPONSE_WEBHOOK_URL: ${{ secrets.SLACK_ISSUE_RESPONSE_WEBHOOK_URL }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index e682ce5890b86e8a3cf181be2d302d66025572c2..0000000000000000000000000000000000000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: "Nix build" - -on: - workflow_call: - inputs: - flake-output: - type: string - default: "default" - cachix-filter: - type: string - default: "" - -jobs: - nix-build: - timeout-minutes: 60 - name: (${{ matrix.system.os }}) Nix Build - continue-on-error: true # TODO: remove when we want this to start blocking CI - strategy: - fail-fast: false - matrix: - system: - - os: x86 Linux - runner: namespace-profile-16x32-ubuntu-2204 - install_nix: true - - os: arm Mac - runner: [macOS, ARM64, test] - install_nix: false - if: github.repository_owner == 'zed-industries' - runs-on: ${{ matrix.system.runner }} - env: - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} - ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} - GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - # on our macs we manually install nix. for some reason the cachix action is running - # under a non-login /bin/bash shell which doesn't source the proper script to add the - # nix profile to PATH, so we manually add them here - - name: Set path - if: ${{ ! matrix.system.install_nix }} - run: | - echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" - - - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31 - if: ${{ matrix.system.install_nix }} - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 - with: - name: zed - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - pushFilter: "${{ inputs.cachix-filter }}" - cachixArgs: "-v" - - - run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config - - - name: Limit /nix/store to 50GB on macs - if: ${{ ! matrix.system.install_nix }} - run: | - if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then - nix-collect-garbage -d || true - fi diff --git a/.github/workflows/nix_build.yml b/.github/workflows/nix_build.yml new file mode 100644 index 0000000000000000000000000000000000000000..4dd45bd3a740a43785e0284f0b86b2cdef50c1c7 --- /dev/null +++ b/.github/workflows/nix_build.yml @@ -0,0 +1,97 @@ +# Generated from xtask::workflows::nix_build +# Rebuild with `cargo xtask workflows`. +name: nix_build +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' +on: + pull_request: + branches: + - '**' + paths: + - nix/** + - flake.* + - Cargo.* + - rust-toolchain.toml + - .cargo/config.toml + push: + branches: + - main + - v[0-9]+.[0-9]+.x + paths: + - nix/** + - flake.* + - Cargo.* + - rust-toolchain.toml + - .cargo/config.toml + workflow_call: {} +jobs: + build_nix_linux_x86_64: + if: github.repository_owner == 'zed-industries' + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + if: github.repository_owner == 'zed-industries' + runs-on: self-mini-macos + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::set_path + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + - name: nix_build::limit_store + run: |- + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 0cc6737a45106713021c769b75dbbb180008dffe..80e6534e70e8f7169514fb8cc569f7b11488cd88 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -1,93 +1,155 @@ -name: Release Nightly - -on: - schedule: - # Fire every day at 7:00am UTC (Roughly before EU workday and after US workday) - - cron: "0 7 * * *" - push: - tags: - - "nightly" - +# Generated from xtask::workflows::release_nightly +# Rebuild with `cargo xtask workflows`. +name: release_nightly env: CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} - +on: + push: + tags: + - nightly + schedule: + - cron: 0 7 * * * jobs: - style: + check_style: + if: github.repository_owner == 'zed-industries' + runs-on: self-mini-macos + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + - name: ./script/clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Check formatting and Clippy lints + run_tests_mac: if: github.repository_owner == 'zed-industries' - runs-on: - - self-hosted - - macOS + runs-on: self-mini-macos steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - fetch-depth: 0 - - - name: Run style checks - uses: ./.github/actions/check_style - - - name: Run clippy - run: ./script/clippy - - tests: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + run: cargo install cargo-nextest --locked + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Run tests + run_tests_windows: if: github.repository_owner == 'zed-industries' - runs-on: - - self-hosted - - macOS - needs: style + runs-on: self-32vcpu-windows-2022 steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Run tests - uses: ./.github/actions/run_tests - - windows-tests: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy.ps1 + shell: pwsh + - name: steps::cargo_install_nextest + run: cargo install cargo-nextest --locked + shell: pwsh + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + shell: pwsh + - name: steps::cleanup_cargo_config + if: always() + run: | + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + shell: pwsh timeout-minutes: 60 - name: Run tests on Windows + bundle_mac_nightly_x86_64: + needs: + - check_style + - run_tests_mac if: github.repository_owner == 'zed-industries' - runs-on: [self-hosted, Windows, X64] + runs-on: self-mini-macos + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Configure CI - run: | - New-Item -ItemType Directory -Path "./../.cargo" -Force - Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" - - - name: Run tests - uses: ./.github/actions/run_tests_windows - - - name: Limit target directory size - run: ./script/clear-target-dir-if-larger-than.ps1 1024 - - - name: Clean CI config file - if: always() - run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue - - bundle-mac: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: release_nightly::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac + run: ./script/bundle-mac x86_64-apple-darwin + shell: bash -euxo pipefail {0} + - name: release_nightly::upload_zed_nightly + run: script/upload-nightly macos x86_64 + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Create a macOS bundle + bundle_mac_nightly_aarch64: + needs: + - check_style + - run_tests_mac if: github.repository_owner == 'zed-industries' - runs-on: - - self-mini-macos - needs: tests + runs-on: self-mini-macos env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} @@ -95,165 +157,162 @@ jobs: APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Set release channel to nightly - run: | - set -eu - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Create macOS app bundle - run: script/bundle-mac - - - name: Upload Zed Nightly - run: script/upload-nightly macos - - bundle-linux-x86: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: release_nightly::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac + run: ./script/bundle-mac aarch64-apple-darwin + shell: bash -euxo pipefail {0} + - name: release_nightly::upload_zed_nightly + run: script/upload-nightly macos aarch64 + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Create a Linux *.tar.gz bundle for x86 + bundle_linux_nightly_x86_64: + needs: + - check_style + - run_tests_mac if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc - needs: tests + runs-on: namespace-profile-32x64-ubuntu-2004 steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Add Rust to the PATH - run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - - name: Install Linux dependencies - run: ./script/linux && ./script/install-mold 2.34.0 - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Set release channel to nightly - run: | - set -euo pipefail - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Create Linux .tar.gz bundle - run: script/bundle-linux - - - name: Upload Zed Nightly - run: script/upload-nightly linux-targz - - bundle-linux-arm: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: release_nightly::add_rust_to_path + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: ./script/linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: ./script/install-mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 100 + shell: bash -euxo pipefail {0} + - name: release_nightly::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: release_nightly::upload_zed_nightly + run: script/upload-nightly linux-targz x86_64 + shell: bash -euxo pipefail {0} timeout-minutes: 60 - name: Create a Linux *.tar.gz bundle for ARM + bundle_linux_nightly_aarch64: + needs: + - check_style + - run_tests_mac if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc - needs: tests + runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Install Linux dependencies - run: ./script/linux - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 100 - - - name: Set release channel to nightly - run: | - set -euo pipefail - version=$(git rev-parse --short HEAD) - echo "Publishing version: ${version} on release channel nightly" - echo "nightly" > crates/zed/RELEASE_CHANNEL - - - name: Create Linux .tar.gz bundle - run: script/bundle-linux - - - name: Upload Zed Nightly - run: script/upload-nightly linux-targz - - freebsd: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: release_nightly::add_rust_to_path + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: ./script/linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 100 + shell: bash -euxo pipefail {0} + - name: release_nightly::set_release_channel_to_nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: release_nightly::upload_zed_nightly + run: script/upload-nightly linux-targz aarch64 + shell: bash -euxo pipefail {0} timeout-minutes: 60 - if: false && github.repository_owner == 'zed-industries' - runs-on: github-8vcpu-ubuntu-2404 - needs: tests - name: Build Zed on FreeBSD - # env: - # MYTOKEN : ${{ secrets.MYTOKEN }} - # MYTOKEN2: "value2" + bundle_windows_nightly_x86_64: + needs: + - check_style + - run_tests_windows + if: github.repository_owner == 'zed-industries' + runs-on: self-32vcpu-windows-2022 + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - - uses: actions/checkout@v4 - - name: Build FreeBSD remote-server - id: freebsd-build - uses: vmactions/freebsd-vm@c3ae29a132c8ef1924775414107a97cac042aad5 # v1.2.0 - with: - # envs: "MYTOKEN MYTOKEN2" - usesh: true - release: 13.5 - copyback: true - prepare: | - pkg install -y \ - bash curl jq git \ - rustup-init cmake-core llvm-devel-lite pkgconf protobuf # ibx11 alsa-lib rust-bindgen-cli - run: | - freebsd-version - sysctl hw.model - sysctl hw.ncpu - sysctl hw.physmem - sysctl hw.usermem - git config --global --add safe.directory /home/runner/work/zed/zed - rustup-init --profile minimal --default-toolchain none -y - . "$HOME/.cargo/env" - ./script/bundle-freebsd - mkdir -p out/ - mv "target/zed-remote-server-freebsd-x86_64.gz" out/ - rm -rf target/ - cargo clean - - - name: Upload Zed Nightly - run: script/upload-nightly freebsd - - bundle-nix: - name: Build and cache Nix package - if: false - needs: tests - secrets: inherit - uses: ./.github/workflows/nix.yml - - bundle-windows-x64: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: release_nightly::set_release_channel_to_nightly + run: | + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: release_nightly::build_zed_installer + run: script/bundle-windows.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: release_nightly::upload_zed_nightly_windows + run: script/upload-nightly.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} timeout-minutes: 60 - name: Create a Windows installer + bundle_windows_nightly_aarch64: + needs: + - check_style + - run_tests_windows if: github.repository_owner == 'zed-industries' - runs-on: [self-hosted, Windows, X64] - needs: windows-tests + runs-on: self-32vcpu-windows-2022 env: AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} @@ -263,65 +322,135 @@ jobs: ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} FILE_DIGEST: SHA256 TIMESTAMP_DIGEST: SHA256 - TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com" + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: release_nightly::set_release_channel_to_nightly + run: | + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: release_nightly::build_zed_installer + run: script/bundle-windows.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: release_nightly::upload_zed_nightly_windows + run: script/upload-nightly.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + timeout-minutes: 60 + build_nix_linux_x86_64: + needs: + - check_style + - run_tests_mac + if: github.repository_owner == 'zed-industries' + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - clean: false - - - name: Set release channel to nightly - working-directory: ${{ env.ZED_WORKSPACE }} - run: | - $ErrorActionPreference = "Stop" - $version = git rev-parse --short HEAD - Write-Host "Publishing version: $version on release channel nightly" - "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" - - - name: Setup Sentry CLI - uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b #v2 - with: - token: ${{ SECRETS.SENTRY_AUTH_TOKEN }} - - - name: Build Zed installer - working-directory: ${{ env.ZED_WORKSPACE }} - run: script/bundle-windows.ps1 - - - name: Upload Zed Nightly - working-directory: ${{ env.ZED_WORKSPACE }} - run: script/upload-nightly.ps1 windows - - update-nightly-tag: - name: Update nightly tag + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + - name: nix_build::build + run: nix build .#default -L --accept-flake-config + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + needs: + - check_style + - run_tests_mac if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest + runs-on: self-mini-macos + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::set_path + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + - name: nix_build::build + run: nix build .#default -L --accept-flake-config + shell: bash -euxo pipefail {0} + - name: nix_build::limit_store + run: |- + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + update_nightly_tag: needs: - - bundle-mac - - bundle-linux-x86 - - bundle-linux-arm - - bundle-windows-x64 + - bundle_mac_nightly_x86_64 + - bundle_mac_nightly_aarch64 + - bundle_linux_nightly_x86_64 + - bundle_linux_nightly_aarch64 + - bundle_windows_nightly_x86_64 + - bundle_windows_nightly_aarch64 + if: github.repository_owner == 'zed-industries' + runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - fetch-depth: 0 - - - name: Update nightly tag - run: | - if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then - echo "Nightly tag already points to current commit. Skipping tagging." - exit 0 - fi - git config user.name github-actions - git config user.email github-actions@github.com - git tag -f nightly - git push origin nightly --force - - - name: Create Sentry release - uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c # v3 - env: - SENTRY_ORG: zed-dev - SENTRY_PROJECT: zed - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - with: - environment: production + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - name: release_nightly::update_nightly_tag + run: | + if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then + echo "Nightly tag already points to current commit. Skipping tagging." + exit 0 + fi + git config user.name github-actions + git config user.email github-actions@github.com + git tag -f nightly + git push origin nightly --force + shell: bash -euxo pipefail {0} + - name: release_nightly::create_sentry_release + uses: getsentry/action-release@526942b68292201ac6bbb99b9a0747d4abee354c + with: + environment: production + env: + SENTRY_ORG: zed-dev + SENTRY_PROJECT: zed + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + timeout-minutes: 60 diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml new file mode 100644 index 0000000000000000000000000000000000000000..9766c7c14b64007692cfb1c68efead5b23382426 --- /dev/null +++ b/.github/workflows/run_bundling.yml @@ -0,0 +1,236 @@ +# Generated from xtask::workflows::run_bundling +# Rebuild with `cargo xtask workflows`. +name: run_bundling +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} +on: + pull_request: + types: + - labeled + - synchronize +jobs: + bundle_mac_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-mini-macos + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac + run: ./script/bundle-mac x86_64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.dmg + path: target/x86_64-apple-darwin/release/Zed.dmg + - name: '@actions/upload-artifact zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-macos-x86_64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-macos-x86_64.gz + path: target/zed-remote-server-macos-x86_64.gz + timeout-minutes: 60 + bundle_mac_arm64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-mini-macos + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: run_bundling::bundle_mac + run: ./script/bundle-mac aarch64-apple-darwin + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.dmg' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.dmg + path: target/aarch64-apple-darwin/release/Zed.dmg + - name: '@actions/upload-artifact zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-macos-aarch64.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-macos-aarch64.gz + path: target/zed-remote-server-macos-aarch64.gz + timeout-minutes: 60 + bundle_linux_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: namespace-profile-32x64-ubuntu-2004 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz + path: target/release/zed-*.tar.gz + - name: '@actions/upload-artifact zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz + path: target/release/zed-remote-server-*.tar.gz + timeout-minutes: 60 + bundle_linux_arm64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: ./script/bundle-linux + run: ./script/bundle-linux + shell: bash -euxo pipefail {0} + - name: '@actions/upload-artifact zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz + path: target/release/zed-*.tar.gz + - name: '@actions/upload-artifact zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz + path: target/release/zed-remote-server-*.tar.gz + timeout-minutes: 60 + bundle_windows_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-32vcpu-windows-2022 + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows + run: script/bundle-windows.ps1 -Architecture x86_64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-x86_64.exe + path: ${{ env.SETUP_PATH }} + timeout-minutes: 60 + bundle_windows_arm64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) + runs-on: self-32vcpu-windows-2022 + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_sentry + uses: matbour/setup-sentry-cli@3e938c54b3018bdd019973689ef984e033b0454b + with: + token: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: run_bundling::bundle_windows + run: script/bundle-windows.ps1 -Architecture aarch64 + shell: pwsh + working-directory: ${{ env.ZED_WORKSPACE }} + - name: '@actions/upload-artifact Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.exe' + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: Zed_${{ github.event.pull_request.head.sha || github.sha }}-aarch64.exe + path: ${{ env.SETUP_PATH }} + timeout-minutes: 60 +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..63c882bf7b0cf447bfd641002bcf67667bbea8b6 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,549 @@ +# Generated from xtask::workflows::run_tests +# Rebuild with `cargo xtask workflows`. +name: run_tests +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + CARGO_INCREMENTAL: '0' +on: + pull_request: + branches: + - '**' + push: + branches: + - main + - v[0-9]+.[0-9]+.x +jobs: + orchestrate: + if: github.repository_owner == 'zed-industries' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} + - id: filter + name: filter + run: | + if [ -z "$GITHUB_BASE_REF" ]; then + echo "Not in a PR context (i.e., push to main/stable/preview)" + COMPARE_REV="$(git rev-parse HEAD~1)" + else + echo "In a PR context comparing to pull_request.base.ref" + git fetch origin "$GITHUB_BASE_REF" --depth=350 + COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)" + fi + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" ${{ github.sha }})" + + check_pattern() { + local output_name="$1" + local pattern="$2" + local grep_arg="$3" + + echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \ + echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \ + echo "${output_name}=false" >> "$GITHUB_OUTPUT" + } + + check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP + check_pattern "run_docs" '^docs/' -qP + check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP + check_pattern "run_nix" '^(nix/|flake\.|Cargo\.|rust-toolchain.toml|\.cargo/config.toml)' -qP + check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))' -qvP + shell: bash -euxo pipefail {0} + outputs: + run_action_checks: ${{ steps.filter.outputs.run_action_checks }} + run_docs: ${{ steps.filter.outputs.run_docs }} + run_licenses: ${{ steps.filter.outputs.run_licenses }} + run_nix: ${{ steps.filter.outputs.run_nix }} + run_tests: ${{ steps.filter.outputs.run_tests }} + check_style: + if: github.repository_owner == 'zed-industries' + runs-on: namespace-profile-4x8-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: ./script/prettier + run: ./script/prettier + shell: bash -euxo pipefail {0} + - name: ./script/check-todos + run: ./script/check-todos + shell: bash -euxo pipefail {0} + - name: ./script/check-keymaps + run: ./script/check-keymaps + shell: bash -euxo pipefail {0} + - name: run_tests::check_style::check_for_typos + uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 + with: + config: ./typos.toml + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_windows: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: self-32vcpu-windows-2022 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy.ps1 + shell: pwsh + - name: steps::cargo_install_nextest + run: cargo install cargo-nextest --locked + shell: pwsh + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + shell: pwsh + - name: steps::cleanup_cargo_config + if: always() + run: | + Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + shell: pwsh + timeout-minutes: 60 + run_tests_linux: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + run: cargo install cargo-nextest --locked + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 100 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + run_tests_mac: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: self-mini-macos + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clippy + run: ./script/clippy + shell: bash -euxo pipefail {0} + - name: steps::cargo_install_nextest + run: cargo install cargo-nextest --locked + shell: bash -euxo pipefail {0} + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 300 + shell: bash -euxo pipefail {0} + - name: steps::cargo_nextest + run: cargo nextest run --workspace --no-fail-fast --failure-output immediate-final + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + doctests: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::cache_rust_dependencies + uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - id: run_doctests + name: run_tests::doctests::run_doctests + run: | + cargo test --workspace --doc --no-fail-fast + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + check_workspace_binaries: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: cargo build -p collab + run: cargo build -p collab + shell: bash -euxo pipefail {0} + - name: cargo build --workspace --bins --examples + run: cargo build --workspace --bins --examples + shell: bash -euxo pipefail {0} + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + check_postgres_and_protobuf_migrations: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: self-mini-macos + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + - name: run_tests::check_postgres_and_protobuf_migrations::remove_untracked_files + run: git clean -df + shell: bash -euxo pipefail {0} + - name: run_tests::check_postgres_and_protobuf_migrations::ensure_fresh_merge + run: | + if [ -z "$GITHUB_BASE_REF" ]; + then + echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV" + else + git checkout -B temp + git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp" + echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" + fi + shell: bash -euxo pipefail {0} + - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_setup_action + uses: bufbuild/buf-setup-action@v1 + with: + version: v1.29.0 + - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_breaking_action + uses: bufbuild/buf-breaking-action@v1 + with: + input: crates/proto/proto/ + against: https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/ + timeout-minutes: 60 + check_dependencies: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_tests::check_dependencies::install_cargo_machete + uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 + with: + command: install + args: cargo-machete@0.7.0 + - name: run_tests::check_dependencies::run_cargo_machete + uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 + with: + command: machete + - name: run_tests::check_dependencies::check_cargo_lock + run: cargo update --locked --workspace + shell: bash -euxo pipefail {0} + - name: run_tests::check_dependencies::check_vulnerable_dependencies + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@67d4f4bd7a9b17a0db54d2a7519187c65e339de8 + with: + license-check: false + timeout-minutes: 60 + check_docs: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_docs == 'true' + runs-on: namespace-profile-8x16-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies + uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: run_tests::check_docs::lychee_link_check + uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 + with: + args: --no-progress --exclude '^http' './docs/src/**/*' + fail: true + jobSummary: false + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: run_tests::check_docs::install_mdbook + uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 + with: + mdbook-version: 0.4.37 + - name: run_tests::check_docs::build_docs + run: | + mkdir -p target/deploy + mdbook build ./docs --dest-dir=../target/deploy/docs/ + shell: bash -euxo pipefail {0} + - name: run_tests::check_docs::lychee_link_check + uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 + with: + args: --no-progress --exclude '^http' 'target/deploy/docs' + fail: true + jobSummary: false + timeout-minutes: 60 + check_licenses: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_licenses == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: ./script/check-licenses + run: ./script/check-licenses + shell: bash -euxo pipefail {0} + - name: ./script/generate-licenses + run: ./script/generate-licenses + shell: bash -euxo pipefail {0} + check_scripts: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_action_checks == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: run_tests::check_scripts::run_shellcheck + run: ./script/shellcheck-scripts error + shell: bash -euxo pipefail {0} + - id: get_actionlint + name: run_tests::check_scripts::download_actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::run_actionlint + run: | + ${{ steps.get_actionlint.outputs.executable }} -color + shell: bash -euxo pipefail {0} + - name: run_tests::check_scripts::check_xtask_workflows + run: | + cargo xtask workflows + if ! git diff --exit-code .github; then + echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'" + echo "Please run 'cargo xtask workflows' locally and commit the changes" + exit 1 + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + build_nix_linux_x86_64: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_nix == 'true' + runs-on: namespace-profile-32x64-ubuntu-2004 + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::install_nix + uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + build_nix_mac_aarch64: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_nix == 'true' + runs-on: self-mini-macos + env: + ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} + ZED_MINIDUMP_ENDPOINT: ${{ secrets.ZED_SENTRY_MINIDUMP_ENDPOINT }} + ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }} + GIT_LFS_SKIP_SMUDGE: '1' + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: nix_build::set_path + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "/Users/administrator/.nix-profile/bin" >> "$GITHUB_PATH" + shell: bash -euxo pipefail {0} + - name: nix_build::cachix_action + uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad + with: + name: zed + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + cachixArgs: -v + pushFilter: -zed-editor-[0-9.]*-nightly + - name: nix_build::build + run: nix build .#debug -L --accept-flake-config + shell: bash -euxo pipefail {0} + - name: nix_build::limit_store + run: |- + if [ "$(du -sm /nix/store | cut -f1)" -gt 50000 ]; then + nix-collect-garbage -d || true + fi + shell: bash -euxo pipefail {0} + timeout-minutes: 60 + continue-on-error: true + tests_pass: + needs: + - orchestrate + - check_style + - run_tests_windows + - run_tests_linux + - run_tests_mac + - doctests + - check_workspace_binaries + - check_postgres_and_protobuf_migrations + - check_dependencies + - check_docs + - check_licenses + - check_scripts + - build_nix_linux_x86_64 + - build_nix_mac_aarch64 + if: github.repository_owner == 'zed-industries' && always() + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - name: run_tests::tests_pass + run: | + set +x + EXIT_CODE=0 + + check_result() { + echo "* $1: $2" + if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi + } + + check_result "orchestrate" "${{ needs.orchestrate.result }}" + check_result "check_style" "${{ needs.check_style.result }}" + check_result "run_tests_windows" "${{ needs.run_tests_windows.result }}" + check_result "run_tests_linux" "${{ needs.run_tests_linux.result }}" + check_result "run_tests_mac" "${{ needs.run_tests_mac.result }}" + check_result "doctests" "${{ needs.doctests.result }}" + check_result "check_workspace_binaries" "${{ needs.check_workspace_binaries.result }}" + check_result "check_postgres_and_protobuf_migrations" "${{ needs.check_postgres_and_protobuf_migrations.result }}" + check_result "check_dependencies" "${{ needs.check_dependencies.result }}" + check_result "check_docs" "${{ needs.check_docs.result }}" + check_result "check_licenses" "${{ needs.check_licenses.result }}" + check_result "check_scripts" "${{ needs.check_scripts.result }}" + check_result "build_nix_linux_x86_64" "${{ needs.build_nix_linux_x86_64.result }}" + check_result "build_nix_mac_aarch64" "${{ needs.build_nix_mac_aarch64.result }}" + + exit $EXIT_CODE + shell: bash -euxo pipefail {0} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} + cancel-in-progress: true diff --git a/.github/workflows/script_checks.yml b/.github/workflows/script_checks.yml deleted file mode 100644 index c32a433e46a6fc5381fa1abbe19b2814fe423c1d..0000000000000000000000000000000000000000 --- a/.github/workflows/script_checks.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Script - -on: - pull_request: - paths: - - "script/**" - push: - branches: - - main - -jobs: - shellcheck: - name: "ShellCheck Scripts" - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Shellcheck ./scripts - run: | - ./script/shellcheck-scripts error diff --git a/.github/workflows/unit_evals.yml b/.github/workflows/unit_evals.yml index c03cf8b087188f3e10a298e52a8278e63765c4f0..53ed33a1af300d6b641b3b9430de0bb6846b27cc 100644 --- a/.github/workflows/unit_evals.yml +++ b/.github/workflows/unit_evals.yml @@ -63,7 +63,7 @@ jobs: - name: Run unit evals shell: bash -euxo pipefail {0} - run: cargo nextest run --workspace --no-fail-fast --features eval --no-capture -E 'test(::eval_)' + run: cargo nextest run --workspace --no-fail-fast --features unit-eval --no-capture -E 'test(::eval_)' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.gitignore b/.gitignore index 7b40c45adf614eb91f1676144e7b70a7b2a373f2..2a91a65b6eaef906681bf3f6e315de07b094c4b1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,12 @@ .venv .vscode .wrangler +.perf-runs /assets/*licenses.* /crates/collab/seed.json /crates/theme/schemas/theme.json /crates/zed/resources/flatpak/flatpak-cargo-sources.json +/crates/project_panel/benches/linux_repo_snapshot.txt /dev.zed.Zed*.json /node_modules/ /plugins/bin diff --git a/.rules b/.rules index da009f1877b4c6ef2f0613995391852d4bf1dc8a..82d15eb9e88299ee7c7fe6c717b2da2646e676a7 100644 --- a/.rules +++ b/.rules @@ -12,6 +12,19 @@ - Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead * When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback. * Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`. +* When creating new crates, prefer specifying the library root path in `Cargo.toml` using `[lib] path = "...rs"` instead of the default `lib.rs`, to maintain consistent and descriptive naming (e.g., `gpui.rs` or `main.rs`). +* Avoid creative additions unless explicitly requested +* Use full words for variable names (no abbreviations like "q" for "queue") +* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references. + Example: + ```rust + executor.spawn({ + let task_ran = task_ran.clone(); + async move { + *task_ran.borrow_mut() = true; + } + }); + ``` # GPUI @@ -46,7 +59,7 @@ Trying to update an entity while it's already being updated must be avoided as t When `read_with`, `update`, or `update_in` are used with an async context, the closure's return value is wrapped in an `anyhow::Result`. -`WeakEntity` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to eachother they will never be dropped. +`WeakEntity` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped. ## Concurrency diff --git a/.zed/settings.json b/.zed/settings.json index 68e05a426f2474cb663aa5ff843905f375170e0f..2760be95819e9340acf55f60616a9c22105ff52a 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -48,7 +48,7 @@ "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, "file_scan_exclusions": [ - "crates/assistant_tools/src/edit_agent/evals/fixtures", + "crates/agent/src/edit_agent/evals/fixtures", "crates/eval/worktrees/", "crates/eval/repos/", "**/.git", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91b1b75f8292f37b122c152d71fe1e38eeccf817..9cbac4af2b57f0350fa9f5665e110e0d6e7f6341 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,54 +1,76 @@ # Contributing to Zed -Thanks for your interest in contributing to Zed, the collaborative platform that is also a code editor! +Thank you for helping us make Zed better! -All activity in Zed forums is subject to our [Code of Conduct](https://zed.dev/code-of-conduct). Additionally, contributors must sign our [Contributor License Agreement](https://zed.dev/cla) before their contributions can be merged. +All activity in Zed forums is subject to our [Code of +Conduct](https://zed.dev/code-of-conduct). Additionally, contributors must sign +our [Contributor License Agreement](https://zed.dev/cla) before their +contributions can be merged. ## Contribution ideas -If you're looking for ideas about what to work on, check out: +Zed is a large project with a number of priorities. We spend most of +our time working on what we believe the product needs, but we also love working +with the community to improve the product in ways we haven't thought of (or had time to get to yet!) -- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed. -- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community. +In particular we love PRs that are: -For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). +- Fixes to existing bugs and issues. +- Small enhancements to existing features, particularly to make them work for more people. +- Small extra features, like keybindings or actions you miss from other editors or extensions. +- Work towards shipping larger features on our roadmap. -## Proposing changes +If you're looking for concrete ideas: -The best way to propose a change is to [start a discussion on our GitHub repository](https://github.com/zed-industries/zed/discussions). +- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community. +- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed. -First, write a short **problem statement**, which _clearly_ and _briefly_ describes the problem you want to solve independently from any specific solution. It doesn't need to be long or formal, but it's difficult to consider a solution in absence of a clear understanding of the problem. +## Sending changes -Next, write a short **solution proposal**. How can the problem (or set of problems) you have stated above be addressed? What are the pros and cons of your approach? Again, keep it brief and informal. This isn't a specification, but rather a starting point for a conversation. +The Zed culture values working code and synchronous conversations over long +discussion threads. -By effectively engaging with the Zed team and community early in your process, we're better positioned to give you feedback and understand your pull request once you open it. If the first thing we see from you is a big changeset, we're much less likely to respond to it in a timely manner. +The best way to get us to take a look at a proposed change is to send a pull +request. We will get back to you (though this sometimes takes longer than we'd +like, sorry). -## Pair programming +Although we will take a look, we tend to only merge about half the PRs that are +submitted. If you'd like your PR to have the best chance of being merged: -We plan to set aside time each week to pair program with contributors on promising pull requests in Zed. This will be an experiment. We tend to prefer pairing over async code review on our team, and we'd like to see how well it works in an open source setting. If we're finding it difficult to get on the same page with async review, we may ask you to pair with us if you're open to it. The closer a contribution is to the goals outlined in our roadmap, the more likely we'll be to spend time pairing on it. +- Include a clear description of what you're solving, and why it's important to you. +- Include tests. +- If it changes the UI, attach screenshots or screen recordings. -## Tips to improve the chances of your PR getting reviewed and merged +The internal advice for reviewers is as follows: -- Discuss your plans ahead of time with the team -- Small, focused, incremental pull requests are much easier to review -- Spend time explaining your changes in the pull request body -- Add test coverage and documentation -- Choose tasks that align with our roadmap -- Pair with us and watch us code to learn the codebase -- Low effort PRs, such as those that just re-arrange syntax, won't be merged without a compelling justification +- If the fix/feature is obviously great, and the code is great. Hit merge. +- If the fix/feature is obviously great, and the code is nearly great. Send PR comments, or offer to pair to get things perfect. +- If the fix/feature is not obviously great, or the code needs rewriting from scratch. Close the PR with a thank you and some explanation. -## File icons +If you need more feedback from us: the best way is to be responsive to +Github comments, or to offer up time to pair with us. -Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner. +If you are making a larger change, or need advice on how to finish the change +you're making, please open the PR early. We would love to help you get +things right, and it's often easier to see how to solve a problem before the +diff gets too big. -We do not accept PRs for file icons that are just an off-the-shelf SVG taken from somewhere else. +## Things we will (probably) not merge -### Adding new icons to the Zed icon theme +Although there are few hard and fast rules, typically we don't merge: -If you would like to add a new icon to the Zed icon theme, [open a Discussion](https://github.com/zed-industries/zed/discussions/new?category=ux-and-design) and we can work with you on getting an icon designed and added to Zed. +- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). +- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. +- Giant refactorings. +- Non-trivial changes with no tests. +- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much. +- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. +- Anything that seems completely AI generated. ## Bird's-eye view of Zed +We suggest you keep the [Zed glossary](docs/src/development/glossary.md) at your side when starting out. It lists and explains some of the structures and terms you will see throughout the codebase. + Zed is made up of several smaller crates - let's go over those you're most likely to interact with: - [`gpui`](/crates/gpui) is a GPU-accelerated UI framework which provides all of the building blocks for Zed. **We recommend familiarizing yourself with the root level GPUI documentation.** diff --git a/Cargo.lock b/Cargo.lock index a4f8c521a1e46b6c312069102bb184e6a5ecbae7..78c972865a4e01ba66357142ff8737b634639b27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,8 +7,8 @@ name = "acp_thread" version = "0.1.0" dependencies = [ "action_log", - "agent", "agent-client-protocol", + "agent_settings", "anyhow", "buffer_diff", "collections", @@ -20,15 +20,18 @@ dependencies = [ "indoc", "itertools 0.14.0", "language", + "language_model", "markdown", "parking_lot", + "portable-pty", "project", "prompt_store", - "rand 0.8.5", + "rand 0.9.2", "serde", "serde_json", "settings", "smol", + "task", "tempfile", "terminal", "ui", @@ -36,7 +39,25 @@ dependencies = [ "util", "uuid", "watch", - "workspace-hack", +] + +[[package]] +name = "acp_tools" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "collections", + "gpui", + "language", + "markdown", + "project", + "serde", + "serde_json", + "settings", + "theme", + "ui", + "util", + "workspace", ] [[package]] @@ -55,13 +76,12 @@ dependencies = [ "log", "pretty_assertions", "project", - "rand 0.8.5", + "rand 0.9.2", "serde_json", "settings", "text", "util", "watch", - "workspace-hack", "zlog", ] @@ -83,23 +103,22 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", ] [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" @@ -116,78 +135,6 @@ dependencies = [ [[package]] name = "agent" version = "0.1.0" -dependencies = [ - "action_log", - "agent_settings", - "anyhow", - "assistant_context", - "assistant_tool", - "assistant_tools", - "chrono", - "client", - "cloud_llm_client", - "collections", - "component", - "context_server", - "convert_case 0.8.0", - "feature_flags", - "fs", - "futures 0.3.31", - "git", - "gpui", - "heed", - "http_client", - "icons", - "indoc", - "itertools 0.14.0", - "language", - "language_model", - "log", - "parking_lot", - "paths", - "postage", - "pretty_assertions", - "project", - "prompt_store", - "rand 0.8.5", - "ref-cast", - "rope", - "schemars", - "serde", - "serde_json", - "settings", - "smol", - "sqlez", - "telemetry", - "text", - "theme", - "thiserror 2.0.12", - "time", - "util", - "uuid", - "workspace", - "workspace-hack", - "zstd", -] - -[[package]] -name = "agent-client-protocol" -version = "0.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "160971bb53ca0b2e70ebc857c21e24eb448745f1396371015f4c59e9a9e51ed0" -dependencies = [ - "anyhow", - "futures 0.3.31", - "log", - "parking_lot", - "schemars", - "serde", - "serde_json", -] - -[[package]] -name = "agent2" -version = "0.1.0" dependencies = [ "acp_thread", "action_log", @@ -195,8 +142,7 @@ dependencies = [ "agent_servers", "agent_settings", "anyhow", - "assistant_tool", - "assistant_tools", + "assistant_text_thread", "chrono", "client", "clock", @@ -204,10 +150,13 @@ dependencies = [ "collections", "context_server", "ctor", + "db", + "derive_more 0.99.20", "editor", "env_logger 0.11.8", "fs", "futures 0.3.31", + "git", "gpui", "gpui_tokio", "handlebars 4.5.0", @@ -221,23 +170,31 @@ dependencies = [ "log", "lsp", "open", + "parking_lot", "paths", - "portable-pty", "pretty_assertions", "project", "prompt_store", + "rand 0.9.2", + "regex", "reqwest_client", "rust-embed", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", + "smallvec", "smol", + "sqlez", + "streaming_diff", + "strsim", "task", + "telemetry", "tempfile", "terminal", "text", "theme", + "thiserror 2.0.17", "tree-sitter-rust", "ui", "unindent", @@ -245,10 +202,41 @@ dependencies = [ "uuid", "watch", "web_search", - "which 6.0.3", - "workspace-hack", "worktree", + "zed_env_vars", "zlog", + "zstd 0.11.2+zstd.1.5.2", +] + +[[package]] +name = "agent-client-protocol" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more 2.0.1", + "futures 0.3.31", + "log", + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf16c18fea41282d6bbadd1549a06be6836bddb1893f44a6235f340fa24e2af" +dependencies = [ + "anyhow", + "derive_more 2.0.1", + "schemars 1.0.4", + "serde", + "serde_json", ] [[package]] @@ -256,37 +244,42 @@ name = "agent_servers" version = "0.1.0" dependencies = [ "acp_thread", + "acp_tools", + "action_log", "agent-client-protocol", - "agentic-coding-protocol", + "agent_settings", "anyhow", + "async-trait", + "client", "collections", - "context_server", "env_logger 0.11.8", + "fs", "futures 0.3.31", "gpui", + "gpui_tokio", + "http_client", "indoc", - "itertools 0.14.0", "language", + "language_model", + "language_models", "libc", "log", "nix 0.29.0", - "paths", "project", - "rand 0.8.5", - "schemars", + "release_channel", + "reqwest_client", "serde", "serde_json", "settings", "smol", - "strum 0.27.1", + "task", "tempfile", - "thiserror 2.0.12", + "terminal", + "thiserror 2.0.17", "ui", "util", "uuid", "watch", - "which 6.0.3", - "workspace-hack", ] [[package]] @@ -296,16 +289,18 @@ dependencies = [ "anyhow", "cloud_llm_client", "collections", + "convert_case 0.8.0", "fs", "gpui", "language_model", "paths", - "schemars", + "project", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", "settings", - "workspace-hack", + "util", ] [[package]] @@ -316,16 +311,14 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", - "agent2", "agent_servers", "agent_settings", "ai_onboarding", "anyhow", - "assistant_context", + "arrayvec", "assistant_slash_command", "assistant_slash_commands", - "assistant_tool", - "assistant_tools", + "assistant_text_thread", "audio", "buffer_diff", "chrono", @@ -348,7 +341,6 @@ dependencies = [ "html_to_markdown", "http_client", "indoc", - "inventory", "itertools 0.14.0", "jsonschema", "language", @@ -365,15 +357,17 @@ dependencies = [ "parking_lot", "paths", "picker", + "postage", "pretty_assertions", "project", "prompt_store", "proto", - "rand 0.8.5", + "rand 0.9.2", + "ref-cast", "release_channel", "rope", "rules_library", - "schemars", + "schemars 1.0.4", "search", "serde", "serde_json", @@ -397,55 +391,35 @@ dependencies = [ "url", "urlencoding", "util", - "uuid", "watch", "workspace", - "workspace-hack", "zed_actions", ] -[[package]] -name = "agentic-coding-protocol" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e6ae951b36fa2f8d9dd6e1af6da2fcaba13d7c866cf6a9e65deda9dc6c5fe4" -dependencies = [ - "anyhow", - "chrono", - "derive_more 2.0.1", - "futures 0.3.31", - "log", - "parking_lot", - "schemars", - "semver", - "serde", - "serde_json", -] - [[package]] name = "ahash" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "version_check", ] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", - "getrandom 0.2.15", + "getrandom 0.3.4", "once_cell", "serde", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -470,17 +444,17 @@ dependencies = [ "smallvec", "telemetry", "ui", - "workspace-hack", "zed_actions", ] [[package]] name = "alacritty_terminal" -version = "0.25.1-dev" -source = "git+https://github.com/zed-industries/alacritty.git?branch=add-hush-login-flag#828457c9ff1f7ea0a0469337cc8a37ee3a1b0590" +version = "0.25.1-rc1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb5f4f1ef69bdb8b2095ddd14b09dd74ee0303aae8bd5372667a54cff689a1b" dependencies = [ "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.9.4", "home", "libc", "log", @@ -488,11 +462,12 @@ dependencies = [ "parking_lot", "piper", "polling", - "regex-automata 0.4.9", + "regex-automata", + "rustix 1.1.2", "rustix-openpty", "serde", "signal-hook", - "unicode-width 0.1.14", + "unicode-width", "vte", "windows-sys 0.59.0", ] @@ -505,9 +480,27 @@ checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] name = "aligned-vec" -version = "0.5.0" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] [[package]] name = "allocator-api2" @@ -522,7 +515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -545,23 +538,17 @@ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] name = "ammonia" -version = "4.1.0" +version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ "cssparser", - "html5ever 0.31.0", + "html5ever 0.35.0", "maplit", "tendril", "url", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -579,9 +566,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -594,37 +581,37 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.60.2", ] [[package]] @@ -635,12 +622,12 @@ dependencies = [ "chrono", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "strum 0.27.1", - "thiserror 2.0.12", - "workspace-hack", + "settings", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -651,9 +638,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "approx" @@ -666,15 +653,12 @@ dependencies = [ [[package]] name = "arbitrary" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" - -[[package]] -name = "arc-swap" -version = "1.7.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arg_enum_proc_macro" @@ -684,9 +668,24 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", +] + +[[package]] +name = "argminmax" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" +dependencies = [ + "num-traits", ] +[[package]] +name = "array-init-cursor" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" + [[package]] name = "arraydeque" version = "0.5.1" @@ -751,7 +750,28 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.9", + "zbus", +] + +[[package]] +name = "ashpd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", "serde", "serde_repr", "url", @@ -765,12 +785,13 @@ dependencies = [ "anyhow", "futures 0.3.31", "gpui", + "log", "net", - "parking_lot", "smol", "tempfile", "util", - "workspace-hack", + "windows 0.61.3", + "zeroize", ] [[package]] @@ -780,76 +801,28 @@ dependencies = [ "anyhow", "gpui", "rust-embed", - "workspace-hack", ] [[package]] -name = "assistant_context" +name = "assistant_slash_command" version = "0.1.0" dependencies = [ - "agent_settings", "anyhow", - "assistant_slash_command", - "assistant_slash_commands", - "chrono", - "client", - "clock", - "cloud_llm_client", + "async-trait", "collections", - "context_server", - "fs", + "derive_more 0.99.20", + "extension", "futures 0.3.31", - "fuzzy", "gpui", - "indoc", "language", "language_model", - "log", - "open_ai", "parking_lot", - "paths", "pretty_assertions", - "project", - "prompt_store", - "proto", - "rand 0.8.5", - "regex", - "rpc", "serde", "serde_json", - "settings", - "smallvec", - "smol", - "telemetry_events", - "text", "ui", - "unindent", "util", - "uuid", - "workspace", - "workspace-hack", -] - -[[package]] -name = "assistant_slash_command" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "collections", - "derive_more 0.99.19", - "extension", - "futures 0.3.31", - "gpui", - "language", - "language_model", - "parking_lot", - "pretty_assertions", - "serde", - "serde_json", - "ui", "workspace", - "workspace-hack", ] [[package]] @@ -858,7 +831,6 @@ version = "0.1.0" dependencies = [ "anyhow", "assistant_slash_command", - "cargo_toml", "chrono", "collections", "context_server", @@ -881,114 +853,58 @@ dependencies = [ "settings", "smol", "text", - "toml 0.8.20", "ui", "util", "workspace", - "workspace-hack", "worktree", "zlog", ] [[package]] -name = "assistant_tool" -version = "0.1.0" -dependencies = [ - "action_log", - "anyhow", - "buffer_diff", - "clock", - "collections", - "ctor", - "derive_more 0.99.19", - "gpui", - "icons", - "indoc", - "language", - "language_model", - "log", - "parking_lot", - "pretty_assertions", - "project", - "rand 0.8.5", - "regex", - "serde", - "serde_json", - "settings", - "text", - "util", - "workspace", - "workspace-hack", - "zlog", -] - -[[package]] -name = "assistant_tools" +name = "assistant_text_thread" version = "0.1.0" dependencies = [ - "action_log", "agent_settings", "anyhow", - "assistant_tool", - "buffer_diff", + "assistant_slash_command", + "assistant_slash_commands", "chrono", "client", "clock", "cloud_llm_client", "collections", - "component", - "derive_more 0.99.19", - "diffy", - "editor", - "feature_flags", + "context_server", "fs", "futures 0.3.31", + "fuzzy", "gpui", - "gpui_tokio", - "handlebars 4.5.0", - "html_to_markdown", - "http_client", "indoc", - "itertools 0.14.0", "language", "language_model", - "language_models", "log", - "lsp", - "markdown", - "open", + "open_ai", + "parking_lot", "paths", - "portable-pty", "pretty_assertions", "project", "prompt_store", - "rand 0.8.5", + "proto", + "rand 0.9.2", "regex", - "reqwest_client", - "rust-embed", - "schemars", + "rpc", "serde", "serde_json", "settings", "smallvec", "smol", - "streaming_diff", - "strsim", - "task", - "tempfile", - "terminal", - "terminal_view", - "theme", - "tree-sitter-rust", + "telemetry_events", + "text", "ui", "unindent", "util", - "watch", - "web_search", - "which 6.0.3", + "uuid", "workspace", - "workspace-hack", - "zlog", + "zed_env_vars", ] [[package]] @@ -1007,7 +923,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -1026,9 +942,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -1038,9 +954,9 @@ dependencies = [ [[package]] name = "async-compat" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bab94bde396a3f7b4962e396fdad640e241ed797d4d8d77fc8c237d14c58fc0" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" dependencies = [ "futures-core", "futures-io", @@ -1051,15 +967,14 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.22" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" dependencies = [ - "deflate64", - "flate2", + "compression-codecs", + "compression-core", "futures-core", "futures-io", - "memchr", "pin-project-lite", ] @@ -1075,26 +990,27 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", "fastrand 2.3.0", - "futures-lite 2.6.0", + "futures-lite 2.6.1", + "pin-project-lite", "slab", ] [[package]] name = "async-fs" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "async-lock", + "async-lock 3.4.1", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -1103,41 +1019,49 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-io", - "async-lock", + "async-lock 3.4.1", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "once_cell", ] [[package]] name = "async-io" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "parking", "polling", - "rustix 0.38.44", + "rustix 1.1.2", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] @@ -1150,7 +1074,7 @@ checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ "async-io", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -1164,21 +1088,20 @@ dependencies = [ [[package]] name = "async-process" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-io", - "async-lock", + "async-lock 3.4.1", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.0", - "futures-lite 2.6.0", - "rustix 0.38.44", - "tracing", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.2", ] [[package]] @@ -1189,44 +1112,44 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-signal" -version = "0.2.10" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", - "async-lock", + "async-lock 3.4.1", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix 0.38.44", + "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "async-std" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-attributes", "async-channel 1.9.0", "async-global-executor", "async-io", - "async-lock", + "async-lock 3.4.1", "async-process", "crossbeam-utils", "futures-channel", "futures-core", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "gloo-timers", "kv-log-macro", "log", @@ -1257,14 +1180,14 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-tar" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42f905d4f623faf634bbd1e001e84e0efc24694afa64be9ad239bf6ca49e1f8" +checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" dependencies = [ "async-std", "filetime", @@ -1288,14 +1211,14 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "async-tungstenite" -version = "0.29.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef0f7efedeac57d9b26170f72965ecfd31473ca52ca7a64e925b0b6f5f079886" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" dependencies = [ "atomic-waker", "futures-core", @@ -1307,7 +1230,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", - "tungstenite 0.26.2", + "tungstenite 0.27.0", ] [[package]] @@ -1318,7 +1241,7 @@ checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" dependencies = [ "async-compression", "crc32fast", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "pin-project", "thiserror 1.0.69", ] @@ -1345,6 +1268,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atoi_simd" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf" +dependencies = [ + "debug_unsafe", +] + [[package]] name = "atomic" version = "0.5.3" @@ -1362,13 +1294,20 @@ name = "audio" version = "0.1.0" dependencies = [ "anyhow", + "async-tar", "collections", - "derive_more 0.99.19", + "crossbeam", + "denoise", "gpui", + "libwebrtc", + "log", "parking_lot", "rodio", + "serde", + "settings", + "smol", + "thiserror 2.0.17", "util", - "workspace-hack", ] [[package]] @@ -1395,7 +1334,6 @@ dependencies = [ "log", "paths", "release_channel", - "schemars", "serde", "serde_json", "settings", @@ -1403,7 +1341,6 @@ dependencies = [ "tempfile", "which 6.0.3", "workspace", - "workspace-hack", ] [[package]] @@ -1413,9 +1350,8 @@ dependencies = [ "anyhow", "log", "simplelog", - "windows 0.61.1", + "windows 0.61.3", "winresource", - "workspace-hack", ] [[package]] @@ -1435,20 +1371,19 @@ dependencies = [ "smol", "util", "workspace", - "workspace-hack", ] [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "av1-grain" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" dependencies = [ "anyhow", "arrayvec", @@ -1460,18 +1395,18 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" dependencies = [ "arrayvec", ] [[package]] name = "aws-config" -version = "1.6.1" +version = "1.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c39646d1a6b51240a1a23bb57ea4eebede7e16fbc237fdc876980233dcecb4f" +checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1499,9 +1434,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.2" +version = "1.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4471bef4c22a06d2c7a1b6492493d3fdf24a805323109d6874f9c94d5906ac14" +checksum = "faf26925f4a5b59eb76722b63c2892b1d70d06fa053c72e4a100ec308c1d47bc" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1511,9 +1446,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" dependencies = [ "aws-lc-sys", "zeroize", @@ -1521,11 +1456,11 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.29.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b1d86e7705efe1be1b569bab41d4fa1e14e220b60a160f78de2db687add079" +checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" dependencies = [ - "bindgen 0.69.5", + "bindgen 0.72.1", "cc", "cmake", "dunce", @@ -1534,9 +1469,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.6" +version = "1.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aff45ffe35196e593ea3b9dd65b320e51e2dda95aff4390bc459e461d09c6ad" +checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1551,7 +1486,6 @@ dependencies = [ "fastrand 2.3.0", "http 0.2.12", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "tracing", @@ -1560,9 +1494,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.82.0" +version = "1.109.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb95f77abd4321348dd2f52a25e1de199732f54d2a35860ad20f5df21c66b44" +checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1579,16 +1513,15 @@ dependencies = [ "fastrand 2.3.0", "http 0.2.12", "hyper 0.14.32", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-kinesis" -version = "1.66.0" +version = "1.91.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e43e5fb05c78cdad4fef5be4503465e4b42292f472fc991823ea4c50078208e4" +checksum = "699a3d645a2ab5cb12ca02eb23979753953414429fd6584ea8841af6bc4e0516" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1603,16 +1536,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-s3" -version = "1.82.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6eab2900764411ab01c8e91a76fd11a63b4e12bc3da97d9e14a0ce1343d86d3" +checksum = "200be4aed61e3c0669f7268bacb768f283f1c32a7014ce57225e1160be2f6ccb" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1635,7 +1567,6 @@ dependencies = [ "http 1.3.1", "http-body 0.4.6", "lru", - "once_cell", "percent-encoding", "regex-lite", "sha2", @@ -1645,9 +1576,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.64.0" +version = "1.86.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d4bdb0e5f80f0689e61c77ab678b2b9304af329616af38aef5b6b967b8e736" +checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1661,16 +1592,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.65.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbbb3ce8da257aedbccdcb1aadafbbb6a5fe9adf445db0e1ea897bdc7e22d08" +checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1684,16 +1614,15 @@ dependencies = [ "bytes 1.10.1", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.65.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a78a8f50a1630db757b60f679c8226a8a70ee2ab5f5e6e51dc67f6c61c7cfd" +checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1708,16 +1637,15 @@ dependencies = [ "aws-types", "fastrand 2.3.0", "http 0.2.12", - "once_cell", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.0" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d03c3c05ff80d54ff860fe38c726f6f494c639ae975203a101335f223386db" +checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -1731,7 +1659,6 @@ dependencies = [ "hmac", "http 0.2.12", "http 1.3.1", - "once_cell", "p256", "percent-encoding", "ring", @@ -1744,9 +1671,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" +checksum = "127fcfad33b7dfc531141fda7e1c402ac65f88aca5511a4d31e2e3d2cd01ce9c" dependencies = [ "futures-util", "pin-project-lite", @@ -1755,16 +1682,14 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.63.1" +version = "0.63.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d21e1ba6f2cdec92044f904356a19f5ad86961acf015741106cdfafd747c0" +checksum = "165d8583d8d906e2fb5511d29201d447cc710864f075debcdd9c31c265412806" dependencies = [ "aws-smithy-http", "aws-smithy-types", "bytes 1.10.1", - "crc32c", - "crc32fast", - "crc64fast-nvme", + "crc-fast", "hex", "http 0.2.12", "http-body 0.4.6", @@ -1777,9 +1702,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.8" +version = "0.60.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c45d3dddac16c5c59d553ece225a88870cf81b7b813c9cc17b78cf4685eac7a" +checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa" dependencies = [ "aws-smithy-types", "bytes 1.10.1", @@ -1788,9 +1713,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.0" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5949124d11e538ca21142d1fba61ab0a2a2c1bc3ed323cdb3e4b878bfb83166" +checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -1801,7 +1726,6 @@ dependencies = [ "http 0.2.12", "http 1.3.1", "http-body 0.4.6", - "once_cell", "percent-encoding", "pin-project-lite", "pin-utils", @@ -1810,56 +1734,57 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.0.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aff1159006441d02e57204bf57a1b890ba68bedb6904ffd2873c1c4c11c546b" +checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.4.9", + "h2 0.3.27", + "h2 0.4.12", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-rustls 0.24.2", - "hyper-rustls 0.27.5", + "hyper-rustls 0.27.7", "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pki-types", "tokio", + "tokio-rustls 0.26.2", "tower 0.5.2", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.61.3" +version = "0.61.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92144e45819cae7dc62af23eac5a038a58aa544432d2102609654376a900bd07" +checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445d065e76bc1ef54963db400319f1dd3ebb3e0a74af20f7f7630625b0cc7cc0" +checksum = "2d1881b1ea6d313f9890710d65c158bdab6fb08c91ea825f74c1c8c357baf4cc" dependencies = [ "aws-smithy-runtime-api", - "once_cell", ] [[package]] name = "aws-smithy-query" -version = "0.60.7" +version = "0.60.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +checksum = "d28a63441360c477465f80c7abac3b9c4d075ca638f982e605b7dc2a2c7156c9" dependencies = [ "aws-smithy-types", "urlencoding", @@ -1867,9 +1792,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.8.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0152749e17ce4d1b47c7747bdfec09dac1ccafdcbc741ebf9daa2a373356730f" +checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1883,7 +1808,6 @@ dependencies = [ "http 1.3.1", "http-body 0.4.6", "http-body 1.0.1", - "once_cell", "pin-project-lite", "pin-utils", "tokio", @@ -1892,9 +1816,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.7.4" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da37cf5d57011cb1753456518ec76e31691f1f474b73934a284eb2a1c76510f" +checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1909,9 +1833,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.0" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836155caafba616c0ff9b07944324785de2ab016141c3550bd1c07882f8cee8f" +checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457" dependencies = [ "base64-simd", "bytes 1.10.1", @@ -1935,18 +1859,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.9" +version = "0.60.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0b0166827aa700d3dc519f72f8b3a91c35d0b8d042dc5d643a91e6f80648fc" +checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.6" +version = "1.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3873f8deed8927ce8d04487630dc9ff73193bab64742a61d050e57a68dec4125" +checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1963,7 +1887,6 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "http_client", - "workspace-hack", ] [[package]] @@ -2042,17 +1965,17 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2085,9 +2008,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bedrock" @@ -2097,20 +2020,13 @@ dependencies = [ "aws-sdk-bedrockruntime", "aws-smithy-types", "futures 0.3.31", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "strum 0.27.1", - "thiserror 2.0.12", - "workspace-hack", + "strum 0.27.2", + "thiserror 2.0.17", ] -[[package]] -name = "beef" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" - [[package]] name = "bigdecimal" version = "0.4.8" @@ -2135,56 +2051,55 @@ dependencies = [ ] [[package]] -name = "bindgen" -version = "0.69.5" +name = "bincode" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" dependencies = [ - "bitflags 2.9.0", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.101", - "which 4.4.2", + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", ] [[package]] name = "bindgen" -version = "0.70.1" +version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", + "log", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash 2.1.1", "shlex", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -2192,7 +2107,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -2227,9 +2142,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" @@ -2239,9 +2154,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -2266,14 +2181,15 @@ dependencies = [ [[package]] name = "blade-graphics" -version = "0.6.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4deb8f595ce7f00dee3543ebf6fd9a20ea86fc421ab79600dac30876250bdae" dependencies = [ "ash", "ash-window", - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", - "codespan-reporting 0.11.1", + "codespan-reporting 0.12.0", "glow", "gpu-alloc", "gpu-alloc-ash", @@ -2291,6 +2207,7 @@ dependencies = [ "objc2-metal", "objc2-quartz-core", "objc2-ui-kit", + "once_cell", "raw-window-handle", "slab", "wasm-bindgen", @@ -2300,17 +2217,19 @@ dependencies = [ [[package]] name = "blade-macros" version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "blade-util" -version = "0.2.0" -source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c" dependencies = [ "blade-graphics", "bytemuck", @@ -2318,15 +2237,6 @@ dependencies = [ "profiling", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - [[package]] name = "blake3" version = "1.8.2" @@ -2366,26 +2276,40 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ "objc2", ] [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "piper", ] +[[package]] +name = "bm25" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbd8ffdfb7b4c2ff038726178a780a94f90525ed0ad264c0afaa75dd8c18a64" +dependencies = [ + "cached", + "deunicode", + "fxhash", + "rust-stemmers", + "stop-words", + "unicode-segmentation", +] + [[package]] name = "borrow-or-share" version = "0.2.2" @@ -2412,9 +2336,15 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + [[package]] name = "breadcrumbs" version = "0.1.0" @@ -2426,19 +2356,39 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", "zed_actions", ] [[package]] -name = "bstr" -version = "1.12.0" +name = "brotli" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ - "memchr", - "regex-automata 0.4.9", - "serde", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata", + "serde", ] [[package]] @@ -2454,14 +2404,13 @@ dependencies = [ "language", "log", "pretty_assertions", - "rand 0.8.5", + "rand 0.9.2", "rope", "serde_json", "sum_tree", "text", "unindent", "util", - "workspace-hack", "zlog", ] @@ -2473,9 +2422,9 @@ checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" dependencies = [ "allocator-api2", ] @@ -2510,28 +2459,28 @@ dependencies = [ [[package]] name = "bytecount" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.9.3" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -2561,6 +2510,9 @@ name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] [[package]] name = "bytes-utils" @@ -2592,6 +2544,39 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cached" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" +dependencies = [ + "ahash 0.8.12", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.15.5", + "once_cell", + "thiserror 2.0.17", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + [[package]] name = "call" version = "0.1.0" @@ -2600,6 +2585,7 @@ dependencies = [ "audio", "client", "collections", + "feature_flags", "fs", "futures 0.3.31", "gpui", @@ -2610,13 +2596,10 @@ dependencies = [ "log", "postage", "project", - "schemars", "serde", - "serde_derive", "settings", "telemetry", "util", - "workspace-hack", ] [[package]] @@ -2625,7 +2608,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "log", "polling", "rustix 0.38.44", @@ -2647,11 +2630,58 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.9" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "candle-core" +version = "0.9.1" +source = "git+https://github.com/zed-industries/candle?branch=9.1-patched#724d75eb3deebefe83f2a7381a45d4fac6eda383" +dependencies = [ + "byteorder", + "float8", + "gemm 0.17.1", + "half", + "memmap2", + "num-traits", + "num_cpus", + "rand 0.9.2", + "rand_distr", + "rayon", + "safetensors", + "thiserror 1.0.69", + "ug", + "yoke 0.7.5", + "zip 1.1.4", +] + +[[package]] +name = "candle-nn" +version = "0.9.1" +source = "git+https://github.com/zed-industries/candle?branch=9.1-patched#724d75eb3deebefe83f2a7381a45d4fac6eda383" dependencies = [ + "candle-core", + "half", + "libc", + "num-traits", + "rayon", + "safetensors", "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "candle-onnx" +version = "0.9.1" +source = "git+https://github.com/zed-industries/candle?branch=9.1-patched#724d75eb3deebefe83f2a7381a45d4fac6eda383" +dependencies = [ + "candle-core", + "candle-nn", + "prost 0.12.6", ] [[package]] @@ -2674,7 +2704,7 @@ checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c" dependencies = [ "cap-primitives", "cap-std", - "rustix 1.0.7", + "rustix 1.1.2", "smallvec", ] @@ -2690,7 +2720,7 @@ dependencies = [ "io-lifetimes", "ipnet", "maybe-owned", - "rustix 1.0.7", + "rustix 1.1.2", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -2715,7 +2745,7 @@ dependencies = [ "cap-primitives", "io-extras", "io-lifetimes", - "rustix 1.0.7", + "rustix 1.1.2", ] [[package]] @@ -2728,7 +2758,7 @@ dependencies = [ "cap-primitives", "iana-time-zone", "once_cell", - "rustix 1.0.7", + "rustix 1.1.2", "winx", ] @@ -2752,7 +2782,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -2762,7 +2792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fbd1fe9db3ebf71b89060adaf7b0504c2d6a425cf061313099547e382c2e472" dependencies = [ "serde", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] @@ -2771,6 +2801,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -2787,23 +2826,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" dependencies = [ "heck 0.4.1", - "indexmap", + "indexmap 2.11.4", "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.101", + "syn 2.0.106", "tempfile", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] name = "cc" -version = "1.2.19" +version = "1.2.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -2836,9 +2876,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -2875,30 +2915,36 @@ dependencies = [ "language", "log", "postage", - "rand 0.8.5", "release_channel", "rpc", "settings", - "sum_tree", "text", "time", "util", - "workspace-hack", ] [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", ] [[package]] @@ -2947,9 +2993,9 @@ dependencies = [ [[package]] name = "circular-buffer" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23bdce1da528cadbac4654b5632bfcd8c6c63e25b1d42cea919a95958790b51d" +checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" [[package]] name = "clang-sys" @@ -2964,9 +3010,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.37" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" dependencies = [ "clap_builder", "clap_derive", @@ -2974,9 +3020,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" dependencies = [ "anstream", "anstyle", @@ -2987,36 +3033,37 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.47" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6" +checksum = "2348487adcd4631696ced64ccdb40d38ac4d31cae7f2eec8817fcea1b9d1c43c" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cli" version = "0.1.0" dependencies = [ "anyhow", + "askpass", "clap", "collections", "core-foundation 0.10.0", @@ -3027,12 +3074,12 @@ dependencies = [ "parking_lot", "paths", "plist", + "rayon", "release_channel", "serde", "tempfile", "util", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] @@ -3046,10 +3093,9 @@ dependencies = [ "clock", "cloud_api_client", "cloud_llm_client", - "cocoa 0.26.0", "collections", "credentials_provider", - "derive_more 0.99.19", + "derive_more 0.99.20", "feature_flags", "fs", "futures 0.3.31", @@ -3059,24 +3105,25 @@ dependencies = [ "http_client_tls", "httparse", "log", + "objc2-foundation", "parking_lot", "paths", "postage", - "rand 0.8.5", + "rand 0.9.2", "regex", "release_channel", "rpc", "rustls-pki-types", - "schemars", "serde", "serde_json", + "serde_urlencoded", "settings", "sha2", "smol", "telemetry", "telemetry_events", "text", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tiny_http", "tokio", @@ -3085,8 +3132,7 @@ dependencies = [ "tokio-socks", "url", "util", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", "worktree", ] @@ -3097,7 +3143,6 @@ dependencies = [ "parking_lot", "serde", "smallvec", - "workspace-hack", ] [[package]] @@ -3112,7 +3157,6 @@ dependencies = [ "http_client", "parking_lot", "serde_json", - "workspace-hack", "yawc", ] @@ -3127,7 +3171,6 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "workspace-hack", ] [[package]] @@ -3135,19 +3178,27 @@ name = "cloud_llm_client" version = "0.1.0" dependencies = [ "anyhow", + "chrono", + "indoc", "pretty_assertions", "serde", "serde_json", - "strum 0.27.1", + "strum 0.27.2", "uuid", - "workspace-hack", ] [[package]] -name = "clru" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" +name = "cloud_zeta2_prompt" +version = "0.1.0" +dependencies = [ + "anyhow", + "cloud_llm_client", + "indoc", + "ordered-float 2.10.1", + "rustc-hash 2.1.1", + "serde", + "strum 0.27.2", +] [[package]] name = "cmake" @@ -3160,9 +3211,12 @@ dependencies = [ [[package]] name = "cobs" -version = "0.2.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] [[package]] name = "cocoa" @@ -3186,7 +3240,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "cocoa-foundation 0.2.0", "core-foundation 0.10.0", @@ -3216,7 +3270,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "core-foundation 0.10.0", "core-graphics-types 0.2.0", @@ -3226,23 +3280,44 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ + "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "ba7a06c0b31fff5ff2e1e7d37dbf940864e2a974b336e1a2938d10af6e8fb283" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "codestral" +version = "0.1.0" +dependencies = [ + "anyhow", + "edit_prediction", + "edit_prediction_context", + "futures 0.3.31", + "gpui", + "http_client", + "language", + "language_models", + "log", + "mistral", + "serde", + "serde_json", + "smol", + "text", ] [[package]] @@ -3251,8 +3326,8 @@ version = "0.44.0" dependencies = [ "agent_settings", "anyhow", - "assistant_context", "assistant_slash_command", + "assistant_text_thread", "async-trait", "async-tungstenite", "audio", @@ -3310,7 +3385,7 @@ dependencies = [ "prometheus", "prompt_store", "prost 0.9.0", - "rand 0.8.5", + "rand 0.9.2", "recent_projects", "release_channel", "remote", @@ -3318,20 +3393,19 @@ dependencies = [ "reqwest 0.11.27", "reqwest_client", "rpc", - "rustc-demangle", "scrypt", "sea-orm", + "sea-orm-macros", "semantic_version", "semver", "serde", - "serde_derive", "serde_json", "session", "settings", "sha2", "smol", "sqlx", - "strum 0.27.1", + "strum 0.27.2", "subtle", "supermaven_api", "task", @@ -3340,7 +3414,7 @@ dependencies = [ "theme", "time", "tokio", - "toml 0.8.20", + "toml 0.8.23", "tower 0.4.13", "tower-http 0.4.4", "tracing", @@ -3349,7 +3423,6 @@ dependencies = [ "util", "uuid", "workspace", - "workspace-hack", "worktree", "zlog", ] @@ -3366,12 +3439,10 @@ dependencies = [ "collections", "db", "editor", - "emojis", "futures 0.3.31", "fuzzy", "gpui", "http_client", - "language", "log", "menu", "notifications", @@ -3379,11 +3450,8 @@ dependencies = [ "pretty_assertions", "project", "release_channel", - "rich_text", "rpc", - "schemars", "serde", - "serde_derive", "serde_json", "settings", "smallvec", @@ -3397,16 +3465,14 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", ] [[package]] name = "collections" version = "0.1.0" dependencies = [ - "indexmap", + "indexmap 2.11.4", "rustc-hash 2.1.1", - "workspace-hack", ] [[package]] @@ -3417,9 +3483,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "combine" @@ -3431,14 +3497,25 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "command-fds" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ec1052629a80c28594777d1252efc8a6b005d13f9edfd8c3fc0f44d5b32489a" +checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" dependencies = [ "nix 0.30.1", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -3471,7 +3548,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -3480,9 +3556,24 @@ name = "command_palette_hooks" version = "0.1.0" dependencies = [ "collections", - "derive_more 0.99.19", + "derive_more 0.99.20", "gpui", - "workspace-hack", + "workspace", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", ] [[package]] @@ -3490,14 +3581,32 @@ name = "component" version = "0.1.0" dependencies = [ "collections", + "documented", "gpui", "inventory", "parking_lot", - "strum 0.27.1", + "strum 0.27.2", "theme", - "workspace-hack", ] +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -3516,7 +3625,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.0", + "unicode-width", "windows-sys 0.59.0", ] @@ -3541,7 +3650,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -3571,14 +3680,14 @@ dependencies = [ "net", "parking_lot", "postage", - "schemars", + "schemars 1.0.4", "serde", "serde_json", + "settings", "smol", "tempfile", "url", "util", - "workspace-hack", ] [[package]] @@ -3587,15 +3696,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "convert_case" version = "0.8.0" @@ -3635,6 +3735,7 @@ dependencies = [ "paths", "project", "rpc", + "semver", "serde", "serde_json", "settings", @@ -3644,7 +3745,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zlog", ] @@ -3693,7 +3793,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -3706,7 +3806,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -3730,7 +3830,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "libc", ] @@ -3741,7 +3841,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "cfg-if", "core-foundation 0.10.0", @@ -3819,20 +3919,20 @@ dependencies = [ [[package]] name = "coreaudio-sys" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" dependencies = [ - "bindgen 0.70.1", + "bindgen 0.72.1", ] [[package]] name = "cosmic-text" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1ecbb5db9a4c2ee642df67bcfa8f044dd867dbbaa21bfab139cbc204ffbf67" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "fontdb 0.16.2", "log", "rangemap", @@ -3861,7 +3961,7 @@ dependencies = [ "jni", "js-sys", "libc", - "mach2", + "mach2 0.4.3", "ndk", "ndk-context", "num-derive", @@ -3877,9 +3977,9 @@ dependencies = [ [[package]] name = "cpp_demangle" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e58d342ad113c2b878f16d5d034c03be492ae460cdbc02b7f0f2284d310c7d" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" dependencies = [ "cfg-if", ] @@ -3926,7 +4026,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.31.1", "hashbrown 0.14.5", "log", "postcard", @@ -3936,7 +4036,7 @@ dependencies = [ "serde_derive", "sha2", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -3983,7 +4083,7 @@ dependencies = [ "cranelift-codegen", "log", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -4000,7 +4100,7 @@ checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" dependencies = [ "cranelift-codegen", "libc", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", ] [[package]] @@ -4011,7 +4111,7 @@ checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" dependencies = [ "cfg-if", "libc", - "mach2", + "mach2 0.4.3", ] [[package]] @@ -4023,7 +4123,7 @@ dependencies = [ "cfg-if", "crash-context", "libc", - "mach2", + "mach2 0.4.3", "parking_lot", ] @@ -4031,22 +4131,27 @@ dependencies = [ name = "crashes" version = "0.1.0" dependencies = [ + "bincode 1.3.3", + "cfg-if", "crash-handler", + "extension_host", "log", + "mach2 0.5.0", "minidumper", "paths", "release_channel", "serde", "serde_json", "smol", - "workspace-hack", + "system_specs", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -4058,32 +4163,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] -name = "crc32c" -version = "0.6.8" +name = "crc-fast" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +checksum = "6bf62af4cc77d8fe1c22dde4e721d87f2f54056139d8c412e1366b740305f56f" dependencies = [ - "rustc_version", + "crc", + "digest", + "libc", + "rand 0.9.2", + "regex", ] [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crc64fast-nvme" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4955638f00a809894c947f85a024020a20815b65a5eea633798ea7924edab2b3" -dependencies = [ - "crc", -] - [[package]] name = "credentials_provider" version = "0.1.0" @@ -4095,7 +4195,6 @@ dependencies = [ "release_channel", "serde", "serde_json", - "workspace-hack", ] [[package]] @@ -4134,6 +4233,19 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -4177,11 +4289,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.2", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -4225,7 +4360,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", "smallvec", ] @@ -4236,14 +4371,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "ctor" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4735f265ba6a1188052ca32d461028a7d1125868be18e287e756019da7607b5" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" dependencies = [ "ctor-proc-macro", "dtor", @@ -4251,83 +4386,87 @@ dependencies = [ [[package]] name = "ctor-proc-macro" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" [[package]] name = "ctrlc" -version = "3.4.6" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" dependencies = [ - "nix 0.29.0", - "windows-sys 0.59.0", + "dispatch", + "nix 0.30.1", + "windows-sys 0.61.2", ] [[package]] name = "cursor-icon" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "cxx" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6354e975ea4ec28033ec3a36fa9baa1a02e3eb22ad740eeb4929370d4f5ba8" +checksum = "d8465678d499296e2cbf9d3acf14307458fd69b471a31b65b3c519efe8b5e187" dependencies = [ "cc", + "cxx-build", "cxxbridge-cmd", "cxxbridge-flags", "cxxbridge-macro", - "foldhash", + "foldhash 0.2.0", "link-cplusplus", ] [[package]] name = "cxx-build" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4400e26ea4b99417e4263b1ce2d8452404d750ba0809a7bd043072593d430d" +checksum = "d74b6bcf49ebbd91f1b1875b706ea46545032a14003b5557b7dfa4bbeba6766e" dependencies = [ "cc", - "codespan-reporting 0.12.0", + "codespan-reporting 0.13.0", + "indexmap 2.11.4", "proc-macro2", "quote", "scratch", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "cxxbridge-cmd" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31860c98f69fc14da5742c5deaf78983e846c7b27804ca8c8319e32eef421bde" +checksum = "94ca2ad69673c4b35585edfa379617ac364bccd0ba0adf319811ba3a74ffa48a" dependencies = [ "clap", - "codespan-reporting 0.12.0", + "codespan-reporting 0.13.0", + "indexmap 2.11.4", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "cxxbridge-flags" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0402a66013f3b8d3d9f2d7c9994656cc81e671054822b0728d7454d9231892f" +checksum = "d29b52102aa395386d77d322b3a0522f2035e716171c2c60aa87cc5e9466e523" [[package]] name = "cxxbridge-macro" -version = "1.0.157" +version = "1.0.187" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c0b38f32d68f3324a981645ee39b2d686af36d03c98a386df3716108c9feae" +checksum = "2a8ebf0b6138325af3ec73324cb3a48b64d57721f17291b151206782e61f66cd" dependencies = [ + "indexmap 2.11.4", "proc-macro2", "quote", - "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4353,7 +4492,7 @@ dependencies = [ "parking_lot", "paths", "proto", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -4364,7 +4503,6 @@ dependencies = [ "tree-sitter", "tree-sitter-go", "util", - "workspace-hack", "zlog", ] @@ -4373,7 +4511,7 @@ name = "dap-types" version = "0.0.1" source = "git+https://github.com/zed-industries/dap-types?rev=1b461b310481d01e02b2603c16d7144b926339f8#1b461b310481d01e02b2603c16d7144b926339f8" dependencies = [ - "schemars", + "schemars 1.0.4", "serde", "serde_json", ] @@ -4396,11 +4534,79 @@ dependencies = [ "paths", "serde", "serde_json", - "shlex", "smol", "task", "util", - "workspace-hack", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.106", ] [[package]] @@ -4444,9 +4650,9 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "data-url" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "db" @@ -4463,18 +4669,18 @@ dependencies = [ "sqlez_macros", "tempfile", "util", - "workspace-hack", + "zed_env_vars", ] [[package]] name = "dbus" -version = "0.9.7" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" dependencies = [ "libc", "libdbus-sys", - "winapi", + "windows-sys 0.59.0", ] [[package]] @@ -4483,15 +4689,21 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "collections", "dap", "extension", "gpui", "serde_json", "task", "util", - "workspace-hack", ] +[[package]] +name = "debug_unsafe" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b" + [[package]] name = "debugger_tools" version = "0.1.0" @@ -4507,7 +4719,6 @@ dependencies = [ "smol", "util", "workspace", - "workspace-hack", ] [[package]] @@ -4516,7 +4727,7 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "anyhow", - "bitflags 2.9.0", + "bitflags 2.9.4", "client", "collections", "command_palette_hooks", @@ -4543,13 +4754,12 @@ dependencies = [ "pretty_assertions", "project", "rpc", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", "settings", - "shlex", - "sysinfo", + "sysinfo 0.37.2", "task", "tasks_ui", "telemetry", @@ -4563,7 +4773,6 @@ dependencies = [ "unindent", "util", "workspace", - "workspace-hack", "zed_actions", "zlog", ] @@ -4584,17 +4793,29 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "workspace-hack", ] [[package]] name = "deflate64" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "denoise" +version = "0.1.0" +dependencies = [ + "candle-core", + "candle-onnx", + "log", + "realfft", + "rodio", + "rustfft", + "thiserror 2.0.17", +] [[package]] name = "der" @@ -4619,25 +4840,36 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] name = "derive_more" -version = "0.99.19" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4657,7 +4889,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "unicode-xid", ] @@ -4667,10 +4899,27 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", +] + +[[package]] +name = "derive_setters" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.106", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "diagnostics" version = "0.1.0" @@ -4681,7 +4930,6 @@ dependencies = [ "component", "ctor", "editor", - "futures 0.3.31", "gpui", "indoc", "language", @@ -4690,7 +4938,7 @@ dependencies = [ "markdown", "pretty_assertions", "project", - "rand 0.8.5", + "rand 0.9.2", "serde", "serde_json", "settings", @@ -4700,7 +4948,6 @@ dependencies = [ "unindent", "util", "workspace", - "workspace-hack", "zlog", ] @@ -4730,7 +4977,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" dependencies = [ - "nu-ansi-term 0.50.1", + "nu-ansi-term", ] [[package]] @@ -4803,8 +5050,8 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", - "windows-sys 0.59.0", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] @@ -4819,7 +5066,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", ] @@ -4831,7 +5078,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -4855,36 +5102,46 @@ dependencies = [ "serde", "serde_json", "settings", + "task", + "theme", "util", - "workspace-hack", "zed", "zlog", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "documented" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6db32f0995bc4553d2de888999075acd0dbeef75ba923503f6a724263dc6f3" +checksum = "ed6b3e31251e87acd1b74911aed84071c8364fc9087972748ade2f1094ccce34" dependencies = [ "documented-macros", - "phf", - "thiserror 1.0.69", + "phf 0.12.1", + "thiserror 2.0.17", ] [[package]] name = "documented-macros" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394bb35929b58f9a5fd418f7c6b17a4b616efcc1e53e6995ca123948f87e5fa" +checksum = "1149cf7462e5e79e17a3c05fd5b1f9055092bbfa95e04c319395c3beacc9370f" dependencies = [ - "convert_case 0.6.0", - "itertools 0.13.0", + "convert_case 0.8.0", + "itertools 0.14.0", "optfield", "proc-macro2", "quote", - "strum 0.26.3", - "syn 2.0.101", + "strum 0.27.2", + "syn 2.0.106", ] [[package]] @@ -4905,7 +5162,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -4946,9 +5203,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dwrote" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe1f192fcce01590bd8d839aca53ce0d11d803bf291b2a6c4ad925a8f0024be" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" dependencies = [ "lazy_static", "libc", @@ -4958,9 +5215,35 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "dyn-stack" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e53799688f5632f364f8fb387488dd05db9fe45db7011be066fc20e7027f8b" +dependencies = [ + "bytemuck", + "reborrow", +] + +[[package]] +name = "dyn-stack" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ec4rs" @@ -4987,8 +5270,6 @@ dependencies = [ "client", "gpui", "language", - "project", - "workspace-hack", ] [[package]] @@ -4998,6 +5279,7 @@ dependencies = [ "anyhow", "client", "cloud_llm_client", + "codestral", "copilot", "edit_prediction", "editor", @@ -5018,11 +5300,45 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", "zed_actions", "zeta", ] +[[package]] +name = "edit_prediction_context" +version = "0.1.0" +dependencies = [ + "anyhow", + "arrayvec", + "clap", + "cloud_llm_client", + "collections", + "futures 0.3.31", + "gpui", + "hashbrown 0.15.5", + "indoc", + "itertools 0.14.0", + "language", + "log", + "ordered-float 2.10.1", + "postage", + "pretty_assertions", + "project", + "regex", + "serde", + "serde_json", + "settings", + "slotmap", + "strum 0.27.2", + "text", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-go", + "util", + "zlog", +] + [[package]] name = "editor" version = "0.1.0" @@ -5035,6 +5351,7 @@ dependencies = [ "clock", "collections", "convert_case 0.8.0", + "criterion", "ctor", "dap", "db", @@ -5061,11 +5378,12 @@ dependencies = [ "parking_lot", "pretty_assertions", "project", - "rand 0.8.5", + "rand 0.9.2", "regex", "release_channel", + "rope", "rpc", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -5093,8 +5411,8 @@ dependencies = [ "url", "util", "uuid", + "vim_mode_setting", "workspace", - "workspace-hack", "zed_actions", "zlog", ] @@ -5151,16 +5469,16 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.2" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.8.20", + "toml 0.9.8", "vswhom", - "winreg 0.52.0", + "winreg 0.55.0", ] [[package]] @@ -5181,7 +5499,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -5205,11 +5523,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "enumflags2" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2f4b465f5318854c6f8dd686ede6c0a9dc67d4b1ac241cf0eb51521a309147" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", "serde", @@ -5217,20 +5547,20 @@ dependencies = [ [[package]] name = "enumflags2_derive" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -5271,6 +5601,26 @@ dependencies = [ "serde", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -5279,11 +5629,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" dependencies = [ "serde", + "serde_core", "typeid", ] @@ -5300,12 +5651,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5339,6 +5690,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" + [[package]] name = "euclid" version = "0.22.11" @@ -5352,18 +5709,17 @@ dependencies = [ name = "eval" version = "0.1.0" dependencies = [ + "acp_thread", "agent", + "agent-client-protocol", "agent_settings", "agent_ui", "anyhow", - "assistant_tool", - "assistant_tools", "async-trait", "buffer_diff", "chrono", "clap", "client", - "cloud_llm_client", "collections", "debug_adapter_extension", "dirs 4.0.0", @@ -5387,6 +5743,7 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", + "rand 0.9.2", "regex", "release_channel", "reqwest_client", @@ -5394,15 +5751,13 @@ dependencies = [ "serde_json", "settings", "shellexpand 2.1.2", - "smol", "telemetry", "terminal_view", - "toml 0.8.20", + "toml 0.8.23", "unindent", "util", "uuid", "watch", - "workspace-hack", ] [[package]] @@ -5413,9 +5768,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -5428,7 +5783,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "pin-project-lite", ] @@ -5446,10 +5801,9 @@ dependencies = [ name = "explorer_command_injector" version = "0.1.0" dependencies = [ - "windows 0.61.1", - "windows-core 0.61.0", - "windows-registry 0.5.1", - "workspace-hack", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-registry 0.5.3", ] [[package]] @@ -5497,12 +5851,11 @@ dependencies = [ "serde", "serde_json", "task", - "toml 0.8.20", + "toml 0.8.23", "url", "util", "wasm-encoder 0.221.3", "wasmparser 0.221.3", - "workspace-hack", ] [[package]] @@ -5523,10 +5876,9 @@ dependencies = [ "serde_json", "theme", "tokio", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "wasmtime", - "workspace-hack", ] [[package]] @@ -5546,6 +5898,7 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", + "gpui_tokio", "http_client", "language", "language_extension", @@ -5556,11 +5909,10 @@ dependencies = [ "parking_lot", "paths", "project", - "rand 0.8.5", + "rand 0.9.2", "release_channel", "remote", "reqwest_client", - "schemars", "semantic_version", "serde", "serde_json", @@ -5571,13 +5923,12 @@ dependencies = [ "tempfile", "theme", "theme_extension", - "toml 0.8.20", + "toml 0.8.23", "url", "util", "wasmparser 0.221.3", "wasmtime", "wasmtime-wasi", - "workspace-hack", "zlog", ] @@ -5605,14 +5956,13 @@ dependencies = [ "serde", "settings", "smallvec", - "strum 0.27.1", + "strum 0.27.2", "telemetry", "theme", "ui", "util", "vim_mode_setting", "workspace", - "workspace-hack", "zed_actions", ] @@ -5622,6 +5972,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.13.0" @@ -5629,8 +5985,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set 0.5.3", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -5640,10 +5996,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" dependencies = [ "bit-set 0.8.0", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -5651,38 +6013,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] -name = "faster-hex" -version = "0.9.0" +name = "fastrand" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" dependencies = [ - "serde", + "instant", ] [[package]] -name = "faster-hex" -version = "0.10.0" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" -dependencies = [ - "heapless", - "serde", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "fastrand" -version = "1.9.0" +name = "fax" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" dependencies = [ - "instant", + "fax_derive", ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "fax_derive" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "fd-lock" @@ -5691,7 +6054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.7", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -5711,26 +6074,18 @@ dependencies = [ "futures 0.3.31", "gpui", "smol", - "workspace-hack", ] [[package]] name = "feedback" version = "0.1.0" dependencies = [ - "client", "editor", "gpui", - "human_bytes", - "menu", - "release_channel", - "serde", - "sysinfo", - "ui", + "system_specs", "urlencoding", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -5761,10 +6116,9 @@ dependencies = [ "picker", "pretty_assertions", "project", - "schemars", + "schemars 1.0.4", "search", "serde", - "serde_derive", "serde_json", "settings", "text", @@ -5772,7 +6126,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zlog", ] @@ -5782,10 +6135,8 @@ version = "0.1.0" dependencies = [ "gpui", "serde", - "settings", "theme", "util", - "workspace-hack", ] [[package]] @@ -5801,16 +6152,22 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -5819,9 +6176,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", "libz-rs-sys", @@ -5841,10 +6198,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" [[package]] -name = "float_next_after" -version = "1.0.0" +name = "float8" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" +dependencies = [ + "half", + "num-traits", + "rand 0.9.2", + "rand_distr", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "fluent-uri" @@ -5882,43 +6251,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "font-kit" -version = "0.14.1" -source = "git+https://github.com/zed-industries/font-kit?rev=5474cfad4b719a72ec8ed2cb7327b2b01fd10568#5474cfad4b719a72ec8ed2cb7327b2b01fd10568" -dependencies = [ - "bitflags 2.9.0", - "byteorder", - "core-foundation 0.10.0", - "core-graphics 0.24.0", - "core-text", - "dirs 5.0.1", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.8.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" dependencies = [ "bytemuck", ] [[package]] name = "fontconfig-parser" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fcfcd44ca6e90c921fee9fa665d530b21ef1327a4c1a6c5250ea44b776ada7" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ "roxmltree", ] @@ -5978,7 +6329,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -6004,9 +6355,9 @@ dependencies = [ [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -6037,7 +6388,7 @@ name = "fs" version = "0.1.0" dependencies = [ "anyhow", - "ashpd", + "ashpd 0.11.0", "async-tar", "async-trait", "cocoa 0.26.0", @@ -6062,8 +6413,7 @@ dependencies = [ "text", "time", "util", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] @@ -6073,7 +6423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes", - "rustix 1.0.7", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -6087,6 +6437,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_benchmarks" +version = "0.1.0" +dependencies = [ + "fs", + "gpui", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -6097,12 +6465,12 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" name = "fsevent" version = "0.1.0" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "fsevent-sys 3.1.0", + "log", "parking_lot", "tempfile", - "workspace-hack", ] [[package]] @@ -6160,17 +6528,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-batch" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f444c45a1cb86f2a7e301469fd50a82084a60dadc25d94529a8312276ecb71a" -dependencies = [ - "futures 0.3.31", - "futures-timer", - "pin-utils", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -6232,9 +6589,9 @@ dependencies = [ [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand 2.3.0", "futures-core", @@ -6251,7 +6608,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -6266,12 +6623,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - [[package]] name = "futures-util" version = "0.3.31" @@ -6299,7 +6650,6 @@ dependencies = [ "gpui", "log", "util", - "workspace-hack", ] [[package]] @@ -6312,17 +6662,249 @@ dependencies = [ ] [[package]] -name = "generator" -version = "0.8.5" +name = "fxhash" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows 0.61.1", + "byteorder", +] + +[[package]] +name = "gemm" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab24cc62135b40090e31a76a9b2766a501979f3070fa27f689c27ec04377d32" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-c32 0.17.1", + "gemm-c64 0.17.1", + "gemm-common 0.17.1", + "gemm-f16 0.17.1", + "gemm-f32 0.17.1", + "gemm-f64 0.17.1", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "seq-macro", +] + +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-c32 0.18.2", + "gemm-c64 0.18.2", + "gemm-common 0.18.2", + "gemm-f16 0.18.2", + "gemm-f32 0.18.2", + "gemm-f64 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c030d0b983d1e34a546b86e08f600c11696fde16199f971cd46c12e67512c0" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-common 0.17.1", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb5f2e79fefb9693d18e1066a557b4546cd334b226beadc68b11a8f9431852a" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-common 0.17.1", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2e7ea062c987abcd8db95db917b4ffb4ecdfd0668471d8dc54734fdff2354e8" +dependencies = [ + "bytemuck", + "dyn-stack 0.10.0", + "half", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.18.22", + "raw-cpuid 10.7.0", + "rayon", + "seq-macro", + "sysctl 0.5.5", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack 0.13.2", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.21.5", + "raw-cpuid 11.6.0", + "rayon", + "seq-macro", + "sysctl 0.6.0", +] + +[[package]] +name = "gemm-f16" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca4c06b9b11952071d317604acb332e924e817bd891bec8dfb494168c7cedd4" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-common 0.17.1", + "gemm-f32 0.17.1", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "gemm-f32 0.18.2", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9a69f51aaefbd9cf12d18faf273d3e982d9d711f60775645ed5c8047b4ae113" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-common 0.17.1", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa397a48544fadf0b81ec8741e5c0fba0043008113f71f2034def1935645d2b0" +dependencies = [ + "dyn-stack 0.10.0", + "gemm-common 0.17.1", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 10.7.0", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack 0.13.2", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid 11.6.0", + "seq-macro", ] [[package]] @@ -6337,46 +6919,73 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.2", + "windows-link 0.2.1", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] +[[package]] +name = "gh-workflow" +version = "0.8.0" +source = "git+https://github.com/zed-industries/gh-workflow?rev=0090c6b6ef82fff02bc8616645953e778d1acc08#0090c6b6ef82fff02bc8616645953e778d1acc08" +dependencies = [ + "async-trait", + "derive_more 2.0.1", + "derive_setters", + "gh-workflow-macros", + "indexmap 2.11.4", + "merge", + "serde", + "serde_json", + "serde_yaml", + "strum_macros 0.27.2", +] + +[[package]] +name = "gh-workflow-macros" +version = "0.8.0" +source = "git+https://github.com/zed-industries/gh-workflow?rev=0090c6b6ef82fff02bc8616645953e778d1acc08#0090c6b6ef82fff02bc8616645953e778d1acc08" +dependencies = [ + "heck 0.5.0", + "quote", + "syn 2.0.106", +] + [[package]] name = "gif" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", @@ -6389,10 +6998,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ "fallible-iterator", - "indexmap", + "indexmap 2.11.4", "stable_deref_trait", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "git" version = "0.1.0" @@ -6401,40 +7016,41 @@ dependencies = [ "askpass", "async-trait", "collections", - "derive_more 0.99.19", + "derive_more 0.99.20", "futures 0.3.31", "git2", "gpui", "http_client", + "itertools 0.14.0", "log", "parking_lot", "pretty_assertions", - "rand 0.8.5", + "rand 0.9.2", "regex", "rope", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "smol", "sum_tree", "tempfile", "text", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "unindent", "url", + "urlencoding", "util", "uuid", - "workspace-hack", ] [[package]] name = "git2" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5220b8ba44c68a9a7f7a7659e864dd73692e417ef0211bea133c7b74e031eeb9" +checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", "libgit2-sys", "log", @@ -6454,13 +7070,11 @@ dependencies = [ "indoc", "pretty_assertions", "regex", - "schemars", "serde", "serde_json", "settings", "url", "util", - "workspace-hack", ] [[package]] @@ -6496,15 +7110,13 @@ dependencies = [ "notifications", "panel", "picker", - "postage", "pretty_assertions", "project", - "schemars", + "schemars 1.0.4", "serde", - "serde_derive", "serde_json", "settings", - "strum 0.27.1", + "strum 0.27.2", "telemetry", "theme", "time", @@ -6513,809 +7125,49 @@ dependencies = [ "unindent", "util", "watch", - "windows 0.61.1", + "windows 0.61.3", "workspace", - "workspace-hack", "zed_actions", + "zeroize", "zlog", ] [[package]] -name = "gix" -version = "0.71.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a61e71ec6817fc3c9f12f812682cfe51ee6ea0d2e27e02fc3849c35524617435" -dependencies = [ - "gix-actor", - "gix-attributes", - "gix-command", - "gix-commitgraph", - "gix-config", - "gix-date", - "gix-diff", - "gix-discover", - "gix-features 0.41.1", - "gix-filter", - "gix-fs 0.14.0", - "gix-glob", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-ignore", - "gix-index", - "gix-lock", - "gix-object", - "gix-odb", - "gix-pack", - "gix-path", - "gix-pathspec", - "gix-protocol", - "gix-ref", - "gix-refspec", - "gix-revision", - "gix-revwalk", - "gix-sec", - "gix-shallow", - "gix-submodule", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-url", - "gix-utils 0.2.0", - "gix-validate 0.9.4", - "gix-worktree", - "once_cell", - "smallvec", - "thiserror 2.0.12", -] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "gix-actor" -version = "0.34.0" +name = "globset" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f438c87d4028aca4b82f82ba8d8ab1569823cfb3e5bc5fa8456a71678b2a20e7" +checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" dependencies = [ + "aho-corasick", "bstr", - "gix-date", - "gix-utils 0.2.0", - "itoa", - "thiserror 2.0.12", - "winnow", + "log", + "regex-automata", + "regex-syntax", ] [[package]] -name = "gix-attributes" -version = "0.25.0" +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e25825e0430aa11096f8b65ced6780d4a96a133f81904edceebb5344c8dd7f" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-quote", - "gix-trace", - "kstring", - "smallvec", - "thiserror 2.0.12", - "unicode-bom", -] - -[[package]] -name = "gix-bitmap" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1db9765c69502650da68f0804e3dc2b5f8ccc6a2d104ca6c85bc40700d37540" -dependencies = [ - "thiserror 2.0.12", -] - -[[package]] -name = "gix-chunk" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1f1d8764958699dc764e3f727cef280ff4d1bd92c107bbf8acd85b30c1bd6f" -dependencies = [ - "thiserror 2.0.12", -] - -[[package]] -name = "gix-command" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0378995847773a697f8e157fe2963ecf3462fe64be05b7b3da000b3b472def8" -dependencies = [ - "bstr", - "gix-path", - "gix-quote", - "gix-trace", - "shell-words", -] - -[[package]] -name = "gix-commitgraph" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "043cbe49b7a7505150db975f3cb7c15833335ac1e26781f615454d9d640a28fe" -dependencies = [ - "bstr", - "gix-chunk", - "gix-hash 0.17.0", - "memmap2", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-config" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6f830bf746604940261b49abf7f655d2c19cadc9f4142ae9379e3a316e8cfa" -dependencies = [ - "bstr", - "gix-config-value", - "gix-features 0.41.1", - "gix-glob", - "gix-path", - "gix-ref", - "gix-sec", - "memchr", - "once_cell", - "smallvec", - "thiserror 2.0.12", - "unicode-bom", - "winnow", -] - -[[package]] -name = "gix-config-value" -version = "0.14.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc2c844c4cf141884678cabef736fd91dd73068b9146e6f004ba1a0457944b6" -dependencies = [ - "bitflags 2.9.0", - "bstr", - "gix-path", - "libc", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-date" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa30058ec7d3511fbc229e4f9e696a35abd07ec5b82e635eff864a2726217e4" -dependencies = [ - "bstr", - "itoa", - "jiff", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-diff" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c975dad2afc85e4e233f444d1efbe436c3cdcf3a07173984509c436d00a3f8" -dependencies = [ - "bstr", - "gix-command", - "gix-filter", - "gix-fs 0.14.0", - "gix-hash 0.17.0", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-trace", - "gix-traverse", - "gix-worktree", - "imara-diff", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-discover" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fb8a4349b854506a3915de18d3341e5f1daa6b489c8affc9ca0d69efe86781" -dependencies = [ - "bstr", - "dunce", - "gix-fs 0.14.0", - "gix-hash 0.17.0", - "gix-path", - "gix-ref", - "gix-sec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-features" -version = "0.41.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016d6050219458d14520fe22bdfdeb9cb71631dec9bc2724767c983f60109634" -dependencies = [ - "crc32fast", - "crossbeam-channel", - "flate2", - "gix-path", - "gix-trace", - "gix-utils 0.2.0", - "libc", - "once_cell", - "parking_lot", - "prodash", - "thiserror 2.0.12", - "walkdir", -] - -[[package]] -name = "gix-features" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f4399af6ec4fd9db84dd4cf9656c5c785ab492ab40a7c27ea92b4241923fed" -dependencies = [ - "gix-trace", - "gix-utils 0.3.0", - "libc", - "prodash", -] - -[[package]] -name = "gix-filter" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2b2bbffdc5cc9b2b82fc82da1b98163c9b423ac2b45348baa83a947ac9ab89" -dependencies = [ - "bstr", - "encoding_rs", - "gix-attributes", - "gix-command", - "gix-hash 0.17.0", - "gix-object", - "gix-packetline-blocking", - "gix-path", - "gix-quote", - "gix-trace", - "gix-utils 0.2.0", - "smallvec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-fs" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951e886120dc5fa8cac053e5e5c89443f12368ca36811b2e43d1539081f9c111" -dependencies = [ - "bstr", - "fastrand 2.3.0", - "gix-features 0.41.1", - "gix-path", - "gix-utils 0.2.0", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-fs" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a0637149b4ef24d3ea55f81f77231401c8463fae6da27331c987957eb597c7" -dependencies = [ - "bstr", - "fastrand 2.3.0", - "gix-features 0.42.1", - "gix-path", - "gix-utils 0.3.0", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-glob" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20972499c03473e773a2099e5fd0c695b9b72465837797a51a43391a1635a030" -dependencies = [ - "bitflags 2.9.0", - "bstr", - "gix-features 0.41.1", - "gix-path", -] - -[[package]] -name = "gix-hash" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "834e79722063958b03342edaa1e17595cd2939bb2b3306b3225d0815566dcb49" -dependencies = [ - "faster-hex 0.9.0", - "gix-features 0.41.1", - "sha1-checked", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-hash" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d4900562c662852a6b42e2ef03442eccebf24f047d8eab4f23bc12ef0d785d8" -dependencies = [ - "faster-hex 0.10.0", - "gix-features 0.42.1", - "sha1-checked", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-hashtable" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b5cb3c308b4144f2612ff64e32130e641279fcf1a84d8d40dad843b4f64904" -dependencies = [ - "gix-hash 0.18.0", - "hashbrown 0.14.5", - "parking_lot", -] - -[[package]] -name = "gix-ignore" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a27c8380f493a10d1457f756a3f81924d578fc08d6535e304dfcafbf0261d18" -dependencies = [ - "bstr", - "gix-glob", - "gix-path", - "gix-trace", - "unicode-bom", -] - -[[package]] -name = "gix-index" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "855bece2d4153453aa5d0a80d51deea1ce8cd6a3b4cf213da85ac344ccb908a7" -dependencies = [ - "bitflags 2.9.0", - "bstr", - "filetime", - "fnv", - "gix-bitmap", - "gix-features 0.41.1", - "gix-fs 0.14.0", - "gix-hash 0.17.0", - "gix-lock", - "gix-object", - "gix-traverse", - "gix-utils 0.2.0", - "gix-validate 0.9.4", - "hashbrown 0.14.5", - "itoa", - "libc", - "memmap2", - "rustix 0.38.44", - "smallvec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-lock" -version = "17.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "570f8b034659f256366dc90f1a24924902f20acccd6a15be96d44d1269e7a796" -dependencies = [ - "gix-tempfile", - "gix-utils 0.3.0", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-object" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4943fcdae6ffc135920c9ea71e0362ed539182924ab7a85dd9dac8d89b0dd69a" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-features 0.41.1", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-path", - "gix-utils 0.2.0", - "gix-validate 0.9.4", - "itoa", - "smallvec", - "thiserror 2.0.12", - "winnow", -] - -[[package]] -name = "gix-odb" -version = "0.68.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50306d40dcc982eb6b7593103f066ea6289c7b094cb9db14f3cd2be0b9f5e610" -dependencies = [ - "arc-swap", - "gix-date", - "gix-features 0.41.1", - "gix-fs 0.14.0", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-object", - "gix-pack", - "gix-path", - "gix-quote", - "parking_lot", - "tempfile", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-pack" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b65fffb09393c26624ca408d32cfe8776fb94cd0a5cdf984905e1d2f39779cb" -dependencies = [ - "clru", - "gix-chunk", - "gix-features 0.41.1", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-object", - "gix-path", - "memmap2", - "smallvec", - "thiserror 2.0.12", - "uluru", -] - -[[package]] -name = "gix-packetline" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "123844a70cf4d5352441dc06bab0da8aef61be94ec239cb631e0ba01dc6d3a04" -dependencies = [ - "bstr", - "faster-hex 0.9.0", - "gix-trace", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-packetline-blocking" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecf3ea2e105c7e45587bac04099824301262a6c43357fad5205da36dbb233b3" -dependencies = [ - "bstr", - "faster-hex 0.9.0", - "gix-trace", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-path" -version = "0.10.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567f65fec4ef10dfab97ae71f26a27fd4d7fe7b8e3f90c8a58551c41ff3fb65b" -dependencies = [ - "bstr", - "gix-trace", - "gix-validate 0.10.0", - "home", - "once_cell", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-pathspec" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8422c3c9066d649074b24025125963f85232bfad32d6d16aea9453b82ec14" -dependencies = [ - "bitflags 2.9.0", - "bstr", - "gix-attributes", - "gix-config-value", - "gix-glob", - "gix-path", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-protocol" -version = "0.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5678ddae1d62880bc30e2200be1b9387af3372e0e88e21f81b4e7f8367355b5a" -dependencies = [ - "bstr", - "gix-date", - "gix-features 0.41.1", - "gix-hash 0.17.0", - "gix-ref", - "gix-shallow", - "gix-transport", - "gix-utils 0.2.0", - "maybe-async", - "thiserror 2.0.12", - "winnow", -] - -[[package]] -name = "gix-quote" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b005c550bf84de3b24aa5e540a23e6146a1c01c7d30470e35d75a12f827f969" -dependencies = [ - "bstr", - "gix-utils 0.2.0", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-ref" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e1f7eb6b7ce82d2d19961f74bd637bab3ea79b1bc7bfb23dbefc67b0415d8b" -dependencies = [ - "gix-actor", - "gix-features 0.41.1", - "gix-fs 0.14.0", - "gix-hash 0.17.0", - "gix-lock", - "gix-object", - "gix-path", - "gix-tempfile", - "gix-utils 0.2.0", - "gix-validate 0.9.4", - "memmap2", - "thiserror 2.0.12", - "winnow", -] - -[[package]] -name = "gix-refspec" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8587b21e2264a6e8938d940c5c99662779c13a10741a5737b15fc85c252ffc" -dependencies = [ - "bstr", - "gix-hash 0.17.0", - "gix-revision", - "gix-validate 0.9.4", - "smallvec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-revision" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342caa4e158df3020cadf62f656307c3948fe4eacfdf67171d7212811860c3e9" -dependencies = [ - "bstr", - "gix-commitgraph", - "gix-date", - "gix-hash 0.17.0", - "gix-object", - "gix-revwalk", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-revwalk" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dc7c3d7e5cdc1ab8d35130106e4af0a4f9f9eca0c81f4312b690780e92bde0d" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-object", - "smallvec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-sec" -version = "0.10.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47aeb0f13de9ef2f3033f5ff218de30f44db827ac9f1286f9ef050aacddd5888" -dependencies = [ - "bitflags 2.9.0", - "gix-path", - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "gix-shallow" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0598aacfe1d52575a21c9492fee086edbb21e228ec36c819c42ab923f434c3" -dependencies = [ - "bstr", - "gix-hash 0.17.0", - "gix-lock", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-submodule" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c7390c2059505c365e9548016d4edc9f35749c6a9112b7b1214400bbc68da2" -dependencies = [ - "bstr", - "gix-config", - "gix-path", - "gix-pathspec", - "gix-refspec", - "gix-url", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-tempfile" -version = "17.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c750e8c008453a2dba67a2b0d928b7716e05da31173a3f5e351d5457ad4470aa" -dependencies = [ - "dashmap 6.1.0", - "gix-fs 0.15.0", - "libc", - "once_cell", - "parking_lot", - "tempfile", -] - -[[package]] -name = "gix-trace" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c396a2036920c69695f760a65e7f2677267ccf483f25046977d87e4cb2665f7" - -[[package]] -name = "gix-transport" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3f68c2870bfca8278389d2484a7f2215b67d0b0cc5277d3c72ad72acf41787e" -dependencies = [ - "bstr", - "gix-command", - "gix-features 0.41.1", - "gix-packetline", - "gix-quote", - "gix-sec", - "gix-url", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-traverse" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c0b049f8bdb61b20016694102f7b507f2e1727e83e9c5e6dad4f7d84ff7384" -dependencies = [ - "bitflags 2.9.0", - "gix-commitgraph", - "gix-date", - "gix-hash 0.17.0", - "gix-hashtable", - "gix-object", - "gix-revwalk", - "smallvec", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-url" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dfe23f93f1ddb84977d80bb0dd7aa09d1bf5d5afc0c9b6820cccacc25ae860" -dependencies = [ - "bstr", - "gix-features 0.41.1", - "gix-path", - "percent-encoding", - "thiserror 2.0.12", - "url", -] - -[[package]] -name = "gix-utils" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189f8724cf903e7fd57cfe0b7bc209db255cacdcb22c781a022f52c3a774f8d0" -dependencies = [ - "fastrand 2.3.0", - "unicode-normalization", -] - -[[package]] -name = "gix-utils" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5351af2b172caf41a3728eb4455326d84e0d70fe26fc4de74ab0bd37df4191c5" -dependencies = [ - "fastrand 2.3.0", - "unicode-normalization", -] - -[[package]] -name = "gix-validate" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b5f1253109da6c79ed7cf6e1e38437080bb6d704c76af14c93e2f255234084" -dependencies = [ - "bstr", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-validate" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b9e00cacde5b51388d28ed746c493b18a6add1f19b5e01d686b3b9ece66d4d" -dependencies = [ - "bstr", - "thiserror 2.0.12", -] - -[[package]] -name = "gix-worktree" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7760dbc4b79aa274fed30adc0d41dca6b917641f26e7867c4071b1fb4dc727b" -dependencies = [ - "bstr", - "gix-attributes", - "gix-features 0.41.1", - "gix-fs 0.14.0", - "gix-glob", - "gix-hash 0.17.0", - "gix-ignore", - "gix-index", - "gix-object", - "gix-path", - "gix-validate 0.9.4", -] - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "globset" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", ] [[package]] name = "glow" -version = "0.14.2" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" dependencies = [ "js-sys", "slotmap", @@ -7327,7 +7179,6 @@ dependencies = [ name = "go_to_line" version = "0.1.0" dependencies = [ - "anyhow", "editor", "gpui", "indoc", @@ -7335,7 +7186,6 @@ dependencies = [ "menu", "project", "rope", - "schemars", "serde", "serde_json", "settings", @@ -7346,7 +7196,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", ] [[package]] @@ -7367,11 +7216,11 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "strum 0.27.1", - "workspace-hack", + "settings", + "strum 0.27.2", ] [[package]] @@ -7380,7 +7229,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "gpu-alloc-types", ] @@ -7401,19 +7250,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] name = "gpui" -version = "0.1.0" +version = "0.2.2" dependencies = [ "anyhow", "as-raw-xcb-connection", - "ashpd", + "ashpd 0.11.0", "async-task", "backtrace", "bindgen 0.71.1", + "bitflags 2.9.4", "blade-graphics", "blade-macros", "blade-util", @@ -7423,6 +7273,7 @@ dependencies = [ "calloop-wayland-source", "cbindgen", "cocoa 0.26.0", + "cocoa-foundation 0.2.0", "collections", "core-foundation 0.10.0", "core-foundation-sys", @@ -7431,13 +7282,12 @@ dependencies = [ "core-video", "cosmic-text", "ctor", - "derive_more 0.99.19", + "derive_more 0.99.20", "embed-resource", "env_logger 0.11.8", "etagere", "filedescriptor", "flume", - "font-kit", "foreign-types 0.5.0", "futures 0.3.31", "gpui_macros", @@ -7460,46 +7310,50 @@ dependencies = [ "parking", "parking_lot", "pathfinder_geometry", + "pin-project", "postage", + "pretty_assertions", "profiling", - "rand 0.8.5", + "rand 0.9.2", "raw-window-handle", "refineable", "reqwest_client", "resvg", - "scap", - "schemars", + "schemars 1.0.4", "seahash", "semantic_version", "serde", - "serde_derive", "serde_json", "slotmap", "smallvec", "smol", - "strum 0.27.1", + "stacksafe", + "strum 0.27.2", "sum_tree", "taffy", - "thiserror 2.0.12", + "thiserror 2.0.17", "unicode-segmentation", "usvg", "util", + "util_macros", "uuid", "waker-fn", "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols", + "wayland-protocols 0.31.2", "wayland-protocols-plasma", - "windows 0.61.1", - "windows-core 0.61.0", + "wayland-protocols-wlr", + "windows 0.61.3", + "windows-core 0.61.2", "windows-numerics", - "windows-registry 0.5.1", - "workspace-hack", + "windows-registry 0.5.3", "x11-clipboard", "x11rb", - "xim", "xkbcommon", + "zed-font-kit", + "zed-scap", + "zed-xim", ] [[package]] @@ -7510,18 +7364,17 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] name = "gpui_tokio" version = "0.1.0" dependencies = [ + "anyhow", "gpui", "tokio", "util", - "workspace-hack", ] [[package]] @@ -7543,9 +7396,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes 1.10.1", "fnv", @@ -7553,7 +7406,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -7562,9 +7415,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes 1.10.1", @@ -7572,7 +7425,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -7581,13 +7434,17 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", "num-traits", + "rand 0.9.2", + "rand_distr", + "zerocopy", ] [[package]] @@ -7619,15 +7476,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -7643,19 +7491,20 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "allocator-api2", ] [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", + "rayon", "serde", ] @@ -7674,7 +7523,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] [[package]] @@ -7701,16 +7550,6 @@ dependencies = [ "http 0.2.12", ] -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - [[package]] name = "heck" version = "0.3.3" @@ -7741,7 +7580,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd54745cfacb7b97dee45e8fdb91814b62bccddb481debb7de0f9ee6b7bf5b43" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "heed-traits", "heed-types", @@ -7766,7 +7605,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" dependencies = [ - "bincode", + "bincode 1.3.3", "byteorder", "heed-traits", "serde", @@ -7775,21 +7614,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - -[[package]] -name = "hermit-abi" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -7858,18 +7685,17 @@ dependencies = [ "markup5ever 0.12.1", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "html5ever" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "mac", - "markup5ever 0.16.1", + "markup5ever 0.35.0", "match_token", ] @@ -7883,7 +7709,6 @@ dependencies = [ "markup5ever_rcdom", "pretty_assertions", "regex", - "workspace-hack", ] [[package]] @@ -7953,27 +7778,31 @@ name = "http_client" version = "0.1.0" dependencies = [ "anyhow", + "async-compression", + "async-fs", + "async-tar", "bytes 1.10.1", - "derive_more 0.99.19", + "derive_more 0.99.20", "futures 0.3.31", "http 1.3.1", "http-body 1.0.1", "log", "parking_lot", - "reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)", "serde", "serde_json", + "sha2", + "tempfile", "url", - "workspace-hack", + "util", + "zed-reqwest", ] [[package]] name = "http_client_tls" version = "0.1.0" dependencies = [ - "rustls 0.23.26", + "rustls 0.23.33", "rustls-platform-verifier", - "workspace-hack", ] [[package]] @@ -7996,9 +7825,9 @@ checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" [[package]] name = "humantime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" @@ -8010,14 +7839,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -8026,19 +7855,21 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes 1.10.1", "futures-channel", - "futures-util", - "h2 0.4.9", + "futures-core", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -8062,16 +7893,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -8093,19 +7923,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64 0.22.1", "bytes 1.10.1", "futures-channel", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -8113,9 +7947,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -8123,7 +7957,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.0", + "windows-core 0.62.2", ] [[package]] @@ -8140,60 +7974,40 @@ name = "icons" version = "0.1.0" dependencies = [ "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", - "yoke", + "potential_utf", + "yoke 0.8.0", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + "zerovec", +] [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -8201,78 +8015,71 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", - "yoke", + "yoke 0.8.0", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "id-arena" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -8281,9 +8088,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -8291,15 +8098,15 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" dependencies = [ "crossbeam-deque", "globset", "log", "memchr", - "regex-automata 0.4.9", + "regex-automata", "same-file", "walkdir", "winapi-util", @@ -8307,9 +8114,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", @@ -8317,8 +8124,9 @@ dependencies = [ "exr", "gif", "image-webp", + "moxcms", "num-traits", - "png", + "png 0.18.0", "qoi", "ravif", "rayon", @@ -8330,9 +8138,9 @@ dependencies = [ [[package]] name = "image-webp" -version = "0.2.1" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", "quick-error", @@ -8350,14 +8158,12 @@ dependencies = [ "language", "log", "project", - "schemars", "serde", "settings", "theme", "ui", "util", "workspace", - "workspace-hack", ] [[package]] @@ -8372,24 +8178,36 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] [[package]] name = "imgref" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" [[package]] name = "indexmap" -version = "2.9.0" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "serde", + "serde_core", ] [[package]] @@ -8400,13 +8218,13 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inherent" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -8426,7 +8244,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "inotify-sys", "libc", ] @@ -8464,10 +8282,11 @@ dependencies = [ "serde_json", "serde_json_lenient", "theme", + "title_bar", "ui", "util", + "util_macros", "workspace", - "workspace-hack", "zed_actions", ] @@ -8482,7 +8301,6 @@ dependencies = [ "smol", "util", "workspace", - "workspace-hack", ] [[package]] @@ -8494,16 +8312,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "interim" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ce9099a85f468663d3225bf87e85d0548968441e1db12248b996b24f0f5b5a" -dependencies = [ - "chrono", - "logos", -] - [[package]] name = "interpolate_name" version = "0.2.4" @@ -8512,14 +8320,14 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "inventory" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" dependencies = [ "rustversion", ] @@ -8542,15 +8350,14 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" [[package]] name = "io-surface" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8283575d5f0b2e7447ec0840363879d71c0fa325d4c699d5b45208ea4a51f45e" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" dependencies = [ "cgl", "core-foundation 0.10.0", "core-foundation-sys", "leaky-cow", - "libc", ] [[package]] @@ -8568,12 +8375,12 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8251fb7bcd9ccd3725ed8deae9fe7db8e586495c9eb5b0c52e6233e5e75ea" dependencies = [ - "bincode", + "bincode 1.3.3", "crossbeam-channel", "fnv", "lazy_static", "libc", - "mio 1.0.3", + "mio 1.1.0", "rand 0.8.5", "serde", "tempfile", @@ -8587,6 +8394,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -8602,7 +8419,7 @@ version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ - "hermit-abi 0.5.0", + "hermit-abi", "libc", "windows-sys 0.59.0", ] @@ -8650,15 +8467,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -8676,128 +8484,26 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.10" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", - "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde", - "windows-sys 0.59.0", ] [[package]] name = "jiff-static" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jj" -version = "0.1.0" -dependencies = [ - "anyhow", - "gpui", - "jj-lib", - "workspace-hack", -] - -[[package]] -name = "jj-lib" -version = "0.29.0" -source = "git+https://github.com/jj-vcs/jj?rev=e18eb8e05efaa153fad5ef46576af145bba1807f#e18eb8e05efaa153fad5ef46576af145bba1807f" -dependencies = [ - "async-trait", - "blake2", - "bstr", - "chrono", - "clru", - "digest", - "dunce", - "either", - "futures 0.3.31", - "gix", - "glob", - "hashbrown 0.15.3", - "hex", - "ignore", - "indexmap", - "interim", - "itertools 0.14.0", - "jj-lib-proc-macros", - "maplit", - "once_cell", - "pest", - "pest_derive", - "pollster 0.4.0", - "prost 0.13.5", - "rand 0.8.5", - "rand_chacha 0.3.1", - "rayon", - "ref-cast", - "regex", - "rustix 1.0.7", - "same-file", - "serde", - "serde_json", - "smallvec", - "strsim", - "tempfile", - "thiserror 2.0.12", - "toml_edit", - "tracing", - "version_check", - "winreg 0.52.0", -] - -[[package]] -name = "jj-lib-proc-macros" -version = "0.29.0" -source = "git+https://github.com/jj-vcs/jj?rev=e18eb8e05efaa153fad5ef46576af145bba1807f#e18eb8e05efaa153fad5ef46576af145bba1807f" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", -] - -[[package]] -name = "jj_ui" -version = "0.1.0" -dependencies = [ - "command_palette_hooks", - "feature_flags", - "fuzzy", - "gpui", - "jj", - "picker", - "ui", - "util", - "workspace", - "workspace-hack", - "zed_actions", + "syn 2.0.106", ] [[package]] @@ -8824,11 +8530,11 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", "libc", ] @@ -8841,25 +8547,17 @@ dependencies = [ "editor", "gpui", "log", - "schemars", "serde", "settings", "shellexpand 2.1.2", "workspace", - "workspace-hack", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" - [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -8877,13 +8575,34 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json_schema_store" +version = "0.1.0" +dependencies = [ + "anyhow", + "dap", + "extension", + "gpui", + "language", + "paths", + "project", + "schemars 1.0.4", + "serde", + "serde_json", + "settings", + "snippet_provider", + "task", + "theme", + "util", +] + [[package]] name = "jsonschema" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "base64 0.22.1", "bytecount", "email_address", @@ -8897,8 +8616,8 @@ dependencies = [ "percent-encoding", "referencing", "regex", - "regex-syntax 0.8.5", - "reqwest 0.12.15 (registry+https://github.com/rust-lang/crates.io-index)", + "regex-syntax", + "reqwest 0.12.24", "serde", "serde_json", "uuid-simd", @@ -8950,6 +8669,44 @@ dependencies = [ "uuid", ] +[[package]] +name = "keymap_editor" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "command_palette", + "component", + "db", + "editor", + "fs", + "fuzzy", + "gpui", + "itertools 0.14.0", + "json_schema_store", + "language", + "log", + "menu", + "notifications", + "paths", + "project", + "search", + "serde", + "serde_json", + "settings", + "telemetry", + "tempfile", + "theme", + "tree-sitter-json", + "tree-sitter-rust", + "ui", + "ui_input", + "util", + "vim", + "workspace", + "zed_actions", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -8962,9 +8719,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.0.8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" dependencies = [ "kqueue-sys", "libc", @@ -8980,22 +8737,14 @@ dependencies = [ "libc", ] -[[package]] -name = "kstring" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" -dependencies = [ - "static_assertions", -] - [[package]] name = "kurbo" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", + "euclid", "smallvec", ] @@ -9027,17 +8776,16 @@ dependencies = [ "http_client", "imara-diff", "indoc", - "inventory", "itertools 0.14.0", "log", "lsp", "parking_lot", "postage", "pretty_assertions", - "rand 0.8.5", + "rand 0.9.2", "regex", "rpc", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -9050,7 +8798,7 @@ dependencies = [ "task", "text", "theme", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "tree-sitter-elixir", "tree-sitter-embedded-template", @@ -9066,7 +8814,6 @@ dependencies = [ "unindent", "util", "watch", - "workspace-hack", "zlog", ] @@ -9082,12 +8829,12 @@ dependencies = [ "futures 0.3.31", "gpui", "language", + "log", "lsp", "project", "serde", "serde_json", "util", - "workspace-hack", ] [[package]] @@ -9107,16 +8854,16 @@ dependencies = [ "icons", "image", "log", + "open_router", "parking_lot", "proto", - "schemars", "serde", "serde_json", + "settings", "smol", "telemetry_events", - "thiserror 2.0.12", + "thiserror 2.0.17", "util", - "workspace-hack", ] [[package]] @@ -9140,6 +8887,7 @@ dependencies = [ "credentials_provider", "deepseek", "editor", + "fs", "futures 0.3.31", "google_ai", "gpui", @@ -9157,22 +8905,33 @@ dependencies = [ "partial-json-fixer", "project", "release_channel", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", "smol", - "strum 0.27.1", - "theme", - "thiserror 2.0.12", + "strum 0.27.2", + "thiserror 2.0.17", "tiktoken-rs", "tokio", "ui", "ui_input", "util", "vercel", - "workspace-hack", "x_ai", + "zed_env_vars", +] + +[[package]] +name = "language_onboarding" +version = "0.1.0" +dependencies = [ + "db", + "editor", + "gpui", + "project", + "ui", + "workspace", ] [[package]] @@ -9192,7 +8951,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", ] [[package]] @@ -9202,6 +8960,7 @@ dependencies = [ "anyhow", "client", "collections", + "command_palette_hooks", "copilot", "editor", "futures 0.3.31", @@ -9210,6 +8969,7 @@ dependencies = [ "language", "lsp", "project", + "proto", "release_channel", "serde_json", "settings", @@ -9218,7 +8978,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", "zlog", ] @@ -9234,41 +8993,37 @@ dependencies = [ "async-trait", "chrono", "collections", - "dap", - "feature_flags", "futures 0.3.31", "gpui", "http_client", + "itertools 0.14.0", + "json_schema_store", "language", "log", "lsp", "node_runtime", "parking_lot", - "paths", "pet", "pet-conda", "pet-core", "pet-fs", "pet-poetry", "pet-reporter", + "pet-virtualenv", "pretty_assertions", "project", "regex", "rope", "rust-embed", - "schemars", "serde", "serde_json", "serde_json_lenient", "settings", - "sha2", "smol", - "snippet_provider", "task", - "tempfile", "text", "theme", - "toml 0.8.20", + "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -9288,9 +9043,9 @@ dependencies = [ "tree-sitter-typescript", "tree-sitter-yaml", "unindent", + "url", "util", "workspace", - "workspace-hack", ] [[package]] @@ -9302,12 +9057,6 @@ dependencies = [ "spin", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leak" version = "0.1.2" @@ -9337,21 +9086,21 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lebe" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libdbus-sys" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" dependencies = [ "cc", "pkg-config", @@ -9359,9 +9108,9 @@ dependencies = [ [[package]] name = "libfuzzer-sys" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" dependencies = [ "arbitrary", "cc", @@ -9369,9 +9118,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.1+1.9.0" +version = "0.18.2+1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e" +checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222" dependencies = [ "cc", "libc", @@ -9381,25 +9130,25 @@ dependencies = [ [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmimalloc-sys" -version = "0.1.42" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d6fac27761dabcd4ee73571cdb06b7022dc99089acbe5435691edffaac0f4" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" dependencies = [ "cc", "libc", @@ -9407,13 +9156,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", - "redox_syscall 0.5.11", + "redox_syscall 0.5.18", ] [[package]] @@ -9452,9 +9201,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" dependencies = [ "zlib-rs", ] @@ -9471,11 +9220,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line_ending_selector" +version = "0.1.0" +dependencies = [ + "editor", + "gpui", + "language", + "picker", + "project", + "ui", + "util", + "workspace", +] + [[package]] name = "link-cplusplus" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" dependencies = [ "cc", ] @@ -9497,15 +9260,21 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "litrs" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "livekit" @@ -9543,7 +9312,7 @@ dependencies = [ "parking_lot", "pbjson-types", "prost 0.12.6", - "rand 0.9.1", + "rand 0.9.2", "reqwest 0.11.27", "scopeguard", "serde", @@ -9591,9 +9360,8 @@ dependencies = [ "prost 0.9.0", "prost-build 0.9.0", "prost-types 0.9.0", - "reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)", "serde", - "workspace-hack", + "zed-reqwest", ] [[package]] @@ -9602,6 +9370,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "audio", "collections", "core-foundation 0.10.0", "core-video", @@ -9621,15 +9390,17 @@ dependencies = [ "parking_lot", "postage", "rodio", - "scap", "serde", "serde_json", + "serde_urlencoded", + "settings", "sha2", "simplelog", "smallvec", "tokio-tungstenite 0.26.2", + "ui", "util", - "workspace-hack", + "zed-scap", ] [[package]] @@ -9650,79 +9421,30 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "workspace-hack", ] [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" dependencies = [ "serde", "value-bag", ] -[[package]] -name = "logos" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6f536c1af4c7cc81edf73da1f8029896e7e1e16a219ef09b184e76a296f3db" -dependencies = [ - "logos-derive", -] - -[[package]] -name = "logos-codegen" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189bbfd0b61330abea797e5e9276408f2edbe4f822d7ad08685d67419aafb34e" -dependencies = [ - "beef", - "fnv", - "lazy_static", - "proc-macro2", - "quote", - "regex-syntax 0.8.5", - "rustc_version", - "syn 2.0.101", -] - -[[package]] -name = "logos-derive" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfe8e1a19049ddbfccbd14ac834b215e11b85b90bab0c2dba7c7b92fb5d5cba" -dependencies = [ - "logos-codegen", -] - -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - [[package]] name = "loop9" version = "0.1.5" @@ -9738,9 +9460,15 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.3", + "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp" version = "0.1.0" @@ -9756,19 +9484,18 @@ dependencies = [ "parking_lot", "postage", "release_channel", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "smol", "util", - "workspace-hack", "zlog", ] [[package]] name = "lsp-types" version = "0.95.1" -source = "git+https://github.com/zed-industries/lsp-types?rev=39f629bdd03d59abd786ed9fc27e8bca02c0c0ec#39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" +source = "git+https://github.com/zed-industries/lsp-types?rev=b71ab4eeb27d9758be8092020a46fe33fbca4e33#b71ab4eeb27d9758be8092020a46fe33fbca4e33" dependencies = [ "bitflags 1.3.2", "serde", @@ -9778,9 +9505,9 @@ dependencies = [ [[package]] name = "lyon" -version = "1.0.1" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f" +checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352" dependencies = [ "lyon_algorithms", "lyon_extra", @@ -9789,9 +9516,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.5" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f13c9be19d257c7d37e70608ed858e8eab4b2afcea2e3c9a622e892acbf43c08" +checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647" dependencies = [ "lyon_path", "num-traits", @@ -9809,9 +9536,9 @@ dependencies = [ [[package]] name = "lyon_geom" -version = "1.0.6" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8af69edc087272df438b3ee436c4bb6d7c04aa8af665cfd398feae627dbd8570" +checksum = "4e16770d760c7848b0c1c2d209101e408207a65168109509f8483837a36cf2e7" dependencies = [ "arrayvec", "euclid", @@ -9820,9 +9547,9 @@ dependencies = [ [[package]] name = "lyon_path" -version = "1.0.7" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0047f508cd7a85ad6bad9518f68cce7b1bf6b943fb71f6da0ee3bc1e8cb75f25" +checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" dependencies = [ "lyon_geom", "num-traits", @@ -9830,15 +9557,34 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579d42360a4b09846eff2feef28f538696c7d6c7439bfa65874ff3cbe0951b2c" +checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353" dependencies = [ "float_next_after", "lyon_path", "num-traits", ] +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "mac" version = "0.1.1" @@ -9847,9 +9593,18 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -9875,7 +9630,9 @@ version = "0.1.0" dependencies = [ "assets", "base64 0.22.1", + "collections", "env_logger 0.11.8", + "fs", "futures 0.3.31", "gpui", "language", @@ -9889,7 +9646,6 @@ dependencies = [ "theme", "ui", "util", - "workspace-hack", ] [[package]] @@ -9902,9 +9658,11 @@ dependencies = [ "editor", "fs", "gpui", + "html5ever 0.27.0", "language", "linkify", "log", + "markup5ever_rcdom", "pretty_assertions", "pulldown-cmark 0.12.2", "settings", @@ -9912,7 +9670,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", ] [[package]] @@ -9922,7 +9679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", @@ -9931,9 +9688,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.16.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", "tendril", @@ -9954,22 +9711,22 @@ dependencies = [ [[package]] name = "match_token" -version = "0.1.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -9978,17 +9735,6 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "maybe-async" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "maybe-owned" version = "0.3.4" @@ -10063,31 +9809,31 @@ dependencies = [ "foreign-types 0.5.0", "metal", "objc", - "workspace-hack", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memfd" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.2", ] [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" dependencies = [ "libc", + "stable_deref_trait", ] [[package]] @@ -10104,7 +9850,28 @@ name = "menu" version = "0.1.0" dependencies = [ "gpui", - "workspace-hack", +] + +[[package]] +name = "merge" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9" +dependencies = [ + "merge_derive", + "num-traits", +] + +[[package]] +name = "merge_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -10113,7 +9880,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block", "core-graphics-types 0.1.3", "foreign-types 0.5.0", @@ -10131,17 +9898,20 @@ dependencies = [ "convert_case 0.8.0", "log", "pretty_assertions", + "serde_json", + "serde_json_lenient", + "settings_json", "streaming-iterator", "tree-sitter", "tree-sitter-json", - "workspace-hack", + "unindent", ] [[package]] name = "mimalloc" -version = "0.1.46" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "995942f432bbb4822a7e9c3faa87a695185b0d09273ba85f097b54f4e458f2af" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" dependencies = [ "libmimalloc-sys", ] @@ -10168,7 +9938,7 @@ version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "debugid", "num-derive", "num-traits", @@ -10183,14 +9953,14 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "cfg-if", "crash-context", "goblin", "libc", "log", - "mach2", + "mach2 0.4.3", "memmap2", "memoffset", "minidump-common", @@ -10227,9 +9997,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -10249,29 +10019,29 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "miow" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359f76430b20a79f9e20e115b3428614e654f04fab314482fc0fda0ebd3c6044" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -10281,32 +10051,40 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] name = "moka" -version = "0.12.10" +version = "0.12.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" dependencies = [ "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", - "loom", + "equivalent", "parking_lot", "portable-atomic", "rustc_version", "smallvec", "tagptr", - "thiserror 1.0.69", "uuid", ] +[[package]] +name = "moxcms" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "msvc_spectre_libs" version = "0.1.3" @@ -10333,7 +10111,7 @@ dependencies = [ "parking_lot", "pretty_assertions", "project", - "rand 0.8.5", + "rand 0.9.2", "rope", "serde", "settings", @@ -10344,7 +10122,6 @@ dependencies = [ "theme", "tree-sitter", "util", - "workspace-hack", "zlog", ] @@ -10356,9 +10133,9 @@ checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "naga" @@ -10368,20 +10145,20 @@ checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", "bit-set 0.8.0", - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg_aliases 0.2.1", "codespan-reporting 0.12.0", "half", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "hexf-parse", - "indexmap", + "indexmap 2.11.4", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.12", + "thiserror 2.0.17", "unicode-ident", ] @@ -10400,7 +10177,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -10442,7 +10219,6 @@ dependencies = [ "futures 0.3.31", "net", "smol", - "workspace-hack", ] [[package]] @@ -10451,7 +10227,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "jni-sys", "log", "ndk-sys", @@ -10482,8 +10258,7 @@ dependencies = [ "async-io", "smol", "tempfile", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] @@ -10498,7 +10273,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -10510,11 +10285,10 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset", ] [[package]] @@ -10523,10 +10297,11 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -10549,7 +10324,6 @@ dependencies = [ "util", "watch", "which 6.0.3", - "workspace-hack", ] [[package]] @@ -10579,11 +10353,11 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "normpath" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10604,7 +10378,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -10614,7 +10387,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "crossbeam-channel", "filetime", "fsevent-sys 4.1.0", @@ -10632,14 +10405,14 @@ name = "notify" version = "8.0.0" source = "git+https://github.com/zed-industries/notify.git?rev=bbb9ea5ae52b253e095737847e367c30653a2e96#bbb9ea5ae52b253e095737847e367c30653a2e96" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "filetime", "fsevent-sys 4.1.0", "inotify 0.11.0", "kqueue", "libc", "log", - "mio 1.0.3", + "mio 1.1.0", "notify-types", "walkdir", "windows-sys 0.59.0", @@ -10662,31 +10435,30 @@ version = "2.0.0" source = "git+https://github.com/zed-industries/notify.git?rev=bbb9ea5ae52b253e095737847e367c30653a2e96#bbb9ea5ae52b253e095737847e367c30653a2e96" [[package]] -name = "ntapi" -version = "0.4.1" +name = "now" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" dependencies = [ - "winapi", + "chrono", ] [[package]] -name = "nu-ansi-term" -version = "0.46.0" +name = "ntapi" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ - "overload", "winapi", ] [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10743,6 +10515,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -10760,7 +10533,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10816,33 +10589,34 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -10891,9 +10665,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -10904,7 +10678,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -10917,7 +10691,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", "objc2", "objc2-core-audio", @@ -10928,9 +10702,9 @@ dependencies = [ [[package]] name = "objc2-core-audio" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", "objc2", @@ -10940,21 +10714,21 @@ dependencies = [ [[package]] name = "objc2-core-audio-types" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "dispatch2", "objc2", ] @@ -10971,18 +10745,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "block2", "dispatch2", "objc2", @@ -10996,7 +10780,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -11009,7 +10793,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -11041,11 +10825,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "crc32fast", - "hashbrown 0.15.3", - "indexmap", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ "memchr", ] +[[package]] +name = "object_store" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1be0c6c22ec0817cdc77d3842f721a17fd30ab6965001415b5402a74e6b740" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes 1.10.1", + "chrono", + "form_urlencoded", + "futures 0.3.31", + "http 1.3.1", + "http-body-util", + "humantime", + "hyper 1.7.0", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "quick-xml 0.38.3", + "rand 0.9.2", + "reqwest 0.12.24", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + [[package]] name = "ollama" version = "0.1.0" @@ -11053,35 +10881,30 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "workspace-hack", + "settings", ] [[package]] name = "onboarding" version = "0.1.0" dependencies = [ - "ai_onboarding", "anyhow", "client", "component", "db", "documented", - "editor", "fs", "fuzzy", "git", "gpui", - "itertools 0.14.0", - "language", - "language_model", "menu", "notifications", "picker", "project", - "schemars", + "schemars 1.0.4", "serde", "settings", "telemetry", @@ -11090,7 +10913,6 @@ dependencies = [ "util", "vim_mode_setting", "workspace", - "workspace-hack", "zed_actions", "zlog", ] @@ -11101,32 +10923,38 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "oo7" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb23d3ec3527d65a83be1c1795cb883c52cfa57147d42acc797127df56fc489" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" dependencies = [ "aes", - "ashpd", + "ashpd 0.12.0", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.1", "blocking", "cbc", "cipher", "digest", "endi", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "futures-util", - "getrandom 0.3.2", + "getrandom 0.3.4", "hkdf", "hmac", "md-5", "num", "num-bigint-dig", "pbkdf2 0.12.2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -11161,11 +10989,11 @@ dependencies = [ "futures 0.3.31", "http_client", "log", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "strum 0.27.1", - "workspace-hack", + "settings", + "strum 0.27.2", ] [[package]] @@ -11175,10 +11003,12 @@ dependencies = [ "anyhow", "futures 0.3.31", "http_client", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "workspace-hack", + "settings", + "strum 0.27.2", + "thiserror 2.0.17", ] [[package]] @@ -11195,11 +11025,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "foreign-types 0.3.2", "libc", @@ -11216,7 +11046,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11227,9 +11057,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" dependencies = [ "cc", "libc", @@ -11239,13 +11069,13 @@ dependencies = [ [[package]] name = "optfield" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa59f025cde9c698fcb4fcb3533db4621795374065bee908215263488f2d2a1d" +checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11303,7 +11133,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11329,7 +11159,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -11351,7 +11180,6 @@ dependencies = [ "outline", "pretty_assertions", "project", - "schemars", "search", "serde", "serde_json", @@ -11362,7 +11190,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "worktree", "zed_actions", ] @@ -11373,12 +11200,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "p256" version = "0.11.1" @@ -11420,7 +11241,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -11433,7 +11254,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -11444,9 +11264,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -11454,15 +11274,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.11", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -11538,8 +11358,8 @@ name = "paths" version = "0.1.0" dependencies = [ "dirs 4.0.0", + "ignore", "util", - "workspace-hack", ] [[package]] @@ -11601,14 +11421,20 @@ dependencies = [ "hmac", ] +[[package]] +name = "pciid-parser" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0008e816fcdaf229cdd540e9b6ca2dc4a10d65c31624abb546c6420a02846e61" + [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -11622,26 +11448,34 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +dependencies = [ + "collections", + "serde", + "serde_json", +] [[package]] name = "pest" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" dependencies = [ "pest", "pest_generator", @@ -11649,24 +11483,23 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" dependencies = [ - "once_cell", "pest", "sha2", ] @@ -11674,7 +11507,7 @@ dependencies = [ [[package]] name = "pet" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "clap", "env_logger 0.10.2", @@ -11711,7 +11544,7 @@ dependencies = [ [[package]] name = "pet-conda" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11730,7 +11563,7 @@ dependencies = [ [[package]] name = "pet-core" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "clap", "lazy_static", @@ -11745,7 +11578,7 @@ dependencies = [ [[package]] name = "pet-env-var-path" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "lazy_static", "log", @@ -11761,7 +11594,7 @@ dependencies = [ [[package]] name = "pet-fs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11770,7 +11603,7 @@ dependencies = [ [[package]] name = "pet-global-virtualenvs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11783,7 +11616,7 @@ dependencies = [ [[package]] name = "pet-homebrew" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "lazy_static", "log", @@ -11801,7 +11634,7 @@ dependencies = [ [[package]] name = "pet-jsonrpc" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "env_logger 0.10.2", "log", @@ -11814,7 +11647,7 @@ dependencies = [ [[package]] name = "pet-linux-global-python" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11827,7 +11660,7 @@ dependencies = [ [[package]] name = "pet-mac-commandlinetools" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11840,7 +11673,7 @@ dependencies = [ [[package]] name = "pet-mac-python-org" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11853,7 +11686,7 @@ dependencies = [ [[package]] name = "pet-mac-xcode" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11866,7 +11699,7 @@ dependencies = [ [[package]] name = "pet-pipenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11879,7 +11712,7 @@ dependencies = [ [[package]] name = "pet-pixi" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11891,7 +11724,7 @@ dependencies = [ [[package]] name = "pet-poetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "base64 0.22.1", "lazy_static", @@ -11906,13 +11739,13 @@ dependencies = [ "serde", "serde_json", "sha2", - "toml 0.8.20", + "toml 0.8.23", ] [[package]] name = "pet-pyenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "lazy_static", "log", @@ -11930,7 +11763,7 @@ dependencies = [ [[package]] name = "pet-python-utils" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11947,7 +11780,7 @@ dependencies = [ [[package]] name = "pet-reporter" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "env_logger 0.10.2", "log", @@ -11961,7 +11794,7 @@ dependencies = [ [[package]] name = "pet-telemetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -11976,7 +11809,7 @@ dependencies = [ [[package]] name = "pet-venv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -11988,7 +11821,7 @@ dependencies = [ [[package]] name = "pet-virtualenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -12000,7 +11833,7 @@ dependencies = [ [[package]] name = "pet-virtualenvwrapper" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "log", "msvc_spectre_libs", @@ -12013,7 +11846,7 @@ dependencies = [ [[package]] name = "pet-windows-registry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" dependencies = [ "lazy_static", "log", @@ -12029,270 +11862,847 @@ dependencies = [ ] [[package]] -name = "pet-windows-store" -version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=845945b830297a50de0e24020b980a65e4820559#845945b830297a50de0e24020b980a65e4820559" +name = "pet-windows-store" +version = "0.1.0" +source = "git+https://github.com/microsoft/python-environment-tools.git?rev=e97b9508befa0062929da65a01054d25c4be861c#e97b9508befa0062929da65a01054d25c4be861c" +dependencies = [ + "lazy_static", + "log", + "msvc_spectre_libs", + "pet-core", + "pet-fs", + "pet-python-utils", + "pet-virtualenv", + "regex", + "winreg 0.55.0", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.11.4", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros 0.12.1", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator 0.12.1", + "phf_shared 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "picker" +version = "0.1.0" +dependencies = [ + "anyhow", + "ctor", + "editor", + "env_logger 0.11.8", + "gpui", + "menu", + "schemars 1.0.4", + "serde", + "serde_json", + "theme", + "ui", + "workspace", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand 2.3.0", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "planus" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3daf8e3d4b712abe1d690838f6e29fb76b76ea19589c4afa39ec30e12f62af71" +dependencies = [ + "array-init-cursor", + "hashbrown 0.15.5", +] + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.4", + "quick-xml 0.38.3", + "serde", + "time", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ - "lazy_static", - "log", - "msvc_spectre_libs", - "pet-core", - "pet-fs", - "pet-python-utils", - "pet-virtualenv", - "regex", - "winreg 0.55.0", + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "petgraph" -version = "0.6.5" +name = "plotters-backend" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ - "fixedbitset", - "indexmap", + "plotters-backend", ] [[package]] -name = "pgvector" -version = "0.4.0" +name = "png" +version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0e8871b6d7ca78348c6cd29b911b94851f3429f0cd403130ca17f26c1fb91a6" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "serde", + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] -name = "phf" -version = "0.11.3" +name = "png" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "phf_macros", - "phf_shared", + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "polars" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", +checksum = "a5f7feb5d56b954e691dff22a8b2d78d77433dcc93c35fe21c3777fdc121b697" +dependencies = [ + "getrandom 0.2.16", + "getrandom 0.3.4", + "polars-arrow", + "polars-core", + "polars-error", + "polars-io", + "polars-lazy", + "polars-ops", + "polars-parquet", + "polars-sql", + "polars-time", + "polars-utils", + "version_check", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "polars-arrow" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "32b4fed2343961b3eea3db2cee165540c3e1ad9d5782350cc55a9e76cf440148" dependencies = [ - "phf_shared", - "rand 0.8.5", + "atoi_simd", + "bitflags 2.9.4", + "bytemuck", + "chrono", + "chrono-tz", + "dyn-clone", + "either", + "ethnum", + "getrandom 0.2.16", + "getrandom 0.3.4", + "hashbrown 0.15.5", + "itoa", + "lz4", + "num-traits", + "polars-arrow-format", + "polars-error", + "polars-schema", + "polars-utils", + "serde", + "simdutf8", + "streaming-iterator", + "strum_macros 0.27.2", + "version_check", + "zstd 0.13.3", ] [[package]] -name = "phf_macros" -version = "0.11.3" +name = "polars-arrow-format" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "a556ac0ee744e61e167f34c1eb0013ce740e0ee6cd8c158b2ec0b518f10e6675" dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.101", + "planus", + "serde", ] [[package]] -name = "phf_shared" -version = "0.11.3" +name = "polars-compute" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "138785beda4e4a90a025219f09d0d15a671b2be9091513ede58e05db6ad4413f" dependencies = [ - "siphasher", + "atoi_simd", + "bytemuck", + "chrono", + "either", + "fast-float2", + "hashbrown 0.15.5", + "itoa", + "num-traits", + "polars-arrow", + "polars-error", + "polars-utils", + "rand 0.9.2", + "ryu", + "serde", + "skiplist", + "strength_reduce", + "strum_macros 0.27.2", + "version_check", ] [[package]] -name = "picker" -version = "0.1.0" +name = "polars-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77b1f08ef6dbb032bb1d0d3365464be950df9905f6827a95b24c4ca5518901d" dependencies = [ - "anyhow", - "ctor", - "editor", - "env_logger 0.11.8", - "gpui", - "menu", - "schemars", + "bitflags 2.9.4", + "boxcar", + "bytemuck", + "chrono", + "chrono-tz", + "comfy-table", + "either", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "itoa", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-dtype", + "polars-error", + "polars-row", + "polars-schema", + "polars-utils", + "rand 0.9.2", + "rand_distr", + "rayon", + "regex", "serde", "serde_json", - "ui", - "util", - "workspace", - "workspace-hack", + "strum_macros 0.27.2", + "uuid", + "version_check", + "xxhash-rust", ] [[package]] -name = "pico-args" -version = "0.5.0" +name = "polars-dtype" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +checksum = "89c43d0ea57168be4546c4d8064479ed8b29a9c79c31a0c7c367ee734b9b7158" +dependencies = [ + "boxcar", + "hashbrown 0.15.5", + "polars-arrow", + "polars-error", + "polars-utils", + "serde", + "uuid", +] [[package]] -name = "pin-project" -version = "1.1.10" +name = "polars-error" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "b9cb5d98f59f8b94673ee391840440ad9f0d2170afced95fc98aa86f895563c0" dependencies = [ - "pin-project-internal", + "object_store", + "parking_lot", + "polars-arrow-format", + "regex", + "signal-hook", + "simdutf8", ] [[package]] -name = "pin-project-internal" -version = "1.1.10" +name = "polars-expr" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "343931b818cf136349135ba11dbc18c27683b52c3477b1ba8ca606cf5ab1965c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "bitflags 2.9.4", + "hashbrown 0.15.5", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-io", + "polars-ops", + "polars-plan", + "polars-row", + "polars-time", + "polars-utils", + "rand 0.9.2", + "rayon", + "recursive", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "polars-io" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "10388c64b8155122488229a881d1c6f4fdc393bc988e764ab51b182fcb2307e4" +dependencies = [ + "async-trait", + "atoi_simd", + "blake3", + "bytes 1.10.1", + "chrono", + "fast-float2", + "fs4", + "futures 0.3.31", + "glob", + "hashbrown 0.15.5", + "home", + "itoa", + "memchr", + "memmap2", + "num-traits", + "object_store", + "percent-encoding", + "polars-arrow", + "polars-core", + "polars-error", + "polars-parquet", + "polars-schema", + "polars-time", + "polars-utils", + "rayon", + "regex", + "reqwest 0.12.24", + "ryu", + "serde", + "serde_json", + "simdutf8", + "tokio", + "tokio-util", + "url", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "polars-lazy" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "0fb6e2c6c2fa4ea0c660df1c06cf56960c81e7c2683877995bae3d4e3d408147" +dependencies = [ + "bitflags 2.9.4", + "chrono", + "either", + "memchr", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ops", + "polars-plan", + "polars-stream", + "polars-time", + "polars-utils", + "rayon", + "version_check", +] [[package]] -name = "piper" -version = "0.2.4" +name = "polars-mem-engine" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "20a856e98e253587c28d8132a5e7e5a75cb2c44731ca090f1481d45f1d123771" dependencies = [ - "atomic-waker", - "fastrand 2.3.0", - "futures-io", + "futures 0.3.31", + "memmap2", + "polars-arrow", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "tokio", ] [[package]] -name = "pkcs1" -version = "0.7.5" +name = "polars-ops" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +checksum = "acf6062173fdc9ba05775548beb66e76643a148d9aeadc9984ed712bc4babd76" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "argminmax", + "base64 0.22.1", + "bytemuck", + "chrono", + "chrono-tz", + "either", + "hashbrown 0.15.5", + "hex", + "indexmap 2.11.4", + "libm", + "memchr", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-schema", + "polars-utils", + "rayon", + "regex", + "regex-syntax", + "strum_macros 0.27.2", + "unicode-normalization", + "unicode-reverse", + "version_check", ] [[package]] -name = "pkcs8" -version = "0.9.0" +name = "polars-parquet" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +checksum = "cc1d769180dec070df0dc4b89299b364bf2cfe32b218ecc4ddd8f1a49ae60669" dependencies = [ - "der 0.6.1", - "spki 0.6.0", + "async-stream", + "base64 0.22.1", + "brotli", + "bytemuck", + "ethnum", + "flate2", + "futures 0.3.31", + "hashbrown 0.15.5", + "lz4", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-error", + "polars-parquet-format", + "polars-utils", + "serde", + "simdutf8", + "snap", + "streaming-decompression", + "zstd 0.13.3", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "polars-parquet-format" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1" dependencies = [ - "der 0.7.10", - "spki 0.7.3", + "async-trait", + "futures 0.3.31", ] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "polars-plan" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "1cd3a2e33ae4484fe407ab2d2ba5684f0889d1ccf3ad6b844103c03638e6d0a0" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "bytes 1.10.1", + "chrono", + "chrono-tz", + "either", + "futures 0.3.31", + "hashbrown 0.15.5", + "memmap2", + "num-traits", + "percent-encoding", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-io", + "polars-ops", + "polars-parquet", + "polars-time", + "polars-utils", + "rayon", + "recursive", + "regex", + "sha2", + "strum_macros 0.27.2", + "version_check", +] [[package]] -name = "plain" -version = "0.2.3" +name = "polars-row" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "18734f17e0e348724df3ae65f3ee744c681117c04b041cac969dfceb05edabc0" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "polars-arrow", + "polars-compute", + "polars-dtype", + "polars-error", + "polars-utils", +] [[package]] -name = "plist" -version = "1.7.1" +name = "polars-schema" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +checksum = "8e6c1ab13e04d5167661a9854ed1ea0482b2ed9b8a0f1118dabed7cd994a85e3" dependencies = [ - "base64 0.22.1", - "indexmap", - "quick-xml 0.32.0", + "indexmap 2.11.4", + "polars-error", + "polars-utils", "serde", - "time", + "version_check", ] [[package]] -name = "plotters" -version = "0.3.7" +name = "polars-sql" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +checksum = "c4e7766da02cc1d464994404d3e88a7a0ccd4933df3627c325480fbd9bbc0a11" dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", + "bitflags 2.9.4", + "hex", + "polars-core", + "polars-error", + "polars-lazy", + "polars-ops", + "polars-plan", + "polars-time", + "polars-utils", + "rand 0.9.2", + "regex", + "serde", + "sqlparser", ] [[package]] -name = "plotters-backend" -version = "0.3.7" +name = "polars-stream" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" +checksum = "31f6c6ca1ea01f9dea424d167e4f33f5ec44cd67fbfac9efd40575ed20521f14" +dependencies = [ + "async-channel 2.5.0", + "async-trait", + "atomic-waker", + "bitflags 2.9.4", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-queue", + "crossbeam-utils", + "futures 0.3.31", + "memmap2", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "polars-arrow", + "polars-core", + "polars-error", + "polars-expr", + "polars-io", + "polars-mem-engine", + "polars-ops", + "polars-parquet", + "polars-plan", + "polars-utils", + "rand 0.9.2", + "rayon", + "recursive", + "slotmap", + "tokio", + "tokio-util", + "version_check", +] [[package]] -name = "plotters-svg" -version = "0.3.7" +name = "polars-time" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "f6a3a6e279a7a984a0b83715660f9e880590c6129ec2104396bfa710bcd76dee" dependencies = [ - "plotters-backend", + "atoi_simd", + "bytemuck", + "chrono", + "chrono-tz", + "now", + "num-traits", + "polars-arrow", + "polars-compute", + "polars-core", + "polars-error", + "polars-ops", + "polars-utils", + "rayon", + "regex", + "strum_macros 0.27.2", ] [[package]] -name = "png" -version = "0.17.16" +name = "polars-utils" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "57b267021b0e5422d7fbc70fd79e51b9f9a8466c585779373a18b0199e973f29" dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", + "bincode 2.0.1", + "bytemuck", + "bytes 1.10.1", + "compact_str", + "either", "flate2", - "miniz_oxide", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "libc", + "memmap2", + "num-traits", + "polars-error", + "rand 0.9.2", + "raw-cpuid 11.6.0", + "rayon", + "regex", + "rmp-serde", + "serde", + "serde_json", + "serde_stacker", + "slotmap", + "stacker", + "uuid", + "version_check", ] [[package]] name = "polling" -version = "3.7.4" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi 0.4.0", + "hermit-abi", "pin-project-lite", - "rustix 0.38.44", - "tracing", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -12301,17 +12711,11 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" @@ -12355,16 +12759,16 @@ dependencies = [ "log", "parking_lot", "pin-project", - "pollster 0.2.5", + "pollster", "static_assertions", "thiserror 1.0.69", ] [[package]] name = "postcard" -version = "1.1.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -12372,6 +12776,15 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -12384,7 +12797,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] @@ -12410,7 +12823,6 @@ dependencies = [ "serde", "serde_json", "util", - "workspace-hack", ] [[package]] @@ -12425,21 +12837,54 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.32" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.101", + "syn 2.0.106", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", ] [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "toml_edit", + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -12461,14 +12906,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -12481,7 +12926,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "version_check", "yansi", ] @@ -12492,37 +12937,27 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "hex", ] -[[package]] -name = "prodash" -version = "29.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04bb108f648884c23b98a0e940ebc2c93c0c3b89f04dbaf7eb8256ce617d1bc" -dependencies = [ - "log", - "parking_lot", -] - [[package]] name = "profiling" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -12554,7 +12989,7 @@ dependencies = [ "gpui", "http_client", "image", - "indexmap", + "indexmap 2.11.4", "itertools 0.14.0", "language", "log", @@ -12562,23 +12997,22 @@ dependencies = [ "markdown", "node_runtime", "parking_lot", - "pathdiff", "paths", "postage", "prettier", "pretty_assertions", - "rand 0.8.5", + "rand 0.9.2", "regex", "release_channel", "remote", "rpc", - "schemars", + "schemars 1.0.4", + "semver", "serde", "serde_json", "settings", "sha2", "shellexpand 2.1.2", - "shlex", "smallvec", "smol", "snippet", @@ -12588,13 +13022,14 @@ dependencies = [ "tempfile", "terminal", "text", - "toml 0.8.20", + "toml 0.8.23", "unindent", "url", "util", + "watch", "which 6.0.3", - "workspace-hack", "worktree", + "zeroize", "zlog", ] @@ -12606,21 +13041,21 @@ dependencies = [ "client", "collections", "command_palette_hooks", + "criterion", "db", "editor", "file_icons", "git", "git_ui", "gpui", - "indexmap", "language", "menu", "pretty_assertions", "project", - "schemars", + "rayon", + "schemars 1.0.4", "search", "serde", - "serde_derive", "serde_json", "settings", "smallvec", @@ -12629,7 +13064,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "worktree", "zed_actions", ] @@ -12654,7 +13088,6 @@ dependencies = [ "theme", "util", "workspace", - "workspace-hack", ] [[package]] @@ -12669,7 +13102,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -12692,11 +13125,9 @@ dependencies = [ "paths", "rope", "serde", - "serde_json", "text", "util", "uuid", - "workspace-hack", ] [[package]] @@ -12719,16 +13150,6 @@ dependencies = [ "prost-derive 0.12.6", ] -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes 1.10.1", - "prost-derive 0.13.5", -] - [[package]] name = "prost-build" version = "0.9.0" @@ -12759,14 +13180,14 @@ dependencies = [ "heck 0.5.0", "itertools 0.12.1", "log", - "multimap 0.10.0", + "multimap 0.10.1", "once_cell", "petgraph", "prettyplease", "prost 0.12.6", "prost-types 0.12.6", "regex", - "syn 2.0.101", + "syn 2.0.106", "tempfile", ] @@ -12793,20 +13214,7 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.101", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -12838,7 +13246,6 @@ dependencies = [ "prost-build 0.9.0", "serde", "typed-path", - "workspace-hack", ] [[package]] @@ -12863,9 +13270,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88" +checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c" dependencies = [ "cc", ] @@ -12896,7 +13303,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "memchr", "pulldown-cmark-escape", "unicase", @@ -12908,7 +13315,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "memchr", "unicase", ] @@ -12931,6 +13338,41 @@ dependencies = [ "wasmtime-math", ] +[[package]] +name = "pulp" +version = "0.18.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a01a0dc67cf4558d279f0c25b0962bd08fc6dec0137699eae304103e882fe6" +dependencies = [ + "bytemuck", + "libm", + "num-complex", + "reborrow", +] + +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "qoi" version = "0.4.1" @@ -12957,27 +13399,28 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.37.4" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", + "serde", ] [[package]] name = "quinn" -version = "0.11.7" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes 1.10.1", "cfg_aliases 0.2.1", @@ -12985,9 +13428,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls 0.23.26", - "socket2", - "thiserror 2.0.12", + "rustls 0.23.33", + "socket2 0.6.1", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -12995,19 +13438,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.10" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes 1.10.1", - "getrandom 0.3.2", - "rand 0.9.1", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls 0.23.26", + "rustls 0.23.33", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -13015,32 +13459,32 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.11" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2", + "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" @@ -13061,9 +13505,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -13095,7 +13539,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -13104,7 +13548,17 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.2", ] [[package]] @@ -13118,9 +13572,9 @@ dependencies = [ [[package]] name = "rangemap" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" +checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" [[package]] name = "rav1e" @@ -13159,9 +13613,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.11.12" +version = "0.11.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" dependencies = [ "avif-serialize", "imgref", @@ -13172,6 +13626,24 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "10.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.9.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -13192,9 +13664,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -13202,9 +13674,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -13212,19 +13684,35 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.25.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", "font-types", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "recent_projects" version = "0.1.0" dependencies = [ "anyhow", + "askpass", "auto_update", "dap", "editor", @@ -13233,6 +13721,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "indoc", "language", "log", "markdown", @@ -13243,7 +13732,6 @@ dependencies = [ "project", "release_channel", "remote", - "schemars", "serde", "serde_json", "settings", @@ -13253,11 +13741,31 @@ dependencies = [ "theme", "ui", "util", + "windows-registry 0.6.1", "workspace", - "workspace-hack", "zed_actions", ] +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -13269,11 +13777,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -13282,40 +13790,40 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", "thiserror 1.0.69", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -13324,7 +13832,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "fluent-uri", "once_cell", "parking_lot", @@ -13337,7 +13845,6 @@ name = "refineable" version = "0.1.0" dependencies = [ "derive_refineable", - "workspace-hack", ] [[package]] @@ -13348,7 +13855,7 @@ checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "log", "rustc-hash 2.1.1", "serde", @@ -13357,60 +13864,44 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-lite" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" - -[[package]] -name = "regex-syntax" -version = "0.6.29" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "release_channel" version = "0.1.0" dependencies = [ "gpui", - "workspace-hack", ] [[package]] @@ -13424,24 +13915,22 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", - "itertools 0.14.0", "log", "parking_lot", "paths", "prost 0.9.0", "release_channel", "rpc", - "schemars", + "schemars 1.0.4", "serde", "serde_json", - "shlex", + "settings", "smol", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.17", "urlencoding", "util", "which 6.0.3", - "workspace-hack", ] [[package]] @@ -13449,16 +13938,14 @@ name = "remote_server" version = "0.1.0" dependencies = [ "action_log", + "agent", "anyhow", "askpass", - "assistant_tool", - "assistant_tools", - "backtrace", "cargo_toml", - "chrono", "clap", "client", "clock", + "collections", "crash-handler", "crashes", "dap", @@ -13477,6 +13964,7 @@ dependencies = [ "gpui", "gpui_tokio", "http_client", + "json_schema_store", "language", "language_extension", "language_model", @@ -13487,8 +13975,10 @@ dependencies = [ "minidumper", "node_runtime", "paths", + "pretty_assertions", "project", "proto", + "rayon", "release_channel", "remote", "reqwest_client", @@ -13499,9 +13989,10 @@ dependencies = [ "settings", "shellexpand 2.1.2", "smol", - "sysinfo", - "telemetry_events", - "toml 0.8.20", + "sysinfo 0.37.2", + "task", + "thiserror 2.0.17", + "toml 0.8.23", "unindent", "util", "watch", @@ -13552,7 +14043,6 @@ dependencies = [ "picker", "project", "runtimelib", - "schemars", "serde", "serde_json", "settings", @@ -13568,7 +14058,6 @@ dependencies = [ "util", "uuid", "workspace", - "workspace-hack", ] [[package]] @@ -13582,7 +14071,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -13617,88 +14106,45 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes 1.10.1", "futures-channel", "futures-core", "futures-util", + "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tower 0.5.2", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-registry 0.4.0", -] - -[[package]] -name = "reqwest" -version = "0.12.15" -source = "git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415#951c770a32f1998d6e999cef3e59e0013e6c4415" -dependencies = [ - "base64 0.22.1", - "bytes 1.10.1", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.4.9", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "mime_guess", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", - "rustls-pemfile 2.2.0", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", - "system-configuration 0.6.1", "tokio", "tokio-rustls 0.26.2", - "tokio-socks", "tokio-util", "tower 0.5.2", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "windows-registry 0.4.0", ] [[package]] @@ -13713,11 +14159,9 @@ dependencies = [ "http_client_tls", "log", "regex", - "reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)", "serde", - "smol", "tokio", - "workspace-hack", + "zed-reqwest", ] [[package]] @@ -13747,9 +14191,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" dependencies = [ "bytemuck", ] @@ -13766,7 +14210,6 @@ dependencies = [ "theme", "ui", "util", - "workspace-hack", ] [[package]] @@ -13777,7 +14220,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -13823,6 +14266,17 @@ dependencies = [ "paste", ] +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "rmpv" version = "1.3.0" @@ -13836,15 +14290,15 @@ dependencies = [ [[package]] name = "rodio" version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b" dependencies = [ "cpal", "dasp_sample", "hound", "num-rational", + "rtrb", "symphonia", - "tracing", + "thiserror 2.0.17", ] [[package]] @@ -13856,13 +14310,11 @@ dependencies = [ "ctor", "gpui", "log", - "rand 0.8.5", + "rand 0.9.2", "rayon", - "smallvec", "sum_tree", "unicode-segmentation", "util", - "workspace-hack", "zlog", ] @@ -13885,17 +14337,16 @@ dependencies = [ "gpui", "parking_lot", "proto", - "rand 0.8.5", + "rand 0.9.2", "rsa", "serde", "serde_json", "sha2", - "strum 0.27.1", + "strum 0.27.2", "tracing", "util", - "workspace-hack", "zlog", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] [[package]] @@ -13918,6 +14369,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtrb" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" + [[package]] name = "rules_library" version = "0.1.0" @@ -13941,7 +14398,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -13972,9 +14428,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fbc0ee50fcb99af7cebb442e5df7b5b45e9460ffa3f8f549cd26b862bec49d" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -13983,33 +14439,43 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.101", + "syn 2.0.106", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.7.0" +version = "8.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" dependencies = [ "globset", "sha2", "walkdir", ] +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + [[package]] name = "rust_decimal" -version = "1.37.1" +version = "1.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" dependencies = [ "arrayvec", "borsh", @@ -14023,9 +14489,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -14048,15 +14514,28 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.0", - "errno 0.3.11", - "itoa", + "bitflags 2.9.4", + "errno 0.3.14", "libc", "linux-raw-sys 0.4.15", "windows-sys 0.59.0", @@ -14064,15 +14543,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.0", - "errno 0.3.11", + "bitflags 2.9.4", + "errno 0.3.14", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -14082,18 +14561,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.0.7", + "rustix 1.1.2", ] [[package]] name = "rustix-openpty" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25c3aad9fc1424eb82c88087789a7d938e1829724f3e4043163baf0d13cfc12" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" dependencies = [ - "errno 0.3.11", + "errno 0.3.14", "libc", - "rustix 0.38.44", + "rustix 1.1.2", ] [[package]] @@ -14110,16 +14589,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.26" +version = "0.23.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.1", + "rustls-webpki 0.103.7", "subtle", "zeroize", ] @@ -14138,14 +14617,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework 3.5.1", ] [[package]] @@ -14178,20 +14657,20 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", "jni", "log", "once_cell", - "rustls 0.23.26", - "rustls-native-certs 0.8.1", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", "rustls-platform-verifier-android", - "rustls-webpki 0.103.1", - "security-framework 3.2.0", + "rustls-webpki 0.103.7", + "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", "windows-sys 0.59.0", @@ -14215,9 +14694,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "aws-lc-rs", "ring", @@ -14227,9 +14706,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" @@ -14237,7 +14716,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", "libm", "smallvec", @@ -14254,7 +14733,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytemuck", "core_maths", "log", @@ -14272,6 +14751,16 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safetensors" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44560c11236a6130a46ce36c836a62936dc81ebf8c36a37947423571be0e55b6" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "salsa20" version = "0.10.2" @@ -14291,33 +14780,24 @@ dependencies = [ ] [[package]] -name = "scap" -version = "0.0.8" -source = "git+https://github.com/zed-industries/scap?rev=808aa5c45b41e8f44729d02e38fd00a2fe2722e7#808aa5c45b41e8f44729d02e38fd00a2fe2722e7" +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "anyhow", - "cocoa 0.25.0", - "core-graphics-helmer-fork", - "log", - "objc", - "rand 0.8.5", - "screencapturekit", - "screencapturekit-sys", - "sysinfo", - "tao-core-video-sys", - "windows 0.61.1", - "windows-capture", - "x11", - "xcb", + "windows-sys 0.61.2", ] [[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +name = "scheduler" +version = "0.1.0" dependencies = [ - "windows-sys 0.59.0", + "async-task", + "backtrace", + "chrono", + "futures 0.3.31", + "parking_lot", + "rand 0.9.2", ] [[package]] @@ -14327,39 +14807,48 @@ dependencies = [ "anyhow", "clap", "env_logger 0.11.8", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "theme", - "workspace-hack", ] [[package]] name = "schemars" -version = "1.0.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ - "chrono", "dyn-clone", - "indexmap", + "indexmap 2.11.4", "ref-cast", "schemars_derive", - "semver", "serde", "serde_json", ] [[package]] name = "schemars_derive" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ca9fcb757952f8e8629b9ab066fc62da523c46c2b247b1708a3be06dd82530b" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14376,9 +14865,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scratch" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" [[package]] name = "screencapturekit" @@ -14420,7 +14909,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14455,7 +14944,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14480,7 +14969,7 @@ dependencies = [ "serde_json", "sqlx", "strum 0.26.3", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "url", @@ -14497,15 +14986,15 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.101", + "syn 2.0.106", "unicode-ident", ] [[package]] name = "sea-query" -version = "0.32.4" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d99447c24da0cded00089e2021e1624af90878c65f7534319448d01da3df869d" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ "bigdecimal", "chrono", @@ -14545,16 +15034,17 @@ version = "0.1.0" dependencies = [ "any_vec", "anyhow", - "bitflags 2.9.0", + "bitflags 2.9.4", "client", "collections", "editor", "futures 0.3.31", "gpui", "language", + "lsp", "menu", "project", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -14563,8 +15053,8 @@ dependencies = [ "ui", "unindent", "util", + "util_macros", "workspace", - "workspace-hack", "zed_actions", ] @@ -14588,7 +15078,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -14597,11 +15087,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.10.0", "core-foundation-sys", "libc", @@ -14610,9 +15100,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -14625,84 +15115,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] -name = "semantic_index" +name = "semantic_version" version = "0.1.0" dependencies = [ "anyhow", - "arrayvec", - "blake3", - "client", - "clock", - "collections", - "feature_flags", - "fs", - "futures 0.3.31", - "futures-batch", - "gpui", - "heed", - "http_client", - "language", - "language_model", - "languages", - "log", - "open_ai", - "parking_lot", - "project", - "reqwest_client", "serde", - "serde_json", - "settings", - "sha2", - "smol", - "streaming-iterator", - "tempfile", - "theme", - "tree-sitter", - "ui", - "unindent", - "util", - "workspace", - "workspace-hack", - "worktree", - "zlog", ] [[package]] -name = "semantic_version" -version = "0.1.0" +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ - "anyhow", "serde", - "workspace-hack", + "serde_core", ] [[package]] -name = "semver" -version = "1.0.26" +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "serde", + "serde_core", + "serde_derive", ] [[package]] -name = "serde" -version = "1.0.219" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14713,7 +15176,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -14727,15 +15190,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -14744,7 +15208,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "memchr", "ryu", @@ -14753,12 +15217,13 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -14769,16 +15234,36 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", ] [[package]] name = "serde_spanned" -version = "0.6.8" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" dependencies = [ "serde", + "serde_core", + "stacker", ] [[package]] @@ -14793,11 +15278,55 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.11.4", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" -version = "0.2.29" +version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" dependencies = [ "cfg-if", "libc", @@ -14813,7 +15342,6 @@ dependencies = [ "serde_json", "util", "uuid", - "workspace-hack", ] [[package]] @@ -14822,6 +15350,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", + "derive_more 0.99.20", "ec4rs", "fs", "futures 0.3.31", @@ -14829,22 +15358,49 @@ dependencies = [ "indoc", "inventory", "log", + "migrator", "paths", "pretty_assertions", - "release_channel", - "rust-embed", - "schemars", + "release_channel", + "rust-embed", + "schemars 1.0.4", + "serde", + "serde_json", + "serde_json_lenient", + "serde_repr", + "serde_with", + "settings_json", + "settings_macros", + "smallvec", + "strum 0.27.2", + "unindent", + "util", + "zlog", +] + +[[package]] +name = "settings_json" +version = "0.1.0" +dependencies = [ + "anyhow", + "pretty_assertions", "serde", - "serde_derive", "serde_json", "serde_json_lenient", - "smallvec", + "serde_path_to_error", "tree-sitter", "tree-sitter-json", "unindent", "util", - "workspace-hack", - "zlog", +] + +[[package]] +name = "settings_macros" +version = "0.1.0" +dependencies = [ + "quote", + "settings", + "syn 2.0.106", ] [[package]] @@ -14864,7 +15420,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", "zed_actions", ] @@ -14873,39 +15428,40 @@ name = "settings_ui" version = "0.1.0" dependencies = [ "anyhow", - "collections", - "command_palette", - "command_palette_hooks", - "component", - "db", + "assets", + "bm25", + "client", "editor", "feature_flags", "fs", + "futures 0.3.31", "fuzzy", "gpui", - "itertools 0.14.0", + "heck 0.5.0", "language", "log", "menu", - "notifications", + "node_runtime", "paths", + "picker", + "pretty_assertions", "project", + "release_channel", + "schemars 1.0.4", "search", "serde", - "serde_json", + "session", "settings", + "strum 0.27.2", "telemetry", - "tempfile", "theme", - "tree-sitter-json", - "tree-sitter-rust", + "title_bar", "ui", "ui_input", "util", - "vim", "workspace", - "workspace-hack", "zed_actions", + "zlog", ] [[package]] @@ -14919,16 +15475,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha1-checked" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" -dependencies = [ - "digest", - "sha1", -] - [[package]] name = "sha1_smol" version = "1.0.1" @@ -14937,9 +15483,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -14997,9 +15543,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -15007,9 +15553,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -15063,7 +15609,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", ] @@ -15093,11 +15639,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skiplist" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354fd282d3177c2951004953e2fdc4cb342fa159bbee8b829852b6a081c8ea1" +dependencies = [ + "rand 0.9.2", + "thiserror 2.0.17", +] + [[package]] name = "skrifa" -version = "0.26.6" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", "read-fonts", @@ -15105,12 +15661,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slash_commands_example" @@ -15130,9 +15683,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -15145,7 +15698,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -15154,15 +15707,15 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-fs", "async-io", - "async-lock", + "async-lock 3.4.1", "async-net", "async-process", "blocking", - "futures-lite 2.6.0", + "futures-lite 2.6.1", ] [[package]] @@ -15171,13 +15724,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "snippet" version = "0.1.0" dependencies = [ "anyhow", "smallvec", - "workspace-hack", ] [[package]] @@ -15193,12 +15751,12 @@ dependencies = [ "indoc", "parking_lot", "paths", - "schemars", + "schemars 1.0.4", "serde", + "serde_json", "serde_json_lenient", "snippet", "util", - "workspace-hack", ] [[package]] @@ -15216,24 +15774,53 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", +] + +[[package]] +name = "soa-rs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75ae4668062b095fda87ba54118697bed601f07f6c68bf50289a25ca0c8c935" +dependencies = [ + "soa-rs-derive", +] + +[[package]] +name = "soa-rs-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c09121507da587d3434e5929ce3321162f36bd3eff403873cb163c06b176913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] name = "socket2" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spdx" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b69356da67e2fc1f542c71ea7e654a361a79c938e4424392ecf4fa065d2193" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" dependencies = [ "smallvec", ] @@ -15253,7 +15840,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -15291,13 +15878,13 @@ dependencies = [ "futures 0.3.31", "indoc", "libsqlite3-sys", + "log", "parking_lot", "smol", "sqlformat", "thread_local", "util", "uuid", - "workspace-hack", ] [[package]] @@ -15306,8 +15893,7 @@ version = "0.1.0" dependencies = [ "sqlez", "sqlformat", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] @@ -15320,11 +15906,20 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "sqlparser" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05a528114c392209b3264855ad491fcce534b94a38771b0a0b97a79379275ce8" +dependencies = [ + "log", +] + [[package]] name = "sqlx" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -15335,9 +15930,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -15346,25 +15941,25 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.11.4", "log", "memchr", "once_cell", "percent-encoding", "rust_decimal", - "rustls 0.23.26", + "rustls 0.23.33", "serde", "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tokio", "tokio-stream", @@ -15376,22 +15971,22 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "sqlx-macros-core" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -15407,22 +16002,21 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.101", - "tempfile", + "syn 2.0.106", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "bytes 1.10.1", "chrono", @@ -15453,7 +16047,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "uuid", @@ -15462,14 +16056,14 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -15496,7 +16090,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "uuid", @@ -15505,9 +16099,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -15523,7 +16117,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tracing", "url", @@ -15532,9 +16126,43 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn 2.0.106", +] [[package]] name = "static_assertions" @@ -15542,6 +16170,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stop-words" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645a3d441ccf4bf47f2e4b7681461986681a6eeea9937d4c3bc9febd61d17c71" +dependencies = [ + "serde_json", +] + [[package]] name = "story" version = "0.1.0" @@ -15549,7 +16186,6 @@ dependencies = [ "gpui", "itertools 0.14.0", "smallvec", - "workspace-hack", ] [[package]] @@ -15575,12 +16211,20 @@ dependencies = [ "settings", "simplelog", "story", - "strum 0.27.1", + "strum 0.27.2", "theme", "title_bar", "ui", "workspace", - "workspace-hack", +] + +[[package]] +name = "streaming-decompression" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" +dependencies = [ + "fallible-streaming-iterator", ] [[package]] @@ -15594,12 +16238,17 @@ name = "streaming_diff" version = "0.1.0" dependencies = [ "ordered-float 2.10.1", - "rand 0.8.5", + "rand 0.9.2", "rope", "util", - "workspace-hack", ] +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -15617,7 +16266,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -15628,8 +16277,8 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -15662,11 +16311,11 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.1", + "strum_macros 0.27.2", ] [[package]] @@ -15679,20 +16328,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -15708,9 +16356,8 @@ dependencies = [ "arrayvec", "ctor", "log", - "rand 0.8.5", + "rand 0.9.2", "rayon", - "workspace-hack", "zlog", ] @@ -15741,7 +16388,6 @@ dependencies = [ "ui", "unicode-segmentation", "util", - "workspace-hack", ] [[package]] @@ -15756,20 +16402,19 @@ dependencies = [ "serde_json", "smol", "util", - "workspace-hack", ] [[package]] name = "sval" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc9739f56c5d0c44a5ed45473ec868af02eb896af8c05f616673a31e1d1bb09" +checksum = "d94c4464e595f0284970fd9c7e9013804d035d4a61ab74b113242c874c05814d" [[package]] name = "sval_buffer" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f39b07436a8c271b34dad5070c634d1d3d76d6776e938ee97b4a66a5e8003d0b" +checksum = "a0f46e34b20a39e6a2bf02b926983149b3af6609fd1ee8a6e63f6f340f3e2164" dependencies = [ "sval", "sval_ref", @@ -15777,18 +16422,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffcb072d857431bf885580dacecf05ed987bac931230736739a79051dbf3499b" +checksum = "03d0970e53c92ab5381d3b2db1828da8af945954d4234225f6dd9c3afbcef3f5" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f214f427ad94a553e5ca5514c95c6be84667cbc5568cce957f03f3477d03d5c" +checksum = "43e5e6e1613e1e7fc2e1a9fdd709622e54c122ceb067a60d170d75efd491a839" dependencies = [ "itoa", "ryu", @@ -15797,9 +16442,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ed34b32e638dec9a99c8ac92d0aa1220d40041026b625474c2b6a4d6f4feb" +checksum = "aec382f7bfa6e367b23c9611f129b94eb7daaf3d8fae45a8d0a0211eb4d4c8e6" dependencies = [ "itoa", "ryu", @@ -15808,9 +16453,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14bae8fcb2f24fee2c42c1f19037707f7c9a29a0cda936d2188d48a961c4bb2a" +checksum = "3049d0f99ce6297f8f7d9953b35a0103b7584d8f638de40e64edb7105fa578ae" dependencies = [ "sval", "sval_buffer", @@ -15819,20 +16464,20 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4eaea3821d3046dcba81d4b8489421da42961889902342691fb7eab491d79e" +checksum = "f88913e77506085c0a8bf6912bb6558591a960faf5317df6c1d9b227224ca6e1" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.14.1" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172dd4aa8cb3b45c8ac8f3b4111d644cd26938b0643ede8f93070812b87fb339" +checksum = "f579fd7254f4be6cd7b450034f856b78523404655848789c451bacc6aa8b387d" dependencies = [ - "serde", + "serde_core", "sval", "sval_nested", ] @@ -15850,9 +16495,9 @@ dependencies = [ "editor", "file_icons", "gpui", + "language", "ui", "workspace", - "workspace-hack", ] [[package]] @@ -15867,9 +16512,9 @@ dependencies = [ [[package]] name = "swash" -version = "0.2.2" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae9a562c7b46107d9c78cd78b75bbe1e991c16734c0aee8ff0ee711fb8b620a" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" dependencies = [ "skrifa", "yazi", @@ -15878,32 +16523,84 @@ dependencies = [ [[package]] name = "symphonia" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", "symphonia-codec-pcm", + "symphonia-codec-vorbis", "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", "symphonia-format-riff", "symphonia-metadata", ] +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + [[package]] name = "symphonia-codec-pcm" -version = "0.5.4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" dependencies = [ "log", "symphonia-core", + "symphonia-utils-xiph", ] [[package]] name = "symphonia-core" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" dependencies = [ "arrayvec", "bitflags 1.3.2", @@ -15912,11 +16609,36 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-format-riff" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" dependencies = [ "extended", "log", @@ -15926,9 +16648,9 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" dependencies = [ "encoding_rs", "lazy_static", @@ -15936,6 +16658,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -15949,9 +16681,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -15979,27 +16711,55 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" dependencies = [ - "crossbeam-queue", + "crossbeam-queue", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", ] [[package]] -name = "synstructure" -version = "0.13.1" +name = "sysctl" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "bitflags 2.9.4", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", ] [[package]] -name = "sys-locale" -version = "0.3.2" +name = "sysctl" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ + "bitflags 2.9.4", + "byteorder", + "enum-as-inner", "libc", + "thiserror 1.0.69", + "walkdir", ] [[package]] @@ -16016,6 +16776,20 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -16033,7 +16807,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -16067,7 +16841,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.20", + "toml 0.8.23", "version-compare", ] @@ -16077,7 +16851,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cap-fs-ext", "cap-std", "fd-lock", @@ -16087,6 +16861,20 @@ dependencies = [ "winx", ] +[[package]] +name = "system_specs" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "gpui", + "human_bytes", + "pciid-parser", + "release_channel", + "serde", + "sysinfo 0.37.2", +] + [[package]] name = "tab_switcher" version = "0.1.0" @@ -16101,7 +16889,7 @@ dependencies = [ "menu", "picker", "project", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "settings", @@ -16110,7 +16898,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zlog", ] @@ -16164,9 +16951,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "target-lexicon" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "task" @@ -16181,14 +16968,13 @@ dependencies = [ "parking_lot", "pretty_assertions", "proto", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", "sha2", "shellexpand 2.1.2", "util", - "workspace-hack", "zed_actions", ] @@ -16215,7 +17001,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -16227,7 +17012,6 @@ dependencies = [ "serde", "serde_json", "telemetry_events", - "workspace-hack", ] [[package]] @@ -16237,20 +17021,19 @@ dependencies = [ "semantic_version", "serde", "serde_json", - "workspace-hack", ] [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.2", + "getrandom 0.3.4", "once_cell", - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -16280,37 +17063,36 @@ dependencies = [ "alacritty_terminal", "anyhow", "collections", - "dirs 4.0.0", "futures 0.3.31", "gpui", + "itertools 0.14.0", "libc", - "rand 0.8.5", + "log", + "rand 0.9.2", "regex", "release_channel", - "schemars", + "schemars 1.0.4", "serde", - "serde_derive", "settings", "smol", - "sysinfo", + "sysinfo 0.37.2", "task", "theme", - "thiserror 2.0.12", + "thiserror 2.0.17", "url", "urlencoding", "util", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.0.7", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.60.2", ] [[package]] @@ -16331,10 +17113,11 @@ dependencies = [ "itertools 0.14.0", "language", "log", + "pretty_assertions", "project", - "rand 0.8.5", + "rand 0.9.2", "regex", - "schemars", + "schemars 1.0.4", "search", "serde", "serde_json", @@ -16347,7 +17130,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -16364,13 +17146,12 @@ dependencies = [ "log", "parking_lot", "postage", - "rand 0.8.5", + "rand 0.9.2", "regex", "rope", "smallvec", "sum_tree", "util", - "workspace-hack", "zlog", ] @@ -16380,28 +17161,23 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more 0.99.19", + "derive_more 0.99.20", "fs", "futures 0.3.31", "gpui", - "indexmap", - "inventory", "log", "palette", "parking_lot", "refineable", - "schemars", + "schemars 1.0.4", "serde", - "serde_derive", "serde_json", "serde_json_lenient", - "serde_repr", "settings", - "strum 0.27.1", - "thiserror 2.0.12", + "strum 0.27.2", + "thiserror 2.0.17", "util", "uuid", - "workspace-hack", ] [[package]] @@ -16413,7 +17189,6 @@ dependencies = [ "fs", "gpui", "theme", - "workspace-hack", ] [[package]] @@ -16422,18 +17197,18 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "collections", "gpui", - "indexmap", + "indexmap 2.11.4", "log", "palette", "serde", "serde_json", "serde_json_lenient", "simplelog", - "strum 0.27.1", + "strum 0.27.2", "theme", "vscode_theme", - "workspace-hack", ] [[package]] @@ -16452,7 +17227,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -16467,11 +17241,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -16482,39 +17256,41 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] @@ -16533,9 +17309,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -16550,15 +17326,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -16572,7 +17348,6 @@ dependencies = [ "core-foundation-sys", "sys-locale", "time", - "workspace-hack", ] [[package]] @@ -16595,7 +17370,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png", + "png 0.17.16", "tiny-skia-path", ] @@ -16625,9 +17400,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -16645,9 +17420,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -16665,6 +17440,7 @@ dependencies = [ "anyhow", "auto_update", "call", + "channel", "chrono", "client", "cloud_llm_client", @@ -16677,10 +17453,9 @@ dependencies = [ "project", "remote", "rpc", - "schemars", + "schemars 1.0.4", "serde", "settings", - "settings_ui", "smallvec", "story", "telemetry", @@ -16688,28 +17463,26 @@ dependencies = [ "tree-sitter-md", "ui", "util", - "windows 0.61.1", + "windows 0.61.3", "workspace", - "workspace-hack", "zed_actions", ] [[package]] name = "tokio" -version = "1.44.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes 1.10.1", "libc", - "mio 1.0.3", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -16725,13 +17498,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -16760,7 +17533,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ - "rustls 0.23.26", + "rustls 0.23.33", "tokio", ] @@ -16820,7 +17593,7 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.26", + "rustls 0.23.33", "rustls-pki-types", "tokio", "tokio-rustls 0.26.2", @@ -16829,14 +17602,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes 1.10.1", "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -16852,59 +17626,114 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.9" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" -version = "0.22.26" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.11.4", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.11.4", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" -version = "0.1.1" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "toolchain_selector" version = "0.1.0" dependencies = [ + "anyhow", + "convert_case 0.8.0", "editor", + "file_finder", + "futures 0.3.31", "fuzzy", "gpui", "language", + "menu", "picker", "project", "ui", "util", "workspace", - "workspace-hack", ] [[package]] @@ -16968,7 +17797,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytes 1.10.1", "futures-core", "futures-util", @@ -16981,6 +17810,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes 1.10.1", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -17007,20 +17854,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -17049,14 +17896,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "serde", "serde_json", "sharded-slab", @@ -17076,18 +17923,28 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", ] [[package]] name = "tree-sitter" -version = "0.25.6" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cf18d43cbf0bfca51f657132cc616a5097edc4424d538bae6fa60142eaf9f0" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.5", + "regex-syntax", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -17117,8 +17974,7 @@ dependencies = [ [[package]] name = "tree-sitter-cpp" version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +source = "git+https://github.com/tree-sitter/tree-sitter-cpp?rev=5cb9b693cfd7bfacab1d9ff4acac1a4150700609#5cb9b693cfd7bfacab1d9ff4acac1a4150700609" dependencies = [ "cc", "tree-sitter-language", @@ -17257,8 +18113,9 @@ dependencies = [ [[package]] name = "tree-sitter-python" -version = "0.23.6" -source = "git+https://github.com/zed-industries/tree-sitter-python?rev=218fcbf3fda3d029225f3dec005cb497d111b35e#218fcbf3fda3d029225f3dec005cb497d111b35e" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" dependencies = [ "cc", "tree-sitter-language", @@ -17297,8 +18154,7 @@ dependencies = [ [[package]] name = "tree-sitter-typescript" version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +source = "git+https://github.com/zed-industries/tree-sitter-typescript?rev=e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899#e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" dependencies = [ "cc", "tree-sitter-language", @@ -17389,11 +18245,30 @@ dependencies = [ "http 1.3.1", "httparse", "log", - "rand 0.9.1", - "rustls 0.23.26", + "rand 0.9.2", + "rustls 0.23.33", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes 1.10.1", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.33", "rustls-pki-types", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.17", "utf-8", ] @@ -17411,9 +18286,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -17441,6 +18316,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "ug" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b70b37e9074642bc5f60bb23247fd072a84314ca9e71cdf8527593406a0dd3" +dependencies = [ + "gemm 0.18.2", + "half", + "libloading", + "memmap2", + "num", + "num-traits", + "num_cpus", + "rayon", + "safetensors", + "serde", + "thiserror 1.0.69", + "tracing", + "yoke 0.7.5", +] + [[package]] name = "ui" version = "0.1.0" @@ -17453,16 +18349,16 @@ dependencies = [ "icons", "itertools 0.14.0", "menu", + "schemars 1.0.4", "serde", "settings", "smallvec", "story", - "strum 0.27.1", + "strum 0.27.2", "theme", "ui_macros", "util", - "windows 0.61.1", - "workspace-hack", + "windows 0.61.3", ] [[package]] @@ -17472,19 +18368,20 @@ dependencies = [ "component", "editor", "gpui", + "menu", "settings", "theme", "ui", - "workspace-hack", ] [[package]] name = "ui_macros" version = "0.1.0" dependencies = [ + "component", "quote", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", + "ui", ] [[package]] @@ -17498,16 +18395,6 @@ dependencies = [ "theme", "ui", "workspace", - "workspace-hack", -] - -[[package]] -name = "uluru" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" -dependencies = [ - "arrayvec", ] [[package]] @@ -17534,12 +18421,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" -[[package]] -name = "unicode-bom" -version = "2.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" - [[package]] name = "unicode-ccc" version = "0.2.0" @@ -17554,9 +18435,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" @@ -17579,6 +18460,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-reverse" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "unicode-script" version = "0.5.7" @@ -17599,15 +18489,9 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -17627,17 +18511,29 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -17684,12 +18580,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -17722,10 +18612,11 @@ dependencies = [ "libc", "log", "nix 0.29.0", - "rand 0.8.5", + "pretty_assertions", + "rand 0.9.2", "regex", "rust-embed", - "schemars", + "schemars 1.0.4", "serde", "serde_json", "serde_json_lenient", @@ -17737,27 +18628,29 @@ dependencies = [ "unicase", "util_macros", "walkdir", - "workspace-hack", + "which 6.0.3", ] [[package]] name = "util_macros" version = "0.1.0" dependencies = [ + "perf", "quote", - "syn 2.0.101", - "workspace-hack", + "syn 2.0.106", ] [[package]] name = "uuid" -version = "1.16.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", + "js-sys", "serde", "sha1_smol", + "wasm-bindgen", ] [[package]] @@ -17773,9 +18666,9 @@ dependencies = [ [[package]] name = "v_frame" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" dependencies = [ "aligned-vec", "num-traits", @@ -17835,10 +18728,9 @@ name = "vercel" version = "0.1.0" dependencies = [ "anyhow", - "schemars", + "schemars 1.0.4", "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] @@ -17868,6 +18760,7 @@ dependencies = [ "editor", "env_logger 0.11.8", "futures 0.3.31", + "fuzzy", "git_ui", "gpui", "indoc", @@ -17875,29 +18768,31 @@ dependencies = [ "language", "log", "lsp", + "menu", "multi_buffer", "nvim-rs", "parking_lot", + "perf", "picker", "project", "project_panel", "regex", "release_channel", - "schemars", + "schemars 1.0.4", "search", "serde", - "serde_derive", "serde_json", "settings", + "settings_ui", "task", "text", "theme", "tokio", "ui", "util", + "util_macros", "vim_mode_setting", "workspace", - "workspace-hack", "zed_actions", ] @@ -17905,12 +18800,16 @@ dependencies = [ name = "vim_mode_setting" version = "0.1.0" dependencies = [ - "anyhow", "gpui", "settings", - "workspace-hack", ] +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vscode_theme" version = "0.2.0" @@ -17953,7 +18852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.9.0", + "bitflags 2.9.4", "cursor-icon", "log", "memchr", @@ -18015,17 +18914,17 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt 0.39.0", + "wit-bindgen 0.46.0", ] [[package]] @@ -18036,35 +18935,36 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -18075,9 +18975,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -18085,22 +18985,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -18141,7 +19041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fd83062c17b9f4985d438603cde0a5e8c5c8198201a6937f778b607924c7da2" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.11.4", "serde", "serde_derive", "serde_json", @@ -18159,7 +19059,7 @@ dependencies = [ "anyhow", "auditable-serde", "flate2", - "indexmap", + "indexmap 2.11.4", "serde", "serde_derive", "serde_json", @@ -18188,8 +19088,8 @@ version = "0.201.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ - "bitflags 2.9.0", - "indexmap", + "bitflags 2.9.4", + "indexmap 2.11.4", "semver", ] @@ -18199,9 +19099,9 @@ version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "bitflags 2.9.0", - "hashbrown 0.15.3", - "indexmap", + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap 2.11.4", "semver", "serde", ] @@ -18212,9 +19112,9 @@ version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" dependencies = [ - "bitflags 2.9.0", - "hashbrown 0.15.3", - "indexmap", + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap 2.11.4", "semver", ] @@ -18237,18 +19137,18 @@ checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "bumpalo", "cc", "cfg-if", "encoding_rs", "hashbrown 0.14.5", - "indexmap", + "indexmap 2.11.4", "libc", "log", - "mach2", + "mach2 0.4.3", "memfd", - "object", + "object 0.36.7", "once_cell", "paste", "postcard", @@ -18261,7 +19161,7 @@ dependencies = [ "serde_derive", "smallvec", "sptr", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "trait-variant", "wasmparser 0.221.3", "wasmtime-asm-macros", @@ -18319,7 +19219,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser 0.221.3", @@ -18344,12 +19244,12 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.31.1", "itertools 0.12.1", "log", - "object", + "object 0.36.7", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "thiserror 1.0.69", "wasmparser 0.221.3", "wasmtime-environ", @@ -18366,17 +19266,17 @@ dependencies = [ "cpp_demangle", "cranelift-bitset", "cranelift-entity", - "gimli", - "indexmap", + "gimli 0.31.1", + "indexmap 2.11.4", "log", - "object", + "object 0.36.7", "postcard", "rustc-demangle", "semver", "serde", "serde_derive", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "wasm-encoder 0.221.3", "wasmparser 0.221.3", "wasmprinter", @@ -18433,7 +19333,7 @@ checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18444,7 +19344,7 @@ checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "bytes 1.10.1", "cap-fs-ext", "cap-net-ext", @@ -18475,9 +19375,9 @@ checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", - "object", - "target-lexicon 0.13.2", + "gimli 0.31.1", + "object 0.36.7", + "target-lexicon 0.13.3", "wasmparser 0.221.3", "wasmtime-cranelift", "wasmtime-environ", @@ -18492,7 +19392,7 @@ checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.11.4", "wit-parser 0.221.3", ] @@ -18513,20 +19413,19 @@ dependencies = [ "futures 0.3.31", "gpui", "parking_lot", - "rand 0.8.5", - "workspace-hack", + "rand 0.9.2", "zlog", ] [[package]] name = "wayland-backend" -version = "0.3.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -18534,23 +19433,23 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.0", - "rustix 0.38.44", + "bitflags 2.9.4", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-cursor" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93029cbb6650748881a00e4922b076092a6a08c11e7fbdb923f064b23968c5d" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.2", "wayland-client", "xcursor", ] @@ -18559,43 +19458,68 @@ dependencies = [ name = "wayland-protocols" version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", + "wayland-protocols 0.31.2", "wayland-scanner", ] [[package]] -name = "wayland-protocols-plasma" -version = "0.2.0" +name = "wayland-protocols-wlr" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.32.9", "wayland-scanner", ] [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml 0.37.4", + "quick-xml 0.37.5", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", @@ -18605,9 +19529,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -18625,11 +19549,11 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.1.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", @@ -18644,7 +19568,6 @@ dependencies = [ "collections", "gpui", "serde", - "workspace-hack", ] [[package]] @@ -18661,7 +19584,6 @@ dependencies = [ "serde", "serde_json", "web_search", - "workspace-hack", ] [[package]] @@ -18705,14 +19627,14 @@ dependencies = [ "reqwest 0.11.27", "scratch", "semver", - "zip", + "zip 0.6.6", ] [[package]] name = "weezl" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "which" @@ -18740,11 +19662,11 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall 0.5.11", + "libredox", "wasite", ] @@ -18756,7 +19678,7 @@ checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d" dependencies = [ "anyhow", "async-trait", - "bitflags 2.9.0", + "bitflags 2.9.4", "thiserror 1.0.69", "tracing", "wasmtime", @@ -18774,7 +19696,7 @@ dependencies = [ "proc-macro2", "quote", "shellexpand 2.1.2", - "syn 2.0.101", + "syn 2.0.106", "witx", ] @@ -18786,7 +19708,7 @@ checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wiggle-generate", ] @@ -18808,11 +19730,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -18829,10 +19751,10 @@ checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" dependencies = [ "anyhow", "cranelift-codegen", - "gimli", + "gimli 0.31.1", "regalloc2", "smallvec", - "target-lexicon 0.13.2", + "target-lexicon 0.13.3", "thiserror 1.0.69", "wasmparser 0.221.3", "wasmtime-cranelift", @@ -18871,14 +19793,14 @@ dependencies = [ [[package]] name = "windows" -version = "0.61.1" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.0", + "windows-core 0.61.2", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -18891,8 +19813,8 @@ dependencies = [ "ctrlc", "parking_lot", "rayon", - "thiserror 2.0.12", - "windows 0.61.1", + "thiserror 2.0.17", + "windows 0.61.3", "windows-future", ] @@ -18902,7 +19824,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", + "windows-core 0.61.2", ] [[package]] @@ -18942,25 +19864,39 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] name = "windows-future" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.0", - "windows-link", + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -18971,7 +19907,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -18982,18 +19918,18 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -19004,7 +19940,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -19015,25 +19951,31 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -19041,8 +19983,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", - "windows-link", + "windows-core 0.61.2", + "windows-link 0.1.3", ] [[package]] @@ -19051,20 +19993,31 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" dependencies = [ - "windows-result 0.3.2", + "windows-result 0.3.4", "windows-strings 0.3.1", - "windows-targets 0.53.0", + "windows-targets 0.53.5", ] [[package]] name = "windows-registry" -version = "0.5.1" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result 0.3.2", - "windows-strings 0.4.0", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -19087,11 +20040,20 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -19110,16 +20072,25 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -19158,6 +20129,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -19206,18 +20195,28 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.1.3", ] [[package]] @@ -19240,9 +20239,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -19264,9 +20263,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -19288,9 +20287,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -19300,9 +20299,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -19324,9 +20323,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -19348,9 +20347,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -19372,9 +20371,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -19396,15 +20395,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.6" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -19428,16 +20427,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -19450,11 +20439,11 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4a67c78ee5782c0c1cb41bebc7e12c6e79644daa1650ebbc1de5d5b08593f7" +checksum = "edcacf11b6f48dd21b9ba002f991bdd5de29b2da8cc2800412f4b80f677e4957" dependencies = [ - "toml 0.8.20", + "toml 0.8.23", "version_check", ] @@ -19470,7 +20459,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "windows-sys 0.59.0", ] @@ -19489,7 +20478,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "288f992ea30e6b5c531b52cdd5f3be81c148554b09ea416f058d16556ba92c27" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "wit-bindgen-rt 0.22.0", "wit-bindgen-rust-macro 0.22.0", ] @@ -19504,6 +20493,12 @@ dependencies = [ "wit-bindgen-rust-macro 0.41.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen-core" version = "0.22.0" @@ -19531,22 +20526,13 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcb8738270f32a2d6739973cbbb7c1b6dd8959ce515578a6e19165853272ee64" -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.0", -] - [[package]] name = "wit-bindgen-rt" version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "futures 0.3.31", "once_cell", ] @@ -19559,7 +20545,7 @@ checksum = "d8a39a15d1ae2077688213611209849cad40e9e5cccf6e61951a425850677ff3" dependencies = [ "anyhow", "heck 0.4.1", - "indexmap", + "indexmap 2.11.4", "wasm-metadata 0.201.0", "wit-bindgen-core 0.22.0", "wit-component 0.201.0", @@ -19573,9 +20559,9 @@ checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.11.4", "prettyplease", - "syn 2.0.101", + "syn 2.0.106", "wasm-metadata 0.227.1", "wit-bindgen-core 0.41.0", "wit-component 0.227.1", @@ -19590,7 +20576,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wit-bindgen-core 0.22.0", "wit-bindgen-rust 0.22.0", ] @@ -19605,7 +20591,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "wit-bindgen-core 0.41.0", "wit-bindgen-rust 0.41.0", ] @@ -19617,8 +20603,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", - "bitflags 2.9.0", - "indexmap", + "bitflags 2.9.4", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -19636,8 +20622,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" dependencies = [ "anyhow", - "bitflags 2.9.0", - "indexmap", + "bitflags 2.9.4", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -19656,7 +20642,7 @@ checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -19674,7 +20660,7 @@ checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", "semver", "serde", @@ -19682,274 +20668,84 @@ dependencies = [ "serde_json", "unicode-xid", "wasmparser 0.221.3", -] - -[[package]] -name = "wit-parser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.227.1", -] - -[[package]] -name = "witx" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" -dependencies = [ - "anyhow", - "log", - "thiserror 1.0.69", - "wast", -] - -[[package]] -name = "workspace" -version = "0.1.0" -dependencies = [ - "any_vec", - "anyhow", - "async-recursion", - "bincode", - "call", - "client", - "clock", - "collections", - "component", - "dap", - "db", - "fs", - "futures 0.3.31", - "gpui", - "http_client", - "itertools 0.14.0", - "language", - "log", - "menu", - "node_runtime", - "parking_lot", - "postage", - "project", - "remote", - "schemars", - "serde", - "serde_json", - "session", - "settings", - "smallvec", - "sqlez", - "strum 0.27.1", - "task", - "telemetry", - "tempfile", - "theme", - "ui", - "util", - "uuid", - "windows 0.61.1", - "workspace-hack", - "zed_actions", - "zlog", -] - -[[package]] -name = "workspace-hack" -version = "0.1.0" -dependencies = [ - "aes", - "ahash 0.8.11", - "aho-corasick", - "anstream", - "arrayvec", - "async-compression", - "async-std", - "async-tungstenite", - "aws-config", - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "base64 0.22.1", - "base64ct", - "bigdecimal", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.9.0", - "bstr", - "bytemuck", - "byteorder", - "bytes 1.10.1", - "cc", - "chrono", - "cipher", - "clap", - "clap_builder", - "codespan-reporting 0.12.0", - "concurrent-queue", - "core-foundation 0.9.4", - "core-foundation-sys", - "cranelift-codegen", - "crc32fast", - "crossbeam-epoch", - "crossbeam-utils", - "crypto-common", - "deranged", - "digest", - "either", - "euclid", - "event-listener 5.4.0", - "event-listener-strategy", - "flate2", - "flume", - "foldhash", - "form_urlencoded", - "futures 0.3.31", - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", - "getrandom 0.2.15", - "getrandom 0.3.2", - "gimli", - "half", - "handlebars 4.5.0", - "hashbrown 0.14.5", - "hashbrown 0.15.3", - "heck 0.4.1", - "hmac", - "hyper 0.14.32", - "hyper-rustls 0.27.5", - "idna", - "indexmap", - "inout", - "itertools 0.12.1", - "itertools 0.13.0", - "jiff", - "lazy_static", - "libc", - "libsqlite3-sys", - "linux-raw-sys 0.4.15", - "linux-raw-sys 0.9.4", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.11.4", "log", - "lyon", - "lyon_path", - "md-5", - "memchr", - "mime_guess", - "miniz_oxide", - "mio 1.0.3", - "naga", - "nix 0.28.0", - "nix 0.29.0", - "nom 7.1.3", - "num-bigint", - "num-bigint-dig", - "num-integer", - "num-iter", - "num-rational", - "num-traits", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "object", - "once_cell", - "percent-encoding", - "phf", - "phf_shared", - "prettyplease", - "proc-macro2", - "prost 0.9.0", - "prost-types 0.9.0", - "quote", - "rand 0.8.5", - "rand 0.9.1", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "regalloc2", - "regex", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", - "ring", - "rust_decimal", - "rustc-hash 1.1.0", - "rustix 0.38.44", - "rustix 1.0.7", - "rustls 0.23.26", - "rustls-webpki 0.103.1", - "schemars", - "scopeguard", - "sea-orm", - "sea-query-binder", - "security-framework 3.2.0", - "security-framework-sys", "semver", "serde", "serde_derive", "serde_json", - "sha1", - "simd-adler32", + "unicode-xid", + "wasmparser 0.227.1", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast", +] + +[[package]] +name = "workspace" +version = "0.1.0" +dependencies = [ + "any_vec", + "anyhow", + "async-recursion", + "call", + "client", + "clock", + "collections", + "component", + "dap", + "db", + "fs", + "futures 0.3.31", + "gpui", + "http_client", + "itertools 0.14.0", + "language", + "log", + "menu", + "node_runtime", + "parking_lot", + "postage", + "pretty_assertions", + "project", + "remote", + "schemars 1.0.4", + "serde", + "serde_json", + "session", + "settings", "smallvec", - "spin", - "sqlx", - "sqlx-macros", - "sqlx-macros-core", - "sqlx-postgres", - "sqlx-sqlite", - "strum 0.26.3", - "subtle", - "syn 1.0.109", - "syn 2.0.101", - "sync_wrapper 1.0.2", - "thiserror 2.0.12", - "time", - "time-macros", - "tokio", - "tokio-rustls 0.26.2", - "tokio-socks", - "tokio-stream", - "tokio-util", - "toml_datetime", - "toml_edit", - "tower 0.5.2", - "tracing", - "tracing-core", - "tungstenite 0.26.2", - "unicode-normalization", - "unicode-properties", - "url", + "sqlez", + "strum 0.27.2", + "task", + "telemetry", + "tempfile", + "theme", + "ui", + "util", "uuid", - "wasmparser 0.221.3", - "wasmtime", - "wasmtime-cranelift", - "wasmtime-environ", - "winapi", - "windows-core 0.61.0", - "windows-numerics", - "windows-sys 0.48.0", - "windows-sys 0.52.0", - "windows-sys 0.59.0", - "winnow", - "zeroize", - "zvariant", + "windows 0.61.3", + "zed_actions", + "zlog", ] [[package]] @@ -19957,6 +20753,7 @@ name = "worktree" version = "0.1.0" dependencies = [ "anyhow", + "async-lock 2.8.0", "clock", "collections", "fs", @@ -19973,9 +20770,8 @@ dependencies = [ "paths", "postage", "pretty_assertions", - "rand 0.8.5", + "rand 0.9.2", "rpc", - "schemars", "serde", "serde_json", "settings", @@ -19984,21 +20780,14 @@ dependencies = [ "sum_tree", "text", "util", - "workspace-hack", "zlog", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wyz" @@ -20031,32 +20820,32 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "rustix 0.38.44", + "rustix 1.1.2", "x11rb-protocol", + "xcursor", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x_ai" version = "0.1.0" dependencies = [ "anyhow", - "schemars", + "schemars 1.0.4", "serde", - "strum 0.27.1", - "workspace-hack", + "strum 0.27.2", ] [[package]] @@ -20070,9 +20859,9 @@ dependencies = [ [[package]] name = "xcb" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1e2f212bb1a92cd8caac8051b829a6582ede155ccb60b5d5908b81b100952be" +checksum = "f07c123b796139bfe0603e654eaf08e132e52387ba95b252c78bad3640ba37ea" dependencies = [ "bitflags 1.3.2", "libc", @@ -20082,37 +20871,14 @@ dependencies = [ [[package]] name = "xcursor" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" - -[[package]] -name = "xdg-home" -version = "1.3.0" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "xim" -version = "0.4.0" -source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" -dependencies = [ - "ahash 0.8.11", - "hashbrown 0.14.5", - "log", - "x11rb", - "xim-ctext", - "xim-parser", -] +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ "encoding_rs", ] @@ -20120,9 +20886,9 @@ dependencies = [ [[package]] name = "xim-parser" version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -20171,12 +20937,23 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "backtrace", "cargo_metadata", "cargo_toml", "clap", - "workspace-hack", + "gh-workflow", + "indexmap 2.11.4", + "indoc", + "toml 0.8.23", + "toml_edit 0.22.27", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml-rust2" version = "0.8.1" @@ -20196,15 +20973,16 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yawc" -version = "0.2.4" -source = "git+https://github.com/deviant-forks/yawc?rev=1899688f3e69ace4545aceb97b2a13881cf26142#1899688f3e69ace4545aceb97b2a13881cf26142" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a5d82922135b4ae73a079a4ffb5501e9aadb4d785b8c660eaa0a8b899028c5" dependencies = [ "base64 0.22.1", "bytes 1.10.1", "flate2", "futures 0.3.31", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "js-sys", "nom 8.0.0", @@ -20247,7 +21025,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -20259,41 +21049,51 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", "synstructure", ] [[package]] name = "zbus" -version = "5.5.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c333f648ea1b647bc95dc1d34807c8e25ed7a6feff3394034dc4776054b236" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", - "async-fs", "async-io", - "async-lock", + "async-lock 3.4.1", "async-process", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", - "futures-lite 2.6.0", + "futures-lite 2.6.1", "hex", - "nix 0.29.0", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", - "static_assertions", "tracing", "uds_windows", - "windows-sys 0.59.0", + "uuid", + "windows-sys 0.61.2", "winnow", - "xdg-home", "zbus_macros", "zbus_names", "zvariant", @@ -20301,14 +21101,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.5.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f325ad10eb0d0a3eb060203494c3b7ec3162a01a59db75d2deee100339709fc0" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "zbus_names", "zvariant", "zvariant_utils", @@ -20328,30 +21128,28 @@ dependencies = [ [[package]] name = "zed" -version = "0.201.0" +version = "0.212.0" dependencies = [ + "acp_tools", "activity_indicator", - "agent", - "agent_servers", "agent_settings", "agent_ui", "anyhow", - "ashpd", + "ashpd 0.11.0", "askpass", "assets", - "assistant_tool", - "assistant_tools", "audio", "auto_update", "auto_update_ui", "backtrace", + "bincode 1.3.3", "breadcrumbs", "call", "channel", - "chrono", "clap", "cli", "client", + "codestral", "collab_ui", "collections", "command_palette", @@ -20384,21 +21182,21 @@ dependencies = [ "gpui_tokio", "http_client", "image_viewer", - "indoc", "inspector_ui", "install_cli", "itertools 0.14.0", - "jj_ui", "journal", + "json_schema_store", + "keymap_editor", "language", "language_extension", "language_model", "language_models", + "language_onboarding", "language_selector", "language_tools", "languages", - "libc", - "livekit_client", + "line_ending_selector", "log", "markdown", "markdown_preview", @@ -20426,7 +21224,6 @@ dependencies = [ "release_channel", "remote", "repl", - "reqwest 0.12.15 (git+https://github.com/zed-industries/reqwest.git?rev=951c770a32f1998d6e999cef3e59e0013e6c4415)", "reqwest_client", "rope", "search", @@ -20442,7 +21239,8 @@ dependencies = [ "snippets_ui", "supermaven", "svg_preview", - "sysinfo", + "sysinfo 0.37.2", + "system_specs", "tab_switcher", "task", "tasks_ui", @@ -20461,22 +21259,132 @@ dependencies = [ "ui_input", "ui_prompt", "url", - "urlencoding", - "util", - "uuid", - "vim", - "vim_mode_setting", - "watch", - "web_search", - "web_search_providers", - "windows 0.61.1", - "winresource", - "workspace", - "workspace-hack", - "zed_actions", - "zeta", - "zlog", - "zlog_settings", + "urlencoding", + "util", + "uuid", + "vim", + "vim_mode_setting", + "watch", + "web_search", + "web_search_providers", + "windows 0.61.3", + "winresource", + "workspace", + "zed-reqwest", + "zed_actions", + "zed_env_vars", + "zeta", + "zeta2", + "zeta2_tools", + "zlog", + "zlog_settings", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" +dependencies = [ + "bitflags 2.9.4", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" +dependencies = [ + "base64 0.22.1", + "bytes 1.10.1", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-rustls 0.27.7", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.33", + "rustls-native-certs 0.8.2", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", + "tokio", + "tokio-rustls 0.26.2", + "tokio-socks", + "tokio-util", + "tower 0.5.2", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.5", + "screencapturekit", + "screencapturekit-sys", + "sysinfo 0.31.4", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "ahash 0.8.12", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", ] [[package]] @@ -20484,10 +21392,16 @@ name = "zed_actions" version = "0.1.0" dependencies = [ "gpui", - "schemars", + "schemars 1.0.4", "serde", "uuid", - "workspace-hack", +] + +[[package]] +name = "zed_env_vars" +version = "0.1.0" +dependencies = [ + "gpui", ] [[package]] @@ -20503,7 +21417,18 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.6.0" +version = "0.7.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen 0.41.0", +] + +[[package]] +name = "zed_extension_api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", @@ -20519,9 +21444,9 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.1" +version = "0.2.3" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -20531,79 +21456,37 @@ dependencies = [ "zed_extension_api 0.1.0", ] -[[package]] -name = "zed_ruff" -version = "0.1.1" -dependencies = [ - "zed_extension_api 0.1.0", -] - -[[package]] -name = "zed_snippets" -version = "0.0.5" -dependencies = [ - "serde_json", - "zed_extension_api 0.1.0", -] - [[package]] name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.6.0", -] - -[[package]] -name = "zed_toml" -version = "0.1.4" -dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] name = "zeno" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc0de2315dc13d00e5df3cd6b8d2124a6eaec6a2d4b6a1c5f37b7efad17fcc17" - -[[package]] -name = "zerocopy" -version = "0.7.35" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.24" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -20623,15 +21506,15 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] @@ -20644,7 +21527,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -20671,26 +21554,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke 0.8.0", + "zerofrom", +] + [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ - "yoke", + "yoke 0.8.0", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", ] [[package]] @@ -20718,13 +21612,15 @@ dependencies = [ "gpui", "http_client", "indoc", + "itertools 0.14.0", "language", "language_model", "log", "menu", + "parking_lot", "postage", "project", - "rand 0.8.5", + "rand 0.9.2", "regex", "release_channel", "reqwest_client", @@ -20732,31 +21628,107 @@ dependencies = [ "serde", "serde_json", "settings", + "strum 0.27.2", "telemetry", "telemetry_events", "theme", - "thiserror 2.0.12", + "thiserror 2.0.17", "tree-sitter-go", "tree-sitter-rust", "ui", - "unindent", "util", "uuid", "workspace", - "workspace-hack", "worktree", "zed_actions", "zlog", ] +[[package]] +name = "zeta2" +version = "0.1.0" +dependencies = [ + "anyhow", + "arrayvec", + "chrono", + "client", + "clock", + "cloud_llm_client", + "cloud_zeta2_prompt", + "collections", + "edit_prediction", + "edit_prediction_context", + "feature_flags", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "language_model", + "log", + "lsp", + "pretty_assertions", + "project", + "release_channel", + "schemars 1.0.4", + "serde", + "serde_json", + "settings", + "thiserror 2.0.17", + "util", + "uuid", + "workspace", + "worktree", +] + +[[package]] +name = "zeta2_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "client", + "cloud_llm_client", + "collections", + "edit_prediction_context", + "editor", + "feature_flags", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "log", + "multi_buffer", + "ordered-float 2.10.1", + "pretty_assertions", + "project", + "regex-syntax", + "serde", + "serde_json", + "settings", + "telemetry", + "text", + "ui", + "ui_input", + "util", + "workspace", + "zeta2", + "zlog", +] + [[package]] name = "zeta_cli" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "client", + "cloud_llm_client", + "cloud_zeta2_prompt", + "collections", "debug_adapter_extension", + "edit_prediction_context", "extension", "fs", "futures 0.3.31", @@ -20767,8 +21739,11 @@ dependencies = [ "language_model", "language_models", "languages", + "log", "node_runtime", + "ordered-float 2.10.1", "paths", + "polars", "project", "prompt_store", "release_channel", @@ -20778,11 +21753,13 @@ dependencies = [ "settings", "shellexpand 2.1.2", "smol", + "soa-rs", "terminal_view", "util", "watch", - "workspace-hack", "zeta", + "zeta2", + "zlog", ] [[package]] @@ -20802,14 +21779,29 @@ dependencies = [ "pbkdf2 0.11.0", "sha1", "time", - "zstd", + "zstd 0.11.2+zstd.1.5.2", +] + +[[package]] +name = "zip" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc23c04387f4da0374be4533ad1208cbb091d5c11d070dfef13676ad6497164" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "indexmap 2.11.4", + "num_enum", + "thiserror 1.0.69", ] [[package]] name = "zlib-rs" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" [[package]] name = "zlog" @@ -20817,21 +21809,18 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "collections", "log", "tempfile", - "workspace-hack", ] [[package]] name = "zlog_settings" version = "0.1.0" dependencies = [ - "anyhow", + "collections", "gpui", - "schemars", - "serde", "settings", - "workspace-hack", "zlog", ] @@ -20841,7 +21830,16 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe", + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.4", ] [[package]] @@ -20854,11 +21852,20 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", @@ -20881,23 +21888,22 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.14" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] [[package]] name = "zvariant" -version = "5.4.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2df9ee044893fcffbdc25de30546edef3e32341466811ca18421e3cd6c5a3ac" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" dependencies = [ "endi", "enumflags2", "serde", - "static_assertions", "url", "winnow", "zvariant_derive", @@ -20906,27 +21912,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.4.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74170caa85b8b84cc4935f2d56a57c7a15ea6185ccdd7eadb57e6edd90f94b2f" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.106", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" dependencies = [ "proc-macro2", "quote", "serde", - "static_assertions", - "syn 2.0.101", + "syn 2.0.106", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 14691cf8a4f3d723e99710b72807ff931c8b7da2..369082ff16736f9f682ad8c5bd09634c03434609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [workspace] resolver = "2" members = [ + "crates/acp_tools", "crates/acp_thread", "crates/action_log", "crates/activity_indicator", "crates/agent", - "crates/agent2", "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", @@ -13,11 +13,9 @@ members = [ "crates/anthropic", "crates/askpass", "crates/assets", - "crates/assistant_context", + "crates/assistant_text_thread", "crates/assistant_slash_command", "crates/assistant_slash_commands", - "crates/assistant_tool", - "crates/assistant_tools", "crates/audio", "crates/auto_update", "crates/auto_update_helper", @@ -34,6 +32,7 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", + "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", @@ -51,8 +50,13 @@ members = [ "crates/debugger_tools", "crates/debugger_ui", "crates/deepseek", + "crates/denoise", "crates/diagnostics", "crates/docs_preprocessor", + "crates/edit_prediction", + "crates/edit_prediction_button", + "crates/edit_prediction_context", + "crates/zeta2_tools", "crates/editor", "crates/eval", "crates/explorer_command_injector", @@ -66,6 +70,7 @@ members = [ "crates/file_finder", "crates/file_icons", "crates/fs", + "crates/fs_benchmarks", "crates/fsevent", "crates/fuzzy", "crates/git", @@ -81,20 +86,20 @@ members = [ "crates/http_client_tls", "crates/icons", "crates/image_viewer", - "crates/edit_prediction", - "crates/edit_prediction_button", "crates/inspector_ui", "crates/install_cli", - "crates/jj", - "crates/jj_ui", "crates/journal", + "crates/json_schema_store", + "crates/keymap_editor", "crates/language", "crates/language_extension", "crates/language_model", "crates/language_models", + "crates/language_onboarding", "crates/language_selector", "crates/language_tools", "crates/languages", + "crates/line_ending_selector", "crates/livekit_api", "crates/livekit_client", "crates/lmstudio", @@ -129,6 +134,7 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/release_channel", + "crates/scheduler", "crates/remote", "crates/remote_server", "crates/repl", @@ -139,10 +145,11 @@ members = [ "crates/rules_library", "crates/schema_generator", "crates/search", - "crates/semantic_index", "crates/semantic_version", "crates/session", "crates/settings", + "crates/settings_json", + "crates/settings_macros", "crates/settings_profile_selector", "crates/settings_ui", "crates/snippet", @@ -156,7 +163,9 @@ members = [ "crates/sum_tree", "crates/supermaven", "crates/supermaven_api", + "crates/codestral", "crates/svg_preview", + "crates/system_specs", "crates/tab_switcher", "crates/task", "crates/tasks_ui", @@ -189,7 +198,9 @@ members = [ "crates/x_ai", "crates/zed", "crates/zed_actions", + "crates/zed_env_vars", "crates/zeta", + "crates/zeta2", "crates/zeta_cli", "crates/zlog", "crates/zlog_settings", @@ -201,17 +212,14 @@ members = [ "extensions/glsl", "extensions/html", "extensions/proto", - "extensions/ruff", "extensions/slash-commands-example", - "extensions/snippets", "extensions/test-extension", - "extensions/toml", # # Tooling # - "tooling/workspace-hack", + "tooling/perf", "tooling/xtask", ] default-members = ["crates/zed"] @@ -226,10 +234,10 @@ edition = "2024" # Workspace member crates # +acp_tools = { path = "crates/acp_tools" } acp_thread = { path = "crates/acp_thread" } action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } -agent2 = { path = "crates/agent2" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } @@ -239,11 +247,9 @@ ai_onboarding = { path = "crates/ai_onboarding" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } assets = { path = "crates/assets" } -assistant_context = { path = "crates/assistant_context" } +assistant_text_thread = { path = "crates/assistant_text_thread" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_commands = { path = "crates/assistant_slash_commands" } -assistant_tool = { path = "crates/assistant_tool" } -assistant_tools = { path = "crates/assistant_tools" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } auto_update_helper = { path = "crates/auto_update_helper" } @@ -260,9 +266,10 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } +cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } -collections = { path = "crates/collections" } +collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } component = { path = "crates/component" } @@ -270,6 +277,7 @@ context_server = { path = "crates/context_server" } copilot = { path = "crates/copilot" } crashes = { path = "crates/crashes" } credentials_provider = { path = "crates/credentials_provider" } +crossbeam = "0.8.4" dap = { path = "crates/dap" } dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } @@ -277,6 +285,7 @@ debug_adapter_extension = { path = "crates/debug_adapter_extension" } debugger_tools = { path = "crates/debugger_tools" } debugger_ui = { path = "crates/debugger_ui" } deepseek = { path = "crates/deepseek" } +derive_refineable = { path = "crates/refineable/derive_refineable" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } extension = { path = "crates/extension" } @@ -294,9 +303,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } -gpui = { path = "crates/gpui", default-features = false, features = [ - "http_client", -] } +gpui = { path = "crates/gpui", default-features = false } gpui_macros = { path = "crates/gpui_macros" } gpui_tokio = { path = "crates/gpui_tokio" } html_to_markdown = { path = "crates/html_to_markdown" } @@ -306,18 +313,22 @@ icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } edit_prediction = { path = "crates/edit_prediction" } edit_prediction_button = { path = "crates/edit_prediction_button" } +edit_prediction_context = { path = "crates/edit_prediction_context" } +zeta2_tools = { path = "crates/zeta2_tools" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } -jj = { path = "crates/jj" } -jj_ui = { path = "crates/jj_ui" } journal = { path = "crates/journal" } +json_schema_store = { path = "crates/json_schema_store" } +keymap_editor = { path = "crates/keymap_editor" } language = { path = "crates/language" } language_extension = { path = "crates/language_extension" } language_model = { path = "crates/language_model" } language_models = { path = "crates/language_models" } +language_onboarding = { path = "crates/language_onboarding" } language_selector = { path = "crates/language_selector" } language_tools = { path = "crates/language_tools" } languages = { path = "crates/languages" } +line_ending_selector = { path = "crates/line_ending_selector" } livekit_api = { path = "crates/livekit_api" } livekit_client = { path = "crates/livekit_client" } lmstudio = { path = "crates/lmstudio" } @@ -342,6 +353,7 @@ outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } panel = { path = "crates/panel" } paths = { path = "crates/paths" } +perf = { path = "tooling/perf" } picker = { path = "crates/picker" } plugin = { path = "crates/plugin" } plugin_macros = { path = "crates/plugin_macros" } @@ -355,20 +367,22 @@ proto = { path = "crates/proto" } recent_projects = { path = "crates/recent_projects" } refineable = { path = "crates/refineable" } release_channel = { path = "crates/release_channel" } +scheduler = { path = "crates/scheduler" } remote = { path = "crates/remote" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } rich_text = { path = "crates/rich_text" } -rodio = { version = "0.21.1", default-features = false } +rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } search = { path = "crates/search" } -semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } session = { path = "crates/session" } settings = { path = "crates/settings" } +settings_json = { path = "crates/settings_json" } +settings_macros = { path = "crates/settings_macros" } settings_ui = { path = "crates/settings_ui" } snippet = { path = "crates/snippet" } snippet_provider = { path = "crates/snippet_provider" } @@ -381,6 +395,8 @@ streaming_diff = { path = "crates/streaming_diff" } sum_tree = { path = "crates/sum_tree" } supermaven = { path = "crates/supermaven" } supermaven_api = { path = "crates/supermaven_api" } +codestral = { path = "crates/codestral" } +system_specs = { path = "crates/system_specs" } tab_switcher = { path = "crates/tab_switcher" } task = { path = "crates/task" } tasks_ui = { path = "crates/tasks_ui" } @@ -414,7 +430,9 @@ worktree = { path = "crates/worktree" } x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } +zed_env_vars = { path = "crates/zed_env_vars" } zeta = { path = "crates/zeta" } +zeta2 = { path = "crates/zeta2" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } @@ -422,10 +440,9 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agentic-coding-protocol = "0.0.10" -agent-client-protocol = "0.0.26" +agent-client-protocol = { version = "0.7.0", features = ["unstable"] } aho-corasick = "1.1" -alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } +alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } @@ -434,11 +451,13 @@ async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" async-fs = "2.1" +async-lock = "2.1" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } async-recursion = "1.0.0" -async-tar = "0.5.0" +async-tar = "0.5.1" +async-task = "4.7" async-trait = "0.1" -async-tungstenite = "0.29.1" +async-tungstenite = "0.31.0" async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] } aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } aws-credential-types = { version = "1.2.2", features = [ @@ -449,23 +468,25 @@ aws-sdk-bedrockruntime = { version = "1.80.0", features = [ ] } aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } +backtrace = "0.3" base64 = "0.22" +bincode = "1.2.1" bitflags = "2.6.0" -blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } -blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } -blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" } -blake3 = "1.5.3" +blade-graphics = { version = "0.7.0" } +blade-macros = { version = "0.3.0" } +blade-util = { version = "0.3.0" } bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" +cfg-if = "1.0.3" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" clap = { version = "4.4", features = ["derive"] } -cocoa = "0.26" -cocoa-foundation = "0.2.0" +cocoa = "=0.26.0" +cocoa-foundation = "=0.2.0" convert_case = "0.8.0" -core-foundation = "0.10.0" +core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } cpal = "0.16" @@ -487,12 +508,15 @@ fork = "0.2.0" futures = "0.3" futures-batch = "0.6.1" futures-lite = "1.13" +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "0090c6b6ef82fff02bc8616645953e778d1acc08" } git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" +hashbrown = "0.15.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" +human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" @@ -504,7 +528,6 @@ indexmap = { version = "2.7.0", features = ["serde"] } indoc = "2" inventory = "0.3.19" itertools = "0.14.0" -jj-lib = { git = "https://github.com/jj-vcs/jj", rev = "e18eb8e05efaa153fad5ef46576af145bba1807f" } json_dotpath = "1.1" jsonschema = "0.30.0" jsonwebtoken = "9.3" @@ -514,7 +537,8 @@ libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } -lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" } +lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "b71ab4eeb27d9758be8092020a46fe33fbca4e33" } +mach2 = "0.5" markup5ever_rcdom = "0.3.0" metal = "0.29" minidumper = "0.8" @@ -524,21 +548,49 @@ nanoid = "0.4" nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" } nix = "0.29" num-format = "0.4.4" +num-traits = "0.2" objc = "0.2" +objc2-foundation = { version = "=0.3.1", default-features = false, features = [ + "NSArray", + "NSAttributedString", + "NSBundle", + "NSCoder", + "NSData", + "NSDate", + "NSDictionary", + "NSEnumerator", + "NSError", + "NSGeometry", + "NSNotification", + "NSNull", + "NSObjCRuntime", + "NSObject", + "NSProcessInfo", + "NSRange", + "NSRunLoop", + "NSString", + "NSURL", + "NSUndoManager", + "NSValue", + "objc2-core-foundation", + "std" +] } open = "5.0.0" ordered-float = "2.1.1" palette = { version = "0.7.5", default-features = false, features = ["std"] } parking_lot = "0.12.1" partial-json-fixer = "0.5.3" parse_int = "0.9" +pciid-parser = "0.8.0" pathdiff = "0.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" } +pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } +pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "e97b9508befa0062929da65a01054d25c4be861c" } portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } @@ -549,11 +601,12 @@ prost-build = "0.9" prost-types = "0.9" pulldown-cmark = { version = "0.12.0", default-features = false } quote = "1.0.9" -rand = "0.8.5" +rand = "0.9" rayon = "1.8" ref-cast = "1.0.24" regex = "1.5" -reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c770a32f1998d6e999cef3e59e0013e6c4415", default-features = false, features = [ +# WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io +reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ "charset", "http2", "macos-system-configuration", @@ -561,41 +614,45 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c77 "rustls-tls-native-roots", "socks", "stream", -] } +], package = "zed-reqwest", version = "0.12.15-zed" } rsa = "0.9.6" runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [ "async-dispatcher-runtime", ] } rust-embed = { version = "8.4", features = ["include-exclude"] } -rustc-demangle = "0.1.23" rustc-hash = "2.1.0" rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" -scap = { git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7", default-features = false } +# WARNING: If you change this, you must also publish a new version of zed-scap to crates.io +scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } schemars = { version = "1.0", features = ["indexmap2"] } semver = "1.0" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_derive = { version = "1.0", features = ["deserialize_in_place"] } -serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } +serde = { version = "1.0.221", features = ["derive", "rc"] } +serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } serde_json_lenient = { version = "0.2", features = [ "preserve_order", "raw_value", ] } +serde_path_to_error = "0.1.17" serde_repr = "0.1" +serde_urlencoded = "0.7" +serde_with = "3.4.0" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" simplelog = "0.12.2" +slotmap = "1.0.6" smallvec = { version = "1.6", features = ["union"] } smol = "2.0" sqlformat = "0.2" +stacksafe = "0.1" streaming-iterator = "0.1" strsim = "0.11" -strum = { version = "0.27.0", features = ["derive"] } +strum = { version = "0.27.2", features = ["derive"] } subtle = "2.5.0" -syn = { version = "2.0.101", features = ["full", "extra-traits"] } +syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } sys-locale = "0.3.1" -sysinfo = "0.31.0" +sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" @@ -611,11 +668,12 @@ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } toml = "0.8" +toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" -tree-sitter = { version = "0.25.6", features = ["wasm"] } +tree-sitter = { version = "0.25.10", features = ["wasm"] } tree-sitter-bash = "0.25.0" tree-sitter-c = "0.23" -tree-sitter-cpp = "0.23" +tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" tree-sitter-diff = "0.1.0" tree-sitter-elixir = "0.3" @@ -629,11 +687,11 @@ tree-sitter-html = "0.23" tree-sitter-jsdoc = "0.23" tree-sitter-json = "0.24" tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" } -tree-sitter-python = { git = "https://github.com/zed-industries/tree-sitter-python", rev = "218fcbf3fda3d029225f3dec005cb497d111b35e" } +tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24" -tree-sitter-typescript = "0.23" +tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } unicase = "2.6" unicode-script = "0.5.7" @@ -658,10 +716,8 @@ wasmtime-wasi = "29" which = "6.0.0" windows-core = "0.61" wit-component = "0.221" -workspace-hack = "0.1.0" -# We can switch back to the published version once https://github.com/infinitefield/yawc/pull/16 is merged and a new -# version is released. -yawc = { git = "https://github.com/deviant-forks/yawc", rev = "1899688f3e69ace4545aceb97b2a13881cf26142" } +yawc = "0.2.5" +zeroize = "1.8" zstd = "0.11" [workspace.dependencies.windows] @@ -684,9 +740,11 @@ features = [ "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_Imaging", + "Win32_Graphics_Hlsl", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Credentials", + "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Com_StructuredStorage", @@ -718,11 +776,10 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5a notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } -# Makes the workspace hack crate refer to the local one, but only when you're building locally -workspace-hack = { path = "tooling/workspace-hack" } - [profile.dev] split-debuginfo = "unpacked" +# https://github.com/rust-lang/cargo/issues/16104 +incremental = false codegen-units = 16 # mirror configuration for crates compiled for the build platform @@ -755,6 +812,7 @@ image_viewer = { codegen-units = 1 } edit_prediction_button = { codegen-units = 1 } install_cli = { codegen-units = 1 } journal = { codegen-units = 1 } +json_schema_store = { codegen-units = 1 } lmstudio = { codegen-units = 1 } menu = { codegen-units = 1 } notifications = { codegen-units = 1 } @@ -801,39 +859,34 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so -# warning on this rule produces a lot of noise. -single_range_in_vec_init = "allow" +# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 +# Remove when the lint gets promoted to `suspicious`. +declare_interior_mutable_const = "deny" + +redundant_clone = "deny" +disallowed_methods = "deny" -# These are all of the rules that currently have violations in the Zed -# codebase. +# We currently do not restrict any style rules +# as it slows down shipping code to Zed. # -# We'll want to drive this list down by either: -# 1. fixing violations of the rule and begin enforcing it -# 2. deciding we want to allow the rule permanently, at which point -# we should codify that separately above. +# Running ./script/clippy can take several minutes, and so it's +# common to skip that step and let CI do it. Any unexpected failures +# (which also take minutes to discover) thus require switching back +# to an old branch, manual fixing, and re-pushing. # -# This list shouldn't be added to; it should only get shorter. -# ============================================================================= - -# There are a bunch of rules currently failing in the `style` group, so -# allow all of those, for now. +# In the future we could improve this by either making sure +# Zed can surface clippy errors in diagnostics (in addition to the +# rust-analyzer errors), or by having CI fix style nits automatically. style = { level = "allow", priority = -1 } -# Temporary list of style lints that we've fixed so far. -module_inception = { level = "deny" } -question_mark = { level = "deny" } -redundant_closure = { level = "deny" } -declare_interior_mutable_const = { level = "deny" } # Individual rules that have violations in the codebase: type_complexity = "allow" -# We often return trait objects from `new` functions. -new_ret_no_self = { level = "allow" } -# We have a few `next` functions that differ in lifetimes -# compared to Iterator::next. Yet, clippy complains about those. -should_implement_trait = { level = "allow" } let_underscore_future = "allow" +# Motivation: We use `vec![a..b]` a lot when dealing with ranges in text, so +# warning on this rule produces a lot of noise. +single_range_in_vec_init = "allow" + # in Rust it can be very tedious to reduce argument count without # running afoul of the borrow checker. too_many_arguments = "allow" @@ -841,6 +894,9 @@ too_many_arguments = "allow" # We often have large enum variants yet we rarely actually bother with splitting them up. large_enum_variant = "allow" +# Boolean expressions can be hard to read, requiring only the minimal form gets in the way +nonminimal_bool = "allow" + [workspace.metadata.cargo-machete] ignored = [ "bindgen", @@ -849,5 +905,5 @@ ignored = [ "serde", "component", "documented", - "workspace-hack", + "sea-orm-macros", ] diff --git a/Cross.toml b/Cross.toml deleted file mode 100644 index b5f0f1103af2ba6956c7910a7196ddd13788bf46..0000000000000000000000000000000000000000 --- a/Cross.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -dockerfile = "Dockerfile-cross" diff --git a/Dockerfile-collab b/Dockerfile-collab index c1621d6ee67e42117315ea49eac99f6f6260f4b7..a85fe93f198475534cb7396abe594f9d02eeb57b 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.89-bookworm as builder +FROM rust:1.90-bookworm as builder WORKDIR app COPY . . diff --git a/Dockerfile-cross b/Dockerfile-cross deleted file mode 100644 index 488309641caed52c4b15d7367fd42bfab1a14418..0000000000000000000000000000000000000000 --- a/Dockerfile-cross +++ /dev/null @@ -1,17 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG CROSS_BASE_IMAGE -FROM ${CROSS_BASE_IMAGE} -WORKDIR /app -ARG TZ=Etc/UTC \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive -ENV CARGO_TERM_COLOR=always - -COPY script/install-mold script/ -RUN ./script/install-mold "2.34.0" -COPY script/remote-server script/ -RUN ./script/remote-server - -COPY . . diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000000000000000000000000000000000000..8a63b64bdb0afcda986ba715cb39849ac574e096 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +.rules \ No newline at end of file diff --git a/Procfile.postgrest b/Procfile.postgrest deleted file mode 100644 index acab58e086ca15426b58529e2055b4126f65467a..0000000000000000000000000000000000000000 --- a/Procfile.postgrest +++ /dev/null @@ -1,2 +0,0 @@ -app: postgrest crates/collab/postgrest_app.conf -llm: postgrest crates/collab/postgrest_llm.conf diff --git a/Procfile.web b/Procfile.web new file mode 100644 index 0000000000000000000000000000000000000000..63190fc2ee1f57b3576236fafa08554b9e67b575 --- /dev/null +++ b/Procfile.web @@ -0,0 +1 @@ +website: cd ../zed.dev; npm run dev -- --port=3000 diff --git a/README.md b/README.md index 38547c1ca441b918b773d8b1a884a1e3f48c785f..adc152b7af163b3c90c73a23e0f45bab1120bddc 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,10 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS and Linux you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). Other platforms are not yet available: -- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) - Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) ### Developing Zed diff --git a/REVIEWERS.conl b/REVIEWERS.conl new file mode 100644 index 0000000000000000000000000000000000000000..78563fe466f38c644cd6a19c76ffe231a086fd56 --- /dev/null +++ b/REVIEWERS.conl @@ -0,0 +1,112 @@ +; This file contains a list of people who're interested in reviewing pull requests +; to certain parts of the code-base. +; +; This is mostly used internally for PR assignment, and may change over time. +; +; If you have permission to merge PRs (mostly equivalent to "do you work at Zed Industries"), +; we strongly encourage you to put your name in the "all" bucket, but you can also add yourself +; to other areas too. + + + = @ConradIrwin + = @maxdeviant + = @SomeoneToIgnore + = @probably-neb + = @danilo-leal + = @Veykril + = @kubkon + = @p1n3appl3 + = @dinocosta + = @smitbarmase + = @cole-miller + +vim + = @ConradIrwin + = @probably-neb + = @p1n3appl3 + = @dinocosta + +gpui + = @mikayla-maki + +git + = @cole-miller + = @danilo-leal + +linux + = @dvdsk + = @smitbarmase + = @p1n3appl3 + = @cole-miller + = @probably-neb + +windows + = @reflectronic + = @localcc + +pickers + = @p1n3appl3 + = @dvdsk + = @SomeoneToIgnore + +audio + = @dvdsk + +helix + = @kubkon + +terminal + = @kubkon + = @Veykril + +debugger + = @kubkon + = @osiewicz + = @Anthony-Eid + +extension + = @kubkon + +settings_ui + = @probably-neb + = @danilo-leal + = @Anthony-Eid + +crashes + = @p1n3appl3 + = @Veykril + +ai + = @rtfeldman + = @danilo-leal + = @benbrandt + +design + = @danilo-leal + +multi_buffer + = @Veykril + = @SomeoneToIgnore + +lsp + = @osiewicz + = @Veykril + = @smitbarmase + = @SomeoneToIgnore + +languages + = @osiewicz + = @Veykril + = @smitbarmase + = @SomeoneToIgnore + = @probably-neb + +project_panel + = @smitbarmase + +tasks + = @SomeoneToIgnore + = @Veykril + +docs + = @probably-neb diff --git a/assets/icons/attach.svg b/assets/icons/attach.svg new file mode 100644 index 0000000000000000000000000000000000000000..f923a3c7c8841fd358cf940d99e7371f010a6f4d --- /dev/null +++ b/assets/icons/attach.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index bca13f8d56a1b644051c5be2f17c0e4cc1cdb43b..aba193930bd1e93062b1e7eef3e4a0de2e7f4ab6 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg index 338697be8a621e80099c308b3dda0a4e11fcfd61..e20013917d3c8b9d28f4fab631ae2fbd99b9297f 100644 --- a/assets/icons/editor_cursor.svg +++ b/assets/icons/editor_cursor.svg @@ -1,9 +1,3 @@ - - - - - - - + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 0000000000000000000000000000000000000000..739d41b231f0e01945fc1fd526632964f921a938 --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/linux.svg b/assets/icons/linux.svg new file mode 100644 index 0000000000000000000000000000000000000000..fc76742a3f236650cb8c514c8263ec2c3b2d4521 --- /dev/null +++ b/assets/icons/linux.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/list_filter.svg b/assets/icons/list_filter.svg new file mode 100644 index 0000000000000000000000000000000000000000..82f41f5f6832a8cb35e2703e0f8ce36d148454dd --- /dev/null +++ b/assets/icons/list_filter.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg index f73102e286c51e5c52fcec40cb976a3bd6a981cf..b9cc19e22febe045ca9ccf4a7e86d69b258f875c 100644 --- a/assets/icons/menu_alt.svg +++ b/assets/icons/menu_alt.svg @@ -1 +1,3 @@ - + + + diff --git a/assets/icons/menu_alt_temp.svg b/assets/icons/menu_alt_temp.svg new file mode 100644 index 0000000000000000000000000000000000000000..87add13216d9eb8c4c3d8f345ff1695e98be2d5d --- /dev/null +++ b/assets/icons/menu_alt_temp.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/paperclip.svg b/assets/icons/paperclip.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a864103c013823096b523f3e0f56db2d7e76009 --- /dev/null +++ b/assets/icons/paperclip.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/pencil_unavailable.svg b/assets/icons/pencil_unavailable.svg new file mode 100644 index 0000000000000000000000000000000000000000..4241d766ace9ec5873553e0c1d77b8c19f6caa79 --- /dev/null +++ b/assets/icons/pencil_unavailable.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/terminal_ghost.svg b/assets/icons/terminal_ghost.svg new file mode 100644 index 0000000000000000000000000000000000000000..7d0d0e068e8a6f01837e860e8223690a95541769 --- /dev/null +++ b/assets/icons/terminal_ghost.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_think.svg b/assets/icons/tool_think.svg index efd5908a907b21c573ebc69fc13f5a210ab5d848..773f5e7fa7795d7bc56bba061d808418897f9287 100644 --- a/assets/icons/tool_think.svg +++ b/assets/icons/tool_think.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg index c714b58747e950ab75d3a02be7eebfe7cd83eda1..ccd45e246c6911c57cb2659764db6e1dc11bf0cb 100644 --- a/assets/icons/undo.svg +++ b/assets/icons/undo.svg @@ -1 +1,4 @@ - + + + + diff --git a/assets/icons/x_circle_filled.svg b/assets/icons/x_circle_filled.svg new file mode 100644 index 0000000000000000000000000000000000000000..52215acda8a6b7fc57820fa90f6ed405e6af637c --- /dev/null +++ b/assets/icons/x_circle_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/zed_agent.svg b/assets/icons/zed_agent.svg new file mode 100644 index 0000000000000000000000000000000000000000..0c80e22c51233fff40b7605d0835b463786b4e84 --- /dev/null +++ b/assets/icons/zed_agent.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg index 470eb0fedeab7535287db64b601b5dfd99b6c05d..812277a100b7e6e4ad44de357fc3556b686a90a0 100644 --- a/assets/icons/zed_assistant.svg +++ b/assets/icons/zed_assistant.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/images/acp_grid.svg b/assets/images/acp_grid.svg new file mode 100644 index 0000000000000000000000000000000000000000..8ebff8e1bc87b17e536c7f97dfa2118130233258 --- /dev/null +++ b/assets/images/acp_grid.svg @@ -0,0 +1,1257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/acp_logo.svg b/assets/images/acp_logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..efaa46707be0a893917c3fc072a14b9c7b6b0c9b --- /dev/null +++ b/assets/images/acp_logo.svg @@ -0,0 +1 @@ + diff --git a/assets/images/acp_logo_serif.svg b/assets/images/acp_logo_serif.svg new file mode 100644 index 0000000000000000000000000000000000000000..a04d32e51c43acf358baa733f03284dbb6de1369 --- /dev/null +++ b/assets/images/acp_logo_serif.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 01c0b4e9696f3ee31d599f171acd27f4c00fdf3c..979e5a6ccc1d4520db65981fb3b8a01094f9c625 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -31,6 +31,7 @@ "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], "ctrl-,": "zed::OpenSettings", + "ctrl-alt-,": "zed::OpenSettingsFile", "ctrl-q": "zed::Quit", "f4": "debugger::Start", "shift-f5": "debugger::Stop", @@ -41,7 +42,7 @@ "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RateCompletions", - "ctrl-shift-i": "edit_prediction::ToggleMenu", + "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", "ctrl-alt-l": "lsp_tool::ToggleMenu" } }, @@ -64,8 +65,8 @@ "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "cut": "editor::Cut", "shift-delete": "editor::Cut", "ctrl-x": "editor::Cut", @@ -121,7 +122,7 @@ "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction", + "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", "shift-f9": "editor::EditLogBreakpoint" } @@ -131,14 +132,14 @@ "bindings": { "shift-enter": "editor::Newline", "enter": "editor::Newline", - "ctrl-enter": "editor::NewlineAbove", - "ctrl-shift-enter": "editor::NewlineBelow", + "ctrl-enter": "editor::NewlineBelow", + "ctrl-shift-enter": "editor::NewlineAbove", "ctrl-k ctrl-z": "editor::ToggleSoftWrap", "ctrl-k z": "editor::ToggleSoftWrap", "find": "buffer_search::Deploy", "ctrl-f": "buffer_search::Deploy", "ctrl-h": "buffer_search::DeployReplace", - "ctrl->": "assistant::QuoteSelection", + "ctrl->": "agent::AddSelectionToThread", "ctrl-<": "assistant::InsertIntoEditor", "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", @@ -171,6 +172,7 @@ "context": "Markdown", "bindings": { "copy": "markdown::Copy", + "ctrl-insert": "markdown::Copy", "ctrl-c": "markdown::Copy" } }, @@ -241,12 +243,15 @@ "ctrl-shift-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl->": "assistant::QuoteSelection", + "ctrl->": "agent::AddSelectionToThread", "ctrl-alt-e": "agent::RemoveAllContext", "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-enter": "agent::ContinueThread", "super-ctrl-b": "agent::ToggleBurnMode", - "alt-enter": "agent::ContinueWithBurnMode" + "alt-enter": "agent::ContinueWithBurnMode", + "ctrl-y": "agent::AllowOnce", + "ctrl-alt-y": "agent::AllowAlways", + "ctrl-alt-z": "agent::RejectOnce" } }, { @@ -259,18 +264,19 @@ "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", + "ctrl-insert": "markdown::CopyAsMarkdown", "ctrl-c": "markdown::CopyAsMarkdown" } }, { - "context": "AgentPanel && prompt_editor", + "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", "ctrl-alt-t": "agent::NewThread" } }, { - "context": "AgentPanel && external_agent_thread", + "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", @@ -327,7 +333,13 @@ } }, { - "context": "AcpThread > Editor", + "context": "AcpThread > ModeSelector", + "bindings": { + "ctrl-enter": "menu::Confirm" + } + }, + { + "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", @@ -336,6 +348,17 @@ "ctrl-shift-n": "agent::RejectAll" } }, + { + "context": "AcpThread > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "agent::Chat", + "shift-ctrl-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" + } + }, { "context": "ThreadHistory", "bindings": { @@ -343,11 +366,12 @@ } }, { - "context": "PromptLibrary", + "context": "RulesLibrary", "bindings": { "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule" + "ctrl-shift-s": "rules_library::ToggleDefaultRule", + "ctrl-w": "workspace::CloseWindow" } }, { @@ -440,8 +464,8 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "ctrl-alt--": "pane::GoBack", - "ctrl-alt-_": "pane::GoForward", "forward": "pane::GoForward", + "ctrl-alt-_": "pane::GoForward", "ctrl-alt-g": "search::SelectNextMatch", "f3": "search::SelectNextMatch", "ctrl-alt-shift-g": "search::SelectPreviousMatch", @@ -467,15 +491,15 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above - "shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below + "shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", "ctrl-alt-shift-up": "editor::DuplicateLineUp", "ctrl-alt-shift-down": "editor::DuplicateLineDown", - "alt-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection - "alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "alt-shift-right": "editor::SelectLargerSyntaxNode", // Expand selection + "alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink selection "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand @@ -503,15 +527,15 @@ "ctrl-k ctrl-l": "editor::ToggleFold", "ctrl-k ctrl-[": "editor::FoldRecursive", "ctrl-k ctrl-]": "editor::UnfoldRecursive", - "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1], - "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2], - "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3], - "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4], - "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5], - "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6], - "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7], - "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8], - "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9], + "ctrl-k ctrl-1": "editor::FoldAtLevel_1", + "ctrl-k ctrl-2": "editor::FoldAtLevel_2", + "ctrl-k ctrl-3": "editor::FoldAtLevel_3", + "ctrl-k ctrl-4": "editor::FoldAtLevel_4", + "ctrl-k ctrl-5": "editor::FoldAtLevel_5", + "ctrl-k ctrl-6": "editor::FoldAtLevel_6", + "ctrl-k ctrl-7": "editor::FoldAtLevel_7", + "ctrl-k ctrl-8": "editor::FoldAtLevel_8", + "ctrl-k ctrl-9": "editor::FoldAtLevel_9", "ctrl-k ctrl-0": "editor::FoldAll", "ctrl-k ctrl-j": "editor::UnfoldAll", "ctrl-space": "editor::ShowCompletions", @@ -571,7 +595,7 @@ "ctrl-n": "workspace::NewFile", "shift-new": "workspace::NewWindow", "ctrl-shift-n": "workspace::NewWindow", - "ctrl-`": "terminal_panel::ToggleFocus", + "ctrl-`": "terminal_panel::Toggle", "f10": ["app_menu::OpenApplicationMenu", "Zed"], "alt-1": ["workspace::ActivatePane", 0], "alt-2": ["workspace::ActivatePane", 1], @@ -585,7 +609,7 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", - "ctrl-alt-y": "workspace::CloseAllDocks", + "ctrl-alt-y": "workspace::ToggleAllDocks", "ctrl-alt-0": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. "ctrl-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], @@ -597,7 +621,7 @@ "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-shift-t": "pane::ReopenClosedItem", - "ctrl-k ctrl-s": "zed::OpenKeymapEditor", + "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", @@ -616,6 +640,7 @@ "alt-save": "workspace::SaveAll", "ctrl-alt-s": "workspace::SaveAll", "ctrl-k m": "language_selector::Toggle", + "ctrl-k ctrl-m": "toolchain::AddToolchain", "escape": "workspace::Unfollow", "ctrl-k ctrl-left": "workspace::ActivatePaneLeft", "ctrl-k ctrl-right": "workspace::ActivatePaneRight", @@ -626,7 +651,9 @@ "ctrl-k shift-up": "workspace::SwapPaneUp", "ctrl-k shift-down": "workspace::SwapPaneDown", "ctrl-shift-x": "zed::Extensions", - "ctrl-shift-r": "task::Rerun", + // All task parameters are captured and unchanged between reruns by default. + // Use the `"reevaluate_context"` parameter to control this. + "ctrl-shift-r": ["task::Rerun", { "reevaluate_context": false }], "ctrl-alt-r": "task::Rerun", "alt-t": "task::Rerun", "alt-shift-t": "task::Spawn", @@ -704,6 +731,14 @@ "tab": "editor::ComposeCompletion" } }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, // Bindings for accepting edit predictions // // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is @@ -846,7 +881,7 @@ "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-ctrl-r": "project_panel::RevealInFileManager", - "ctrl-shift-enter": "project_panel::OpenWithSystem", + "ctrl-shift-enter": "workspace::OpenWithSystem", "alt-d": "project_panel::CompareMarkedFiles", "shift-find": "project_panel::NewSearchInDirectory", "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", @@ -985,7 +1020,8 @@ "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", - "alt-down": "collab_panel::MoveChannelDown" + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes" } }, { @@ -1016,6 +1052,13 @@ "tab": "channel_modal::ToggleMode" } }, + { + "context": "ToolchainSelector", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-a": "toolchain::AddToolchain" + } + }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { @@ -1043,6 +1086,13 @@ "ctrl-backspace": "tab_switcher::CloseSelectedItem" } }, + { + "context": "StashList || (StashList > Picker > Editor)", + "bindings": { + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem" + } + }, { "context": "Terminal", "bindings": { @@ -1085,7 +1135,8 @@ "ctrl-shift-space": "terminal::ToggleViMode", "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", - "alt-t": "terminal::RerunTask" + "alt-t": "terminal::RerunTask", + "ctrl-shift-5": "pane::SplitRight" } }, { @@ -1102,6 +1153,13 @@ "ctrl-enter": "menu::Confirm" } }, + { + "context": "ContextServerToolsModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1182,12 +1240,80 @@ "context": "Onboarding", "use_key_equivalents": true, "bindings": { - "ctrl-1": "onboarding::ActivateBasicsPage", - "ctrl-2": "onboarding::ActivateEditingPage", - "ctrl-3": "onboarding::ActivateAISetupPage", - "ctrl-escape": "onboarding::Finish", - "alt-tab": "onboarding::SignIn", + "ctrl-enter": "onboarding::Finish", + "alt-shift-l": "onboarding::SignIn", "alt-shift-a": "onboarding::OpenAccount" } + }, + { + "context": "InvalidBuffer", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-enter": "workspace::OpenWithSystem" + } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "ctrl-m": "settings_editor::Minimize", + "ctrl-f": "search::FocusSearch", + "left": "settings_editor::ToggleFocusNav", + "ctrl-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "ctrl-pageup": "settings_editor::FocusPreviousFile", + "ctrl-pagedown": "settings_editor::FocusNextFile" + } + }, + { + "context": "StashDiff > Editor", + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash" + } + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry" + } + }, + { + "context": "Zeta2Feedback > Editor", + "bindings": { + "enter": "editor::Newline", + "ctrl-enter up": "dev::Zeta2RatePredictionPositive", + "ctrl-enter down": "dev::Zeta2RatePredictionNegative" + } + }, + { + "context": "Zeta2Context > Editor", + "bindings": { + "alt-left": "dev::Zeta2ContextGoBack", + "alt-right": "dev::Zeta2ContextGoForward" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index e5b7fff9e1ce269f4f1c2f630f6bd41d790ffd21..4f9b85ff03790a8c9a59a657a3e0ca0710d41e25 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -40,6 +40,7 @@ "cmd--": ["zed::DecreaseBufferFontSize", { "persist": false }], "cmd-0": ["zed::ResetBufferFontSize", { "persist": false }], "cmd-,": "zed::OpenSettings", + "cmd-alt-,": "zed::OpenSettingsFile", "cmd-q": "zed::Quit", "cmd-h": "zed::Hide", "alt-cmd-h": "zed::HideOthers", @@ -70,9 +71,9 @@ "cmd-k q": "editor::Rewrap", "cmd-backspace": "editor::DeleteToBeginningOfLine", "cmd-delete": "editor::DeleteToEndOfLine", - "alt-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-w": "editor::DeleteToPreviousWordStart", - "alt-delete": "editor::DeleteToNextWordEnd", + "alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "cmd-x": "editor::Cut", "cmd-c": "editor::Copy", "cmd-v": "editor::Paste", @@ -162,7 +163,7 @@ "cmd-alt-f": "buffer_search::DeployReplace", "cmd-alt-l": ["buffer_search::Deploy", { "selection_search_enabled": true }], "cmd-e": ["buffer_search::Deploy", { "focus": false }], - "cmd->": "assistant::QuoteSelection", + "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", "alt-enter": "editor::OpenSelectionsInMultibuffer" @@ -218,7 +219,7 @@ } }, { - "context": "Editor && !agent_diff", + "context": "Editor && !agent_diff && !AgentPanel", "use_key_equivalents": true, "bindings": { "cmd-alt-z": "git::Restore", @@ -281,12 +282,15 @@ "cmd-shift-i": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", "shift-alt-escape": "agent::ExpandMessageEditor", - "cmd->": "assistant::QuoteSelection", + "cmd->": "agent::AddSelectionToThread", "cmd-alt-e": "agent::RemoveAllContext", "cmd-shift-e": "project_panel::ToggleFocus", "cmd-ctrl-b": "agent::ToggleBurnMode", "cmd-shift-enter": "agent::ContinueThread", - "alt-enter": "agent::ContinueWithBurnMode" + "alt-enter": "agent::ContinueWithBurnMode", + "cmd-y": "agent::AllowOnce", + "cmd-alt-y": "agent::AllowAlways", + "cmd-alt-z": "agent::RejectOnce" } }, { @@ -303,7 +307,7 @@ } }, { - "context": "AgentPanel && prompt_editor", + "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", @@ -311,7 +315,7 @@ } }, { - "context": "AgentPanel && external_agent_thread", + "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", @@ -379,13 +383,31 @@ } }, { - "context": "AcpThread > Editor", + "context": "AcpThread > ModeSelector", + "bindings": { + "cmd-enter": "menu::Confirm" + } + }, + { + "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" + "cmd-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" + } + }, + { + "context": "AcpThread > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "cmd-enter": "agent::Chat", + "shift-ctrl-r": "agent::OpenAgentDiff", + "cmd-shift-y": "agent::KeepAll", + "cmd-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" } }, { @@ -401,7 +423,7 @@ } }, { - "context": "PromptLibrary", + "context": "RulesLibrary", "use_key_equivalents": true, "bindings": { "cmd-n": "rules_library::NewRule", @@ -517,17 +539,21 @@ "bindings": { "cmd-[": "editor::Outdent", "cmd-]": "editor::Indent", - "cmd-ctrl-p": "editor::AddSelectionAbove", // Insert cursor above - "cmd-alt-up": "editor::AddSelectionAbove", - "cmd-ctrl-n": "editor::AddSelectionBelow", // Insert cursor below - "cmd-alt-down": "editor::AddSelectionBelow", + "cmd-ctrl-p": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], // Insert cursor above + "cmd-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], + "cmd-ctrl-n": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], // Insert cursor below + "cmd-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], "cmd-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", "alt-shift-up": "editor::DuplicateLineUp", "alt-shift-down": "editor::DuplicateLineDown", - "ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection - "ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "cmd-ctrl-left": "editor::SelectSmallerSyntaxNode", // Shrink selection + "cmd-ctrl-right": "editor::SelectLargerSyntaxNode", // Expand selection + "cmd-ctrl-up": "editor::SelectPreviousSyntaxNode", // Move selection up + "ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand selection (VSCode version) + "ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink selection (VSCode version) + "cmd-ctrl-down": "editor::SelectNextSyntaxNode", // Move selection down "cmd-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "cmd-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "cmd-f2": "editor::SelectAllMatches", // Select all occurrences of current word @@ -556,15 +582,15 @@ "cmd-k cmd-l": "editor::ToggleFold", "cmd-k cmd-[": "editor::FoldRecursive", "cmd-k cmd-]": "editor::UnfoldRecursive", - "cmd-k cmd-1": ["editor::FoldAtLevel", 1], - "cmd-k cmd-2": ["editor::FoldAtLevel", 2], - "cmd-k cmd-3": ["editor::FoldAtLevel", 3], - "cmd-k cmd-4": ["editor::FoldAtLevel", 4], - "cmd-k cmd-5": ["editor::FoldAtLevel", 5], - "cmd-k cmd-6": ["editor::FoldAtLevel", 6], - "cmd-k cmd-7": ["editor::FoldAtLevel", 7], - "cmd-k cmd-8": ["editor::FoldAtLevel", 8], - "cmd-k cmd-9": ["editor::FoldAtLevel", 9], + "cmd-k cmd-1": "editor::FoldAtLevel_1", + "cmd-k cmd-2": "editor::FoldAtLevel_2", + "cmd-k cmd-3": "editor::FoldAtLevel_3", + "cmd-k cmd-4": "editor::FoldAtLevel_4", + "cmd-k cmd-5": "editor::FoldAtLevel_5", + "cmd-k cmd-6": "editor::FoldAtLevel_6", + "cmd-k cmd-7": "editor::FoldAtLevel_7", + "cmd-k cmd-8": "editor::FoldAtLevel_8", + "cmd-k cmd-9": "editor::FoldAtLevel_9", "cmd-k cmd-0": "editor::FoldAll", "cmd-k cmd-j": "editor::UnfoldAll", // Using `ctrl-space` / `ctrl-shift-space` in Zed requires disabling the macOS global shortcut. @@ -639,7 +665,7 @@ "alt-shift-enter": "toast::RunAction", "cmd-shift-s": "workspace::SaveAs", "cmd-shift-n": "workspace::NewWindow", - "ctrl-`": "terminal_panel::ToggleFocus", + "ctrl-`": "terminal_panel::Toggle", "cmd-1": ["workspace::ActivatePane", 0], "cmd-2": ["workspace::ActivatePane", 1], "cmd-3": ["workspace::ActivatePane", 2], @@ -653,7 +679,7 @@ "cmd-alt-b": "workspace::ToggleRightDock", "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", - "alt-cmd-y": "workspace::CloseAllDocks", + "alt-cmd-y": "workspace::ToggleAllDocks", // For 0px parameter, uses UI font size value. "ctrl-alt-0": "workspace::ResetActiveDockSize", "ctrl-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], @@ -664,7 +690,7 @@ "cmd-shift-f": "pane::DeploySearch", "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], "cmd-shift-t": "pane::ReopenClosedItem", - "cmd-k cmd-s": "zed::OpenKeymapEditor", + "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", @@ -680,6 +706,7 @@ "cmd-?": "agent::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", "cmd-k m": "language_selector::Toggle", + "cmd-k cmd-m": "toolchain::AddToolchain", "escape": "workspace::Unfollow", "cmd-k cmd-left": "workspace::ActivatePaneLeft", "cmd-k cmd-right": "workspace::ActivatePaneRight", @@ -700,7 +727,9 @@ "bindings": { "cmd-n": "workspace::NewFile", "cmd-shift-r": "task::Spawn", - "cmd-alt-r": "task::Rerun", + // All task parameters are captured and unchanged between reruns by default. + // Use the `"reevaluate_context"` parameter to control this. + "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] @@ -772,6 +801,14 @@ "tab": "editor::ComposeCompletion" } }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, { "context": "Editor && edit_prediction", "bindings": { @@ -905,7 +942,7 @@ "cmd-backspace": ["project_panel::Trash", { "skip_prompt": true }], "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-cmd-r": "project_panel::RevealInFileManager", - "ctrl-shift-enter": "project_panel::OpenWithSystem", + "ctrl-shift-enter": "workspace::OpenWithSystem", "alt-d": "project_panel::CompareMarkedFiles", "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", @@ -1048,7 +1085,8 @@ "use_key_equivalents": true, "bindings": { "alt-up": "collab_panel::MoveChannelUp", - "alt-down": "collab_panel::MoveChannelDown" + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes" } }, { @@ -1084,6 +1122,13 @@ "tab": "channel_modal::ToggleMode" } }, + { + "context": "ToolchainSelector", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-a": "toolchain::AddToolchain" + } + }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, @@ -1113,6 +1158,14 @@ "ctrl-backspace": "tab_switcher::CloseSelectedItem" } }, + { + "context": "StashList || (StashList > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem" + } + }, { "context": "Terminal", "use_key_equivalents": true, @@ -1165,6 +1218,7 @@ "ctrl-alt-down": "pane::SplitDown", "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", + "cmd-d": "pane::SplitRight", "cmd-alt-r": "terminal::RerunTask" } }, @@ -1204,6 +1258,13 @@ "cmd-enter": "menu::Confirm" } }, + { + "context": "ContextServerToolsModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, @@ -1284,12 +1345,81 @@ "context": "Onboarding", "use_key_equivalents": true, "bindings": { - "cmd-1": "onboarding::ActivateBasicsPage", - "cmd-2": "onboarding::ActivateEditingPage", - "cmd-3": "onboarding::ActivateAISetupPage", - "cmd-escape": "onboarding::Finish", + "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", "alt-shift-a": "onboarding::OpenAccount" } + }, + { + "context": "InvalidBuffer", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-enter": "workspace::OpenWithSystem" + } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "cmd-m": "settings_editor::Minimize", + "cmd-f": "search::FocusSearch", + "left": "settings_editor::ToggleFocusNav", + "cmd-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "cmd-{": "settings_editor::FocusPreviousFile", + "cmd-}": "settings_editor::FocusNextFile" + } + }, + { + "context": "StashDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash" + } + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry" + } + }, + { + "context": "Zeta2Feedback > Editor", + "bindings": { + "enter": "editor::Newline", + "cmd-enter up": "dev::Zeta2RatePredictionPositive", + "cmd-enter down": "dev::Zeta2RatePredictionNegative" + } + }, + { + "context": "Zeta2Context > Editor", + "bindings": { + "alt-left": "dev::Zeta2ContextGoBack", + "alt-right": "dev::Zeta2ContextGoForward" + } } ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json new file mode 100644 index 0000000000000000000000000000000000000000..29146f3080d6ecad75bb9754503bb93c6710ff30 --- /dev/null +++ b/assets/keymaps/default-windows.json @@ -0,0 +1,1348 @@ +[ + // Standard Windows bindings + { + "use_key_equivalents": true, + "bindings": { + "home": "menu::SelectFirst", + "shift-pageup": "menu::SelectFirst", + "pageup": "menu::SelectFirst", + "end": "menu::SelectLast", + "shift-pagedown": "menu::SelectLast", + "pagedown": "menu::SelectLast", + "ctrl-n": "menu::SelectNext", + "tab": "menu::SelectNext", + "down": "menu::SelectNext", + "ctrl-p": "menu::SelectPrevious", + "shift-tab": "menu::SelectPrevious", + "up": "menu::SelectPrevious", + "enter": "menu::Confirm", + "ctrl-enter": "menu::SecondaryConfirm", + "ctrl-c": "menu::Cancel", + "escape": "menu::Cancel", + "shift-alt-enter": "menu::Restart", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + "ctrl-shift-w": "workspace::CloseWindow", + "shift-escape": "workspace::ToggleZoom", + "ctrl-o": "workspace::Open", + "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], + "ctrl-,": "zed::OpenSettings", + "ctrl-alt-,": "zed::OpenSettingsFile", + "ctrl-q": "zed::Quit", + "f4": "debugger::Start", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f6": "debugger::Pause", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + "f11": "zed::ToggleFullScreen", + "ctrl-shift-i": "edit_prediction::ToggleMenu", + "shift-alt-l": "lsp_tool::ToggleMenu" + } + }, + { + "context": "Picker || menu", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "editor::Cancel", + "shift-backspace": "editor::Backspace", + "backspace": "editor::Backspace", + "delete": "editor::Delete", + "tab": "editor::Tab", + "shift-tab": "editor::Backtab", + "ctrl-k": "editor::CutToEndOfLine", + "ctrl-k ctrl-q": "editor::Rewrap", + "ctrl-k q": "editor::Rewrap", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], + "shift-delete": "editor::Cut", + "ctrl-x": "editor::Cut", + "ctrl-insert": "editor::Copy", + "ctrl-c": "editor::Copy", + "shift-insert": "editor::Paste", + "ctrl-v": "editor::Paste", + "ctrl-z": "editor::Undo", + "ctrl-y": "editor::Redo", + "ctrl-shift-z": "editor::Redo", + "up": "editor::MoveUp", + "ctrl-up": "editor::LineUp", + "ctrl-down": "editor::LineDown", + "pageup": "editor::MovePageUp", + "alt-pageup": "editor::PageUp", + "shift-pageup": "editor::SelectPageUp", + "home": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "down": "editor::MoveDown", + "pagedown": "editor::MovePageDown", + "alt-pagedown": "editor::PageDown", + "shift-pagedown": "editor::SelectPageDown", + "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }], + "left": "editor::MoveLeft", + "right": "editor::MoveRight", + "ctrl-left": "editor::MoveToPreviousWordStart", + "ctrl-right": "editor::MoveToNextWordEnd", + "ctrl-home": "editor::MoveToBeginning", + "ctrl-end": "editor::MoveToEnd", + "shift-up": "editor::SelectUp", + "shift-down": "editor::SelectDown", + "shift-left": "editor::SelectLeft", + "shift-right": "editor::SelectRight", + "ctrl-shift-left": "editor::SelectToPreviousWordStart", + "ctrl-shift-right": "editor::SelectToNextWordEnd", + "ctrl-shift-home": "editor::SelectToBeginning", + "ctrl-shift-end": "editor::SelectToEnd", + "ctrl-a": "editor::SelectAll", + "ctrl-l": "editor::SelectLine", + "shift-alt-f": "editor::Format", + "shift-alt-o": "editor::OrganizeImports", + "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "ctrl-alt-space": "editor::ShowCharacterPalette", + "ctrl-;": "editor::ToggleLineNumbers", + "ctrl-'": "editor::ToggleSelectedDiffHunks", + "ctrl-\"": "editor::ExpandAllDiffHunks", + "ctrl-i": "editor::ShowSignatureHelp", + "alt-g b": "git::Blame", + "alt-g m": "git::OpenModifiedFiles", + "menu": "editor::OpenContextMenu", + "shift-f10": "editor::OpenContextMenu", + "ctrl-shift-e": "editor::ToggleEditPrediction", + "f9": "editor::ToggleBreakpoint", + "shift-f9": "editor::EditLogBreakpoint" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "shift-enter": "editor::Newline", + "enter": "editor::Newline", + "ctrl-enter": "editor::NewlineBelow", + "ctrl-shift-enter": "editor::NewlineAbove", + "ctrl-k ctrl-z": "editor::ToggleSoftWrap", + "ctrl-k z": "editor::ToggleSoftWrap", + "ctrl-f": "buffer_search::Deploy", + "ctrl-h": "buffer_search::DeployReplace", + "ctrl-shift-.": "agent::AddSelectionToThread", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-alt-e": "editor::SelectEnclosingSymbol", + "ctrl-shift-backspace": "editor::GoToPreviousChange", + "ctrl-shift-alt-backspace": "editor::GoToNextChange", + "alt-enter": "editor::OpenSelectionsInMultibuffer" + } + }, + { + "context": "Editor && mode == full && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-]": "editor::NextEditPrediction", + "alt-[": "editor::PreviousEditPrediction" + } + }, + { + "context": "Editor && !edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-\\": "editor::ShowEditPrediction" + } + }, + { + "context": "Editor && mode == auto_height", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow" + } + }, + { + "context": "Markdown", + "use_key_equivalents": true, + "bindings": { + "ctrl-c": "markdown::Copy" + } + }, + { + "context": "Editor && jupyter && !ContextEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-enter": "repl::Run", + "ctrl-alt-enter": "repl::RunInPlace" + } + }, + { + "context": "Editor && !agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-k ctrl-r": "git::Restore", + "alt-y": "git::StageAndNext", + "shift-alt-y": "git::UnstageAndNext" + } + }, + { + "context": "Editor && editor_agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-r": "agent::OpenAgentDiff" + } + }, + { + "context": "AgentDiff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "ContextEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "assistant::Assist", + "ctrl-s": "workspace::Save", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-enter": "assistant::Split", + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline", + "ctrl-k c": "assistant::CopyCode", + "ctrl-g": "search::SelectNextMatch", + "ctrl-shift-g": "search::SelectPreviousMatch", + "ctrl-k l": "agent::OpenRulesLibrary" + } + }, + { + "context": "AgentPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + "shift-alt-n": "agent::NewTextThread", + "ctrl-shift-h": "agent::OpenHistory", + "shift-alt-c": "agent::OpenSettings", + "shift-alt-p": "agent::OpenRulesLibrary", + "ctrl-i": "agent::ToggleProfileSelector", + "shift-alt-/": "agent::ToggleModelSelector", + "ctrl-shift-a": "agent::ToggleContextPicker", + "ctrl-shift-j": "agent::ToggleNavigationMenu", + "ctrl-shift-i": "agent::ToggleOptionsMenu", + // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "shift-alt-escape": "agent::ExpandMessageEditor", + "ctrl-shift-.": "agent::AddSelectionToThread", + "shift-alt-e": "agent::RemoveAllContext", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-enter": "agent::ContinueThread", + "super-ctrl-b": "agent::ToggleBurnMode", + "alt-enter": "agent::ContinueWithBurnMode", + "ctrl-y": "agent::AllowOnce", + "ctrl-alt-y": "agent::AllowAlways", + "ctrl-alt-z": "agent::RejectOnce" + } + }, + { + "context": "AgentPanel > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "agent::DeleteRecentlyOpenThread" + } + }, + { + "context": "AgentPanel > Markdown", + "use_key_equivalents": true, + "bindings": { + "ctrl-c": "markdown::CopyAsMarkdown" + } + }, + { + "context": "AgentPanel && text_thread", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewTextThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "AgentPanel && acp_thread", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewExternalAgentThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "agent::Chat", + "enter": "editor::Newline", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "EditMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "AgentFeedbackMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "ContextStrip", + "use_key_equivalents": true, + "bindings": { + "up": "agent::FocusUp", + "right": "agent::FocusRight", + "left": "agent::FocusLeft", + "down": "agent::FocusDown", + "backspace": "agent::RemoveFocusedContext", + "enter": "agent::AcceptSuggestedContext" + } + }, + { + "context": "AcpThread > ModeSelector", + "bindings": { + "ctrl-enter": "menu::Confirm" + } + }, + { + "context": "AcpThread > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" + } + }, + { + "context": "AcpThread > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "agent::Chat", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "shift-tab": "agent::CycleModeSelector" + } + }, + { + "context": "ThreadHistory", + "use_key_equivalents": true, + "bindings": { + "backspace": "agent::RemoveSelectedThread" + } + }, + { + "context": "RulesLibrary", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "rules_library::NewRule", + "ctrl-shift-s": "rules_library::ToggleDefaultRule", + "ctrl-w": "workspace::CloseWindow" + } + }, + { + "context": "BufferSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "buffer_search::Dismiss", + "tab": "buffer_search::FocusEditor", + "enter": "search::SelectNextMatch", + "shift-enter": "search::SelectPreviousMatch", + "alt-enter": "search::SelectAllMatches", + "ctrl-f": "search::FocusSearch", + "ctrl-h": "search::ToggleReplace", + "ctrl-l": "search::ToggleSelection" + } + }, + { + "context": "BufferSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-enter": "search::ReplaceAll" + } + }, + { + "context": "BufferSearchBar && !in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "ctrl-shift-f": "search::FocusSearch", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "ProjectSearchBar > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-alt-enter": "search::ReplaceAll" + } + }, + { + "context": "ProjectSearchView", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "alt-1": ["pane::ActivateItem", 0], + "alt-2": ["pane::ActivateItem", 1], + "alt-3": ["pane::ActivateItem", 2], + "alt-4": ["pane::ActivateItem", 3], + "alt-5": ["pane::ActivateItem", 4], + "alt-6": ["pane::ActivateItem", 5], + "alt-7": ["pane::ActivateItem", 6], + "alt-8": ["pane::ActivateItem", 7], + "alt-9": ["pane::ActivateItem", 8], + "alt-0": "pane::ActivateLastItem", + "ctrl-pageup": "pane::ActivatePreviousItem", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-shift-pageup": "pane::SwapItemLeft", + "ctrl-shift-pagedown": "pane::SwapItemRight", + "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-shift-alt-t": ["pane::CloseOtherItems", { "close_pinned": false }], + "ctrl-shift-alt-w": "workspace::CloseInactiveTabsAndPanes", + "ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }], + "ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }], + "ctrl-k u": ["pane::CloseCleanItems", { "close_pinned": false }], + "ctrl-k w": ["pane::CloseAllItems", { "close_pinned": false }], + "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", + "back": "pane::GoBack", + "alt--": "pane::GoBack", + "forward": "pane::GoForward", + "alt-=": "pane::GoForward", + "f3": "search::SelectNextMatch", + "shift-f3": "search::SelectPreviousMatch", + "ctrl-shift-f": "project_search::ToggleFocus", + "shift-alt-h": "search::ToggleReplace", + "alt-l": "search::ToggleSelection", + "alt-enter": "search::SelectAllMatches", + "alt-c": "search::ToggleCaseSensitive", + "alt-w": "search::ToggleWholeWord", + "alt-f": "project_search::ToggleFilters", + "alt-r": "search::ToggleRegex", + // "ctrl-shift-alt-x": "search::ToggleRegex", + "ctrl-k shift-enter": "pane::TogglePinTab" + } + }, + // Bindings from VS Code + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "editor::Outdent", + "ctrl-]": "editor::Indent", + "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below + "ctrl-shift-k": "editor::DeleteLine", + "alt-up": "editor::MoveLineUp", + "alt-down": "editor::MoveLineDown", + "shift-alt-up": "editor::DuplicateLineUp", + "shift-alt-down": "editor::DuplicateLineDown", + "shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand selection + "shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink selection + "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection + "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word + "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch + "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch + "ctrl-k ctrl-i": "editor::Hover", + "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "f2": "editor::Rename", + "f12": "editor::GoToDefinition", + "alt-f12": "editor::GoToDefinitionSplit", + "ctrl-shift-f10": "editor::GoToDefinitionSplit", + "ctrl-f12": "editor::GoToImplementation", + "shift-f12": "editor::GoToTypeDefinition", + "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit", + "shift-alt-f12": "editor::FindAllReferences", + "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains + "ctrl-shift-\\": "editor::MoveToEnclosingBracket", + "ctrl-shift-[": "editor::Fold", + "ctrl-shift-]": "editor::UnfoldLines", + "ctrl-k ctrl-l": "editor::ToggleFold", + "ctrl-k ctrl-[": "editor::FoldRecursive", + "ctrl-k ctrl-]": "editor::UnfoldRecursive", + "ctrl-k ctrl-1": "editor::FoldAtLevel_1", + "ctrl-k ctrl-2": "editor::FoldAtLevel_2", + "ctrl-k ctrl-3": "editor::FoldAtLevel_3", + "ctrl-k ctrl-4": "editor::FoldAtLevel_4", + "ctrl-k ctrl-5": "editor::FoldAtLevel_5", + "ctrl-k ctrl-6": "editor::FoldAtLevel_6", + "ctrl-k ctrl-7": "editor::FoldAtLevel_7", + "ctrl-k ctrl-8": "editor::FoldAtLevel_8", + "ctrl-k ctrl-9": "editor::FoldAtLevel_9", + "ctrl-k ctrl-0": "editor::FoldAll", + "ctrl-k ctrl-j": "editor::UnfoldAll", + "ctrl-space": "editor::ShowCompletions", + "ctrl-shift-space": "editor::ShowWordCompletions", + "ctrl-.": "editor::ToggleCodeActions", + "ctrl-k r": "editor::RevealInFileManager", + "ctrl-k p": "editor::CopyPath", + "ctrl-\\": "pane::SplitRight", + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "alt-.": "editor::GoToHunk", + "alt-,": "editor::GoToPreviousHunk" + } + }, + { + "context": "Editor && extension == md", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "markdown::OpenPreviewToTheSide", + "ctrl-shift-v": "markdown::OpenPreview" + } + }, + { + "context": "Editor && extension == svg", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "svg::OpenPreviewToTheSide", + "ctrl-shift-v": "svg::OpenPreview" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-o": "outline::Toggle", + "ctrl-g": "go_to_line::Toggle" + } + }, + { + "context": "Workspace", + "use_key_equivalents": true, + "bindings": { + // Change the default action on `menu::Confirm` by setting the parameter + // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], + "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], + // Change to open path modal for existing remote connection by setting the parameter + // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", + "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + "shift-alt-b": "branches::OpenRecent", + "shift-alt-enter": "toast::RunAction", + "ctrl-shift-`": "workspace::NewTerminal", + "ctrl-s": "workspace::Save", + "ctrl-k ctrl-shift-s": "workspace::SaveWithoutFormat", + "ctrl-shift-s": "workspace::SaveAs", + "ctrl-n": "workspace::NewFile", + "ctrl-shift-n": "workspace::NewWindow", + "ctrl-`": "terminal_panel::Toggle", + "f10": ["app_menu::OpenApplicationMenu", "Zed"], + "alt-1": ["workspace::ActivatePane", 0], + "alt-2": ["workspace::ActivatePane", 1], + "alt-3": ["workspace::ActivatePane", 2], + "alt-4": ["workspace::ActivatePane", 3], + "alt-5": ["workspace::ActivatePane", 4], + "alt-6": ["workspace::ActivatePane", 5], + "alt-7": ["workspace::ActivatePane", 6], + "alt-8": ["workspace::ActivatePane", 7], + "alt-9": ["workspace::ActivatePane", 8], + "ctrl-alt-b": "workspace::ToggleRightDock", + "ctrl-b": "workspace::ToggleLeftDock", + "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-shift-y": "workspace::ToggleAllDocks", + "alt-r": "workspace::ResetActiveDockSize", + // For 0px parameter, uses UI font size value. + "shift-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], + "shift-alt-=": ["workspace::IncreaseActiveDockSize", { "px": 0 }], + "shift-alt-0": "workspace::ResetOpenDocksSize", + "ctrl-shift-f": "pane::DeploySearch", + "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-shift-t": "pane::ReopenClosedItem", + "ctrl-k ctrl-s": "zed::OpenKeymap", + "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-alt-super-p": "settings_profile_selector::Toggle", + "ctrl-t": "project_symbols::Toggle", + "ctrl-p": "file_finder::Toggle", + "ctrl-tab": "tab_switcher::Toggle", + "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-e": "file_finder::Toggle", + "f1": "command_palette::Toggle", + "ctrl-shift-p": "command_palette::Toggle", + "ctrl-shift-m": "diagnostics::Deploy", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-b": "outline_panel::ToggleFocus", + "ctrl-shift-g": "git_panel::ToggleFocus", + "ctrl-shift-d": "debug_panel::ToggleFocus", + "ctrl-shift-/": "agent::ToggleFocus", + "ctrl-k s": "workspace::SaveAll", + "ctrl-k m": "language_selector::Toggle", + "ctrl-m ctrl-m": "toolchain::AddToolchain", + "escape": "workspace::Unfollow", + "ctrl-k ctrl-left": "workspace::ActivatePaneLeft", + "ctrl-k ctrl-right": "workspace::ActivatePaneRight", + "ctrl-k ctrl-up": "workspace::ActivatePaneUp", + "ctrl-k ctrl-down": "workspace::ActivatePaneDown", + "ctrl-k shift-left": "workspace::SwapPaneLeft", + "ctrl-k shift-right": "workspace::SwapPaneRight", + "ctrl-k shift-up": "workspace::SwapPaneUp", + "ctrl-k shift-down": "workspace::SwapPaneDown", + "ctrl-shift-x": "zed::Extensions", + // All task parameters are captured and unchanged between reruns by default. + // Use the `"reevaluate_context"` parameter to control this. + "ctrl-shift-r": ["task::Rerun", { "reevaluate_context": false }], + "alt-t": "task::Rerun", + "shift-alt-t": "task::Spawn", + "shift-alt-r": ["task::Spawn", { "reveal_target": "center" }], + // also possible to spawn tasks by name: + // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] + // or by tag: + // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], + "f5": "debugger::Rerun", + "ctrl-f4": "workspace::CloseActiveDock", + "ctrl-w": "workspace::CloseActiveDock" + } + }, + { + "context": "Workspace && debugger_running", + "use_key_equivalents": true, + "bindings": { + "f5": "zed::NoAction" + } + }, + { + "context": "Workspace && debugger_stopped", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Continue" + } + }, + { + "context": "ApplicationMenu", + "use_key_equivalents": true, + "bindings": { + "f10": "menu::Cancel", + "left": "app_menu::ActivateMenuLeft", + "right": "app_menu::ActivateMenuRight" + } + }, + // Bindings from Sublime Text + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-u": "editor::UndoSelection", + "ctrl-shift-u": "editor::RedoSelection", + "ctrl-shift-j": "editor::JoinLines", + "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", + "shift-alt-h": "editor::DeleteToPreviousSubwordStart", + "ctrl-alt-delete": "editor::DeleteToNextSubwordEnd", + "shift-alt-d": "editor::DeleteToNextSubwordEnd", + "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", + "ctrl-alt-right": "editor::MoveToNextSubwordEnd", + "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" + } + }, + // Bindings from Atom + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" + } + }, + // Bindings that should be unified with bindings for more general actions + { + "context": "Editor && renaming", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmRename" + } + }, + { + "context": "Editor && showing_completions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCompletion", + "shift-enter": "editor::ConfirmCompletionReplace", + "tab": "editor::ComposeCompletion" + } + }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, + // Bindings for accepting edit predictions + // + // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is + // because alt-tab may not be available, as it is often used for window switching. + { + "context": "Editor && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "tab": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && edit_prediction_conflict", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && showing_code_actions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCodeAction" + } + }, + { + "context": "Editor && (showing_code_actions || showing_completions)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "editor::ContextMenuPrevious", + "up": "editor::ContextMenuPrevious", + "ctrl-n": "editor::ContextMenuNext", + "down": "editor::ContextMenuNext", + "pageup": "editor::ContextMenuFirst", + "pagedown": "editor::ContextMenuLast" + } + }, + { + "context": "Editor && showing_signature_help && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "up": "editor::SignatureHelpPrevious", + "down": "editor::SignatureHelpNext" + } + }, + // Custom bindings + { + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", + // Only available in debug builds: opens an element inspector for development. + "shift-alt-i": "dev::ToggleInspector" + } + }, + { + "context": "!Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-c": "collab_panel::ToggleFocus" + } + }, + { + "context": "!ContextEditor > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + "shift-enter": "editor::ExpandExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + "ctrl-shift-e": "pane::RevealInProjectPanel", + "ctrl-f8": "editor::GoToHunk", + "ctrl-shift-f8": "editor::GoToPreviousHunk", + "ctrl-enter": "assistant::InlineAssist", + "ctrl-shift-;": "editor::ToggleInlayHints" + } + }, + { + "context": "PromptEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "agent::CyclePreviousInlineAssist", + "ctrl-]": "agent::CycleNextInlineAssist", + "shift-alt-e": "agent::RemoveAllContext" + } + }, + { + "context": "Prompt", + "use_key_equivalents": true, + "bindings": { + "left": "menu::SelectPrevious", + "right": "menu::SelectNext", + "h": "menu::SelectPrevious", + "l": "menu::SelectNext" + } + }, + { + "context": "ProjectSearchBar && !in_replace", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "project_search::SearchInNew" + } + }, + { + "context": "OutlinePanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "shift-alt-c": "outline_panel::CopyPath", + "ctrl-shift-alt-c": "workspace::CopyRelativePath", + "ctrl-alt-r": "outline_panel::RevealInFileManager", + "space": "outline_panel::OpenSelectedEntry", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "alt-enter": "editor::OpenExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit" + } + }, + { + "context": "ProjectPanel", + "use_key_equivalents": true, + "bindings": { + "left": "project_panel::CollapseSelectedEntry", + "right": "project_panel::ExpandSelectedEntry", + "ctrl-n": "project_panel::NewFile", + "alt-n": "project_panel::NewDirectory", + "ctrl-x": "project_panel::Cut", + "ctrl-insert": "project_panel::Copy", + "ctrl-c": "project_panel::Copy", + "shift-insert": "project_panel::Paste", + "ctrl-v": "project_panel::Paste", + "shift-alt-c": "project_panel::CopyPath", + "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", + "enter": "project_panel::Rename", + "f2": "project_panel::Rename", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-alt-r": "project_panel::RevealInFileManager", + "ctrl-shift-enter": "project_panel::OpenWithSystem", + "alt-d": "project_panel::CompareMarkedFiles", + "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "escape": "menu::Cancel" + } + }, + { + "context": "ProjectPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "space": "project_panel::Open" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "enter": "menu::Confirm", + "alt-y": "git::StageFile", + "shift-alt-y": "git::UnstageFile", + "space": "git::ToggleStaged", + "shift-space": "git::StageRange", + "tab": "git_panel::FocusEditor", + "shift-tab": "git_panel::FocusEditor", + "escape": "git_panel::ToggleFocus", + "alt-enter": "menu::SecondaryConfirm", + "delete": ["git::RestoreFile", { "skip_prompt": false }], + "backspace": ["git::RestoreFile", { "skip_prompt": false }], + "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] + } + }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, + { + "context": "GitCommit > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "GitPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-g ctrl-g": "git::Fetch", + "ctrl-g up": "git::Push", + "ctrl-g down": "git::Pull", + "ctrl-g shift-up": "git::ForcePush", + "ctrl-g d": "git::Diff", + "ctrl-g backspace": "git::RestoreTrackedFiles", + "ctrl-g shift-backspace": "git::TrashUntrackedFiles", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend" + } + }, + { + "context": "GitDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll" + } + }, + { + "context": "AskPass > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm" + } + }, + { + "context": "CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::FocusChanges", + "tab": "git_panel::FocusChanges", + "shift-tab": "git_panel::FocusChanges", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-up": "git_panel::FocusChanges", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "DebugPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-t": "debugger::ToggleThreadPicker", + "ctrl-i": "debugger::ToggleSessionPicker", + "shift-alt-escape": "debugger::ToggleExpandItem" + } + }, + { + "context": "VariableList", + "use_key_equivalents": true, + "bindings": { + "left": "variable_list::CollapseSelectedEntry", + "right": "variable_list::ExpandSelectedEntry", + "enter": "variable_list::EditVariable", + "ctrl-c": "variable_list::CopyVariableValue", + "ctrl-alt-c": "variable_list::CopyVariableName", + "delete": "variable_list::RemoveWatch", + "backspace": "variable_list::RemoveWatch", + "alt-enter": "variable_list::AddWatch" + } + }, + { + "context": "BreakpointList", + "use_key_equivalents": true, + "bindings": { + "space": "debugger::ToggleEnableBreakpoint", + "backspace": "debugger::UnsetBreakpoint", + "left": "debugger::PreviousBreakpointProperty", + "right": "debugger::NextBreakpointProperty" + } + }, + { + "context": "CollabPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "ctrl-backspace": "collab_panel::Remove", + "space": "menu::Confirm" + } + }, + { + "context": "CollabPanel", + "use_key_equivalents": true, + "bindings": { + "alt-up": "collab_panel::MoveChannelUp", + "alt-down": "collab_panel::MoveChannelDown", + "alt-enter": "collab_panel::OpenSelectedChannelNotes" + } + }, + { + "context": "(CollabPanel && editing) > Editor", + "use_key_equivalents": true, + "bindings": { + "space": "collab_panel::InsertSpace" + } + }, + { + "context": "ChannelModal", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "tab": "picker::ConfirmCompletion", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }] + } + }, + { + "context": "ChannelModal > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "ToolchainSelector", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-a": "toolchain::AddToolchain" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "file_finder::Toggle", + "ctrl-shift-a": "file_finder::ToggleSplitMenu", + "ctrl-shift-i": "file_finder::ToggleFilterMenu" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight" + } + }, + { + "context": "TabSwitcher", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-tab": "menu::SelectPrevious", + "ctrl-up": "menu::SelectPrevious", + "ctrl-down": "menu::SelectNext", + "ctrl-backspace": "tab_switcher::CloseSelectedItem" + } + }, + { + "context": "StashList || (StashList > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "stash_picker::DropStashItem", + "ctrl-shift-v": "stash_picker::ShowStashItem" + } + }, + { + "context": "Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-alt-space": "terminal::ShowCharacterPalette", + "ctrl-insert": "terminal::Copy", + "ctrl-shift-c": "terminal::Copy", + "shift-insert": "terminal::Paste", + "ctrl-v": "terminal::Paste", + "ctrl-shift-v": "terminal::Paste", + "ctrl-enter": "assistant::InlineAssist", + "alt-b": ["terminal::SendText", "\u001bb"], + "alt-f": ["terminal::SendText", "\u001bf"], + "alt-.": ["terminal::SendText", "\u001b."], + "ctrl-delete": ["terminal::SendText", "\u001bd"], + "ctrl-n": "workspace::NewTerminal", + // Overrides for conflicting keybindings + "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"], + "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], + "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], + "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-shift-a": "editor::SelectAll", + "ctrl-shift-f": "buffer_search::Deploy", + "ctrl-shift-l": "terminal::Clear", + "ctrl-shift-w": "pane::CloseActiveItem", + "up": ["terminal::SendKeystroke", "up"], + "pageup": ["terminal::SendKeystroke", "pageup"], + "down": ["terminal::SendKeystroke", "down"], + "pagedown": ["terminal::SendKeystroke", "pagedown"], + "escape": ["terminal::SendKeystroke", "escape"], + "enter": ["terminal::SendKeystroke", "enter"], + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + "shift-up": "terminal::ScrollLineUp", + "shift-down": "terminal::ScrollLineDown", + "shift-home": "terminal::ScrollToTop", + "shift-end": "terminal::ScrollToBottom", + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-shift-r": "terminal::RerunTask", + "ctrl-alt-r": "terminal::RerunTask", + "alt-t": "terminal::RerunTask", + "ctrl-shift-5": "pane::SplitRight" + } + }, + { + "context": "Terminal && selection", + "bindings": { + "ctrl-c": "terminal::Copy" + } + }, + { + "context": "ZedPredictModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "ConfigureContextServerModal > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "menu::Confirm" + } + }, + { + "context": "ContextServerToolsModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "OnboardingAiConfigurationModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "Diagnostics", + "use_key_equivalents": true, + "bindings": { + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" + } + }, + { + "context": "DebugConsole > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "alt-enter": "console::WatchExpression" + } + }, + { + "context": "RunModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-tab": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivatePreviousItem" + } + }, + { + "context": "MarkdownPreview", + "use_key_equivalents": true, + "bindings": { + "pageup": "markdown::MovePageUp", + "pagedown": "markdown::MovePageDown" + } + }, + { + "context": "KeymapEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "search::FocusSearch", + "alt-f": "keymap_editor::ToggleKeystrokeSearch", + "alt-c": "keymap_editor::ToggleConflictFilter", + "enter": "keymap_editor::EditBinding", + "alt-enter": "keymap_editor::CreateBinding", + "ctrl-c": "keymap_editor::CopyAction", + "ctrl-shift-c": "keymap_editor::CopyContext", + "ctrl-t": "keymap_editor::ShowMatchingKeybinds" + } + }, + { + "context": "KeystrokeInput", + "use_key_equivalents": true, + "bindings": { + "enter": "keystroke_input::StartRecording", + "escape escape escape": "keystroke_input::StopRecording", + "delete": "keystroke_input::ClearKeystrokes" + } + }, + { + "context": "KeybindEditorModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "menu::Confirm", + "escape": "menu::Cancel" + } + }, + { + "context": "KeybindEditorModal > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Onboarding", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "onboarding::Finish", + "alt-shift-l": "onboarding::SignIn", + "shift-alt-a": "onboarding::OpenAccount" + } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "escape": "workspace::CloseWindow", + "ctrl-m": "settings_editor::Minimize", + "ctrl-f": "search::FocusSearch", + "left": "settings_editor::ToggleFocusNav", + "ctrl-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "ctrl-pageup": "settings_editor::FocusPreviousFile", + "ctrl-pagedown": "settings_editor::FocusNextFile" + } + }, + { + "context": "StashDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-space": "git::ApplyCurrentStash", + "ctrl-shift-space": "git::PopCurrentStash", + "ctrl-shift-backspace": "git::DropCurrentStash" + } + }, + { + "context": "SettingsWindow > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "up": "settings_editor::FocusPreviousNavEntry", + "shift-tab": "settings_editor::FocusPreviousNavEntry", + "down": "settings_editor::FocusNextNavEntry", + "tab": "settings_editor::FocusNextNavEntry", + "right": "settings_editor::ExpandNavEntry", + "left": "settings_editor::CollapseNavEntry", + "pageup": "settings_editor::FocusPreviousRootNavEntry", + "pagedown": "settings_editor::FocusNextRootNavEntry", + "home": "settings_editor::FocusFirstNavEntry", + "end": "settings_editor::FocusLastNavEntry" + } + }, + { + "context": "Zeta2Feedback > Editor", + "bindings": { + "enter": "editor::Newline", + "ctrl-enter up": "dev::Zeta2RatePredictionPositive", + "ctrl-enter down": "dev::Zeta2RatePredictionNegative" + } + }, + { + "context": "Zeta2Context > Editor", + "bindings": { + "alt-left": "dev::Zeta2ContextGoBack", + "alt-right": "dev::Zeta2ContextGoForward" + } + } +] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 86ee068b06ef38ccec8215e4296c718dd873c824..98992b19fac72055807063edae8b7b23652062d3 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -24,8 +24,8 @@ "ctrl-<": "editor::ScrollCursorCenter", // editor:scroll-to-cursor "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous - "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below - "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above + "alt-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // editor:add-selection-below + "alt-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // editor:add-selection-above "ctrl-j": "editor::JoinLines", // editor:join-lines "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines "ctrl-up": "editor::MoveLineUp", // editor:move-line-up diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 1c381b0cf05531e7fd5743d71be1b4d662bb4c0d..4d2d13a90d96c31f72b1bb0ccc74608f81004eda 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -17,8 +17,8 @@ "bindings": { "ctrl-i": "agent::ToggleFocus", "ctrl-shift-i": "agent::ToggleFocus", - "ctrl-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode - "ctrl-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode + "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode + "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", "ctrl-shift-k": "assistant::InsertIntoEditor" } diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 0ff3796f03d85affdae88d009e88e73516ba385a..c5cf22c81220bf286187252394f8fde26bdd6509 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -8,11 +8,23 @@ "ctrl-g": "menu::Cancel" } }, + { + // Workaround to avoid falling back to default bindings. + // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: must be declared before the `Editor` override. + // NOTE: in macos the 'ctrl-x' 'ctrl-p' and 'ctrl-n' rebindings are not needed, since they default to 'cmd'. + "context": "Editor", + "bindings": { + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second + "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer + "ctrl-n": null // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + } + }, { "context": "Editor", "bindings": { "ctrl-g": "editor::Cancel", - "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line "ctrl-space": "editor::SetMark", // set-mark @@ -29,8 +41,10 @@ "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line "alt-m": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], // back-to-indentation - "alt-f": "editor::MoveToNextSubwordEnd", // forward-word - "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word + "alt-left": "editor::MoveToPreviousWordStart", // left-word + "alt-right": "editor::MoveToNextWordEnd", // right-word + "alt-f": "editor::MoveToNextWordEnd", // forward-word + "alt-b": "editor::MoveToPreviousWordStart", // backward-word "alt-u": "editor::ConvertToUpperCase", // upcase-word "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word @@ -38,10 +52,13 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char - "alt-d": "editor::DeleteToNextWordEnd", // kill-word + "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word + "alt-backspace": "editor::DeleteToPreviousWordStart", // backward-kill-word + "alt-delete": "editor::DeleteToPreviousWordStart", // backward-kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save @@ -51,14 +68,19 @@ "ctrl-x u": "editor::Undo", // undo "alt-{": "editor::MoveToStartOfParagraph", // backward-paragraph "alt-}": "editor::MoveToEndOfParagraph", // forward-paragraph + "ctrl-up": "editor::MoveToStartOfParagraph", // backward-paragraph + "ctrl-down": "editor::MoveToEndOfParagraph", // forward-paragraph "ctrl-v": "editor::MovePageDown", // scroll-up "alt-v": "editor::MovePageUp", // scroll-down "ctrl-x [": "editor::MoveToBeginning", // beginning-of-buffer "ctrl-x ]": "editor::MoveToEnd", // end-of-buffer "alt-<": "editor::MoveToBeginning", // beginning-of-buffer "alt->": "editor::MoveToEnd", // end-of-buffer + "ctrl-home": "editor::MoveToBeginning", // beginning-of-buffer + "ctrl-end": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward + "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line "alt-q": "editor::Rewrap" // fill-paragraph } @@ -84,10 +106,19 @@ "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], + "alt-m": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], "alt-f": "editor::SelectToNextWordEnd", - "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-b": "editor::SelectToPreviousWordStart", + "alt-{": "editor::SelectToStartOfParagraph", + "alt-}": "editor::SelectToEndOfParagraph", + "ctrl-up": "editor::SelectToStartOfParagraph", + "ctrl-down": "editor::SelectToEndOfParagraph", + "ctrl-x [": "editor::SelectToBeginning", + "ctrl-x ]": "editor::SelectToEnd", "alt-<": "editor::SelectToBeginning", "alt->": "editor::SelectToEnd", + "ctrl-home": "editor::SelectToBeginning", + "ctrl-end": "editor::SelectToEnd", "ctrl-g": "editor::Cancel" } }, @@ -105,15 +136,28 @@ "ctrl-n": "editor::SignatureHelpNext" } }, + // Example setting for using emacs-style tab + // (i.e. indent the current line / selection or perform symbol completion depending on context) + // { + // "context": "Editor && !showing_code_actions && !showing_completions", + // "bindings": { + // "tab": "editor::AutoIndent" // indent-for-tab-command + // } + // }, { "context": "Workspace", "bindings": { + "alt-x": "command_palette::Toggle", // execute-extended-command + "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer + "ctrl-x ctrl-b": "tab_switcher::Toggle", // list-buffers + // "ctrl-x ctrl-c": "workspace::CloseWindow" // in case you only want to exit the current Zed instance "ctrl-x ctrl-c": "zed::Quit", // save-buffers-kill-terminal "ctrl-x 5 0": "workspace::CloseWindow", // delete-frame "ctrl-x 5 2": "workspace::NewWindow", // make-frame-command "ctrl-x o": "workspace::ActivateNextPane", // other-window "ctrl-x k": "pane::CloseActiveItem", // kill-buffer "ctrl-x 0": "pane::CloseActiveItem", // delete-window + // "ctrl-x 1": "pane::JoinAll", // in case you prefer to delete the splits but keep the buffers open "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows "ctrl-x 2": "pane::SplitDown", // split-window-below "ctrl-x 3": "pane::SplitRight", // split-window-right @@ -124,10 +168,19 @@ } }, { - // Workaround to enable using emacs in the Zed terminal. + // Workaround to enable using native emacs from the Zed terminal. // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: + // "terminal::SendKeystroke" only works for a single key stroke (e.g. ctrl-x), + // so override with null for compound sequences (e.g. ctrl-x ctrl-c). "context": "Terminal", "bindings": { + // If you want to perfect your emacs-in-zed setup, also consider the following. + // You may need to enable "option_as_meta" from the Zed settings for "alt-x" to work. + // "alt-x": ["terminal::SendKeystroke", "alt-x"], + // "ctrl-x": ["terminal::SendKeystroke", "ctrl-x"], + // "ctrl-n": ["terminal::SendKeystroke", "ctrl-n"], + // ... "ctrl-x ctrl-c": null, // save-buffers-kill-terminal "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 3df1243feda88680a4ce03cd0b25ab9ea9a36edd..cf28c43dbd7f8335f30ef7702e584bea5c0ba5e0 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -1,7 +1,7 @@ [ { "bindings": { - "ctrl-alt-s": "zed::OpenSettings", + "ctrl-alt-s": "zed::OpenSettingsFile", "ctrl-{": "pane::ActivatePreviousItem", "ctrl-}": "pane::ActivateNextItem", "shift-escape": null, // Unmap workspace::zoom @@ -91,7 +91,7 @@ { "context": "Workspace", "bindings": { - "ctrl-shift-f12": "workspace::CloseAllDocks", + "ctrl-shift-f12": "workspace::ToggleAllDocks", "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "alt-shift-f10": "task::Spawn", "ctrl-e": "file_finder::Toggle", @@ -125,7 +125,7 @@ { "context": "Workspace || Editor", "bindings": { - "alt-f12": "terminal_panel::ToggleFocus", + "alt-f12": "terminal_panel::Toggle", "ctrl-shift-k": "git::Push" } }, diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index ece9d69dd102c019072678373e9328f302d4cb07..eefd59e5bd1aa48125d0c6e3d662f3cb4e270be7 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -28,8 +28,8 @@ { "context": "Editor", "bindings": { - "ctrl-alt-up": "editor::AddSelectionAbove", - "ctrl-alt-down": "editor::AddSelectionBelow", + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], "ctrl-shift-up": "editor::MoveLineUp", "ctrl-shift-down": "editor::MoveLineDown", "ctrl-shift-m": "editor::SelectLargerSyntaxNode", @@ -50,8 +50,8 @@ "ctrl-k ctrl-u": "editor::ConvertToUpperCase", "ctrl-k ctrl-l": "editor::ConvertToLowerCase", "shift-alt-m": "markdown::OpenPreviewToTheSide", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index df48e51767e54524c6645630d1fcb6b1cdeba599..ca015b667faa05db53d8fdc3bd82352d9bcc62aa 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -25,8 +25,8 @@ "cmd-<": "editor::ScrollCursorCenter", "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], - "ctrl-shift-down": "editor::AddSelectionBelow", - "ctrl-shift-up": "editor::AddSelectionAbove", + "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], + "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], "alt-enter": "editor::Newline", "cmd-shift-d": "editor::DuplicateLineDown", "ctrl-cmd-up": "editor::MoveLineUp", diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index fdf9c437cf395c074e42ae9c9dc53c1aa6ff66c2..97abc7dd819485850107eca6762fc1ed60ec0515 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -17,8 +17,8 @@ "bindings": { "cmd-i": "agent::ToggleFocus", "cmd-shift-i": "agent::ToggleFocus", - "cmd-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode - "cmd-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode + "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode + "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", "cmd-shift-k": "assistant::InsertIntoEditor" } diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 0ff3796f03d85affdae88d009e88e73516ba385a..ea831c0c059ea082d002f3af01b8d97be9e86616 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -4,15 +4,24 @@ // from the command palette. [ { + "context": "!GitPanel", "bindings": { "ctrl-g": "menu::Cancel" } }, + { + // Workaround to avoid falling back to default bindings. + // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: must be declared before the `Editor` override. + "context": "Editor", + "bindings": { + "ctrl-g": null // currently activates `go_to_line::Toggle` when there is nothing to cancel + } + }, { "context": "Editor", "bindings": { "ctrl-g": "editor::Cancel", - "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line "ctrl-space": "editor::SetMark", // set-mark @@ -29,8 +38,10 @@ "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line "alt-m": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], // back-to-indentation - "alt-f": "editor::MoveToNextSubwordEnd", // forward-word - "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word + "alt-left": "editor::MoveToPreviousWordStart", // left-word + "alt-right": "editor::MoveToNextWordEnd", // right-word + "alt-f": "editor::MoveToNextWordEnd", // forward-word + "alt-b": "editor::MoveToPreviousWordStart", // backward-word "alt-u": "editor::ConvertToUpperCase", // upcase-word "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word @@ -38,10 +49,13 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char - "alt-d": "editor::DeleteToNextWordEnd", // kill-word + "alt-d": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], // kill-word + "alt-backspace": "editor::DeleteToPreviousWordStart", // backward-kill-word + "alt-delete": "editor::DeleteToPreviousWordStart", // backward-kill-word "ctrl-k": "editor::KillRingCut", // kill-line "ctrl-w": "editor::Cut", // kill-region "alt-w": "editor::Copy", // kill-ring-save @@ -51,14 +65,19 @@ "ctrl-x u": "editor::Undo", // undo "alt-{": "editor::MoveToStartOfParagraph", // backward-paragraph "alt-}": "editor::MoveToEndOfParagraph", // forward-paragraph + "ctrl-up": "editor::MoveToStartOfParagraph", // backward-paragraph + "ctrl-down": "editor::MoveToEndOfParagraph", // forward-paragraph "ctrl-v": "editor::MovePageDown", // scroll-up "alt-v": "editor::MovePageUp", // scroll-down "ctrl-x [": "editor::MoveToBeginning", // beginning-of-buffer "ctrl-x ]": "editor::MoveToEnd", // end-of-buffer "alt-<": "editor::MoveToBeginning", // beginning-of-buffer "alt->": "editor::MoveToEnd", // end-of-buffer + "ctrl-home": "editor::MoveToBeginning", // beginning-of-buffer + "ctrl-end": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward + "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line "alt-q": "editor::Rewrap" // fill-paragraph } @@ -84,10 +103,19 @@ "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], + "alt-m": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false, "stop_at_indent": true }], "alt-f": "editor::SelectToNextWordEnd", - "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-b": "editor::SelectToPreviousWordStart", + "alt-{": "editor::SelectToStartOfParagraph", + "alt-}": "editor::SelectToEndOfParagraph", + "ctrl-up": "editor::SelectToStartOfParagraph", + "ctrl-down": "editor::SelectToEndOfParagraph", + "ctrl-x [": "editor::SelectToBeginning", + "ctrl-x ]": "editor::SelectToEnd", "alt-<": "editor::SelectToBeginning", "alt->": "editor::SelectToEnd", + "ctrl-home": "editor::SelectToBeginning", + "ctrl-end": "editor::SelectToEnd", "ctrl-g": "editor::Cancel" } }, @@ -105,15 +133,28 @@ "ctrl-n": "editor::SignatureHelpNext" } }, + // Example setting for using emacs-style tab + // (i.e. indent the current line / selection or perform symbol completion depending on context) + // { + // "context": "Editor && !showing_code_actions && !showing_completions", + // "bindings": { + // "tab": "editor::AutoIndent" // indent-for-tab-command + // } + // }, { "context": "Workspace", "bindings": { + "alt-x": "command_palette::Toggle", // execute-extended-command + "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer + "ctrl-x ctrl-b": "tab_switcher::Toggle", // list-buffers + // "ctrl-x ctrl-c": "workspace::CloseWindow" // in case you only want to exit the current Zed instance "ctrl-x ctrl-c": "zed::Quit", // save-buffers-kill-terminal "ctrl-x 5 0": "workspace::CloseWindow", // delete-frame "ctrl-x 5 2": "workspace::NewWindow", // make-frame-command "ctrl-x o": "workspace::ActivateNextPane", // other-window "ctrl-x k": "pane::CloseActiveItem", // kill-buffer "ctrl-x 0": "pane::CloseActiveItem", // delete-window + // "ctrl-x 1": "pane::JoinAll", // in case you prefer to delete the splits but keep the buffers open "ctrl-x 1": "pane::CloseOtherItems", // delete-other-windows "ctrl-x 2": "pane::SplitDown", // split-window-below "ctrl-x 3": "pane::SplitRight", // split-window-right @@ -124,10 +165,19 @@ } }, { - // Workaround to enable using emacs in the Zed terminal. + // Workaround to enable using native emacs from the Zed terminal. // Unbind so Zed ignores these keys and lets emacs handle them. + // NOTE: + // "terminal::SendKeystroke" only works for a single key stroke (e.g. ctrl-x), + // so override with null for compound sequences (e.g. ctrl-x ctrl-c). "context": "Terminal", "bindings": { + // If you want to perfect your emacs-in-zed setup, also consider the following. + // You may need to enable "option_as_meta" from the Zed settings for "alt-x" to work. + // "alt-x": ["terminal::SendKeystroke", "alt-x"], + // "ctrl-x": ["terminal::SendKeystroke", "ctrl-x"], + // "ctrl-n": ["terminal::SendKeystroke", "ctrl-n"], + // ... "ctrl-x ctrl-c": null, // save-buffers-kill-terminal "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 66962811f48a429f2f5d036241c64d6549f60334..e5e5aeb0b8516285136438d40b57fb17fc9a9777 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -93,7 +93,7 @@ { "context": "Workspace", "bindings": { - "cmd-shift-f12": "workspace::CloseAllDocks", + "cmd-shift-f12": "workspace::ToggleAllDocks", "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-alt-r": "task::Spawn", "cmd-e": "file_finder::Toggle", @@ -127,7 +127,7 @@ { "context": "Workspace || Editor", "bindings": { - "alt-f12": "terminal_panel::ToggleFocus", + "alt-f12": "terminal_panel::Toggle", "cmd-shift-k": "git::Push" } }, diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index 9fa528c75fa75061c34d767c3e9f9082c9eb2a81..d1bffca755b611d9046d4b7e794d2303835227a2 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -28,8 +28,8 @@ { "context": "Editor", "bindings": { - "ctrl-shift-up": "editor::AddSelectionAbove", - "ctrl-shift-down": "editor::AddSelectionBelow", + "ctrl-shift-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": false }], + "ctrl-shift-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": false }], "cmd-ctrl-up": "editor::MoveLineUp", "cmd-ctrl-down": "editor::MoveLineDown", "cmd-shift-space": "editor::SelectAll", @@ -52,8 +52,8 @@ "cmd-k cmd-l": "editor::ConvertToLowerCase", "cmd-shift-j": "editor::JoinLines", "shift-alt-m": "markdown::OpenPreviewToTheSide", - "ctrl-backspace": "editor::DeleteToPreviousWordStart", - "ctrl-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "ctrl-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index 0bd8873b1749d2423d97df480b1aadeb28fe9bab..f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -21,10 +21,10 @@ { "context": "Editor", "bindings": { - "alt-backspace": "editor::DeleteToPreviousWordStart", - "alt-shift-backspace": "editor::DeleteToNextWordEnd", - "alt-delete": "editor::DeleteToNextWordEnd", - "alt-shift-delete": "editor::DeleteToNextWordEnd", + "alt-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-shift-backspace": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], + "alt-shift-delete": ["editor::DeleteToNextWordEnd", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-delete": "editor::DeleteToNextSubwordEnd", "alt-left": ["editor::MoveToPreviousWordStart", { "stop_at_soft_wraps": true }], diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index be6d34a1342b6fabe0561643c74034d3c99a04b6..d6bdff1cd02fcd0bfb31fb48d2c47a321c54de2c 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -32,34 +32,6 @@ "(": "vim::SentenceBackward", ")": "vim::SentenceForward", "|": "vim::GoToColumn", - "] ]": "vim::NextSectionStart", - "] [": "vim::NextSectionEnd", - "[ [": "vim::PreviousSectionStart", - "[ ]": "vim::PreviousSectionEnd", - "] m": "vim::NextMethodStart", - "] shift-m": "vim::NextMethodEnd", - "[ m": "vim::PreviousMethodStart", - "[ shift-m": "vim::PreviousMethodEnd", - "[ *": "vim::PreviousComment", - "[ /": "vim::PreviousComment", - "] *": "vim::NextComment", - "] /": "vim::NextComment", - "[ -": "vim::PreviousLesserIndent", - "[ +": "vim::PreviousGreaterIndent", - "[ =": "vim::PreviousSameIndent", - "] -": "vim::NextLesserIndent", - "] +": "vim::NextGreaterIndent", - "] =": "vim::NextSameIndent", - "] b": "pane::ActivateNextItem", - "[ b": "pane::ActivatePreviousItem", - "] shift-b": "pane::ActivateLastItem", - "[ shift-b": ["pane::ActivateItem", 0], - "] space": "vim::InsertEmptyLineBelow", - "[ space": "vim::InsertEmptyLineAbove", - "[ e": "editor::MoveLineUp", - "] e": "editor::MoveLineDown", - "[ f": "workspace::FollowNextCollaborator", - "] f": "workspace::FollowNextCollaborator", // Word motions "w": "vim::NextWordStart", @@ -83,10 +55,6 @@ "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPreviousMatch", "%": "vim::Matching", - "] }": ["vim::UnmatchedForward", { "char": "}" }], - "[ {": ["vim::UnmatchedBackward", { "char": "{" }], - "] )": ["vim::UnmatchedForward", { "char": ")" }], - "[ (": ["vim::UnmatchedBackward", { "char": "(" }], "f": ["vim::PushFindForward", { "before": false, "multiline": false }], "t": ["vim::PushFindForward", { "before": true, "multiline": false }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": false }], @@ -127,8 +95,6 @@ "g g": "vim::StartOfDocument", "g h": "editor::Hover", "g B": "editor::BlameHover", - "g t": "pane::ActivateNextItem", - "g shift-t": "pane::ActivatePreviousItem", "g d": "editor::GoToDefinition", "g shift-d": "editor::GoToDeclaration", "g y": "editor::GoToTypeDefinition", @@ -219,6 +185,48 @@ ".": "vim::Repeat" } }, + { + "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", + "bindings": { + "] ]": "vim::NextSectionStart", + "] [": "vim::NextSectionEnd", + "[ [": "vim::PreviousSectionStart", + "[ ]": "vim::PreviousSectionEnd", + "] m": "vim::NextMethodStart", + "] shift-m": "vim::NextMethodEnd", + "[ m": "vim::PreviousMethodStart", + "[ shift-m": "vim::PreviousMethodEnd", + "[ *": "vim::PreviousComment", + "[ /": "vim::PreviousComment", + "] *": "vim::NextComment", + "] /": "vim::NextComment", + "[ -": "vim::PreviousLesserIndent", + "[ +": "vim::PreviousGreaterIndent", + "[ =": "vim::PreviousSameIndent", + "] -": "vim::NextLesserIndent", + "] +": "vim::NextGreaterIndent", + "] =": "vim::NextSameIndent", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0], + "] space": "vim::InsertEmptyLineBelow", + "[ space": "vim::InsertEmptyLineAbove", + "[ e": "editor::MoveLineUp", + "] e": "editor::MoveLineDown", + "[ f": "workspace::FollowNextCollaborator", + "] f": "workspace::FollowNextCollaborator", + "] }": ["vim::UnmatchedForward", { "char": "}" }], + "[ {": ["vim::UnmatchedBackward", { "char": "{" }], + "] )": ["vim::UnmatchedForward", { "char": ")" }], + "[ (": ["vim::UnmatchedBackward", { "char": "(" }], + "[ r": "vim::GoToPreviousReference", + "] r": "vim::GoToNextReference", + // tree-sitter related commands + "[ x": "vim::SelectLargerSyntaxNode", + "] x": "vim::SelectSmallerSyntaxNode" + } + }, { "context": "vim_mode == normal", "bindings": { @@ -232,6 +240,7 @@ "delete": "vim::DeleteRight", "g shift-j": "vim::JoinLinesNoWhitespace", "y": "vim::PushYank", + "shift-y": "vim::YankLine", "x": "vim::DeleteRight", "shift-x": "vim::DeleteLeft", "ctrl-a": "vim::Increment", @@ -249,9 +258,6 @@ "g w": "vim::PushRewrap", "g q": "vim::PushRewrap", "insert": "vim::InsertBefore", - // tree-sitter related commands - "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode", "] d": "editor::GoToDiagnostic", "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", @@ -317,10 +323,28 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister", - // tree-sitter related commands - "[ x": "editor::SelectLargerSyntaxNode", - "] x": "editor::SelectSmallerSyntaxNode" + "\"": "vim::PushRegister" + } + }, + { + "context": "vim_mode == helix_select", + "bindings": { + "v": "vim::NormalBefore", + ";": "vim::HelixCollapseSelection", + "~": "vim::ChangeCase", + "ctrl-a": "vim::Increment", + "ctrl-x": "vim::Decrement", + "shift-j": "vim::JoinLines", + "i": "vim::InsertBefore", + "a": "vim::InsertAfter", + "p": "vim::Paste", + "u": "vim::Undo", + "r": "vim::PushReplace", + "s": "vim::Substitute", + "ctrl-pageup": "pane::ActivatePreviousItem", + "ctrl-pagedown": "pane::ActivateNextItem", + ".": "vim::Repeat", + "alt-.": "vim::RepeatFind" } }, { @@ -337,7 +361,7 @@ "ctrl-x ctrl-z": "editor::Cancel", "ctrl-x ctrl-e": "vim::LineDown", "ctrl-x ctrl-y": "vim::LineUp", - "ctrl-w": "editor::DeleteToPreviousWordStart", + "ctrl-w": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-t": "vim::Indent", "ctrl-d": "vim::Outdent", @@ -354,6 +378,15 @@ "ctrl-s": "editor::ShowSignatureHelp" } }, + { + "context": "showing_completions", + "bindings": { + "ctrl-d": "vim::ScrollDown", + "ctrl-u": "vim::ScrollUp", + "ctrl-e": "vim::LineDown", + "ctrl-y": "vim::LineUp" + } + }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", "bindings": { @@ -385,57 +418,72 @@ "bindings": { "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel", - ";": "vim::HelixCollapseSelection", - ":": "command_palette::Toggle", - "left": "vim::WrappingLeft", - "right": "vim::WrappingRight", + "ctrl-[": "editor::Cancel" + } + }, + { + "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", + "bindings": { + // Movement "h": "vim::WrappingLeft", + "left": "vim::WrappingLeft", "l": "vim::WrappingRight", - "y": "vim::HelixYank", - "alt-;": "vim::OtherEnd", - "ctrl-r": "vim::Redo", - "f": ["vim::PushFindForward", { "before": false, "multiline": true }], + "right": "vim::WrappingRight", "t": ["vim::PushFindForward", { "before": true, "multiline": true }], - "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], + "f": ["vim::PushFindForward", { "before": false, "multiline": true }], "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], + "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], + "alt-.": "vim::RepeatFind", + + // Changes + "shift-r": "editor::Paste", + "`": "vim::ConvertToLowerCase", + "alt-`": "vim::ConvertToUpperCase", + "insert": "vim::InsertBefore", + "shift-u": "editor::Redo", + "ctrl-r": "vim::Redo", + "y": "vim::HelixYank", + "p": "vim::HelixPaste", + "shift-p": ["vim::HelixPaste", { "before": true }], ">": "vim::Indent", "<": "vim::Outdent", "=": "vim::AutoIndent", - "g u": "vim::PushLowercase", - "g shift-u": "vim::PushUppercase", - "g ~": "vim::PushOppositeCase", - "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap", - "insert": "vim::InsertBefore", - "alt-.": "vim::RepeatFind", + "d": "vim::HelixDelete", + "c": "vim::HelixSubstitute", + "alt-c": "vim::HelixSubstituteNoYank", + + // Selection manipulation + "s": "vim::HelixSelectRegex", "alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }], - // tree-sitter related commands - "[ x": "editor::SelectLargerSyntaxNode", - "] x": "editor::SelectSmallerSyntaxNode", - "] d": "editor::GoToDiagnostic", - "[ d": "editor::GoToPreviousDiagnostic", - "] c": "editor::GoToHunk", - "[ c": "editor::GoToPreviousHunk", + ";": "vim::HelixCollapseSelection", + "alt-;": "vim::OtherEnd", + ",": "vim::HelixKeepNewestSelection", + "shift-c": "vim::HelixDuplicateBelow", + "alt-shift-c": "vim::HelixDuplicateAbove", + "%": "editor::SelectAll", + "x": "vim::HelixSelectLine", + "shift-x": "editor::SelectLine", + "ctrl-c": "editor::ToggleComments", + "alt-o": "editor::SelectLargerSyntaxNode", + "alt-i": "editor::SelectSmallerSyntaxNode", + "alt-p": "editor::SelectPreviousSyntaxNode", + "alt-n": "editor::SelectNextSyntaxNode", + // Goto mode - "g n": "pane::ActivateNextItem", - "g p": "pane::ActivatePreviousItem", - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", - "shift-h": "pane::ActivatePreviousItem", - "shift-l": "pane::ActivateNextItem", - "g l": "vim::EndOfLine", + "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", + "g l": "vim::EndOfLine", "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" - "g e": "vim::EndOfDocument", - "g r": "editor::FindAllReferences", // zed specific "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", + "g r": "editor::FindAllReferences", // zed specific + "g n": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", + "g p": "pane::ActivatePreviousItem", + "shift-h": "pane::ActivatePreviousItem", + "g .": "vim::HelixGotoLastModification", // go to last modification - "x": "editor::SelectLine", - "shift-x": "editor::SelectLine", - "%": "editor::SelectAll", // Window mode "space w h": "workspace::ActivatePaneLeft", "space w l": "workspace::ActivatePaneRight", @@ -446,6 +494,7 @@ "space w r": "pane::SplitRight", "space w v": "pane::SplitDown", "space w d": "pane::SplitDown", + // Space mode "space f": "file_finder::Toggle", "space k": "editor::Hover", @@ -456,17 +505,18 @@ "space a": "editor::ToggleCodeActions", "space h": "editor::SelectAllMatches", "space c": "editor::ToggleComments", - "space y": "editor::Copy", "space p": "editor::Paste", - // Match mode - "m m": "vim::Matching", - "m i w": ["workspace::SendKeystrokes", "v i w"], - "shift-u": "editor::Redo", - "ctrl-c": "editor::ToggleComments", - "d": "vim::HelixDelete", - "c": "vim::Substitute", - "shift-c": "editor::AddSelectionBelow", - "alt-shift-c": "editor::AddSelectionAbove" + "space y": "editor::Copy", + + // Other + ":": "command_palette::Toggle", + "m": "vim::PushHelixMatch", + "]": ["vim::PushHelixNext", { "around": true }], + "[": ["vim::PushHelixPrevious", { "around": true }], + "g q": "vim::PushRewrap", + "g w": "vim::PushRewrap" + // "tab": "pane::ActivateNextItem", + // "shift-tab": "pane::ActivatePrevItem", } }, { @@ -529,7 +579,7 @@ } }, { - "context": "vim_operator == a || vim_operator == i || vim_operator == cs", + "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", "bindings": { "w": "vim::Word", "shift-w": ["vim::Word", { "ignore_punctuation": true }], @@ -545,18 +595,18 @@ // "q": "vim::AnyQuotes", "q": "vim::MiniQuotes", "|": "vim::VerticalBars", - "(": "vim::Parentheses", + "(": ["vim::Parentheses", { "opening": true }], ")": "vim::Parentheses", "b": "vim::Parentheses", // "b": "vim::AnyBrackets", // "b": "vim::MiniBrackets", - "[": "vim::SquareBrackets", + "[": ["vim::SquareBrackets", { "opening": true }], "]": "vim::SquareBrackets", "r": "vim::SquareBrackets", - "{": "vim::CurlyBrackets", + "{": ["vim::CurlyBrackets", { "opening": true }], "}": "vim::CurlyBrackets", "shift-b": "vim::CurlyBrackets", - "<": "vim::AngleBrackets", + "<": ["vim::AngleBrackets", { "opening": true }], ">": "vim::AngleBrackets", "a": "vim::Argument", "i": "vim::IndentObj", @@ -566,6 +616,48 @@ "e": "vim::EntireFile" } }, + { + "context": "vim_operator == helix_m", + "bindings": { + "m": "vim::Matching" + } + }, + { + "context": "vim_operator == helix_next", + "bindings": { + "z": "vim::NextSectionStart", + "shift-z": "vim::NextSectionEnd", + "*": "vim::NextComment", + "/": "vim::NextComment", + "-": "vim::NextLesserIndent", + "+": "vim::NextGreaterIndent", + "=": "vim::NextSameIndent", + "b": "pane::ActivateNextItem", + "shift-b": "pane::ActivateLastItem", + "x": "editor::SelectSmallerSyntaxNode", + "d": "editor::GoToDiagnostic", + "c": "editor::GoToHunk", + "space": "vim::InsertEmptyLineBelow" + } + }, + { + "context": "vim_operator == helix_previous", + "bindings": { + "z": "vim::PreviousSectionStart", + "shift-z": "vim::PreviousSectionEnd", + "*": "vim::PreviousComment", + "/": "vim::PreviousComment", + "-": "vim::PreviousLesserIndent", + "+": "vim::PreviousGreaterIndent", + "=": "vim::PreviousSameIndent", + "b": "pane::ActivatePreviousItem", + "shift-b": ["pane::ActivateItem", 0], + "x": "editor::SelectLargerSyntaxNode", + "d": "editor::GoToPreviousDiagnostic", + "c": "editor::GoToPreviousHunk", + "space": "vim::InsertEmptyLineAbove" + } + }, { "context": "vim_operator == c", "bindings": { @@ -734,7 +826,7 @@ } }, { - "context": "VimControl || !Editor && !Terminal", + "context": "VimControl && !menu || !Editor && !Terminal", "bindings": { // window related commands (ctrl-w X) "ctrl-w": null, @@ -754,10 +846,10 @@ "ctrl-w shift-right": "workspace::SwapPaneRight", "ctrl-w shift-up": "workspace::SwapPaneUp", "ctrl-w shift-down": "workspace::SwapPaneDown", - "ctrl-w shift-h": "workspace::SwapPaneLeft", - "ctrl-w shift-l": "workspace::SwapPaneRight", - "ctrl-w shift-k": "workspace::SwapPaneUp", - "ctrl-w shift-j": "workspace::SwapPaneDown", + "ctrl-w shift-h": "workspace::MovePaneLeft", + "ctrl-w shift-l": "workspace::MovePaneRight", + "ctrl-w shift-k": "workspace::MovePaneUp", + "ctrl-w shift-j": "workspace::MovePaneDown", "ctrl-w >": "vim::ResizePaneRight", "ctrl-w <": "vim::ResizePaneLeft", "ctrl-w -": "vim::ResizePaneDown", @@ -788,7 +880,9 @@ "ctrl-w ctrl-o": "workspace::CloseInactiveTabsAndPanes", "ctrl-w o": "workspace::CloseInactiveTabsAndPanes", "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", - "ctrl-w n": "workspace::NewFileSplitHorizontal" + "ctrl-w n": "workspace::NewFileSplitHorizontal", + "g t": "vim::GoToTab", + "g shift-t": "vim::GoToPreviousTab" } }, { @@ -807,19 +901,21 @@ "/": "project_panel::NewSearchInDirectory", "d": "project_panel::NewDirectory", "enter": "project_panel::OpenPermanent", - "escape": "project_panel::ToggleFocus", + "escape": "vim::ToggleProjectPanelFocus", "h": "project_panel::CollapseSelectedEntry", - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", "l": "project_panel::ExpandSelectedEntry", - "o": "project_panel::OpenPermanent", "shift-d": "project_panel::Delete", "shift-r": "project_panel::Rename", "t": "project_panel::OpenPermanent", - "v": "project_panel::OpenPermanent", + "v": "project_panel::OpenSplitVertical", + "o": "project_panel::OpenSplitHorizontal", "p": "project_panel::Open", "x": "project_panel::RevealInFileManager", - "s": "project_panel::OpenWithSystem", + "s": "workspace::OpenWithSystem", "z d": "project_panel::CompareMarkedFiles", "] c": "project_panel::SelectNextGitEntry", "[ c": "project_panel::SelectPrevGitEntry", @@ -829,7 +925,22 @@ "{": "project_panel::SelectPrevDirectory", "shift-g": "menu::SelectLast", "g g": "menu::SelectFirst", - "-": "project_panel::SelectParent" + "-": "project_panel::SelectParent", + "ctrl-u": "project_panel::ScrollUp", + "ctrl-d": "project_panel::ScrollDown", + "z t": "project_panel::ScrollCursorTop", + "z z": "project_panel::ScrollCursorCenter", + "z b": "project_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9] } }, { @@ -874,7 +985,9 @@ "bindings": { "ctrl-h": "editor::Backspace", "ctrl-u": "editor::DeleteToBeginningOfLine", - "ctrl-w": "editor::DeleteToPreviousWordStart" + "ctrl-w": "editor::DeleteToPreviousWordStart", + "ctrl-p": "menu::SelectPrevious", + "ctrl-n": "menu::SelectNext" } }, { @@ -906,5 +1019,16 @@ // and Windows. "alt-l": "editor::AcceptEditPrediction" } + }, + { + "context": "SettingsWindow > NavigationMenu && !search", + "bindings": { + "l": "settings_editor::ExpandNavEntry", + "h": "settings_editor::CollapseNavEntry", + "k": "settings_editor::FocusPreviousNavEntry", + "j": "settings_editor::FocusNextNavEntry", + "g g": "settings_editor::FocusFirstNavEntry", + "shift-g": "settings_editor::FocusLastNavEntry" + } } ] diff --git a/assets/prompts/assistant_system_prompt.hbs b/assets/prompts/assistant_system_prompt.hbs deleted file mode 100644 index b4545f5a7449bf8c562ea15d722ae8199c42e97a..0000000000000000000000000000000000000000 --- a/assets/prompts/assistant_system_prompt.hbs +++ /dev/null @@ -1,179 +0,0 @@ -You are a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. - -## Communication - -1. Be conversational but professional. -2. Refer to the user in the second person and yourself in the first person. -3. Format your responses in markdown. Use backticks to format file, directory, function, and class names. -4. NEVER lie or make things up. -5. Refrain from apologizing all the time when results are unexpected. Instead, just try your best to proceed or explain the circumstances to the user without apologizing. - -{{#if has_tools}} -## Tool Use - -1. Make sure to adhere to the tools schema. -2. Provide every required argument. -3. DO NOT use tools to access items that are already available in the context section. -4. Use only the tools that are currently available. -5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. -7. Avoid HTML entity escaping - use plain characters instead. - -## Searching and Reading - -If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions. - -{{! TODO: If there are files, we should mention it but otherwise omit that fact }} -If appropriate, use tool calls to explore the current project, which contains the following root directories: - -{{#each worktrees}} -- `{{abs_path}}` -{{/each}} - -- Bias towards not asking the user for help if you can find the answer yourself. -- When providing paths to tools, the path should always start with the name of a project root directory listed above. -- Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path! -{{# if (has_tool 'grep') }} -- When looking for symbols in the project, prefer the `grep` tool. -- As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project. -- The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file. -{{/if}} -{{else}} -You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you). - -As such, if you need the user to perform any actions for you, you must request them explicitly. Bias towards giving a response to the best of your ability, and then making requests for the user to take action (e.g. to give you more context) only optionally. - -The one exception to this is if the user references something you don't know about - for example, the name of a source code file, function, type, or other piece of code that you have no awareness of. In this case, you MUST NOT MAKE SOMETHING UP, or assume you know what that thing is or how it works. Instead, you must ask the user for clarification rather than giving a response. -{{/if}} - -## Code Block Formatting - -Whenever you mention a code block, you MUST use ONLY use the following format: -```path/to/Something.blah#L123-456 -(code goes here) -``` -The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah -is a path in the project. (If there is no valid path in the project, then you can use -/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser -does not understand the more common ```language syntax, or bare ``` blocks. It only -understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again. -Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP! -You have made a mistake. You can only ever put paths after triple backticks! - -Based on all the information I've gathered, here's a summary of how this system works: -1. The README file is loaded into the system. -2. The system finds the first two headers, including everything in between. In this case, that would be: -```path/to/README.md#L8-12 -# First Header -This is the info under the first header. -## Sub-header -``` -3. Then the system finds the last header in the README: -```path/to/README.md#L27-29 -## Last Header -This is the last header in the README. -``` -4. Finally, it passes this information on to the next process. - - -In Markdown, hash marks signify headings. For example: -```/dev/null/example.md#L1-3 -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -Here are examples of ways you must never render code blocks: - -In Markdown, hash marks signify headings. For example: -``` -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because it does not include the path. - -In Markdown, hash marks signify headings. For example: -```markdown -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because it has the language instead of the path. - -In Markdown, hash marks signify headings. For example: - # Level 1 heading - ## Level 2 heading - ### Level 3 heading - -This example is unacceptable because it uses indentation to mark the code block -instead of backticks with a path. - -In Markdown, hash marks signify headings. For example: -```markdown -/dev/null/example.md#L1-3 -# Level 1 heading -## Level 2 heading -### Level 3 heading -``` - -This example is unacceptable because the path is in the wrong place. The path must be directly after the opening backticks. - -{{#if has_tools}} -## Fixing Diagnostics - -1. Make 1-2 attempts at fixing diagnostics, then defer to the user. -2. Never simplify code you've written just to solve diagnostics. Complete, mostly correct code is more valuable than perfect code that doesn't solve the problem. - -## Debugging - -When debugging, only make code changes if you are certain that you can solve the problem. -Otherwise, follow debugging best practices: -1. Address the root cause instead of the symptoms. -2. Add descriptive logging statements and error messages to track variable and code state. -3. Add test functions and statements to isolate the problem. - -{{/if}} -## Calling External APIs - -1. Unless explicitly requested by the user, use the best suited external APIs and packages to solve the task. There is no need to ask the user for permission. -2. When selecting which version of an API or package to use, choose one that is compatible with the user's dependency management file(s). If no such file exists or if the package is not present, use the latest version that is in your training data. -3. If an external API requires an API Key, be sure to point this out to the user. Adhere to best security practices (e.g. DO NOT hardcode an API key in a place where it can be exposed) - -## System Information - -Operating System: {{os}} -Default Shell: {{shell}} - -{{#if (or has_rules has_user_rules)}} -## User's Custom Instructions - -The following additional instructions are provided by the user, and should be followed to the best of your ability{{#if has_tools}} without interfering with the tool use guidelines{{/if}}. - -{{#if has_rules}} -There are project rules that apply to these root directories: -{{#each worktrees}} -{{#if rules_file}} -`{{root_name}}/{{rules_file.path_in_worktree}}`: -`````` -{{{rules_file.text}}} -`````` -{{/if}} -{{/each}} -{{/if}} - -{{#if has_user_rules}} -The user has specified the following rules that should be applied: -{{#each user_rules}} - -{{#if title}} -Rules title: {{title}} -{{/if}} -`````` -{{contents}}} -`````` -{{/each}} -{{/if}} -{{/if}} diff --git a/assets/prompts/content_prompt.hbs b/assets/prompts/content_prompt.hbs index e601e6dc63376af54e6e1a9bfa53cdbd57190e22..6db53ff48ff251b90f9120398852a9160ca41755 100644 --- a/assets/prompts/content_prompt.hbs +++ b/assets/prompts/content_prompt.hbs @@ -29,7 +29,9 @@ Generate {{content_type}} based on the following prompt: Match the indentation in the original file in the inserted {{content_type}}, don't include any indentation on blank lines. -Immediately start with the following format with no remarks: +Return ONLY the {{content_type}} to insert. Do NOT include any XML tags like , , or any surrounding markup from the input. + +Respond with a code block containing the {{content_type}} to insert. Replace \{{INSERTED_CODE}} with your actual {{content_type}}: ``` \{{INSERTED_CODE}} @@ -66,7 +68,9 @@ Only make changes that are necessary to fulfill the prompt, leave everything els Start at the indentation level in the original file in the rewritten {{content_type}}. Don't stop until you've rewritten the entire section, even if you have no more changes to make, always write out the whole section with no unnecessary elisions. -Immediately start with the following format with no remarks: +Return ONLY the rewritten {{content_type}}. Do NOT include any XML tags like , , or any surrounding markup from the input. + +Respond with a code block containing the rewritten {{content_type}}. Replace \{{REWRITTEN_CODE}} with your actual rewritten {{content_type}}: ``` \{{REWRITTEN_CODE}} diff --git a/assets/settings/default.json b/assets/settings/default.json index 6a8b034268d39c14d3f57273f1cb80a025e3cf5e..f62cc1844732db2a49dc835a155e861f4268632f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1,4 +1,8 @@ { + "$schema": "zed://schemas/settings", + /// The displayed name of this project. If not set or null, the root directory name + /// will be displayed. + "project_name": null, // The name of the Zed theme to use for the UI. // // `mode` is one of: @@ -71,8 +75,10 @@ "ui_font_weight": 400, // The default font size for text in the UI "ui_font_size": 16, - // The default font size for text in the agent panel. Falls back to the UI font size if unset. - "agent_font_size": null, + // The default font size for agent responses in the agent panel. Falls back to the UI font size if unset. + "agent_ui_font_size": null, + // The default font size for user messages in the agent panel. + "agent_buffer_font_size": 12, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -114,6 +120,7 @@ // Whether to enable vim modes and key bindings. "vim_mode": false, // Whether to enable helix mode and key bindings. + // Enabling this mode will automatically enable vim mode. "helix_mode": false, // Whether to show the informational hover box when moving the mouse // over symbols in the editor. @@ -162,6 +169,12 @@ // 2. Always quit the application // "on_last_window_closed": "quit_app", "on_last_window_closed": "platform_default", + // Whether to show padding for zoomed panels. + // When enabled, zoomed center panels (e.g. code editor) will have padding all around, + // while zoomed bottom/left/right panels will have padding to the top/right/left (respectively). + // + // Default: true + "zoomed_padding": true, // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, @@ -182,8 +195,8 @@ // 4. A box drawn around the following character // "hollow" // - // Default: not set, defaults to "bar" - "cursor_shape": null, + // Default: "bar" + "cursor_shape": "bar", // Determines when the mouse cursor should be hidden in an editor or input box. // // 1. Never hide the mouse cursor: @@ -217,9 +230,25 @@ "current_line_highlight": "all", // Whether to highlight all occurrences of the selected text in an editor. "selection_highlight": true, + // Whether the text selection should have rounded corners. + "rounded_selection": true, // The debounce delay before querying highlights from the language // server based on the current cursor location. "lsp_highlight_debounce": 75, + // The minimum APCA perceptual contrast between foreground and background colors. + // APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x, + // especially for dark mode. Values range from 0 to 106. + // + // Based on APCA Readability Criterion (ARC) Bronze Simple Mode: + // https://readtech.org/ARC/tests/bronze-simple-mode/ + // - 0: No contrast adjustment + // - 45: Minimum for large fluent text (36px+) + // - 60: Minimum for other content text + // - 75: Minimum for body text + // - 90: Preferred for body text + // + // This only affects text drawn over highlight backgrounds in the editor. + "minimum_contrast_for_highlights": 45, // Whether to pop the completions menu while typing in an editor without // explicitly requesting it. "show_completions_on_input": true, @@ -260,8 +289,8 @@ // - "warning" // - "info" // - "hint" - // - null — allow all diagnostics (default) - "diagnostics_max_severity": null, + // - "all" — allow all diagnostics (default) + "diagnostics_max_severity": "all", // Whether to show wrap guides (vertical rulers) in the editor. // Setting this to true will show a guide at the 'preferred_line_length' value // if 'soft_wrap' is set to 'preferred_line_length', and will show any @@ -273,6 +302,8 @@ "redact_private_values": false, // The default number of lines to expand excerpts in the multibuffer by. "expand_excerpt_lines": 5, + // The default number of context lines shown in multibuffer excerpts. + "excerpt_context_lines": 2, // Globs to match against file paths to determine if a file is private. "private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"], // Whether to use additional LSP queries to format (and amend) the code after @@ -280,12 +311,14 @@ "use_on_type_format": true, // Whether to automatically add matching closing characters when typing // opening parenthesis, bracket, brace, single or double quote characters. - // For example, when you type (, Zed will add a closing ) at the correct position. + // For example, when you type '(', Zed will add a closing ) at the correct position. "use_autoclose": true, // Whether to automatically surround selected text when typing opening parenthesis, // bracket, brace, single or double quote characters. - // For example, when you select text and type (, Zed will surround the text with (). + // For example, when you select text and type '(', Zed will surround the text with (). "use_auto_surround": true, + // Whether indentation should be adjusted based on the context whilst typing. + "auto_indent": true, // Whether indentation of pasted content should be adjusted based on the context. "auto_indent_on_paste": true, // Controls how the editor handles the autoclosed characters. @@ -335,6 +368,11 @@ // - It is adjacent to an edge (start or end) // - It is adjacent to a whitespace (left or right) "show_whitespaces": "selection", + // Visible characters used to render whitespace when show_whitespaces is enabled. + "whitespace_map": { + "space": "•", + "tab": "→" + }, // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default @@ -355,6 +393,8 @@ // Whether to show code action buttons in the editor toolbar. "code_actions": false }, + // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). + "use_system_window_tabs": false, // Titlebar related settings "title_bar": { // Whether to show the branch icon beside branch switcher in the titlebar. @@ -372,6 +412,39 @@ // Whether to show the menus in the titlebar. "show_menus": false }, + "audio": { + // Opt into the new audio system. + "experimental.rodio_audio": false, + // Requires 'rodio_audio: true' + // + // Automatically increase or decrease you microphone's volume. This affects how + // loud you sound to others. + // + // Recommended: off (default) + // Microphones are too quite in zed, until everyone is on experimental + // audio and has auto speaker volume on this will make you very loud + // compared to other speakers. + "experimental.auto_microphone_volume": false, + // Requires 'rodio_audio: true' + // + // Automatically increate or decrease the volume of other call members. + // This only affects how things sound for you. + "experimental.auto_speaker_volume": true, + // Requires 'rodio_audio: true' + // + // Remove background noises. Works great for typing, cars, dogs, AC. Does + // not work well on music. + "experimental.denoise": true, + // Requires 'rodio_audio: true' + // + // Use audio parameters compatible with the previous versions of + // experimental audio and non-experimental audio. When this is false you + // will sound strange to anyone not on the latest experimental audio. In + // the future we will migrate by setting this to false + // + // You need to rejoin a call for this setting to apply + "experimental.legacy_audio_compatible": true + }, // Scrollbar related settings "scrollbar": { // When to show the scrollbar in the editor. @@ -552,6 +625,7 @@ // Toggle certain types of hints on and off, all switched on by default. "show_type_hints": true, "show_parameter_hints": true, + "show_value_hints": true, // Corresponds to null/None LSP hint type value. "show_other_hints": true, // Whether to show a background for inlay hints. @@ -645,8 +719,14 @@ // "never" "show": "always" }, + // Whether to enable drag-and-drop operations in the project panel. + "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window. - "hide_root": false + "hide_root": false, + // Whether to hide the hidden entries in the project panel. + "hide_hidden": false, + // Whether to automatically open files when pasting them in the project panel. + "open_file_on_paste": true }, "outline_panel": { // Whether to show the outline panel button in the status bar @@ -710,20 +790,10 @@ // Default width of the collaboration panel. "default_width": 240 }, - "chat_panel": { - // When to show the chat panel button in the status bar. - // Can be 'never', 'always', or 'when_in_call', - // or a boolean (interpreted as 'never'/'always'). - "button": "when_in_call", - // Where to the chat panel. Can be 'left' or 'right'. - "dock": "right", - // Default width of the chat panel. - "default_width": 240 - }, "git_panel": { // Whether to show the git panel button in the status bar. "button": true, - // Where to show the git panel. Can be 'left' or 'right'. + // Where to dock the git panel. Can be 'left' or 'right'. "dock": "left", // Default width of the git panel. "default_width": 360, @@ -768,7 +838,7 @@ "agent": { // Whether the agent is enabled. "enabled": true, - /// What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. + // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. "preferred_completion_mode": "normal", // Whether to show the agent panel button in the status bar. "button": true, @@ -778,6 +848,8 @@ "default_width": 640, // Default height when the agent panel is docked to the bottom. "default_height": 320, + // The view to use by default (thread, or text_thread) + "default_view": "thread", // The default model to use when creating new threads. "default_model": { // The provider to use. @@ -808,9 +880,10 @@ // } ], // When enabled, the agent can run potentially destructive actions without asking for your confirmation. + // + // Note: This setting has no effect on external agents that support permission modes, such as Claude Code. + // You can set `agent_servers.claude.default_mode` to `bypassPermissions` to skip all permission requests. "always_allow_tool_actions": false, - // When enabled, the agent will stream edits. - "stream_edits": false, // When enabled, agent edits will be displayed in single-file editors for review "single_file_review": true, // When enabled, show voting thumbs for feedback on agent edits. @@ -833,6 +906,7 @@ "now": true, "find_path": true, "read_file": true, + "open": true, "grep": true, "terminal": true, "thinking": true, @@ -844,7 +918,6 @@ // We don't know which of the context server tools are safe for the "Ask" profile, so we don't enable them by default. // "enable_all_context_servers": true, "tools": { - "contents": true, "diagnostics": true, "fetch": true, "list_directory": true, @@ -876,22 +949,22 @@ // Default: false "play_sound_when_agent_done": false, - /// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. - /// - /// Default: true + // Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. + // + // Default: true "expand_edit_card": true, - /// Whether to have terminal cards in the agent panel expanded, showing the whole command output. - /// - /// Default: true - "expand_terminal_card": true - }, - // The settings for slash commands. - "slash_commands": { - // Settings for the `/project` slash command. - "project": { - // Whether `/project` is enabled. - "enabled": false - } + // Whether to have terminal cards in the agent panel expanded, showing the whole command output. + // + // Default: true + "expand_terminal_card": true, + // Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel. + // + // Default: false + "use_modifier_to_send": false, + // Minimum number of lines to display in the agent message editor. + // + // Default: 4 + "message_editor_min_lines": 4 }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, @@ -904,6 +977,7 @@ // // This is typically customized on a per-language basis. "language_servers": ["..."], + // When to automatically save edited buffers. This setting can // take four values. // @@ -932,7 +1006,7 @@ // Show git status colors in the editor tabs. "git_status": false, // Position of the close button on the editor tabs. - // One of: ["right", "left", "hidden"] + // One of: ["right", "left"] "close_position": "right", // Whether to show the file icon for a tab. "file_icons": false, @@ -1017,10 +1091,10 @@ // Only the file Zed had indexed will be used, not necessary all the gitignored files. // // Can accept 3 values: - // * `true`: Use all gitignored files - // * `false`: Use only the files Zed had indexed - // * `null`: Be smart and search for ignored when called from a gitignored worktree - "include_ignored": null + // * "all": Use all gitignored files + // * "indexed": Use only the files Zed had indexed + // * "smart": Be smart and search for ignored when called from a gitignored worktree + "include_ignored": "smart" }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. @@ -1030,25 +1104,31 @@ // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, - // Whether or not to perform a buffer format before saving: [on, off, prettier, language_server] + // Whether or not to perform a buffer format before saving: [on, off] // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored "format_on_save": "on", - // How to perform a buffer format. This setting can take 4 values: + // How to perform a buffer format. This setting can take multiple values: // - // 1. Format code using the current language server: + // 1. Default. Format files using Zed's Prettier integration (if applicable), + // or falling back to formatting via language server: + // "formatter": "auto" + // 2. Format code using the current language server: // "formatter": "language_server" - // 2. Format code using an external command: + // 3. Format code using a specific language server: + // "formatter": {"language_server": {"name": "ruff"}} + // 4. Format code using an external command: // "formatter": { // "external": { // "command": "prettier", // "arguments": ["--stdin-filepath", "{buffer_path}"] // } // } - // 3. Format code using Zed's Prettier integration: + // 5. Format code using Zed's Prettier integration: // "formatter": "prettier" - // 4. Default. Format files using Zed's Prettier integration (if applicable), - // or falling back to formatting via language server: - // "formatter": "auto" + // 6. Format code using a code action + // "formatter": {"code_action": "source.fixAll.eslint"} + // 7. An array of any format step specified above to apply in order + // "formatter": [{"code_action": "source.fixAll.eslint"}, "prettier"] "formatter": "auto", // How to soft-wrap long lines of text. // Possible values: @@ -1131,11 +1211,6 @@ // The minimum severity of the diagnostics to show inline. // Inherits editor's diagnostics' max severity settings when `null`. "max_severity": null - }, - "cargo": { - // When enabled, Zed disables rust-analyzer's check on save and starts to query - // Cargo diagnostics separately. - "fetch_cargo_diagnostics": false } }, // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file @@ -1165,6 +1240,10 @@ // 2. Hide the gutter // "git_gutter": "hide" "git_gutter": "tracked_files", + /// Sets the debounce threshold (in milliseconds) after which changes are reflected in the git gutter. + /// + /// Default: 0 + "gutter_debounce": 0, // Control whether the git blame information is shown inline, // in the currently focused line. "inline_blame": { @@ -1180,6 +1259,13 @@ // The minimum column number to show the inline blame information at "min_column": 0 }, + "blame": { + "show_avatar": true + }, + // Control which information is shown in the branch picker. + "branch_picker": { + "show_author_name": true + }, // How git hunks are displayed visually in the editor. // This setting can take two values: // @@ -1234,7 +1320,16 @@ // "proxy": "", // "proxy_no_verify": false // }, - // Whether edit predictions are enabled when editing text threads. + "copilot": { + "enterprise_uri": null, + "proxy": null, + "proxy_no_verify": null + }, + "codestral": { + "model": null, + "max_tokens": null + }, + // Whether edit predictions are enabled when editing text threads in the agent panel. // This setting has no effect if globally disabled. "enabled_in_text_threads": true }, @@ -1250,10 +1345,14 @@ }, // Status bar-related settings. "status_bar": { + // Whether to show the status bar. + "experimental.show": true, // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. - "cursor_position_button": true + "cursor_position_button": true, + // Whether to show active line endings button in the status bar. + "line_endings_button": false }, // Settings specific to the terminal "terminal": { @@ -1316,8 +1415,8 @@ // 4. A box drawn around the following character // "hollow" // - // Default: not set, defaults to "block" - "cursor_shape": null, + // Default: "block" + "cursor_shape": "block", // Set whether Alternate Scroll mode (code: ?1007) is active by default. // Alternate Scroll mode converts mouse scroll events into up / down key // presses when in the alternate screen (e.g. when running applications @@ -1339,8 +1438,8 @@ // Whether or not selecting text in the terminal will automatically // copy to the system clipboard. "copy_on_select": false, - // Whether to keep the text selection after copying it to the clipboard - "keep_selection_on_copy": false, + // Whether to keep the text selection after copying it to the clipboard. + "keep_selection_on_copy": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's @@ -1359,7 +1458,7 @@ // "line_height": { // "custom": 2 // }, - "line_height": "comfortable", + "line_height": "standard", // Activate the python virtual environment, if one is found, in the // terminal's working directory (as resolved by the working_directory // setting). Set this to "off" to disable this behavior. @@ -1379,7 +1478,7 @@ // // The shell running in the terminal needs to be configured to emit the title. // Example: `echo -e "\e]2;New Title\007";` - "breadcrumbs": true + "breadcrumbs": false }, // Scrollbar-related settings "scrollbar": { @@ -1459,7 +1558,8 @@ // } // "file_types": { - "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json"], + "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"], + "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], "Shell Script": [".env.*"] }, // Settings for which version of Node.js and NPM to use when installing @@ -1485,6 +1585,14 @@ "auto_install_extensions": { "html": true }, + // The capabilities granted to extensions. + // + // This list can be customized to restrict what extensions are able to do. + "granted_extension_capabilities": [ + { "kind": "process:exec", "command": "*", "args": ["**"] }, + { "kind": "download_file", "host": "*", "path": ["**"] }, + { "kind": "npm:install", "package": "*" } + ], // Controls how completions are processed for this language. "completions": { // Controls how words are completed. @@ -1501,6 +1609,11 @@ // // Default: fallback "words": "fallback", + // Minimum number of characters required to automatically trigger word-based completions. + // Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command. + // + // Default: 3 + "words_min_length": 3, // Whether to fetch LSP completions or not. // // Default: true @@ -1573,7 +1686,7 @@ "ensure_final_newline_on_save": false }, "Elixir": { - "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] }, "Elm": { "tab_size": 4 @@ -1587,6 +1700,7 @@ "preferred_line_length": 72 }, "Go": { + "hard_tabs": true, "code_actions_on_format": { "source.organizeImports": true }, @@ -1598,7 +1712,7 @@ } }, "HEEX": { - "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] }, "HTML": { "prettier": { @@ -1627,6 +1741,9 @@ "allowed": true } }, + "Kotlin": { + "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."] + }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], @@ -1640,9 +1757,6 @@ "use_on_type_format": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", - "completions": { - "words": "disabled" - }, "prettier": { "allowed": true } @@ -1656,13 +1770,20 @@ } }, "Plain Text": { - "completions": { - "words": "disabled" - }, - "allow_rewrap": "anywhere" + "allow_rewrap": "anywhere", + "soft_wrap": "editor_width" }, "Python": { - "debuggers": ["Debugpy"] + "code_actions_on_format": { + "source.organizeImports.ruff": true + }, + "formatter": { + "language_server": { + "name": "ruff" + } + }, + "debuggers": ["Debugpy"], + "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."] }, "Ruby": { "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."] @@ -1704,10 +1825,11 @@ }, "SystemVerilog": { "format_on_save": "off", + "language_servers": ["!slang", "..."], "use_on_type_format": false }, "Vue.js": { - "language_servers": ["vue-language-server", "..."], + "language_servers": ["vue-language-server", "vtsls", "..."], "prettier": { "allowed": true } @@ -1732,6 +1854,7 @@ "anthropic": { "api_url": "https://api.anthropic.com" }, + "bedrock": {}, "google": { "api_url": "https://generativelanguage.googleapis.com" }, @@ -1749,31 +1872,45 @@ "api_url": "http://localhost:1234/api/v0" }, "deepseek": { - "api_url": "https://api.deepseek.com" + "api_url": "https://api.deepseek.com/v1" }, "mistral": { "api_url": "https://api.mistral.ai/v1" - } + }, + "vercel": { + "api_url": "https://api.v0.dev/v1" + }, + "x_ai": { + "api_url": "https://api.x.ai/v1" + }, + "zed.dev": {} + }, + "session": { + // Whether or not to restore unsaved buffers on restart. + // + // If this is true, user won't be prompted whether to save/discard + // dirty files when closing the application. + // + // Default: true + "restore_unsaved_buffers": true }, // Zed's Prettier integration settings. // Allows to enable/disable formatting with Prettier // and configure default Prettier, used when no project-level Prettier installation is found. "prettier": { - // // Whether to consider prettier formatter or not when attempting to format a file. - // "allowed": false, - // - // // Use regular Prettier json configuration. - // // If Prettier is allowed, Zed will use this for its Prettier instance for any applicable file, if - // // the project has no other Prettier installed. - // "plugins": [], - // - // // Use regular Prettier json configuration. - // // If Prettier is allowed, Zed will use this for its Prettier instance for any applicable file, if - // // the project has no other Prettier installed. + // Enables or disables formatting with Prettier for any given language. + "allowed": false, + // Forces Prettier integration to use a specific parser name when formatting files with the language. + "plugins": [], + // Default Prettier options, in the format as in package.json section for Prettier. + // If project installs Prettier via its package.json, these options will be ignored. // "trailingComma": "es5", // "tabWidth": 4, // "semi": false, // "singleQuote": true + // Forces Prettier integration to use a specific parser name when formatting files with the language + // when set to a non-empty string. + "parser": "" }, // Settings for auto-closing of JSX tags. "jsx_tag_auto_close": { @@ -1793,6 +1930,15 @@ // } // } }, + // DAP Specific settings. + "dap": { + // Specify the DAP name as a key here. + "CodeLLDB": { + "env": { + "RUST_LOG": "info" + } + } + }, // Common language server settings. "global_lsp_settings": { // Whether to show the LSP servers button in the status bar. @@ -1800,13 +1946,23 @@ }, // Jupyter settings "jupyter": { - "enabled": true + "enabled": true, + "kernel_selections": {} // Specify the language name as the key and the kernel name as the value. // "kernel_selections": { // "python": "conda-base" // "typescript": "deno" // } }, + // REPL settings. + "repl": { + // Maximum number of columns to keep in REPL's scrollback buffer. + // Clamped with [20, 512] range. + "max_columns": 128, + // Maximum number of lines to keep in REPL's scrollback buffer. + // Clamped with [4, 256] range. + "max_lines": 32 + }, // Vim settings "vim": { "default_mode": "normal", @@ -1897,7 +2053,10 @@ "debugger": { "stepping_granularity": "line", "save_breakpoints": true, + "timeout": 2000, "dock": "bottom", + "log_dap_communications": true, + "format_dap_log_messages": true, "button": true }, // Configures any number of settings profiles that are temporarily applied on @@ -1906,7 +2065,7 @@ // Examples: // "profiles": { // "Presenting": { - // "agent_font_size": 20.0, + // "agent_ui_font_size": 20.0, // "buffer_font_size": 20.0, // "theme": "One Light", // "ui_font_size": 20.0 @@ -1919,5 +2078,11 @@ // } // } // } - "profiles": [] + "profiles": {}, + + // A map of log scopes to the desired log level. + // Useful for filtering out noisy logs or enabling more verbose logging. + // + // Example: {"log": {"client": "warn"}} + "log": {} } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79c550671f85d7b107db5e85883caa28fe41411..a79e98063237ca297a89b0d151bd48149061b7bb 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -44,7 +44,11 @@ // } // } "shell": "system", + // Whether to show the task line in the output of the spawned task, defaults to `true`. + "show_summary": true, + // Whether to show the command line in the output of the spawned task, defaults to `true`. + "show_command": true // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] + // "tags": [] } ] diff --git a/assets/sounds/guest_joined_call.wav b/assets/sounds/guest_joined_call.wav new file mode 100644 index 0000000000000000000000000000000000000000..336a6ca754b09c408f63c411193157a20c14bb78 Binary files /dev/null and b/assets/sounds/guest_joined_call.wav differ diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index f9f8720729008efb9a17cf45bd23ce51df7d3657..7c84c603bda7fd7590067ec9f566f3582ba6aefd 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -93,7 +93,7 @@ "terminal.ansi.bright_cyan": "#4c806fff", "terminal.ansi.dim_cyan": "#cbf2e4ff", "terminal.ansi.white": "#bfbdb6ff", - "terminal.ansi.bright_white": "#bfbdb6ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#787876ff", "link_text.hover": "#5ac1feff", "conflict": "#feb454ff", @@ -192,7 +192,7 @@ "font_weight": null }, "comment": { - "color": "#abb5be8c", + "color": "#5c6773ff", "font_style": null, "font_weight": null }, @@ -239,7 +239,7 @@ "hint": { "color": "#628b80ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#ff8f3fff", @@ -316,6 +316,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#a6a5a0ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#d2a6ffff", "font_style": null, @@ -479,7 +484,7 @@ "terminal.ansi.bright_cyan": "#ace0cbff", "terminal.ansi.dim_cyan": "#2a5f4aff", "terminal.ansi.white": "#fcfcfcff", - "terminal.ansi.bright_white": "#fcfcfcff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#bcbec0ff", "link_text.hover": "#3b9ee5ff", "conflict": "#f1ad49ff", @@ -578,7 +583,7 @@ "font_weight": null }, "comment": { - "color": "#787b8099", + "color": "#abb0b6ff", "font_style": null, "font_weight": null }, @@ -625,7 +630,7 @@ "hint": { "color": "#8ca7c2ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#fa8d3eff", @@ -702,6 +707,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#73777bff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#a37accff", "font_style": null, @@ -865,7 +875,7 @@ "terminal.ansi.bright_cyan": "#4c806fff", "terminal.ansi.dim_cyan": "#cbf2e4ff", "terminal.ansi.white": "#cccac2ff", - "terminal.ansi.bright_white": "#cccac2ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#898a8aff", "link_text.hover": "#72cffeff", "conflict": "#fecf72ff", @@ -964,7 +974,7 @@ "font_weight": null }, "comment": { - "color": "#b8cfe680", + "color": "#5c6773ff", "font_style": null, "font_weight": null }, @@ -1011,7 +1021,7 @@ "hint": { "color": "#7399a3ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#ffad65ff", @@ -1088,6 +1098,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#b4b3aeff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#dfbfffff", "font_style": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 459825c733dbf2eae1e5269885b1b2c135bd72c4..a0f0a3ad637a4d212c8bf38f95f2e8424919d6bf 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -6,8 +6,8 @@ { "name": "Gruvbox Dark", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -49,8 +49,9 @@ "panel.background": "#3a3735ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#373432ff", @@ -94,7 +95,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -248,7 +249,7 @@ "hint": { "color": "#8c957dff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#fb4833ff", @@ -325,6 +326,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#e5d5adff", "font_style": null, @@ -406,8 +412,8 @@ { "name": "Gruvbox Dark Hard", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -449,8 +455,9 @@ "panel.background": "#393634ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#343130ff", @@ -494,7 +501,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -648,7 +655,7 @@ "hint": { "color": "#8c957dff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#fb4833ff", @@ -725,6 +732,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#e5d5adff", "font_style": null, @@ -806,8 +818,8 @@ { "name": "Gruvbox Dark Soft", "appearance": "dark", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#5b534dff", "border.variant": "#494340ff", "border.focused": "#303a36ff", @@ -849,8 +861,9 @@ "panel.background": "#3b3735ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#fbf1c74c", - "scrollbar.thumb.hover_background": "#494340ff", + "scrollbar.thumb.active_background": "#83a598ac", + "scrollbar.thumb.hover_background": "#fbf1c74c", + "scrollbar.thumb.background": "#a899844c", "scrollbar.thumb.border": "#494340ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#393634ff", @@ -894,7 +907,7 @@ "terminal.ansi.bright_cyan": "#45603eff", "terminal.ansi.dim_cyan": "#c7dfbdff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", @@ -1048,7 +1061,7 @@ "hint": { "color": "#8c957dff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#fb4833ff", @@ -1125,6 +1138,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#83a598ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#e5d5adff", "font_style": null, @@ -1206,8 +1224,8 @@ { "name": "Gruvbox Light", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -1249,8 +1267,9 @@ "panel.background": "#ecddb4ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eee0b7ff", @@ -1294,7 +1313,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", @@ -1448,7 +1467,7 @@ "hint": { "color": "#677562ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#9d0006ff", @@ -1525,6 +1544,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#066578ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#413d3aff", "font_style": null, @@ -1606,8 +1630,8 @@ { "name": "Gruvbox Light Hard", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -1649,8 +1673,9 @@ "panel.background": "#ecddb5ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eee1bbff", @@ -1694,7 +1719,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#f9f5d7ff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", @@ -1848,7 +1873,7 @@ "hint": { "color": "#677562ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#9d0006ff", @@ -1925,6 +1950,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#066578ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#413d3aff", "font_style": null, @@ -2006,8 +2036,8 @@ { "name": "Gruvbox Light Soft", "appearance": "light", - "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "style": { + "accents": ["#cc241dff", "#98971aff", "#d79921ff", "#458588ff", "#b16286ff", "#689d6aff", "#d65d0eff"], "border": "#c8b899ff", "border.variant": "#ddcca7ff", "border.focused": "#adc5ccff", @@ -2049,8 +2079,9 @@ "panel.background": "#ecdcb3ff", "panel.focused_border": null, "pane.focused_border": null, - "scrollbar.thumb.background": "#2828284c", - "scrollbar.thumb.hover_background": "#ddcca7ff", + "scrollbar.thumb.active_background": "#458588ac", + "scrollbar.thumb.hover_background": "#2828284c", + "scrollbar.thumb.background": "#7c6f644c", "scrollbar.thumb.border": "#ddcca7ff", "scrollbar.track.background": "#00000000", "scrollbar.track.border": "#eddeb5ff", @@ -2094,7 +2125,7 @@ "terminal.ansi.bright_cyan": "#9fbca8ff", "terminal.ansi.dim_cyan": "#253e2eff", "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#f2e5bcff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#b0a189ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", @@ -2248,7 +2279,7 @@ "hint": { "color": "#677562ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#9d0006ff", @@ -2325,6 +2356,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#066578ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#413d3aff", "font_style": null, diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 23ebbcc67efaa9ca45748a5726ac1fd72488c451..6849cd05dc70752216789ae04e81fad232f7b14b 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -93,7 +93,7 @@ "terminal.ansi.bright_cyan": "#3a565bff", "terminal.ansi.dim_cyan": "#b9d9dfff", "terminal.ansi.white": "#dce0e5ff", - "terminal.ansi.bright_white": "#dce0e5ff", + "terminal.ansi.bright_white": "#fafafaff", "terminal.ansi.dim_white": "#575d65ff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", @@ -244,7 +244,7 @@ "hint": { "color": "#788ca6ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#b477cfff", @@ -321,6 +321,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#d07277ff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#b1574bff", "font_style": null, @@ -468,7 +473,7 @@ "terminal.bright_foreground": "#242529ff", "terminal.dim_foreground": "#fafafaff", "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#242529ff", + "terminal.ansi.bright_black": "#747579ff", "terminal.ansi.dim_black": "#97979aff", "terminal.ansi.red": "#d36151ff", "terminal.ansi.bright_red": "#f0b0a4ff", @@ -489,7 +494,7 @@ "terminal.ansi.bright_cyan": "#a3bedaff", "terminal.ansi.dim_cyan": "#254058ff", "terminal.ansi.white": "#fafafaff", - "terminal.ansi.bright_white": "#fafafaff", + "terminal.ansi.bright_white": "#ffffffff", "terminal.ansi.dim_white": "#aaaaaaff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", @@ -638,7 +643,7 @@ "hint": { "color": "#7274a7ff", "font_style": null, - "font_weight": 700 + "font_weight": null }, "keyword": { "color": "#a449abff", @@ -715,6 +720,11 @@ "font_style": null, "font_weight": null }, + "punctuation.markup": { + "color": "#d3604fff", + "font_style": null, + "font_weight": null + }, "punctuation.special": { "color": "#b92b46ff", "font_style": null, diff --git a/ci/Dockerfile.namespace b/ci/Dockerfile.namespace new file mode 100644 index 0000000000000000000000000000000000000000..f370dae194a0a3e614354ba70f65237e27c3382e --- /dev/null +++ b/ci/Dockerfile.namespace @@ -0,0 +1,21 @@ +ARG NAMESPACE_BASE_IMAGE_REF="" + +# Your image must build FROM NAMESPACE_BASE_IMAGE_REF +FROM ${NAMESPACE_BASE_IMAGE_REF} AS base + +# Remove problematic git-lfs packagecloud source +RUN sudo rm -f /etc/apt/sources.list.d/*git-lfs*.list +# Install git and SSH for cloning private repositories +RUN sudo apt-get update && \ + sudo apt-get install -y git openssh-client + +# Clone the Zed repository +RUN git clone https://github.com/zed-industries/zed.git ~/zed + +# Run the Linux installation script +WORKDIR /home/runner/zed +RUN ./script/linux + +# Clean up unnecessary files to reduce image size +RUN sudo apt-get clean && sudo rm -rf \ + /home/runner/zed diff --git a/clippy.toml b/clippy.toml index e606ad4c79b5cfe289b1f8460b1f46715103fe1b..0ce7a6cd68d4e8210788eb7a67aa06c742cc8274 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,5 +3,21 @@ avoid-breaking-exported-api = false ignore-interior-mutability = [ # Suppresses clippy::mutable_key_type, which is a false positive as the Eq # and Hash impls do not use fields with interior mutability. - "agent::context::AgentContextKey" + "agent_ui::context::AgentContextKey" +] +disallowed-methods = [ + { path = "std::process::Command::spawn", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::spawn" }, + { path = "std::process::Command::output", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::output" }, + { path = "std::process::Command::status", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::status" }, + { path = "std::process::Command::stdin", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdin" }, + { path = "std::process::Command::stdout", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdout" }, + { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, + { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, + { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, +] +disallowed-types = [ + # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, + # { path = "std::collections::HashSet", replacement = "collections::HashSet" }, + # { path = "indexmap::IndexSet", replacement = "collections::IndexSet" }, + # { path = "indexmap::IndexMap", replacement = "collections::IndexMap" }, ] diff --git a/compose.yml b/compose.yml index d0d9bac425356687bfb33efab9ee24e76d1b30a0..cee63e968b2153235bd47dec1429ccbc5a55db8e 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:15 + image: docker.io/library/postgres:15 container_name: zed_postgres ports: - 5432:5432 @@ -23,7 +23,7 @@ services: - ./.blob_store:/data livekit_server: - image: livekit/livekit-server + image: docker.io/livekit/livekit-server container_name: livekit_server entrypoint: /livekit-server --config /livekit.yaml ports: @@ -33,34 +33,8 @@ services: volumes: - ./livekit.yaml:/livekit.yaml - postgrest_app: - image: postgrest/postgrest - container_name: postgrest_app - ports: - - 8081:8081 - environment: - PGRST_DB_URI: postgres://postgres@postgres:5432/zed - volumes: - - ./crates/collab/postgrest_app.conf:/etc/postgrest.conf - command: postgrest /etc/postgrest.conf - depends_on: - - postgres - - postgrest_llm: - image: postgrest/postgrest - container_name: postgrest_llm - ports: - - 8082:8082 - environment: - PGRST_DB_URI: postgres://postgres@postgres:5432/zed_llm - volumes: - - ./crates/collab/postgrest_llm.conf:/etc/postgrest.conf - command: postgrest /etc/postgrest.conf - depends_on: - - postgres - stripe-mock: - image: stripe/stripe-mock:v0.178.0 + image: docker.io/stripe/stripe-mock:v0.178.0 ports: - 12111:12111 - 12112:12112 diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 2b9a6513c8e91a165bbc51aae3e5b2e831cfb234..09202dc57cb96f5f258e64063f5d61169fa7a045 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -18,7 +18,7 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true agent-client-protocol.workspace = true -agent.workspace = true +agent_settings.workspace = true anyhow.workspace = true buffer_diff.workspace = true collections.workspace = true @@ -28,21 +28,23 @@ futures.workspace = true gpui.workspace = true itertools.workspace = true language.workspace = true +language_model.workspace = true markdown.workspace = true parking_lot = { workspace = true, optional = true } +portable-pty.workspace = true project.workspace = true prompt_store.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +task.workspace = true terminal.workspace = true ui.workspace = true url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index fb312653265a408f9ab98a06449d572ab5063714..5ecf2be445ecf8afc6a93e2961302758ea0037ae 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3,13 +3,21 @@ mod diff; mod mention; mod terminal; +use ::terminal::terminal_settings::TerminalSettings; +use agent_settings::AgentSettings; +use collections::HashSet; pub use connection::*; pub use diff::*; +use language::language_settings::FormatOnSave; pub use mention::*; +use project::lsp_store::{FormatTrigger, LspFormatTarget}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, SettingsLocation}; +use task::{Shell, ShellBuilder}; pub use terminal::*; use action_log::ActionLog; -use agent_client_protocol as acp; +use agent_client_protocol::{self as acp}; use anyhow::{Context as _, Result, anyhow}; use editor::Bias; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; @@ -24,9 +32,11 @@ use std::fmt::{Formatter, Write}; use std::ops::Range; use std::process::ExitStatus; use std::rc::Rc; +use std::time::{Duration, Instant}; use std::{fmt::Display, mem, path::PathBuf, sync::Arc}; use ui::App; -use util::ResultExt; +use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; +use uuid::Uuid; #[derive(Debug)] pub struct UserMessage { @@ -48,7 +58,7 @@ impl UserMessage { if self .checkpoint .as_ref() - .map_or(false, |checkpoint| checkpoint.show) + .is_some_and(|checkpoint| checkpoint.show) { writeln!(markdown, "## User (checkpoint)").unwrap(); } else { @@ -85,9 +95,14 @@ pub enum AssistantMessageChunk { } impl AssistantMessageChunk { - pub fn from_str(chunk: &str, language_registry: &Arc, cx: &mut App) -> Self { + pub fn from_str( + chunk: &str, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) -> Self { Self::Message { - block: ContentBlock::new(chunk.into(), language_registry, cx), + block: ContentBlock::new(chunk.into(), language_registry, path_style, cx), } } @@ -176,38 +191,49 @@ impl ToolCall { tool_call: acp::ToolCall, status: ToolCallStatus, language_registry: Arc, + path_style: PathStyle, + terminals: &HashMap>, cx: &mut App, - ) -> Self { - Self { + ) -> Result { + let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") { + first_line.to_owned() + "…" + } else { + tool_call.title + }; + let mut content = Vec::with_capacity(tool_call.content.len()); + for item in tool_call.content { + content.push(ToolCallContent::from_acp( + item, + language_registry.clone(), + path_style, + terminals, + cx, + )?); + } + + let result = Self { id: tool_call.id, - label: cx.new(|cx| { - Markdown::new( - tool_call.title.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), + label: cx + .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, - content: tool_call - .content - .into_iter() - .map(|content| ToolCallContent::from_acp(content, language_registry.clone(), cx)) - .collect(), + content, locations: tool_call.locations, resolved_locations: Vec::default(), status, raw_input: tool_call.raw_input, raw_output: tool_call.raw_output, - } + }; + Ok(result) } fn update_fields( &mut self, fields: acp::ToolCallUpdateFields, language_registry: Arc, + path_style: PathStyle, + terminals: &HashMap>, cx: &mut App, - ) { + ) -> Result<()> { let acp::ToolCallUpdateFields { kind, status, @@ -228,15 +254,32 @@ impl ToolCall { if let Some(title) = title { self.label.update(cx, |label, cx| { - label.replace(title, cx); + if let Some((first_line, _)) = title.split_once("\n") { + label.replace(first_line.to_owned() + "…", cx) + } else { + label.replace(title, cx); + } }); } if let Some(content) = content { - self.content = content - .into_iter() - .map(|chunk| ToolCallContent::from_acp(chunk, language_registry.clone(), cx)) - .collect(); + let new_content_len = content.len(); + let mut content = content.into_iter(); + + // Reuse existing content if we can + for (old, new) in self.content.iter_mut().zip(content.by_ref()) { + old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + } + for new in content { + self.content.push(ToolCallContent::from_acp( + new, + language_registry.clone(), + path_style, + terminals, + cx, + )?) + } + self.content.truncate(new_content_len); } if let Some(locations) = locations { @@ -248,17 +291,17 @@ impl ToolCall { } if let Some(raw_output) = raw_output { - if self.content.is_empty() { - if let Some(markdown) = markdown_for_raw_output(&raw_output, &language_registry, cx) - { - self.content - .push(ToolCallContent::ContentBlock(ContentBlock::Markdown { - markdown, - })); - } + if self.content.is_empty() + && let Some(markdown) = markdown_for_raw_output(&raw_output, &language_registry, cx) + { + self.content + .push(ToolCallContent::ContentBlock(ContentBlock::Markdown { + markdown, + })); } self.raw_output = Some(raw_output); } + Ok(()) } pub fn diffs(&self) -> impl Iterator> { @@ -294,14 +337,12 @@ impl ToolCall { location: acp::ToolCallLocation, project: WeakEntity, cx: &mut AsyncApp, - ) -> Option { + ) -> Option { let buffer = project .update(cx, |project, cx| { - if let Some(path) = project.project_path_for_absolute_path(&location.path, cx) { - Some(project.open_buffer(path, cx)) - } else { - None - } + project + .project_path_for_absolute_path(&location.path, cx) + .map(|path| project.open_buffer(path, cx)) }) .ok()??; let buffer = buffer.await.log_err()?; @@ -318,17 +359,14 @@ impl ToolCall { }) .ok()?; - Some(AgentLocation { - buffer: buffer.downgrade(), - position, - }) + Some(ResolvedLocation { buffer, position }) } fn resolve_locations( &self, project: Entity, cx: &mut App, - ) -> Task>> { + ) -> Task>> { let locations = self.locations.clone(); project.update(cx, |_, cx| { cx.spawn(async move |project, cx| { @@ -342,6 +380,23 @@ impl ToolCall { } } +// Separate so we can hold a strong reference to the buffer +// for saving on the thread +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedLocation { + buffer: Entity, + position: Anchor, +} + +impl From<&ResolvedLocation> for AgentLocation { + fn from(value: &ResolvedLocation) -> Self { + Self { + buffer: value.buffer.downgrade(), + position: value.position, + } + } +} + #[derive(Debug)] pub enum ToolCallStatus { /// The tool call hasn't started running yet, but we start showing it to @@ -404,21 +459,23 @@ impl ContentBlock { pub fn new( block: acp::ContentBlock, language_registry: &Arc, + path_style: PathStyle, cx: &mut App, ) -> Self { let mut this = Self::Empty; - this.append(block, language_registry, cx); + this.append(block, language_registry, path_style, cx); this } pub fn new_combined( blocks: impl IntoIterator, language_registry: Arc, + path_style: PathStyle, cx: &mut App, ) -> Self { let mut this = Self::Empty; for block in blocks { - this.append(block, &language_registry, cx); + this.append(block, &language_registry, path_style, cx); } this } @@ -427,16 +484,17 @@ impl ContentBlock { &mut self, block: acp::ContentBlock, language_registry: &Arc, + path_style: PathStyle, cx: &mut App, ) { - if matches!(self, ContentBlock::Empty) { - if let acp::ContentBlock::ResourceLink(resource_link) = block { - *self = ContentBlock::ResourceLink { resource_link }; - return; - } + if matches!(self, ContentBlock::Empty) + && let acp::ContentBlock::ResourceLink(resource_link) = block + { + *self = ContentBlock::ResourceLink { resource_link }; + return; } - let new_content = self.block_string_contents(block); + let new_content = self.block_string_contents(block, path_style); match self { ContentBlock::Empty => { @@ -446,7 +504,7 @@ impl ContentBlock { markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx)); } ContentBlock::ResourceLink { resource_link } => { - let existing_content = Self::resource_link_md(&resource_link.uri); + let existing_content = Self::resource_link_md(&resource_link.uri, path_style); let combined = format!("{}\n{}", existing_content, new_content); *self = Self::create_markdown_block(combined, language_registry, cx); @@ -465,11 +523,11 @@ impl ContentBlock { } } - fn block_string_contents(&self, block: acp::ContentBlock) -> String { + fn block_string_contents(&self, block: acp::ContentBlock, path_style: PathStyle) -> String { match block { - acp::ContentBlock::Text(text_content) => text_content.text.clone(), + acp::ContentBlock::Text(text_content) => text_content.text, acp::ContentBlock::ResourceLink(resource_link) => { - Self::resource_link_md(&resource_link.uri) + Self::resource_link_md(&resource_link.uri, path_style) } acp::ContentBlock::Resource(acp::EmbeddedResource { resource: @@ -478,14 +536,14 @@ impl ContentBlock { .. }), .. - }) => Self::resource_link_md(&uri), + }) => Self::resource_link_md(&uri, path_style), acp::ContentBlock::Image(image) => Self::image_md(&image), acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), } } - fn resource_link_md(uri: &str) -> String { - if let Some(uri) = MentionUri::parse(&uri).log_err() { + fn resource_link_md(uri: &str, path_style: PathStyle) -> String { + if let Some(uri) = MentionUri::parse(uri, path_style).log_err() { uri.as_link().to_string() } else { uri.to_string() @@ -496,7 +554,7 @@ impl ContentBlock { "`Image`".into() } - fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { + pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { match self { ContentBlock::Empty => "", ContentBlock::Markdown { markdown } => markdown.read(cx).source(), @@ -531,16 +589,57 @@ impl ToolCallContent { pub fn from_acp( content: acp::ToolCallContent, language_registry: Arc, + path_style: PathStyle, + terminals: &HashMap>, cx: &mut App, - ) -> Self { + ) -> Result { match content { - acp::ToolCallContent::Content { content } => { - Self::ContentBlock(ContentBlock::new(content, &language_registry, cx)) - } - acp::ToolCallContent::Diff { diff } => { - Self::Diff(cx.new(|cx| Diff::from_acp(diff, language_registry, cx))) + acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new( + content, + &language_registry, + path_style, + cx, + ))), + acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| { + Diff::finalized( + diff.path.to_string_lossy().into_owned(), + diff.old_text, + diff.new_text, + language_registry, + cx, + ) + }))), + acp::ToolCallContent::Terminal { terminal_id } => terminals + .get(&terminal_id) + .cloned() + .map(Self::Terminal) + .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)), + } + } + + pub fn update_from_acp( + &mut self, + new: acp::ToolCallContent, + language_registry: Arc, + path_style: PathStyle, + terminals: &HashMap>, + cx: &mut App, + ) -> Result<()> { + let needs_update = match (&self, &new) { + (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + old_diff.read(cx).needs_update( + new_diff.old_text.as_deref().unwrap_or(""), + &new_diff.new_text, + cx, + ) } + _ => true, + }; + + if needs_update { + *self = Self::from_acp(new, language_registry, path_style, terminals, cx)?; } + Ok(()) } pub fn to_markdown(&self, cx: &App) -> String { @@ -658,6 +757,52 @@ impl PlanEntry { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub max_tokens: u64, + pub used_tokens: u64, +} + +impl TokenUsage { + pub fn ratio(&self) -> TokenUsageRatio { + #[cfg(debug_assertions)] + let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD") + .unwrap_or("0.8".to_string()) + .parse() + .unwrap(); + #[cfg(not(debug_assertions))] + let warning_threshold: f32 = 0.8; + + // When the maximum is unknown because there is no selected model, + // avoid showing the token limit warning. + if self.max_tokens == 0 { + TokenUsageRatio::Normal + } else if self.used_tokens >= self.max_tokens { + TokenUsageRatio::Exceeded + } else if self.used_tokens as f32 / self.max_tokens as f32 >= warning_threshold { + TokenUsageRatio::Warning + } else { + TokenUsageRatio::Normal + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenUsageRatio { + Normal, + Warning, + Exceeded, +} + +#[derive(Debug, Clone)] +pub struct RetryStatus { + pub last_error: SharedString, + pub attempt: usize, + pub max_attempts: usize, + pub started_at: Instant, + pub duration: Duration, +} + pub struct AcpThread { title: SharedString, entries: Vec, @@ -668,44 +813,190 @@ pub struct AcpThread { send_task: Option>, connection: Rc, session_id: acp::SessionId, + token_usage: Option, + prompt_capabilities: acp::PromptCapabilities, + _observe_prompt_capabilities: Task>, + terminals: HashMap>, + pending_terminal_output: HashMap>>, + pending_terminal_exit: HashMap, } +#[derive(Debug)] pub enum AcpThreadEvent { NewEntry, + TitleUpdated, + TokenUsageUpdated, EntryUpdated(usize), EntriesRemoved(Range), ToolAuthorizationRequired, + Retry(RetryStatus), Stopped, Error, - ServerExited(ExitStatus), + LoadError(LoadError), + PromptCapabilitiesUpdated, + Refusal, + AvailableCommandsUpdated(Vec), + ModeUpdated(acp::SessionModeId), } impl EventEmitter for AcpThread {} -#[derive(PartialEq, Eq)] +#[derive(Debug, Clone)] +pub enum TerminalProviderEvent { + Created { + terminal_id: acp::TerminalId, + label: String, + cwd: Option, + output_byte_limit: Option, + terminal: Entity<::terminal::Terminal>, + }, + Output { + terminal_id: acp::TerminalId, + data: Vec, + }, + TitleChanged { + terminal_id: acp::TerminalId, + title: String, + }, + Exit { + terminal_id: acp::TerminalId, + status: acp::TerminalExitStatus, + }, +} + +#[derive(Debug, Clone)] +pub enum TerminalProviderCommand { + WriteInput { + terminal_id: acp::TerminalId, + bytes: Vec, + }, + Resize { + terminal_id: acp::TerminalId, + cols: u16, + rows: u16, + }, + Close { + terminal_id: acp::TerminalId, + }, +} + +impl AcpThread { + pub fn on_terminal_provider_event( + &mut self, + event: TerminalProviderEvent, + cx: &mut Context, + ) { + match event { + TerminalProviderEvent::Created { + terminal_id, + label, + cwd, + output_byte_limit, + terminal, + } => { + let entity = self.register_terminal_created( + terminal_id.clone(), + label, + cwd, + output_byte_limit, + terminal, + cx, + ); + + if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) { + for data in chunks.drain(..) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.write_output(&data, cx); + }) + }); + } + } + + if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) { + entity.update(cx, |_term, cx| { + cx.notify(); + }); + } + + cx.notify(); + } + TerminalProviderEvent::Output { terminal_id, data } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.write_output(&data, cx); + }) + }); + } else { + self.pending_terminal_output + .entry(terminal_id) + .or_default() + .push(data); + } + } + TerminalProviderEvent::TitleChanged { terminal_id, title } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |term, cx| { + term.inner().update(cx, |inner, cx| { + inner.breadcrumb_text = title; + cx.emit(::terminal::Event::BreadcrumbsChanged); + }) + }); + } + } + TerminalProviderEvent::Exit { + terminal_id, + status, + } => { + if let Some(entity) = self.terminals.get(&terminal_id) { + entity.update(cx, |_term, cx| { + cx.notify(); + }); + } else { + self.pending_terminal_exit.insert(terminal_id, status); + } + } + } + } +} + +#[derive(PartialEq, Eq, Debug)] pub enum ThreadStatus { Idle, - WaitingForToolConfirmation, Generating, } #[derive(Debug, Clone)] pub enum LoadError { Unsupported { - error_message: SharedString, - upgrade_message: SharedString, - upgrade_command: String, + command: SharedString, + current_version: SharedString, + minimum_version: SharedString, + }, + FailedToInstall(SharedString), + Exited { + status: ExitStatus, }, - Exited(i32), Other(SharedString), } impl Display for LoadError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - LoadError::Unsupported { error_message, .. } => write!(f, "{}", error_message), - LoadError::Exited(status) => write!(f, "Server exited with status {}", status), - LoadError::Other(msg) => write!(f, "{}", msg), + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + write!( + f, + "version {current_version} from {path} is not supported (need at least {minimum_version})" + ) + } + LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"), + LoadError::Exited { status } => write!(f, "Server exited with status {status}"), + LoadError::Other(msg) => write!(f, "{msg}"), } } } @@ -717,10 +1008,21 @@ impl AcpThread { title: impl Into, connection: Rc, project: Entity, + action_log: Entity, session_id: acp::SessionId, + mut prompt_capabilities_rx: watch::Receiver, cx: &mut Context, ) -> Self { - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let prompt_capabilities = prompt_capabilities_rx.borrow().clone(); + let task = cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + loop { + let caps = prompt_capabilities_rx.recv().await?; + this.update(cx, |this, cx| { + this.prompt_capabilities = caps; + cx.emit(AcpThreadEvent::PromptCapabilitiesUpdated); + })?; + } + }); Self { action_log, @@ -732,9 +1034,19 @@ impl AcpThread { send_task: None, connection, session_id, + token_usage: None, + prompt_capabilities, + _observe_prompt_capabilities: task, + terminals: HashMap::default(), + pending_terminal_output: HashMap::default(), + pending_terminal_exit: HashMap::default(), } } + pub fn prompt_capabilities(&self) -> acp::PromptCapabilities { + self.prompt_capabilities.clone() + } + pub fn connection(&self) -> &Rc { &self.connection } @@ -761,16 +1073,16 @@ impl AcpThread { pub fn status(&self) -> ThreadStatus { if self.send_task.is_some() { - if self.waiting_for_tool_confirmation() { - ThreadStatus::WaitingForToolConfirmation - } else { - ThreadStatus::Generating - } + ThreadStatus::Generating } else { ThreadStatus::Idle } } + pub fn token_usage(&self) -> Option<&TokenUsage> { + self.token_usage.as_ref() + } + pub fn has_pending_edit_tool_calls(&self) -> bool { for entry in self.entries.iter().rev() { match entry { @@ -808,13 +1120,13 @@ impl AcpThread { cx: &mut Context, ) -> Result<(), acp::Error> { match update { - acp::SessionUpdate::UserMessageChunk { content } => { + acp::SessionUpdate::UserMessageChunk(acp::ContentChunk { content, .. }) => { self.push_user_content_block(None, content, cx); } - acp::SessionUpdate::AgentMessageChunk { content } => { + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) => { self.push_assistant_content_block(content, false, cx); } - acp::SessionUpdate::AgentThoughtChunk { content } => { + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content, .. }) => { self.push_assistant_content_block(content, true, cx); } acp::SessionUpdate::ToolCall(tool_call) => { @@ -826,6 +1138,14 @@ impl AcpThread { acp::SessionUpdate::Plan(plan) => { self.update_plan(plan, cx); } + acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate { + available_commands, + .. + }) => cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands)), + acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { + current_mode_id, + .. + }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), } Ok(()) } @@ -837,6 +1157,7 @@ impl AcpThread { cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() @@ -848,12 +1169,12 @@ impl AcpThread { }) = last_entry { *id = message_id.or(id.take()); - content.append(chunk.clone(), &language_registry, cx); + content.append(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); } else { - let content = ContentBlock::new(chunk.clone(), &language_registry, cx); + let content = ContentBlock::new(chunk.clone(), &language_registry, path_style, cx); self.push_entry( AgentThreadEntry::UserMessage(UserMessage { id: message_id, @@ -873,6 +1194,7 @@ impl AcpThread { cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry @@ -882,10 +1204,10 @@ impl AcpThread { match (chunks.last_mut(), is_thought) { (Some(AssistantMessageChunk::Message { block }), false) | (Some(AssistantMessageChunk::Thought { block }), true) => { - block.append(chunk, &language_registry, cx) + block.append(chunk, &language_registry, path_style, cx) } _ => { - let block = ContentBlock::new(chunk, &language_registry, cx); + let block = ContentBlock::new(chunk, &language_registry, path_style, cx); if is_thought { chunks.push(AssistantMessageChunk::Thought { block }) } else { @@ -894,7 +1216,7 @@ impl AcpThread { } } } else { - let block = ContentBlock::new(chunk, &language_registry, cx); + let block = ContentBlock::new(chunk, &language_registry, path_style, cx); let chunk = if is_thought { AssistantMessageChunk::Thought { block } } else { @@ -915,6 +1237,30 @@ impl AcpThread { cx.emit(AcpThreadEvent::NewEntry); } + pub fn can_set_title(&mut self, cx: &mut Context) -> bool { + self.connection.set_title(&self.session_id, cx).is_some() + } + + pub fn set_title(&mut self, title: SharedString, cx: &mut Context) -> Task> { + if title != self.title { + self.title = title.clone(); + cx.emit(AcpThreadEvent::TitleUpdated); + if let Some(set_title) = self.connection.set_title(&self.session_id, cx) { + return set_title.run(title, cx); + } + } + Task::ready(Ok(())) + } + + pub fn update_token_usage(&mut self, usage: Option, cx: &mut Context) { + self.token_usage = usage; + cx.emit(AcpThreadEvent::TokenUsageUpdated); + } + + pub fn update_retry_status(&mut self, status: RetryStatus, cx: &mut Context) { + cx.emit(AcpThreadEvent::Retry(status)); + } + pub fn update_tool_call( &mut self, update: impl Into, @@ -922,28 +1268,55 @@ impl AcpThread { ) -> Result<()> { let update = update.into(); let languages = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); + + let ix = match self.index_for_tool_call(update.id()) { + Some(ix) => ix, + None => { + // Tool call not found - create a failed tool call entry + let failed_tool_call = ToolCall { + id: update.id().clone(), + label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), + kind: acp::ToolKind::Fetch, + content: vec![ToolCallContent::ContentBlock(ContentBlock::new( + acp::ContentBlock::Text(acp::TextContent { + text: "Tool call not found".to_string(), + annotations: None, + meta: None, + }), + &languages, + path_style, + cx, + ))], + status: ToolCallStatus::Failed, + locations: Vec::new(), + resolved_locations: Vec::new(), + raw_input: None, + raw_output: None, + }; + self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx); + return Ok(()); + } + }; + let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else { + unreachable!() + }; - let (ix, current_call) = self - .tool_call_mut(update.id()) - .context("Tool call not found")?; match update { ToolCallUpdate::UpdateFields(update) => { let location_updated = update.fields.locations.is_some(); - current_call.update_fields(update.fields, languages, cx); + call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?; if location_updated { - self.resolve_locations(update.id.clone(), cx); + self.resolve_locations(update.id, cx); } } ToolCallUpdate::UpdateDiff(update) => { - current_call.content.clear(); - current_call - .content - .push(ToolCallContent::Diff(update.diff)); + call.content.clear(); + call.content.push(ToolCallContent::Diff(update.diff)); } ToolCallUpdate::UpdateTerminal(update) => { - current_call.content.clear(); - current_call - .content + call.content.clear(); + call.content .push(ToolCallContent::Terminal(update.terminal)); } } @@ -966,21 +1339,38 @@ impl AcpThread { /// Fails if id does not match an existing entry. pub fn upsert_tool_call_inner( &mut self, - tool_call_update: acp::ToolCallUpdate, + update: acp::ToolCallUpdate, status: ToolCallStatus, cx: &mut Context, ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); - let id = tool_call_update.id.clone(); + let path_style = self.project.read(cx).path_style(cx); + let id = update.id.clone(); + + if let Some(ix) = self.index_for_tool_call(&id) { + let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else { + unreachable!() + }; - if let Some((ix, current_call)) = self.tool_call_mut(&id) { - current_call.update_fields(tool_call_update.fields, language_registry, cx); - current_call.status = status; + call.update_fields( + update.fields, + language_registry, + path_style, + &self.terminals, + cx, + )?; + call.status = status; cx.emit(AcpThreadEvent::EntryUpdated(ix)); } else { - let call = - ToolCall::from_acp(tool_call_update.try_into()?, status, language_registry, cx); + let call = ToolCall::from_acp( + update.try_into()?, + status, + language_registry, + self.project.read(cx).path_style(cx), + &self.terminals, + cx, + )?; self.push_entry(AgentThreadEntry::ToolCall(call), cx); }; @@ -988,6 +1378,22 @@ impl AcpThread { Ok(()) } + fn index_for_tool_call(&self, id: &acp::ToolCallId) -> Option { + self.entries + .iter() + .enumerate() + .rev() + .find_map(|(index, entry)| { + if let AgentThreadEntry::ToolCall(tool_call) = entry + && &tool_call.id == id + { + Some(index) + } else { + None + } + }) + } + fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> { // The tool call we are looking for is typically the last one, or very close to the end. // At the moment, it doesn't seem like a hashmap would be a good fit for this use case. @@ -1006,6 +1412,22 @@ impl AcpThread { }) } + pub fn tool_call(&mut self, id: &acp::ToolCallId) -> Option<(usize, &ToolCall)> { + self.entries + .iter() + .enumerate() + .rev() + .find_map(|(index, tool_call)| { + if let AgentThreadEntry::ToolCall(tool_call) = tool_call + && &tool_call.id == id + { + Some((index, tool_call)) + } else { + None + } + }) + } + pub fn resolve_locations(&mut self, id: acp::ToolCallId, cx: &mut Context) { let project = self.project.clone(); let Some((_, tool_call)) = self.tool_call_mut(&id) else { @@ -1014,35 +1436,46 @@ impl AcpThread { let task = tool_call.resolve_locations(project, cx); cx.spawn(async move |this, cx| { let resolved_locations = task.await; + this.update(cx, |this, cx| { let project = this.project.clone(); + + for location in resolved_locations.iter().flatten() { + this.shared_buffers + .insert(location.buffer.clone(), location.buffer.read(cx).snapshot()); + } let Some((ix, tool_call)) = this.tool_call_mut(&id) else { return; }; + if let Some(Some(location)) = resolved_locations.last() { project.update(cx, |project, cx| { - if let Some(agent_location) = project.agent_location() { - let should_ignore = agent_location.buffer == location.buffer - && location - .buffer - .update(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let old_position = - agent_location.position.to_point(&snapshot); - let new_position = location.position.to_point(&snapshot); - // ignore this so that when we get updates from the edit tool - // the position doesn't reset to the startof line - old_position.row == new_position.row - && old_position.column > new_position.column - }) - .ok() - .unwrap_or_default(); - if !should_ignore { - project.set_agent_location(Some(location.clone()), cx); - } + let should_ignore = if let Some(agent_location) = project + .agent_location() + .filter(|agent_location| agent_location.buffer == location.buffer) + { + let snapshot = location.buffer.read(cx).snapshot(); + let old_position = agent_location.position.to_point(&snapshot); + let new_position = location.position.to_point(&snapshot); + + // ignore this so that when we get updates from the edit tool + // the position doesn't reset to the startof line + old_position.row == new_position.row + && old_position.column > new_position.column + } else { + false + }; + if !should_ignore { + project.set_agent_location(Some(location.into()), cx); } }); } + + let resolved_locations = resolved_locations + .iter() + .map(|l| l.as_ref().map(|l| AgentLocation::from(l))) + .collect::>(); + if tool_call.resolved_locations != resolved_locations { tool_call.resolved_locations = resolved_locations; cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -1056,10 +1489,31 @@ impl AcpThread { &mut self, tool_call: acp::ToolCallUpdate, options: Vec, + respect_always_allow_setting: bool, cx: &mut Context, - ) -> Result, acp::Error> { + ) -> Result> { let (tx, rx) = oneshot::channel(); + if respect_always_allow_setting && AgentSettings::get_global(cx).always_allow_tool_actions { + // Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions, + // some tools would (incorrectly) continue to auto-accept. + if let Some(allow_once_option) = options.iter().find_map(|option| { + if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { + Some(option.id.clone()) + } else { + None + } + }) { + self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?; + return Ok(async { + acp::RequestPermissionOutcome::Selected { + option_id: allow_once_option, + } + } + .boxed()); + } + } + let status = ToolCallStatus::WaitingForConfirmation { options, respond_tx: tx, @@ -1067,7 +1521,16 @@ impl AcpThread { self.upsert_tool_call_inner(tool_call, status, cx)?; cx.emit(AcpThreadEvent::ToolAuthorizationRequired); - Ok(rx) + + let fut = async { + match rx.await { + Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, + } + } + .boxed(); + + Ok(fut) } pub fn authorize_tool_call( @@ -1101,26 +1564,27 @@ impl AcpThread { cx.emit(AcpThreadEvent::EntryUpdated(ix)); } - /// Returns true if the last turn is awaiting tool authorization - pub fn waiting_for_tool_confirmation(&self) -> bool { + pub fn first_tool_awaiting_confirmation(&self) -> Option<&ToolCall> { + let mut first_tool_call = None; + for entry in self.entries.iter().rev() { match &entry { - AgentThreadEntry::ToolCall(call) => match call.status { - ToolCallStatus::WaitingForConfirmation { .. } => return true, - ToolCallStatus::Pending - | ToolCallStatus::InProgress - | ToolCallStatus::Completed - | ToolCallStatus::Failed - | ToolCallStatus::Rejected - | ToolCallStatus::Canceled => continue, - }, + AgentThreadEntry::ToolCall(call) => { + if let ToolCallStatus::WaitingForConfirmation { .. } = call.status { + first_tool_call = Some(call); + } else { + continue; + } + } AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { - // Reached the beginning of the turn - return false; + // Reached the beginning of the turn. + // If we had pending permission requests in the previous turn, they have been cancelled. + break; } } } - false + + first_tool_call } pub fn plan(&self) -> &Plan { @@ -1169,6 +1633,7 @@ impl AcpThread { vec![acp::ContentBlock::Text(acp::TextContent { text: message.to_string(), annotations: None, + meta: None, })], cx, ) @@ -1182,34 +1647,36 @@ impl AcpThread { let block = ContentBlock::new_combined( message.clone(), self.project.read(cx).languages().clone(), + self.project.read(cx).path_style(cx), cx, ); let request = acp::PromptRequest { prompt: message.clone(), session_id: self.session_id.clone(), + meta: None, }; let git_store = self.project.read(cx).git_store().clone(); - let message_id = if self - .connection - .session_editor(&self.session_id, cx) - .is_some() - { + let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { Some(UserMessageId::new()) } else { None }; - self.push_entry( - AgentThreadEntry::UserMessage(UserMessage { - id: message_id.clone(), - content: block, - chunks: message, - checkpoint: None, - }), - cx, - ); self.run_turn(cx, async move |this, cx| { + this.update(cx, |this, cx| { + this.push_entry( + AgentThreadEntry::UserMessage(UserMessage { + id: message_id.clone(), + content: block, + chunks: message, + checkpoint: None, + }), + cx, + ); + }) + .ok(); + let old_checkpoint = git_store .update(cx, |git, cx| git.checkpoint(cx))? .await @@ -1228,6 +1695,10 @@ impl AcpThread { }) } + pub fn can_resume(&self, cx: &App) -> bool { + self.connection.resume(&self.session_id, cx).is_some() + } + pub fn resume(&mut self, cx: &mut Context) -> BoxFuture<'static, Result<()>> { self.run_turn(cx, async move |this, cx| { this.update(cx, |this, cx| { @@ -1262,6 +1733,8 @@ impl AcpThread { .await?; this.update(cx, |this, cx| { + this.project + .update(cx, |project, cx| project.set_agent_location(None, cx)); match response { Ok(Err(e)) => { this.send_task.take(); @@ -1272,7 +1745,8 @@ impl AcpThread { let canceled = matches!( result, Ok(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Canceled + stop_reason: acp::StopReason::Cancelled, + meta: None, })) ); @@ -1285,6 +1759,45 @@ impl AcpThread { this.send_task.take(); } + // Handle refusal - distinguish between user prompt and tool call refusals + if let Ok(Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Refusal, + meta: _, + })) = result + { + if let Some((user_msg_ix, _)) = this.last_user_message() { + // Check if there's a completed tool call with results after the last user message + // This indicates the refusal is in response to tool output, not the user's prompt + let has_completed_tool_call_after_user_msg = + this.entries.iter().skip(user_msg_ix + 1).any(|entry| { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + // Check if the tool call has completed and has output + matches!(tool_call.status, ToolCallStatus::Completed) + && tool_call.raw_output.is_some() + } else { + false + } + }); + + if has_completed_tool_call_after_user_msg { + // Refusal is due to tool output - don't truncate, just notify + // The model refused based on what the tool returned + cx.emit(AcpThreadEvent::Refusal); + } else { + // User prompt was refused - truncate back to before the user message + let range = user_msg_ix..this.entries.len(); + if range.start < range.end { + this.entries.truncate(user_msg_ix); + cx.emit(AcpThreadEvent::EntriesRemoved(range)); + } + cx.emit(AcpThreadEvent::Refusal); + } + } else { + // No user message found, treat as general refusal + cx.emit(AcpThreadEvent::Refusal); + } + } + cx.emit(AcpThreadEvent::Stopped); Ok(()) } @@ -1320,13 +1833,13 @@ impl AcpThread { cx.foreground_executor().spawn(send_task) } - /// Rewinds this thread to before the entry at `index`, removing it and all - /// subsequent entries while reverting any changes made from that point. - pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context) -> Task> { - let Some(session_editor) = self.connection.session_editor(&self.session_id, cx) else { - return Task::ready(Err(anyhow!("not supported"))); - }; - let Some(message) = self.user_message(&id) else { + /// Restores the git working tree to the state at the given checkpoint (if one exists) + pub fn restore_checkpoint( + &mut self, + id: UserMessageId, + cx: &mut Context, + ) -> Task> { + let Some((_, message)) = self.user_message_mut(&id) else { return Task::ready(Err(anyhow!("message not found"))); }; @@ -1334,24 +1847,42 @@ impl AcpThread { .checkpoint .as_ref() .map(|c| c.git_checkpoint.clone()); - + let rewind = self.rewind(id.clone(), cx); let git_store = self.project.read(cx).git_store().clone(); - cx.spawn(async move |this, cx| { + + cx.spawn(async move |_, cx| { + rewind.await?; if let Some(checkpoint) = checkpoint { git_store .update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))? .await?; } - cx.update(|cx| session_editor.truncate(id.clone(), cx))? - .await?; + Ok(()) + }) + } + + /// Rewinds this thread to before the entry at `index`, removing it and all + /// subsequent entries while rejecting any action_log changes made from that point. + /// Unlike `restore_checkpoint`, this method does not restore from git. + pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context) -> Task> { + let Some(truncate) = self.connection.truncate(&self.session_id, cx) else { + return Task::ready(Err(anyhow!("not supported"))); + }; + + cx.spawn(async move |this, cx| { + cx.update(|cx| truncate.run(id.clone(), cx))?.await?; this.update(cx, |this, cx| { if let Some((ix, _)) = this.user_message_mut(&id) { let range = ix..this.entries.len(); this.entries.truncate(ix); cx.emit(AcpThreadEvent::EntriesRemoved(range)); } - }) + this.action_log() + .update(cx, |action_log, cx| action_log.reject_all_edits(cx)) + })? + .await; + Ok(()) }) } @@ -1408,24 +1939,10 @@ impl AcpThread { }) } - fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> { - self.entries.iter().find_map(|entry| { - if let AgentThreadEntry::UserMessage(message) = entry { - if message.id.as_ref() == Some(&id) { - Some(message) - } else { - None - } - } else { - None - } - }) - } - fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> { self.entries.iter_mut().enumerate().find_map(|(ix, entry)| { if let AgentThreadEntry::UserMessage(message) = entry { - if message.id.as_ref() == Some(&id) { + if message.id.as_ref() == Some(id) { Some((ix, message)) } else { None @@ -1443,17 +1960,26 @@ impl AcpThread { limit: Option, reuse_shared_snapshot: bool, cx: &mut Context, - ) -> Task> { + ) -> Task> { + // Args are 1-based, move to 0-based + let line = line.unwrap_or_default().saturating_sub(1); + let limit = limit.unwrap_or(u32::MAX); let project = self.project.clone(); let action_log = self.action_log.clone(); cx.spawn(async move |this, cx| { - let load = project.update(cx, |project, cx| { - let path = project - .project_path_for_absolute_path(&path, cx) - .context("invalid path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - let buffer = load??.await?; + let load = project + .update(cx, |project, cx| { + let path = project + .project_path_for_absolute_path(&path, cx) + .ok_or_else(|| { + acp::Error::resource_not_found(Some(path.display().to_string())) + })?; + Ok(project.open_buffer(path, cx)) + }) + .map_err(|e| acp::Error::internal_error().with_data(e.to_string())) + .flatten()?; + + let buffer = load.await?; let snapshot = if reuse_shared_snapshot { this.read_with(cx, |this, _| { @@ -1471,44 +1997,39 @@ impl AcpThread { action_log.update(cx, |action_log, cx| { action_log.buffer_read(buffer.clone(), cx); })?; - project.update(cx, |project, cx| { - let position = buffer - .read(cx) - .snapshot() - .anchor_before(Point::new(line.unwrap_or_default(), 0)); - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position, - }), - cx, - ); - })?; - buffer.update(cx, |buffer, _| buffer.snapshot())? + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?; + this.update(cx, |this, _| { + this.shared_buffers.insert(buffer.clone(), snapshot.clone()); + })?; + snapshot }; - this.update(cx, |this, _| { - let text = snapshot.text(); - this.shared_buffers.insert(buffer.clone(), snapshot); - if line.is_none() && limit.is_none() { - return Ok(text); - } - let limit = limit.unwrap_or(u32::MAX) as usize; - let Some(line) = line else { - return Ok(text.lines().take(limit).collect::()); - }; + let max_point = snapshot.max_point(); + let start_position = Point::new(line, 0); - let count = text.lines().count(); - if count < line as usize { - anyhow::bail!("There are only {} lines", count); - } - Ok(text - .lines() - .skip(line as usize + 1) - .take(limit) - .collect::()) - })? + if start_position > max_point { + return Err(acp::Error::invalid_params().with_data(format!( + "Attempting to read beyond the end of the file, line {}:{}", + max_point.row + 1, + max_point.column + ))); + } + + let start = snapshot.anchor_before(start_position); + let end = snapshot.anchor_before(Point::new(line.saturating_add(limit), 0)); + + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: start, + }), + cx, + ); + })?; + + Ok(snapshot.text_for_range(start..end).collect::()) }) } @@ -1550,42 +2071,227 @@ impl AcpThread { .collect::>() }) .await; - cx.update(|cx| { - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: edits - .last() - .map(|(range, _)| range.end) - .unwrap_or(Anchor::MIN), - }), - cx, - ); - }); + project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: edits + .last() + .map(|(range, _)| range.end) + .unwrap_or(Anchor::MIN), + }), + cx, + ); + })?; + + let format_on_save = cx.update(|cx| { action_log.update(cx, |action_log, cx| { action_log.buffer_read(buffer.clone(), cx); }); - buffer.update(cx, |buffer, cx| { + + let format_on_save = buffer.update(cx, |buffer, cx| { buffer.edit(edits, None, cx); + + let settings = language::language_settings::language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ); + + settings.format_on_save != FormatOnSave::Off }); action_log.update(cx, |action_log, cx| { action_log.buffer_edited(buffer.clone(), cx); }); + format_on_save })?; + + if format_on_save { + let format_task = project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, + FormatTrigger::Save, + cx, + ) + })?; + format_task.await.log_err(); + + action_log.update(cx, |action_log, cx| { + action_log.buffer_edited(buffer.clone(), cx); + })?; + } + project .update(cx, |project, cx| project.save_buffer(buffer, cx))? .await }) } + pub fn create_terminal( + &self, + command: String, + args: Vec, + extra_env: Vec, + cwd: Option, + output_byte_limit: Option, + cx: &mut Context, + ) -> Task>> { + let env = match &cwd { + Some(dir) => self.project.update(cx, |project, cx| { + let worktree = project.find_worktree(dir.as_path(), cx); + let shell = TerminalSettings::get( + worktree.as_ref().map(|(worktree, path)| SettingsLocation { + worktree_id: worktree.read(cx).id(), + path: &path, + }), + cx, + ) + .shell + .clone(); + project.directory_environment(&shell, dir.as_path().into(), cx) + }), + None => Task::ready(None).shared(), + }; + let env = cx.spawn(async move |_, _| { + let mut env = env.await.unwrap_or_default(); + // Disables paging for `git` and hopefully other commands + env.insert("PAGER".into(), "".into()); + for var in extra_env { + env.insert(var.name, var.value); + } + env + }); + + let project = self.project.clone(); + let language_registry = project.read(cx).languages().clone(); + let is_windows = project.read(cx).path_style(cx).is_windows(); + + let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into()); + let terminal_task = cx.spawn({ + let terminal_id = terminal_id.clone(); + async move |_this, cx| { + let env = env.await; + let shell = project + .update(cx, |project, cx| { + project + .remote_client() + .and_then(|r| r.read(cx).default_system_shell()) + })? + .unwrap_or_else(|| get_default_system_shell_preferring_bash()); + let (task_command, task_args) = + ShellBuilder::new(&Shell::Program(shell), is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + let terminal = project + .update(cx, |project, cx| { + project.create_terminal_task( + task::SpawnInTerminal { + command: Some(task_command), + args: task_args, + cwd: cwd.clone(), + env, + ..Default::default() + }, + cx, + ) + })? + .await?; + + cx.new(|cx| { + Terminal::new( + terminal_id, + &format!("{} {}", command, args.join(" ")), + cwd, + output_byte_limit.map(|l| l as usize), + terminal, + language_registry, + cx, + ) + }) + } + }); + + cx.spawn(async move |this, cx| { + let terminal = terminal_task.await?; + this.update(cx, |this, _cx| { + this.terminals.insert(terminal_id, terminal.clone()); + terminal + }) + }) + } + + pub fn kill_terminal( + &mut self, + terminal_id: acp::TerminalId, + cx: &mut Context, + ) -> Result<()> { + self.terminals + .get(&terminal_id) + .context("Terminal not found")? + .update(cx, |terminal, cx| { + terminal.kill(cx); + }); + + Ok(()) + } + + pub fn release_terminal( + &mut self, + terminal_id: acp::TerminalId, + cx: &mut Context, + ) -> Result<()> { + self.terminals + .remove(&terminal_id) + .context("Terminal not found")? + .update(cx, |terminal, cx| { + terminal.kill(cx); + }); + + Ok(()) + } + + pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result> { + self.terminals + .get(&terminal_id) + .context("Terminal not found") + .cloned() + } + pub fn to_markdown(&self, cx: &App) -> String { self.entries.iter().map(|e| e.to_markdown(cx)).collect() } - pub fn emit_server_exited(&mut self, status: ExitStatus, cx: &mut Context) { - cx.emit(AcpThreadEvent::ServerExited(status)); + pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context) { + cx.emit(AcpThreadEvent::LoadError(error)); + } + + pub fn register_terminal_created( + &mut self, + terminal_id: acp::TerminalId, + command_label: String, + working_dir: Option, + output_byte_limit: Option, + terminal: Entity<::terminal::Terminal>, + cx: &mut Context, + ) -> Entity { + let language_registry = self.project.read(cx).languages().clone(); + + let entity = cx.new(|cx| { + Terminal::new( + terminal_id.clone(), + &command_label, + working_dir.clone(), + output_byte_limit.map(|l| l as usize), + terminal, + language_registry, + cx, + ) + }); + self.terminals.insert(terminal_id.clone(), entity.clone()); + entity } } @@ -1636,10 +2342,10 @@ mod tests { use super::*; use anyhow::anyhow; use futures::{channel::mpsc, future::LocalBoxFuture, select}; - use gpui::{AsyncApp, TestAppContext, WeakEntity}; + use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; use project::{FakeFs, Fs}; - use rand::Rng as _; + use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; use smol::stream::StreamExt as _; @@ -1663,6 +2369,145 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_output_buffered_before_created_renders(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| connection.new_thread(project, std::path::Path::new(path!("/test")), cx)) + .await + .unwrap(); + + let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + + // Send Output BEFORE Created - should be buffered by acp_thread + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: b"hello buffered".to_vec(), + }, + cx, + ); + }); + + // Create a display-only terminal and then send Created + let lower = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + ) + .unwrap(); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Buffered Test".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + }); + + // After Created, buffered Output should have been flushed into the renderer + let content = thread.read_with(cx, |thread, cx| { + let term = thread.terminal(terminal_id.clone()).unwrap(); + term.read_with(cx, |t, cx| t.inner().read(cx).get_content()) + }); + + assert!( + content.contains("hello buffered"), + "expected buffered output to render, got: {content}" + ); + } + + #[gpui::test] + async fn test_terminal_output_and_exit_buffered_before_created(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| connection.new_thread(project, std::path::Path::new(path!("/test")), cx)) + .await + .unwrap(); + + let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + + // Send Output BEFORE Created + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data: b"pre-exit data".to_vec(), + }, + cx, + ); + }); + + // Send Exit BEFORE Created + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status: acp::TerminalExitStatus { + exit_code: Some(0), + signal: None, + meta: None, + }, + }, + cx, + ); + }); + + // Now create a display-only lower-level terminal and send Created + let lower = cx.new(|cx| { + let builder = ::terminal::TerminalBuilder::new_display_only( + ::terminal::terminal_settings::CursorShape::default(), + ::terminal::terminal_settings::AlternateScroll::On, + None, + 0, + ) + .unwrap(); + builder.subscribe(cx) + }); + + thread.update(cx, |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: "Buffered Exit Test".to_string(), + cwd: None, + output_byte_limit: None, + terminal: lower.clone(), + }, + cx, + ); + }); + + // Output should be present after Created (flushed from buffer) + let content = thread.read_with(cx, |thread, cx| { + let term = thread.terminal(terminal_id.clone()).unwrap(); + term.read_with(cx, |t, cx| t.inner().read(cx).get_content()) + }); + + assert!( + content.contains("pre-exit data"), + "expected pre-exit data to render, got: {content}" + ); + } + #[gpui::test] async fn test_push_user_content_block(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -1682,6 +2527,7 @@ mod tests { acp::ContentBlock::Text(acp::TextContent { annotations: None, text: "Hello, ".to_string(), + meta: None, }), cx, ); @@ -1705,6 +2551,7 @@ mod tests { acp::ContentBlock::Text(acp::TextContent { annotations: None, text: "world!".to_string(), + meta: None, }), cx, ); @@ -1726,6 +2573,7 @@ mod tests { acp::ContentBlock::Text(acp::TextContent { annotations: None, text: "Assistant response".to_string(), + meta: None, }), false, cx, @@ -1739,6 +2587,7 @@ mod tests { acp::ContentBlock::Text(acp::TextContent { annotations: None, text: "New user message".to_string(), + meta: None, }), cx, ); @@ -1767,23 +2616,26 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk { + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content: "Thinking ".into(), - }, + meta: None, + }), cx, ) .unwrap(); thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk { + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { content: "hard!".into(), - }, + meta: None, + }), cx, ) .unwrap(); })?; Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, }) } .boxed_local() @@ -1854,6 +2706,7 @@ mod tests { .unwrap(); Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, }) } .boxed_local() @@ -1897,6 +2750,188 @@ mod tests { request.await.unwrap(); } + #[gpui::test] + async fn test_reading_from_line(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\nfour\n"})) + .await; + let project = Project::test(fs.clone(), [], cx).await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/tmp/foo"), true, cx) + }) + .await + .unwrap(); + + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx)) + .await + .unwrap(); + + // Whole file + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, "one\ntwo\nthree\nfour\n"); + + // Only start line + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(3), None, false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, "three\nfour\n"); + + // Only limit + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, "one\ntwo\n"); + + // Range + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(2), Some(2), false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, "two\nthree\n"); + + // Invalid + let err = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(6), Some(2), false, cx) + }) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Invalid params: \"Attempting to read beyond the end of the file, line 5:0\"" + ); + } + + #[gpui::test] + async fn test_reading_empty_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/tmp"), json!({"foo": ""})).await; + let project = Project::test(fs.clone(), [], cx).await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/tmp/foo"), true, cx) + }) + .await + .unwrap(); + + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx)) + .await + .unwrap(); + + // Whole file + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, ""); + + // Only start line + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(1), None, false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, ""); + + // Only limit + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, ""); + + // Range + let content = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(1), Some(1), false, cx) + }) + .await + .unwrap(); + + assert_eq!(content, ""); + + // Invalid + let err = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/tmp/foo").into(), Some(5), Some(2), false, cx) + }) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Invalid params: \"Attempting to read beyond the end of the file, line 1:0\"" + ); + } + #[gpui::test] + async fn test_reading_non_existing_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/tmp"), json!({})).await; + let project = Project::test(fs.clone(), [], cx).await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/tmp"), true, cx) + }) + .await + .unwrap(); + + let connection = Rc::new(FakeAgentConnection::new()); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx)) + .await + .unwrap(); + + // Out of project file + let err = thread + .update(cx, |thread, cx| { + thread.read_text_file(path!("/foo").into(), None, None, false, cx) + }) + .await + .unwrap_err(); + + assert_eq!(err.code, acp::ErrorCode::RESOURCE_NOT_FOUND.code); + } + #[gpui::test] async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) { init_test(cx); @@ -1922,6 +2957,7 @@ mod tests { locations: vec![], raw_input: None, raw_output: None, + meta: None, }), cx, ) @@ -1930,6 +2966,7 @@ mod tests { .unwrap(); Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, }) } .boxed_local() @@ -1978,6 +3015,7 @@ mod tests { status: Some(acp::ToolCallStatus::Completed), ..Default::default() }, + meta: None, }), cx, ) @@ -2020,11 +3058,13 @@ mod tests { path: "/test/test.txt".into(), old_text: None, new_text: "foo".into(), + meta: None, }, }], locations: vec![], raw_input: None, raw_output: None, + meta: None, }), cx, ) @@ -2033,6 +3073,7 @@ mod tests { .unwrap(); Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, }) } .boxed_local() @@ -2086,15 +3127,17 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk { + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content: content.text.to_uppercase().into(), - }, + meta: None, + }), cx, ) .unwrap(); })?; Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, }) } .boxed_local() @@ -2123,7 +3166,7 @@ mod tests { "} ); }); - assert_eq!(fs.files(), vec![Path::new("/test/file-0")]); + assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]); cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["ipsum".into()], cx))) .await @@ -2153,7 +3196,10 @@ mod tests { }); assert_eq!( fs.files(), - vec![Path::new("/test/file-0"), Path::new("/test/file-1")] + vec![ + Path::new(path!("/test/file-0")), + Path::new(path!("/test/file-1")) + ] ); // Checkpoint isn't stored when there are no changes. @@ -2194,7 +3240,10 @@ mod tests { }); assert_eq!( fs.files(), - vec![Path::new("/test/file-0"), Path::new("/test/file-1")] + vec![ + Path::new(path!("/test/file-0")), + Path::new(path!("/test/file-1")) + ] ); // Rewinding the conversation truncates the history and restores the checkpoint. @@ -2203,7 +3252,7 @@ mod tests { let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else { panic!("unexpected entries {:?}", thread.entries) }; - thread.rewind(message.id.clone().unwrap(), cx) + thread.restore_checkpoint(message.id.clone().unwrap(), cx) }) .await .unwrap(); @@ -2222,7 +3271,283 @@ mod tests { "} ); }); - assert_eq!(fs.files(), vec![Path::new("/test/file-0")]); + assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]); + } + + #[gpui::test] + async fn test_tool_result_refusal(cx: &mut TestAppContext) { + use std::sync::atomic::AtomicUsize; + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + + // Create a connection that simulates refusal after tool result + let prompt_count = Arc::new(AtomicUsize::new(0)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let prompt_count = prompt_count.clone(); + move |_request, thread, mut cx| { + let count = prompt_count.fetch_add(1, SeqCst); + async move { + if count == 0 { + // First prompt: Generate a tool call with result + thread.update(&mut cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::ToolCall(acp::ToolCall { + id: acp::ToolCallId("tool1".into()), + title: "Test Tool".into(), + kind: acp::ToolKind::Fetch, + status: acp::ToolCallStatus::Completed, + content: vec![], + locations: vec![], + raw_input: Some(serde_json::json!({"query": "test"})), + raw_output: Some( + serde_json::json!({"result": "inappropriate content"}), + ), + meta: None, + }), + cx, + ) + .unwrap(); + })?; + + // Now return refusal because of the tool result + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Refusal, + meta: None, + }) + } else { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + meta: None, + }) + } + } + .boxed_local() + } + })); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Track if we see a Refusal event + let saw_refusal_event = Arc::new(std::sync::Mutex::new(false)); + let saw_refusal_event_captured = saw_refusal_event.clone(); + thread.update(cx, |_thread, cx| { + cx.subscribe( + &thread, + move |_thread, _event_thread, event: &AcpThreadEvent, _cx| { + if matches!(event, AcpThreadEvent::Refusal) { + *saw_refusal_event_captured.lock().unwrap() = true; + } + }, + ) + .detach(); + }); + + // Send a user message - this will trigger tool call and then refusal + let send_task = thread.update(cx, |thread, cx| { + thread.send( + vec![acp::ContentBlock::Text(acp::TextContent { + text: "Hello".into(), + annotations: None, + meta: None, + })], + cx, + ) + }); + cx.background_executor.spawn(send_task).detach(); + cx.run_until_parked(); + + // Verify that: + // 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt) + // 2. The user message was NOT truncated + assert!( + *saw_refusal_event.lock().unwrap(), + "Refusal event should be emitted for tool result refusals" + ); + + thread.read_with(cx, |thread, _| { + let entries = thread.entries(); + assert!(entries.len() >= 2, "Should have user message and tool call"); + + // Verify user message is still there + assert!( + matches!(entries[0], AgentThreadEntry::UserMessage(_)), + "User message should not be truncated" + ); + + // Verify tool call is there with result + if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] { + assert!( + tool_call.raw_output.is_some(), + "Tool call should have output" + ); + } else { + panic!("Expected tool call at index 1"); + } + }); + } + + #[gpui::test] + async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + + let refuse_next = Arc::new(AtomicBool::new(false)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let refuse_next = refuse_next.clone(); + move |_request, _thread, _cx| { + if refuse_next.load(SeqCst) { + async move { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Refusal, + meta: None, + }) + } + .boxed_local() + } else { + async move { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + meta: None, + }) + } + .boxed_local() + } + } + })); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Track if we see a Refusal event + let saw_refusal_event = Arc::new(std::sync::Mutex::new(false)); + let saw_refusal_event_captured = saw_refusal_event.clone(); + thread.update(cx, |_thread, cx| { + cx.subscribe( + &thread, + move |_thread, _event_thread, event: &AcpThreadEvent, _cx| { + if matches!(event, AcpThreadEvent::Refusal) { + *saw_refusal_event_captured.lock().unwrap() = true; + } + }, + ) + .detach(); + }); + + // Send a message that will be refused + refuse_next.store(true, SeqCst); + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) + .await + .unwrap(); + + // Verify that a Refusal event WAS emitted for user prompt refusal + assert!( + *saw_refusal_event.lock().unwrap(), + "Refusal event should be emitted for user prompt refusals" + ); + + // Verify the message was truncated (user prompt refusal) + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.to_markdown(cx), ""); + }); + } + + #[gpui::test] + async fn test_refusal(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + + let refuse_next = Arc::new(AtomicBool::new(false)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let refuse_next = refuse_next.clone(); + move |request, thread, mut cx| { + let refuse_next = refuse_next.clone(); + async move { + if refuse_next.load(SeqCst) { + return Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Refusal, + meta: None, + }); + } + + let acp::ContentBlock::Text(content) = &request.prompt[0] else { + panic!("expected text content block"); + }; + thread.update(&mut cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content: content.text.to_uppercase().into(), + meta: None, + }), + cx, + ) + .unwrap(); + })?; + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + meta: None, + }) + } + .boxed_local() + } + })); + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc! {" + ## User + + hello + + ## Assistant + + HELLO + + "} + ); + }); + + // Simulate refusing the second message. The message should be truncated + // when a user prompt is refused. + refuse_next.store(true, SeqCst); + cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx))) + .await + .unwrap(); + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc! {" + ## User + + hello + + ## Assistant + + HELLO + + "} + ); + }); } async fn run_until_first_tool_call( @@ -2306,18 +3631,33 @@ mod tests { self: Rc, project: Entity, _cwd: &Path, - cx: &mut gpui::App, + cx: &mut App, ) -> Task>> { let session_id = acp::SessionId( - rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) + rand::rng() + .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) .collect::() .into(), ); - let thread = - cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx)); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let thread = cx.new(|cx| { + AcpThread::new( + "Test", + self.clone(), + project, + action_log, + session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }), + cx, + ) + }); self.sessions.lock().insert(session_id, thread.downgrade()); Task::ready(Ok(thread)) } @@ -2345,13 +3685,14 @@ mod tests { } else { Task::ready(Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, + meta: None, })) } } fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { let sessions = self.sessions.lock(); - let thread = sessions.get(&session_id).unwrap().clone(); + let thread = sessions.get(session_id).unwrap().clone(); cx.spawn(async move |cx| { thread @@ -2362,11 +3703,11 @@ mod tests { .detach(); } - fn session_editor( + fn truncate( &self, session_id: &acp::SessionId, - _cx: &mut App, - ) -> Option> { + _cx: &App, + ) -> Option> { Some(Rc::new(FakeAgentSessionEditor { _session_id: session_id.clone(), })) @@ -2381,9 +3722,70 @@ mod tests { _session_id: acp::SessionId, } - impl AgentSessionEditor for FakeAgentSessionEditor { - fn truncate(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> { + impl AgentSessionTruncate for FakeAgentSessionEditor { + fn run(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> { Task::ready(Ok(())) } } + + #[gpui::test] + async fn test_tool_call_not_found_creates_failed_entry(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + // Try to update a tool call that doesn't exist + let nonexistent_id = acp::ToolCallId("nonexistent-tool-call".into()); + thread.update(cx, |thread, cx| { + let result = thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { + id: nonexistent_id.clone(), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::Completed), + ..Default::default() + }, + meta: None, + }), + cx, + ); + + // The update should succeed (not return an error) + assert!(result.is_ok()); + + // There should now be exactly one entry in the thread + assert_eq!(thread.entries.len(), 1); + + // The entry should be a failed tool call + if let AgentThreadEntry::ToolCall(tool_call) = &thread.entries[0] { + assert_eq!(tool_call.id, nonexistent_id); + assert!(matches!(tool_call.status, ToolCallStatus::Failed)); + assert_eq!(tool_call.kind, acp::ToolKind::Fetch); + + // Check that the content contains the error message + assert_eq!(tool_call.content.len(), 1); + if let ToolCallContent::ContentBlock(content_block) = &tool_call.content[0] { + match content_block { + ContentBlock::Markdown { markdown } => { + let markdown_text = markdown.read(cx).source(); + assert!(markdown_text.contains("Tool call not found")); + } + ContentBlock::Empty => panic!("Expected markdown content, got empty"), + ContentBlock::ResourceLink { .. } => { + panic!("Expected markdown content, got resource link") + } + } + } else { + panic!("Expected ContentBlock, got: {:?}", tool_call.content[0]); + } + } else { + panic!("Expected ToolCall entry, got: {:?}", thread.entries[0]); + } + }); + } } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 7497d2309f1de72186c23773429cc6e8c57de2d2..fe66f954370f8118d054ee56f1e9f68f2de7e6f4 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -3,12 +3,14 @@ use agent_client_protocol::{self as acp}; use anyhow::Result; use collections::IndexMap; use gpui::{Entity, SharedString, Task}; +use language_model::LanguageModelProviderId; use project::Project; +use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc}; use ui::{App, IconName}; use uuid::Uuid; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct UserMessageId(Arc); impl UserMessageId { @@ -39,18 +41,26 @@ pub trait AgentConnection { fn resume( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } fn cancel(&self, session_id: &acp::SessionId, cx: &mut App); - fn session_editor( + fn truncate( &self, _session_id: &acp::SessionId, - _cx: &mut App, - ) -> Option> { + _cx: &App, + ) -> Option> { + None + } + + fn set_title( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { None } @@ -58,7 +68,19 @@ pub trait AgentConnection { /// /// If the agent does not support model selection, returns [None]. /// This allows sharing the selector in UI components. - fn model_selector(&self) -> Option> { + fn model_selector(&self, _session_id: &acp::SessionId) -> Option> { + None + } + + fn telemetry(&self) -> Option> { + None + } + + fn session_modes( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { None } @@ -71,21 +93,68 @@ impl dyn AgentConnection { } } -pub trait AgentSessionEditor { - fn truncate(&self, message_id: UserMessageId, cx: &mut App) -> Task>; +pub trait AgentSessionTruncate { + fn run(&self, message_id: UserMessageId, cx: &mut App) -> Task>; } pub trait AgentSessionResume { fn run(&self, cx: &mut App) -> Task>; } +pub trait AgentSessionSetTitle { + fn run(&self, title: SharedString, cx: &mut App) -> Task>; +} + +pub trait AgentTelemetry { + /// The name of the agent used for telemetry. + fn agent_name(&self) -> String; + + /// A representation of the current thread state that can be serialized for + /// storage with telemetry events. + fn thread_data( + &self, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task>; +} + +pub trait AgentSessionModes { + fn current_mode(&self) -> acp::SessionModeId; + + fn all_modes(&self) -> Vec; + + fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task>; +} + #[derive(Debug)] -pub struct AuthRequired; +pub struct AuthRequired { + pub description: Option, + pub provider_id: Option, +} + +impl AuthRequired { + pub fn new() -> Self { + Self { + description: None, + provider_id: None, + } + } + + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + pub fn with_language_model_provider(mut self, provider_id: LanguageModelProviderId) -> Self { + self.provider_id = Some(provider_id); + self + } +} impl Error for AuthRequired {} impl fmt::Display for AuthRequired { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "AuthRequired") + write!(f, "Authentication required") } } @@ -108,61 +177,48 @@ pub trait AgentModelSelector: 'static { /// If the session doesn't exist or the model is invalid, it returns an error. /// /// # Parameters - /// - `session_id`: The ID of the session (thread) to apply the model to. /// - `model`: The model to select (should be one from [list_models]). /// - `cx`: The GPUI app context. /// /// # Returns /// A task resolving to `Ok(())` on success or an error. - fn select_model( - &self, - session_id: acp::SessionId, - model_id: AgentModelId, - cx: &mut App, - ) -> Task>; + fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task>; /// Retrieves the currently selected model for a specific session (thread). /// /// # Parameters - /// - `session_id`: The ID of the session (thread) to query. /// - `cx`: The GPUI app context. /// /// # Returns /// A task resolving to the selected model (always set) or an error (e.g., session not found). - fn selected_model( - &self, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task>; + fn selected_model(&self, cx: &mut App) -> Task>; /// Whenever the model list is updated the receiver will be notified. - fn watch(&self, cx: &mut App) -> watch::Receiver<()>; -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct AgentModelId(pub SharedString); - -impl std::ops::Deref for AgentModelId { - type Target = SharedString; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for AgentModelId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) + /// Optional for agents that don't update their model list. + fn watch(&self, _cx: &mut App) -> Option> { + None } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentModelInfo { - pub id: AgentModelId, + pub id: acp::ModelId, pub name: SharedString, + pub description: Option, pub icon: Option, } +impl From for AgentModelInfo { + fn from(info: acp::ModelInfo) -> Self { + Self { + id: info.model_id, + name: info.name.into(), + description: info.description.map(|desc| desc.into()), + icon: None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AgentModelGroupName(pub SharedString); @@ -185,8 +241,9 @@ impl AgentModelList { mod test_support { use std::sync::Arc; + use action_log::ActionLog; use collections::HashMap; - use futures::future::try_join_all; + use futures::{channel::oneshot, future::try_join_all}; use gpui::{AppContext as _, WeakEntity}; use parking_lot::Mutex; @@ -194,11 +251,16 @@ mod test_support { #[derive(Clone, Default)] pub struct StubAgentConnection { - sessions: Arc>>>, + sessions: Arc>>, permission_requests: HashMap>, next_prompt_updates: Arc>>, } + struct Session { + thread: WeakEntity, + response_tx: Option>, + } + impl StubAgentConnection { pub fn new() -> Self { Self { @@ -226,15 +288,33 @@ mod test_support { update: acp::SessionUpdate, cx: &mut App, ) { + assert!( + self.next_prompt_updates.lock().is_empty(), + "Use either send_update or set_next_prompt_updates" + ); + self.sessions .lock() .get(&session_id) .unwrap() + .thread .update(cx, |thread, cx| { - thread.handle_session_update(update.clone(), cx).unwrap(); + thread.handle_session_update(update, cx).unwrap(); }) .unwrap(); } + + pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) { + self.sessions + .lock() + .get_mut(&session_id) + .unwrap() + .response_tx + .take() + .expect("No pending turn") + .send(stop_reason) + .unwrap(); + } } impl AgentConnection for StubAgentConnection { @@ -249,9 +329,30 @@ mod test_support { cx: &mut gpui::App, ) -> Task>> { let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); - let thread = - cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx)); - self.sessions.lock().insert(session_id, thread.downgrade()); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let thread = cx.new(|cx| { + AcpThread::new( + "Test", + self.clone(), + project, + action_log, + session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }), + cx, + ) + }); + self.sessions.lock().insert( + session_id, + Session { + thread: thread.downgrade(), + response_tx: None, + }, + ); Task::ready(Ok(thread)) } @@ -269,54 +370,83 @@ mod test_support { params: acp::PromptRequest, cx: &mut App, ) -> Task> { - let sessions = self.sessions.lock(); - let thread = sessions.get(¶ms.session_id).unwrap(); + let mut sessions = self.sessions.lock(); + let Session { + thread, + response_tx, + } = sessions.get_mut(¶ms.session_id).unwrap(); let mut tasks = vec![]; - for update in self.next_prompt_updates.lock().drain(..) { - let thread = thread.clone(); - let update = update.clone(); - let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) - { - Some((tool_call.clone(), options.clone())) - } else { - None - }; - let task = cx.spawn(async move |cx| { - if let Some((tool_call, options)) = permission_request { - let permission = thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization( - tool_call.clone().into(), - options.clone(), - cx, - ) + if self.next_prompt_updates.lock().is_empty() { + let (tx, rx) = oneshot::channel(); + response_tx.replace(tx); + cx.spawn(async move |_| { + let stop_reason = rx.await?; + Ok(acp::PromptResponse { + stop_reason, + meta: None, + }) + }) + } else { + for update in self.next_prompt_updates.lock().drain(..) { + let thread = thread.clone(); + let update = update.clone(); + let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = + &update + && let Some(options) = self.permission_requests.get(&tool_call.id) + { + Some((tool_call.clone(), options.clone())) + } else { + None + }; + let task = cx.spawn(async move |cx| { + if let Some((tool_call, options)) = permission_request { + thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + tool_call.clone().into(), + options.clone(), + false, + cx, + ) + })?? + .await; + } + thread.update(cx, |thread, cx| { + thread.handle_session_update(update.clone(), cx).unwrap(); })?; - permission?.await?; - } - thread.update(cx, |thread, cx| { - thread.handle_session_update(update.clone(), cx).unwrap(); - })?; - anyhow::Ok(()) - }); - tasks.push(task); - } - cx.spawn(async move |_| { - try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, + anyhow::Ok(()) + }); + tasks.push(task); + } + + cx.spawn(async move |_| { + try_join_all(tasks).await?; + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + meta: None, + }) }) - }) + } } - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() + fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { + if let Some(end_turn_tx) = self + .sessions + .lock() + .get_mut(session_id) + .unwrap() + .response_tx + .take() + { + end_turn_tx.send(acp::StopReason::Cancelled).unwrap(); + } } - fn session_editor( + fn truncate( &self, _session_id: &agent_client_protocol::SessionId, - _cx: &mut App, - ) -> Option> { + _cx: &App, + ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) } @@ -327,8 +457,8 @@ mod test_support { struct StubAgentSessionEditor; - impl AgentSessionEditor for StubAgentSessionEditor { - fn truncate(&self, _: UserMessageId, _: &mut App) -> Task> { + impl AgentSessionTruncate for StubAgentSessionEditor { + fn run(&self, _: UserMessageId, _: &mut App) -> Task> { Task::ready(Ok(())) } } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index a2c2d6c3229ae96bf45dfc870e8600a5f778a6f0..055b2f7fb86ffe9d7f12459b6b16405ce77815a0 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -1,18 +1,12 @@ -use agent_client_protocol as acp; use anyhow::Result; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{MultiBuffer, PathKey}; +use editor::{MultiBuffer, PathKey, multibuffer_context_lines}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task}; use itertools::Itertools; use language::{ Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _, Point, Rope, TextBuffer, }; -use std::{ - cmp::Reverse, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc}; use util::ResultExt; pub enum Diff { @@ -21,69 +15,54 @@ pub enum Diff { } impl Diff { - pub fn from_acp( - diff: acp::Diff, + pub fn finalized( + path: String, + old_text: Option, + new_text: String, language_registry: Arc, cx: &mut Context, ) -> Self { - let acp::Diff { - path, - old_text, - new_text, - } = diff; - let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); - let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); - let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); - let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); - let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); - + let base_text = old_text.clone().unwrap_or(String::new()).into(); let task = cx.spawn({ let multibuffer = multibuffer.clone(); let path = path.clone(); + let buffer = new_buffer.clone(); async move |_, cx| { let language = language_registry - .language_for_file_path(&path) + .load_language_for_file_path(Path::new(&path)) .await .log_err(); - new_buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; - - let old_buffer_snapshot = old_buffer.update(cx, |buffer, cx| { - buffer.set_language(language, cx); - buffer.snapshot() - })?; + buffer.update(cx, |buffer, cx| buffer.set_language(language.clone(), cx))?; - buffer_diff - .update(cx, |diff, cx| { - diff.set_base_text( - old_buffer_snapshot, - Some(language_registry), - new_buffer_snapshot, - cx, - ) - })? - .await?; + let diff = build_buffer_diff( + old_text.unwrap_or("".into()).into(), + &buffer, + Some(language_registry.clone()), + cx, + ) + .await?; multibuffer .update(cx, |multibuffer, cx| { let hunk_ranges = { - let buffer = new_buffer.read(cx); - let diff = buffer_diff.read(cx); - diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + let buffer = buffer.read(cx); + let diff = diff.read(cx); + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) .collect::>() }; multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&new_buffer, cx), - new_buffer.clone(), + PathKey::for_buffer(&buffer, cx), + buffer.clone(), hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); - multibuffer.add_diff(buffer_diff, cx); + multibuffer.add_diff(diff, cx); }) .log_err(); @@ -94,23 +73,26 @@ impl Diff { Self::Finalized(FinalizedDiff { multibuffer, path, + base_text, + new_buffer, _update_diff: task, }) } pub fn new(buffer: Entity, cx: &mut Context) -> Self { - let buffer_snapshot = buffer.read(cx).snapshot(); - let base_text = buffer_snapshot.text(); - let language_registry = buffer.read(cx).language_registry(); - let text_snapshot = buffer.read(cx).text_snapshot(); + let buffer_text_snapshot = buffer.read(cx).text_snapshot(); + let base_text_snapshot = buffer.read(cx).snapshot(); + let base_text = base_text_snapshot.text(); + debug_assert_eq!(buffer_text_snapshot.text(), base_text); let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - let _ = diff.set_base_text( - buffer_snapshot.clone(), - language_registry, - text_snapshot, - cx, - ); + let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, base_text_snapshot); + let snapshot = diff.snapshot(cx); + let secondary_diff = cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer_text_snapshot, cx); + diff.set_snapshot(snapshot, &buffer_text_snapshot, cx); + diff + }); + diff.set_secondary_diff(secondary_diff); diff }); @@ -128,7 +110,7 @@ impl Diff { diff.update(cx); } }), - buffer, + new_buffer: buffer, diff: buffer_diff, revealed_ranges: Vec::new(), update_diff: Task::ready(Ok(())), @@ -163,14 +145,17 @@ impl Diff { .map(|buffer| buffer.read(cx).text()) .join("\n"); let path = match self { - Diff::Pending(PendingDiff { buffer, .. }) => { - buffer.read(cx).file().map(|file| file.path().as_ref()) - } - Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_path()), + Diff::Pending(PendingDiff { + new_buffer: buffer, .. + }) => buffer + .read(cx) + .file() + .map(|file| file.path().display(file.path_style(cx))), + Diff::Finalized(FinalizedDiff { path, .. }) => Some(path.as_str().into()), }; format!( "Diff: {}\n```\n{}\n```\n", - path.unwrap_or(Path::new("untitled")).display(), + path.unwrap_or("untitled".into()), buffer_text ) } @@ -178,12 +163,33 @@ impl Diff { pub fn has_revealed_range(&self, cx: &App) -> bool { self.multibuffer().read(cx).excerpt_paths().next().is_some() } + + pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { + match self { + Diff::Pending(PendingDiff { + base_text, + new_buffer, + .. + }) => { + base_text.as_str() != old_text + || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text) + } + Diff::Finalized(FinalizedDiff { + base_text, + new_buffer, + .. + }) => { + base_text.as_str() != old_text + || !new_buffer.read(cx).as_rope().chunks().equals_str(new_text) + } + } + } } pub struct PendingDiff { multibuffer: Entity, base_text: Arc, - buffer: Entity, + new_buffer: Entity, diff: Entity, revealed_ranges: Vec>, _subscription: Subscription, @@ -192,7 +198,7 @@ pub struct PendingDiff { impl PendingDiff { pub fn update(&mut self, cx: &mut Context) { - let buffer = self.buffer.clone(); + let buffer = self.new_buffer.clone(); let buffer_diff = self.diff.clone(); let base_text = self.base_text.clone(); self.update_diff = cx.spawn(async move |diff, cx| { @@ -209,7 +215,10 @@ impl PendingDiff { ) .await?; buffer_diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &text_snapshot, cx) + diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx); + diff.secondary_diff().unwrap().update(cx, |diff, cx| { + diff.set_snapshot(diff_snapshot.clone(), &text_snapshot, cx); + }); })?; diff.update(cx, |diff, cx| { if let Diff::Pending(diff) = diff { @@ -227,24 +236,24 @@ impl PendingDiff { fn finalize(&self, cx: &mut Context) -> FinalizedDiff { let ranges = self.excerpt_ranges(cx); let base_text = self.base_text.clone(); - let language_registry = self.buffer.read(cx).language_registry().clone(); + let new_buffer = self.new_buffer.read(cx); + let language_registry = new_buffer.language_registry(); - let path = self - .buffer - .read(cx) + let path = new_buffer .file() - .map(|file| file.path().as_ref()) - .unwrap_or(Path::new("untitled")) + .map(|file| file.path().display(file.path_style(cx))) + .unwrap_or("untitled".into()) .into(); + let replica_id = new_buffer.replica_id(); // Replace the buffer in the multibuffer with the snapshot let buffer = cx.new(|cx| { - let language = self.buffer.read(cx).language().cloned(); + let language = self.new_buffer.read(cx).language().cloned(); let buffer = TextBuffer::new_normalized( - 0, + replica_id, cx.entity_id().as_non_zero_u64().into(), - self.buffer.read(cx).line_ending(), - self.buffer.read(cx).as_rope().clone(), + self.new_buffer.read(cx).line_ending(), + self.new_buffer.read(cx).as_rope().clone(), ); let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); buffer.set_language(language, cx); @@ -253,7 +262,6 @@ impl PendingDiff { let buffer_diff = cx.spawn({ let buffer = buffer.clone(); - let language_registry = language_registry.clone(); async move |_this, cx| { build_buffer_diff(base_text, &buffer, language_registry, cx).await } @@ -269,7 +277,7 @@ impl PendingDiff { path_key, buffer, ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); multibuffer.add_diff(buffer_diff.clone(), cx); @@ -281,7 +289,9 @@ impl PendingDiff { FinalizedDiff { path, + base_text: self.base_text.clone(), multibuffer: self.multibuffer.clone(), + new_buffer: self.new_buffer.clone(), _update_diff: update_diff, } } @@ -290,10 +300,10 @@ impl PendingDiff { let ranges = self.excerpt_ranges(cx); self.multibuffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&self.buffer, cx), - self.buffer.clone(), + PathKey::for_buffer(&self.new_buffer, cx), + self.new_buffer.clone(), ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); let end = multibuffer.len(cx); @@ -303,16 +313,16 @@ impl PendingDiff { } fn excerpt_ranges(&self, cx: &App) -> Vec> { - let buffer = self.buffer.read(cx); + let buffer = self.new_buffer.read(cx); let diff = self.diff.read(cx); let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) .collect::>(); ranges.extend( self.revealed_ranges .iter() - .map(|range| range.to_point(&buffer)), + .map(|range| range.to_point(buffer)), ); ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); @@ -336,7 +346,9 @@ impl PendingDiff { } pub struct FinalizedDiff { - path: PathBuf, + path: String, + base_text: Arc, + new_buffer: Entity, multibuffer: Entity, _update_diff: Task>, } @@ -390,3 +402,21 @@ async fn build_buffer_diff( diff }) } + +#[cfg(test)] +mod tests { + use gpui::{AppContext as _, TestAppContext}; + use language::Buffer; + + use crate::Diff; + + #[gpui::test] + async fn test_pending_diff(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("hello!", cx)); + let _diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_text("HELLO!", cx); + }); + cx.run_until_parked(); + } +} diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b9b021c4ca1f728ba82e7df111456ace656bb3bc..b78eac4903a259a1044892fb2c8233f7e973f025 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,29 +1,33 @@ -use agent::ThreadId; +use agent_client_protocol as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; +use serde::{Deserialize, Serialize}; use std::{ fmt, - ops::Range, + ops::RangeInclusive, path::{Path, PathBuf}, - str::FromStr, }; use ui::{App, IconName, SharedString}; use url::Url; +use util::paths::PathStyle; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum MentionUri { File { abs_path: PathBuf, - is_directory: bool, + }, + PastedImage, + Directory { + abs_path: PathBuf, }, Symbol { - path: PathBuf, + abs_path: PathBuf, name: String, - line_range: Range, + line_range: RangeInclusive, }, Thread { - id: ThreadId, + id: acp::SessionId, name: String, }, TextThread { @@ -35,8 +39,9 @@ pub enum MentionUri { name: String, }, Selection { - path: PathBuf, - line_range: Range, + #[serde(default, skip_serializing_if = "Option::is_none")] + abs_path: Option, + line_range: RangeInclusive, }, Fetch { url: Url, @@ -44,48 +49,58 @@ pub enum MentionUri { } impl MentionUri { - pub fn parse(input: &str) -> Result { + pub fn parse(input: &str, path_style: PathStyle) -> Result { + fn parse_line_range(fragment: &str) -> Result> { + let range = fragment + .strip_prefix("L") + .context("Line range must start with \"L\"")?; + let (start, end) = range + .split_once(":") + .context("Line range must use colon as separator")?; + let range = start + .parse::() + .context("Parsing line range start")? + .checked_sub(1) + .context("Line numbers should be 1-based")? + ..=end + .parse::() + .context("Parsing line range end")? + .checked_sub(1) + .context("Line numbers should be 1-based")?; + Ok(range) + } + let url = url::Url::parse(input)?; let path = url.path(); match url.scheme() { "file" => { + let path = if path_style.is_windows() { + path.trim_start_matches("/") + } else { + path + }; + if let Some(fragment) = url.fragment() { - let range = fragment - .strip_prefix("L") - .context("Line range must start with \"L\"")?; - let (start, end) = range - .split_once(":") - .context("Line range must use colon as separator")?; - let line_range = start - .parse::() - .context("Parsing line range start")? - .checked_sub(1) - .context("Line numbers should be 1-based")? - ..end - .parse::() - .context("Parsing line range end")? - .checked_sub(1) - .context("Line numbers should be 1-based")?; + let line_range = parse_line_range(fragment)?; if let Some(name) = single_query_param(&url, "symbol")? { Ok(Self::Symbol { name, - path: path.into(), + abs_path: path.into(), line_range, }) } else { Ok(Self::Selection { - path: path.into(), + abs_path: Some(path.into()), line_range, }) } + } else if input.ends_with("/") { + Ok(Self::Directory { + abs_path: path.into(), + }) } else { - let file_path = - PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path)); - let is_directory = input.ends_with("/"); - Ok(Self::File { - abs_path: file_path, - is_directory, + abs_path: path.into(), }) } } @@ -93,7 +108,7 @@ impl MentionUri { if let Some(thread_id) = path.strip_prefix("/agent/thread/") { let name = single_query_param(&url, "name")?.context("Missing thread name")?; Ok(Self::Thread { - id: thread_id.into(), + id: acp::SessionId(thread_id.into()), name, }) } else if let Some(path) = path.strip_prefix("/agent/text-thread/") { @@ -109,6 +124,50 @@ impl MentionUri { id: rule_id.into(), name, }) + } else if path.starts_with("/agent/pasted-image") { + Ok(Self::PastedImage) + } else if path.starts_with("/agent/untitled-buffer") { + let fragment = url + .fragment() + .context("Missing fragment for untitled buffer selection")?; + let line_range = parse_line_range(fragment)?; + Ok(Self::Selection { + abs_path: None, + line_range, + }) + } else if let Some(name) = path.strip_prefix("/agent/symbol/") { + let fragment = url + .fragment() + .context("Missing fragment for untitled buffer selection")?; + let line_range = parse_line_range(fragment)?; + let path = + single_query_param(&url, "path")?.context("Missing path for symbol")?; + Ok(Self::Symbol { + name: name.to_string(), + abs_path: path.into(), + line_range, + }) + } else if path.starts_with("/agent/file") { + let path = + single_query_param(&url, "path")?.context("Missing path for file")?; + Ok(Self::File { + abs_path: path.into(), + }) + } else if path.starts_with("/agent/directory") { + let path = + single_query_param(&url, "path")?.context("Missing path for directory")?; + Ok(Self::Directory { + abs_path: path.into(), + }) + } else if path.starts_with("/agent/selection") { + let fragment = url.fragment().context("Missing fragment for selection")?; + let line_range = parse_line_range(fragment)?; + let path = + single_query_param(&url, "path")?.context("Missing path for selection")?; + Ok(Self::Selection { + abs_path: Some(path.into()), + line_range, + }) } else { bail!("invalid zed url: {:?}", input); } @@ -120,36 +179,33 @@ impl MentionUri { pub fn name(&self) -> String { match self { - MentionUri::File { abs_path, .. } => abs_path + MentionUri::File { abs_path, .. } | MentionUri::Directory { abs_path, .. } => abs_path .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(), + MentionUri::PastedImage => "Image".to_string(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), MentionUri::TextThread { name, .. } => name.clone(), MentionUri::Rule { name, .. } => name.clone(), MentionUri::Selection { - path, line_range, .. - } => selection_name(path, line_range), + abs_path: path, + line_range, + .. + } => selection_name(path.as_deref(), line_range), MentionUri::Fetch { url } => url.to_string(), } } pub fn icon_path(&self, cx: &mut App) -> SharedString { match self { - MentionUri::File { - abs_path, - is_directory, - } => { - if *is_directory { - FileIcons::get_folder_icon(false, cx) - .unwrap_or_else(|| IconName::Folder.path().into()) - } else { - FileIcons::get_icon(&abs_path, cx) - .unwrap_or_else(|| IconName::File.path().into()) - } + MentionUri::File { abs_path } => { + FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into()) } + MentionUri::PastedImage => IconName::Image.path().into(), + MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx) + .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), MentionUri::Thread { .. } => IconName::Thread.path().into(), MentionUri::TextThread { .. } => IconName::Thread.path().into(), @@ -165,40 +221,49 @@ impl MentionUri { pub fn to_uri(&self) -> Url { match self { - MentionUri::File { - abs_path, - is_directory, - } => { + MentionUri::File { abs_path } => { let mut url = Url::parse("file:///").unwrap(); - let mut path = abs_path.to_string_lossy().to_string(); - if *is_directory && !path.ends_with("/") { - path.push_str("/"); - } - url.set_path(&path); + url.set_path(&abs_path.to_string_lossy()); + url + } + MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(), + MentionUri::Directory { abs_path } => { + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&abs_path.to_string_lossy()); url } MentionUri::Symbol { - path, + abs_path, name, line_range, } => { let mut url = Url::parse("file:///").unwrap(); - url.set_path(&path.to_string_lossy()); + url.set_path(&abs_path.to_string_lossy()); url.query_pairs_mut().append_pair("symbol", name); url.set_fragment(Some(&format!( "L{}:{}", - line_range.start + 1, - line_range.end + 1 + line_range.start() + 1, + line_range.end() + 1 ))); url } - MentionUri::Selection { path, line_range } => { - let mut url = Url::parse("file:///").unwrap(); - url.set_path(&path.to_string_lossy()); + MentionUri::Selection { + abs_path, + line_range, + } => { + let mut url = if let Some(path) = abs_path { + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&path.to_string_lossy()); + url + } else { + let mut url = Url::parse("zed:///").unwrap(); + url.set_path("/agent/untitled-buffer"); + url + }; url.set_fragment(Some(&format!( "L{}:{}", - line_range.start + 1, - line_range.end + 1 + line_range.start() + 1, + line_range.end() + 1 ))); url } @@ -210,7 +275,10 @@ impl MentionUri { } MentionUri::TextThread { path, name } => { let mut url = Url::parse("zed:///").unwrap(); - url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy())); + url.set_path(&format!( + "/agent/text-thread/{}", + path.to_string_lossy().trim_start_matches('/') + )); url.query_pairs_mut().append_pair("name", name); url } @@ -225,14 +293,6 @@ impl MentionUri { } } -impl FromStr for MentionUri { - type Err = anyhow::Error; - - fn from_str(s: &str) -> anyhow::Result { - Self::parse(s) - } -} - pub struct MentionLink<'a>(&'a MentionUri); impl fmt::Display for MentionLink<'_> { @@ -256,30 +316,30 @@ fn single_query_param(url: &Url, name: &'static str) -> Result> { } } -pub fn selection_name(path: &Path, line_range: &Range) -> String { +pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive) -> String { format!( "{} ({}:{})", - path.file_name().unwrap_or_default().display(), - line_range.start + 1, - line_range.end + 1 + path.and_then(|path| path.file_name()) + .unwrap_or("Untitled".as_ref()) + .display(), + *line_range.start() + 1, + *line_range.end() + 1 ) } #[cfg(test)] mod tests { + use util::{path, uri}; + use super::*; #[test] fn test_parse_file_uri() { - let file_uri = "file:///path/to/file.rs"; - let parsed = MentionUri::parse(file_uri).unwrap(); + let file_uri = uri!("file:///path/to/file.rs"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); match &parsed { - MentionUri::File { - abs_path, - is_directory, - } => { - assert_eq!(abs_path.to_str().unwrap(), "/path/to/file.rs"); - assert!(!is_directory); + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/file.rs"))); } _ => panic!("Expected File variant"), } @@ -288,53 +348,40 @@ mod tests { #[test] fn test_parse_directory_uri() { - let file_uri = "file:///path/to/dir/"; - let parsed = MentionUri::parse(file_uri).unwrap(); + let file_uri = uri!("file:///path/to/dir/"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); match &parsed { - MentionUri::File { - abs_path, - is_directory, - } => { - assert_eq!(abs_path.to_str().unwrap(), "/path/to/dir/"); - assert!(is_directory); + MentionUri::Directory { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/dir/"))); } - _ => panic!("Expected File variant"), + _ => panic!("Expected Directory variant"), } assert_eq!(parsed.to_uri().to_string(), file_uri); } - #[test] - fn test_to_directory_uri_with_slash() { - let uri = MentionUri::File { - abs_path: PathBuf::from("/path/to/dir/"), - is_directory: true, - }; - assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/"); - } - #[test] fn test_to_directory_uri_without_slash() { - let uri = MentionUri::File { - abs_path: PathBuf::from("/path/to/dir"), - is_directory: true, + let uri = MentionUri::Directory { + abs_path: PathBuf::from(path!("/path/to/dir/")), }; - assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/"); + let expected = uri!("file:///path/to/dir/"); + assert_eq!(uri.to_uri().to_string(), expected); } #[test] fn test_parse_symbol_uri() { - let symbol_uri = "file:///path/to/file.rs?symbol=MySymbol#L10:20"; - let parsed = MentionUri::parse(symbol_uri).unwrap(); + let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20"); + let parsed = MentionUri::parse(symbol_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Symbol { - path, + abs_path: path, name, line_range, } => { - assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"); + assert_eq!(path, Path::new(path!("/path/to/file.rs"))); assert_eq!(name, "MySymbol"); - assert_eq!(line_range.start, 9); - assert_eq!(line_range.end, 19); + assert_eq!(line_range.start(), &9); + assert_eq!(line_range.end(), &19); } _ => panic!("Expected Symbol variant"), } @@ -343,23 +390,43 @@ mod tests { #[test] fn test_parse_selection_uri() { - let selection_uri = "file:///path/to/file.rs#L5:15"; - let parsed = MentionUri::parse(selection_uri).unwrap(); + let selection_uri = uri!("file:///path/to/file.rs#L5:15"); + let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap(); match &parsed { - MentionUri::Selection { path, line_range } => { - assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"); - assert_eq!(line_range.start, 4); - assert_eq!(line_range.end, 14); + MentionUri::Selection { + abs_path: path, + line_range, + } => { + assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs"))); + assert_eq!(line_range.start(), &4); + assert_eq!(line_range.end(), &14); } _ => panic!("Expected Selection variant"), } assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_untitled_selection_uri() { + let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); + let parsed = MentionUri::parse(selection_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::Selection { + abs_path: None, + line_range, + } => { + assert_eq!(line_range.start(), &0); + assert_eq!(line_range.end(), &9); + } + _ => panic!("Expected Selection variant without path"), + } + assert_eq!(parsed.to_uri().to_string(), selection_uri); + } + #[test] fn test_parse_thread_uri() { let thread_uri = "zed:///agent/thread/session123?name=Thread+name"; - let parsed = MentionUri::parse(thread_uri).unwrap(); + let parsed = MentionUri::parse(thread_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Thread { id: thread_id, @@ -376,7 +443,7 @@ mod tests { #[test] fn test_parse_rule_uri() { let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule"; - let parsed = MentionUri::parse(rule_uri).unwrap(); + let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Rule { id, name } => { assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52"); @@ -390,7 +457,7 @@ mod tests { #[test] fn test_parse_fetch_http_uri() { let http_uri = "http://example.com/path?query=value#fragment"; - let parsed = MentionUri::parse(http_uri).unwrap(); + let parsed = MentionUri::parse(http_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Fetch { url } => { assert_eq!(url.to_string(), http_uri); @@ -403,7 +470,7 @@ mod tests { #[test] fn test_parse_fetch_https_uri() { let https_uri = "https://example.com/api/endpoint"; - let parsed = MentionUri::parse(https_uri).unwrap(); + let parsed = MentionUri::parse(https_uri, PathStyle::local()).unwrap(); match &parsed { MentionUri::Fetch { url } => { assert_eq!(url.to_string(), https_uri); @@ -415,46 +482,70 @@ mod tests { #[test] fn test_invalid_scheme() { - assert!(MentionUri::parse("ftp://example.com").is_err()); - assert!(MentionUri::parse("ssh://example.com").is_err()); - assert!(MentionUri::parse("unknown://example.com").is_err()); + assert!(MentionUri::parse("ftp://example.com", PathStyle::local()).is_err()); + assert!(MentionUri::parse("ssh://example.com", PathStyle::local()).is_err()); + assert!(MentionUri::parse("unknown://example.com", PathStyle::local()).is_err()); } #[test] fn test_invalid_zed_path() { - assert!(MentionUri::parse("zed:///invalid/path").is_err()); - assert!(MentionUri::parse("zed:///agent/unknown/test").is_err()); + assert!(MentionUri::parse("zed:///invalid/path", PathStyle::local()).is_err()); + assert!(MentionUri::parse("zed:///agent/unknown/test", PathStyle::local()).is_err()); } #[test] fn test_invalid_line_range_format() { // Missing L prefix - assert!(MentionUri::parse("file:///path/to/file.rs#10:20").is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#10:20"), PathStyle::local()).is_err() + ); // Missing colon separator - assert!(MentionUri::parse("file:///path/to/file.rs#L1020").is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L1020"), PathStyle::local()).is_err() + ); // Invalid numbers - assert!(MentionUri::parse("file:///path/to/file.rs#L10:abc").is_err()); - assert!(MentionUri::parse("file:///path/to/file.rs#Labc:20").is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L10:abc"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#Labc:20"), PathStyle::local()).is_err() + ); } #[test] fn test_invalid_query_parameters() { // Invalid query parameter name - assert!(MentionUri::parse("file:///path/to/file.rs#L10:20?invalid=test").is_err()); + assert!( + MentionUri::parse( + uri!("file:///path/to/file.rs#L10:20?invalid=test"), + PathStyle::local() + ) + .is_err() + ); // Too many query parameters assert!( - MentionUri::parse("file:///path/to/file.rs#L10:20?symbol=test&another=param").is_err() + MentionUri::parse( + uri!("file:///path/to/file.rs#L10:20?symbol=test&another=param"), + PathStyle::local() + ) + .is_err() ); } #[test] fn test_zero_based_line_numbers() { // Test that 0-based line numbers are rejected (should be 1-based) - assert!(MentionUri::parse("file:///path/to/file.rs#L0:10").is_err()); - assert!(MentionUri::parse("file:///path/to/file.rs#L1:0").is_err()); - assert!(MentionUri::parse("file:///path/to/file.rs#L0:0").is_err()); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L0:10"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L1:0"), PathStyle::local()).is_err() + ); + assert!( + MentionUri::parse(uri!("file:///path/to/file.rs#L0:0"), PathStyle::local()).is_err() + ); } } diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 41d7fb89bb2eb59207bf0a6557129a088b435f3a..9ca6d4021b316231930ab7803957dab3a0139f1e 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,37 +1,51 @@ -use gpui::{App, AppContext, Context, Entity}; +use agent_client_protocol as acp; +use anyhow::Result; +use futures::{FutureExt as _, future::Shared}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; use language::LanguageRegistry; use markdown::Markdown; +use project::Project; +use settings::{Settings as _, SettingsLocation}; use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant}; +use task::Shell; +use terminal::terminal_settings::TerminalSettings; +use util::get_default_system_shell_preferring_bash; pub struct Terminal { + id: acp::TerminalId, command: Entity, working_dir: Option, terminal: Entity, started_at: Instant, output: Option, + output_byte_limit: Option, + _output_task: Shared>, } pub struct TerminalOutput { pub ended_at: Instant, pub exit_status: Option, - pub was_content_truncated: bool, + pub content: String, pub original_content_len: usize, pub content_line_count: usize, - pub finished_with_empty_output: bool, } impl Terminal { pub fn new( - command: String, + id: acp::TerminalId, + command_label: &str, working_dir: Option, + output_byte_limit: Option, terminal: Entity, language_registry: Arc, cx: &mut Context, ) -> Self { + let command_task = terminal.read(cx).wait_for_completed_task(cx); Self { + id, command: cx.new(|cx| { Markdown::new( - format!("```\n{}\n```", command).into(), + format!("```\n{}\n```", command_label).into(), Some(language_registry.clone()), None, cx, @@ -41,27 +55,97 @@ impl Terminal { terminal, started_at: Instant::now(), output: None, + output_byte_limit, + _output_task: cx + .spawn(async move |this, cx| { + let exit_status = command_task.await; + + this.update(cx, |this, cx| { + let (content, original_content_len) = this.truncated_output(cx); + let content_line_count = this.terminal.read(cx).total_lines(); + + this.output = Some(TerminalOutput { + ended_at: Instant::now(), + exit_status, + content, + original_content_len, + content_line_count, + }); + cx.notify(); + }) + .ok(); + + let exit_status = exit_status.map(portable_pty::ExitStatus::from); + + acp::TerminalExitStatus { + exit_code: exit_status.as_ref().map(|e| e.exit_code()), + signal: exit_status.and_then(|e| e.signal().map(Into::into)), + meta: None, + } + }) + .shared(), } } - pub fn finish( - &mut self, - exit_status: Option, - original_content_len: usize, - truncated_content_len: usize, - content_line_count: usize, - finished_with_empty_output: bool, - cx: &mut Context, - ) { - self.output = Some(TerminalOutput { - ended_at: Instant::now(), - exit_status, - was_content_truncated: truncated_content_len < original_content_len, - original_content_len, - content_line_count, - finished_with_empty_output, + pub fn id(&self) -> &acp::TerminalId { + &self.id + } + + pub fn wait_for_exit(&self) -> Shared> { + self._output_task.clone() + } + + pub fn kill(&mut self, cx: &mut App) { + self.terminal.update(cx, |terminal, _cx| { + terminal.kill_active_task(); }); - cx.notify(); + } + + pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse { + if let Some(output) = self.output.as_ref() { + let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); + + acp::TerminalOutputResponse { + output: output.content.clone(), + truncated: output.original_content_len > output.content.len(), + exit_status: Some(acp::TerminalExitStatus { + exit_code: exit_status.as_ref().map(|e| e.exit_code()), + signal: exit_status.and_then(|e| e.signal().map(Into::into)), + meta: None, + }), + meta: None, + } + } else { + let (current_content, original_len) = self.truncated_output(cx); + + acp::TerminalOutputResponse { + truncated: current_content.len() < original_len, + output: current_content, + exit_status: None, + meta: None, + } + } + } + + fn truncated_output(&self, cx: &App) -> (String, usize) { + let terminal = self.terminal.read(cx); + let mut content = terminal.get_content(); + + let original_content_len = content.len(); + + if let Some(limit) = self.output_byte_limit + && content.len() > limit + { + let mut end_ix = limit.min(content.len()); + while !content.is_char_boundary(end_ix) { + end_ix -= 1; + } + // Don't truncate mid-line, clear the remainder of the last line + end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); + content.truncate(end_ix); + } + + (content, original_content_len) } pub fn command(&self) -> &Entity { @@ -91,3 +175,68 @@ impl Terminal { ) } } + +pub async fn create_terminal_entity( + command: String, + args: &[String], + env_vars: Vec<(String, String)>, + cwd: Option, + project: &Entity, + cx: &mut AsyncApp, +) -> Result> { + let mut env = if let Some(dir) = &cwd { + project + .update(cx, |project, cx| { + let worktree = project.find_worktree(dir.as_path(), cx); + let shell = TerminalSettings::get( + worktree.as_ref().map(|(worktree, path)| SettingsLocation { + worktree_id: worktree.read(cx).id(), + path: &path, + }), + cx, + ) + .shell + .clone(); + project.directory_environment(&shell, dir.clone().into(), cx) + })? + .await + .unwrap_or_default() + } else { + Default::default() + }; + + // Disables paging for `git` and hopefully other commands + env.insert("PAGER".into(), "".into()); + env.extend(env_vars); + + // Use remote shell or default system shell, as appropriate + let shell = project + .update(cx, |project, cx| { + project + .remote_client() + .and_then(|r| r.read(cx).default_system_shell()) + .map(Shell::Program) + })? + .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash())); + let is_windows = project + .read_with(cx, |project, cx| project.path_style(cx).is_windows()) + .unwrap_or(cfg!(windows)); + let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows) + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + + project + .update(cx, |project, cx| { + project.create_terminal_task( + task::SpawnInTerminal { + command: Some(task_command), + args: task_args, + cwd, + env, + ..Default::default() + }, + cx, + ) + })? + .await +} diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0720c4b6685ecf7fa20d8cacd2b61baa765c961c --- /dev/null +++ b/crates/acp_tools/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "acp_tools" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + + +[lints] +workspace = true + +[lib] +path = "src/acp_tools.rs" +doctest = false + +[dependencies] +agent-client-protocol.workspace = true +collections.workspace = true +gpui.workspace = true +language.workspace= true +markdown.workspace = true +project.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/agent2/LICENSE-GPL b/crates/acp_tools/LICENSE-GPL similarity index 100% rename from crates/agent2/LICENSE-GPL rename to crates/acp_tools/LICENSE-GPL diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs new file mode 100644 index 0000000000000000000000000000000000000000..a40bcbd93c878a85c85d7edd312e713988234966 --- /dev/null +++ b/crates/acp_tools/src/acp_tools.rs @@ -0,0 +1,636 @@ +use std::{ + cell::RefCell, + collections::HashSet, + fmt::Display, + rc::{Rc, Weak}, + sync::Arc, + time::Duration, +}; + +use agent_client_protocol as acp; +use collections::HashMap; +use gpui::{ + App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, + ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, + prelude::*, +}; +use language::LanguageRegistry; +use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; +use project::Project; +use settings::Settings; +use theme::ThemeSettings; +use ui::{Tooltip, prelude::*}; +use util::ResultExt as _; +use workspace::{ + Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, +}; + +actions!(dev, [OpenAcpLogs]); + +pub fn init(cx: &mut App) { + cx.observe_new( + |workspace: &mut Workspace, _window, _cx: &mut Context| { + workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| { + let acp_tools = + Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx))); + workspace.add_item_to_active_pane(acp_tools, None, true, window, cx); + }); + }, + ) + .detach(); +} + +struct GlobalAcpConnectionRegistry(Entity); + +impl Global for GlobalAcpConnectionRegistry {} + +#[derive(Default)] +pub struct AcpConnectionRegistry { + active_connection: RefCell>, +} + +struct ActiveConnection { + server_name: SharedString, + connection: Weak, +} + +impl AcpConnectionRegistry { + pub fn default_global(cx: &mut App) -> Entity { + if cx.has_global::() { + cx.global::().0.clone() + } else { + let registry = cx.new(|_cx| AcpConnectionRegistry::default()); + cx.set_global(GlobalAcpConnectionRegistry(registry.clone())); + registry + } + } + + pub fn set_active_connection( + &self, + server_name: impl Into, + connection: &Rc, + cx: &mut Context, + ) { + self.active_connection.replace(Some(ActiveConnection { + server_name: server_name.into(), + connection: Rc::downgrade(connection), + })); + cx.notify(); + } +} + +struct AcpTools { + project: Entity, + focus_handle: FocusHandle, + expanded: HashSet, + watched_connection: Option, + connection_registry: Entity, + _subscription: Subscription, +} + +struct WatchedConnection { + server_name: SharedString, + messages: Vec, + list_state: ListState, + connection: Weak, + incoming_request_methods: HashMap>, + outgoing_request_methods: HashMap>, + _task: Task<()>, +} + +impl AcpTools { + fn new(project: Entity, cx: &mut Context) -> Self { + let connection_registry = AcpConnectionRegistry::default_global(cx); + + let subscription = cx.observe(&connection_registry, |this, _, cx| { + this.update_connection(cx); + cx.notify(); + }); + + let mut this = Self { + project, + focus_handle: cx.focus_handle(), + expanded: HashSet::default(), + watched_connection: None, + connection_registry, + _subscription: subscription, + }; + this.update_connection(cx); + this + } + + fn update_connection(&mut self, cx: &mut Context) { + let active_connection = self.connection_registry.read(cx).active_connection.borrow(); + let Some(active_connection) = active_connection.as_ref() else { + return; + }; + + if let Some(watched_connection) = self.watched_connection.as_ref() { + if Weak::ptr_eq( + &watched_connection.connection, + &active_connection.connection, + ) { + return; + } + } + + if let Some(connection) = active_connection.connection.upgrade() { + let mut receiver = connection.subscribe(); + let task = cx.spawn(async move |this, cx| { + while let Ok(message) = receiver.recv().await { + this.update(cx, |this, cx| { + this.push_stream_message(message, cx); + }) + .ok(); + } + }); + + self.watched_connection = Some(WatchedConnection { + server_name: active_connection.server_name.clone(), + messages: vec![], + list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), + connection: active_connection.connection.clone(), + incoming_request_methods: HashMap::default(), + outgoing_request_methods: HashMap::default(), + _task: task, + }); + } + } + + fn push_stream_message(&mut self, stream_message: acp::StreamMessage, cx: &mut Context) { + let Some(connection) = self.watched_connection.as_mut() else { + return; + }; + let language_registry = self.project.read(cx).languages().clone(); + let index = connection.messages.len(); + + let (request_id, method, message_type, params) = match stream_message.message { + acp::StreamMessageContent::Request { id, method, params } => { + let method_map = match stream_message.direction { + acp::StreamMessageDirection::Incoming => { + &mut connection.incoming_request_methods + } + acp::StreamMessageDirection::Outgoing => { + &mut connection.outgoing_request_methods + } + }; + + method_map.insert(id.clone(), method.clone()); + (Some(id), method.into(), MessageType::Request, Ok(params)) + } + acp::StreamMessageContent::Response { id, result } => { + let method_map = match stream_message.direction { + acp::StreamMessageDirection::Incoming => { + &mut connection.outgoing_request_methods + } + acp::StreamMessageDirection::Outgoing => { + &mut connection.incoming_request_methods + } + }; + + if let Some(method) = method_map.remove(&id) { + (Some(id), method.into(), MessageType::Response, result) + } else { + ( + Some(id), + "[unrecognized response]".into(), + MessageType::Response, + result, + ) + } + } + acp::StreamMessageContent::Notification { method, params } => { + (None, method.into(), MessageType::Notification, Ok(params)) + } + }; + + let message = WatchedConnectionMessage { + name: method, + message_type, + request_id, + direction: stream_message.direction, + collapsed_params_md: match params.as_ref() { + Ok(params) => params + .as_ref() + .map(|params| collapsed_params_md(params, &language_registry, cx)), + Err(err) => { + if let Ok(err) = &serde_json::to_value(err) { + Some(collapsed_params_md(&err, &language_registry, cx)) + } else { + None + } + } + }, + + expanded_params_md: None, + params, + }; + + connection.messages.push(message); + connection.list_state.splice(index..index, 1); + cx.notify(); + } + + fn serialize_observed_messages(&self) -> Option { + let connection = self.watched_connection.as_ref()?; + + let messages: Vec = connection + .messages + .iter() + .filter_map(|message| { + let params = match &message.params { + Ok(Some(params)) => params.clone(), + Ok(None) => serde_json::Value::Null, + Err(err) => serde_json::to_value(err).ok()?, + }; + Some(serde_json::json!({ + "_direction": match message.direction { + acp::StreamMessageDirection::Incoming => "incoming", + acp::StreamMessageDirection::Outgoing => "outgoing", + }, + "_type": message.message_type.to_string().to_lowercase(), + "id": message.request_id, + "method": message.name.to_string(), + "params": params, + })) + }) + .collect(); + + serde_json::to_string_pretty(&messages).ok() + } + + fn clear_messages(&mut self, cx: &mut Context) { + if let Some(connection) = self.watched_connection.as_mut() { + connection.messages.clear(); + connection.list_state.reset(0); + self.expanded.clear(); + cx.notify(); + } + } + + fn render_message( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(connection) = self.watched_connection.as_ref() else { + return Empty.into_any(); + }; + + let Some(message) = connection.messages.get(index) else { + return Empty.into_any(); + }; + + let base_size = TextSize::Editor.rems(cx); + + let theme_settings = ThemeSettings::get_global(cx); + let text_style = window.text_style(); + + let colors = cx.theme().colors(); + let expanded = self.expanded.contains(&index); + + v_flex() + .w_full() + .px_4() + .py_3() + .border_color(colors.border) + .border_b_1() + .gap_2() + .items_start() + .font_buffer(cx) + .text_size(base_size) + .id(index) + .group("message") + .hover(|this| this.bg(colors.element_background.opacity(0.5))) + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded.contains(&index) { + this.expanded.remove(&index); + } else { + this.expanded.insert(index); + let Some(connection) = &mut this.watched_connection else { + return; + }; + let Some(message) = connection.messages.get_mut(index) else { + return; + }; + message.expanded(this.project.read(cx).languages().clone(), cx); + connection.list_state.scroll_to_reveal_item(index); + } + cx.notify() + })) + .child( + h_flex() + .w_full() + .gap_2() + .items_center() + .flex_shrink_0() + .child(match message.direction { + acp::StreamMessageDirection::Incoming => { + ui::Icon::new(ui::IconName::ArrowDown).color(Color::Error) + } + acp::StreamMessageDirection::Outgoing => { + ui::Icon::new(ui::IconName::ArrowUp).color(Color::Success) + } + }) + .child( + Label::new(message.name.clone()) + .buffer_font(cx) + .color(Color::Muted), + ) + .child(div().flex_1()) + .child( + div() + .child(ui::Chip::new(message.message_type.to_string())) + .visible_on_hover("message"), + ) + .children( + message + .request_id + .as_ref() + .map(|req_id| div().child(ui::Chip::new(req_id.to_string()))), + ), + ) + // I'm aware using markdown is a hack. Trying to get something working for the demo. + // Will clean up soon! + .when_some( + if expanded { + message.expanded_params_md.clone() + } else { + message.collapsed_params_md.clone() + }, + |this, params| { + this.child( + div().pl_6().w_full().child( + MarkdownElement::new( + params, + MarkdownStyle { + base_text_style: text_style, + selection_background_color: colors.element_selection_background, + syntax: cx.theme().syntax().clone(), + code_block_overflow_x_scroll: true, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some( + theme_settings.buffer_font.family.clone(), + ), + font_size: Some((base_size * 0.8).into()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, + ) + .code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: expanded, + border: false, + }, + ), + ), + ) + }, + ) + .into_any() + } +} + +struct WatchedConnectionMessage { + name: SharedString, + request_id: Option, + direction: acp::StreamMessageDirection, + message_type: MessageType, + params: Result, acp::Error>, + collapsed_params_md: Option>, + expanded_params_md: Option>, +} + +impl WatchedConnectionMessage { + fn expanded(&mut self, language_registry: Arc, cx: &mut App) { + let params_md = match &self.params { + Ok(Some(params)) => Some(expanded_params_md(params, &language_registry, cx)), + Err(err) => { + if let Some(err) = &serde_json::to_value(err).log_err() { + Some(expanded_params_md(&err, &language_registry, cx)) + } else { + None + } + } + _ => None, + }; + self.expanded_params_md = params_md; + } +} + +fn collapsed_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string(params).unwrap_or_default(); + let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4); + + for ch in params_json.chars() { + match ch { + '{' => spaced_out_json.push_str("{ "), + '}' => spaced_out_json.push_str(" }"), + ':' => spaced_out_json.push_str(": "), + ',' => spaced_out_json.push_str(", "), + c => spaced_out_json.push(c), + } + } + + let params_md = format!("```json\n{}\n```", spaced_out_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +fn expanded_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string_pretty(params).unwrap_or_default(); + let params_md = format!("```json\n{}\n```", params_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +enum MessageType { + Request, + Response, + Notification, +} + +impl Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::Request => write!(f, "Request"), + MessageType::Response => write!(f, "Response"), + MessageType::Notification => write!(f, "Notification"), + } + } +} + +enum AcpToolsEvent {} + +impl EventEmitter for AcpTools {} + +impl Item for AcpTools { + type Event = AcpToolsEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString { + format!( + "ACP: {}", + self.watched_connection + .as_ref() + .map_or("Disconnected", |connection| &connection.server_name) + ) + .into() + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(ui::Icon::new(IconName::Thread)) + } +} + +impl Focusable for AcpTools { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AcpTools { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().editor_background) + .child(match self.watched_connection.as_ref() { + Some(connection) => { + if connection.messages.is_empty() { + h_flex() + .size_full() + .justify_center() + .items_center() + .child("No messages recorded yet") + .into_any() + } else { + list( + connection.list_state.clone(), + cx.processor(Self::render_message), + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any() + } + } + None => h_flex() + .size_full() + .justify_center() + .items_center() + .child("No active connection") + .into_any(), + }) + } +} + +pub struct AcpToolsToolbarItemView { + acp_tools: Option>, + just_copied: bool, +} + +impl AcpToolsToolbarItemView { + pub fn new() -> Self { + Self { + acp_tools: None, + just_copied: false, + } + } +} + +impl Render for AcpToolsToolbarItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(acp_tools) = self.acp_tools.as_ref() else { + return Empty.into_any_element(); + }; + + let acp_tools = acp_tools.clone(); + let has_messages = acp_tools + .read(cx) + .watched_connection + .as_ref() + .is_some_and(|connection| !connection.messages.is_empty()); + + h_flex() + .gap_2() + .child({ + let acp_tools = acp_tools.clone(); + IconButton::new( + "copy_all_messages", + if self.just_copied { + IconName::Check + } else { + IconName::Copy + }, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(if self.just_copied { + "Copied!" + } else { + "Copy All Messages" + })) + .disabled(!has_messages) + .on_click(cx.listener(move |this, _, _window, cx| { + if let Some(content) = acp_tools.read(cx).serialize_observed_messages() { + cx.write_to_clipboard(ClipboardItem::new_string(content)); + + this.just_copied = true; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + this.update(cx, |this, cx| { + this.just_copied = false; + cx.notify(); + }) + }) + .detach(); + } + })) + }) + .child( + IconButton::new("clear_messages", IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Messages")) + .disabled(!has_messages) + .on_click(cx.listener(move |_this, _, _window, cx| { + acp_tools.update(cx, |acp_tools, cx| { + acp_tools.clear_messages(cx); + }); + })), + ) + .into_any() + } +} + +impl EventEmitter for AcpToolsToolbarItemView {} + +impl ToolbarItemView for AcpToolsToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if let Some(item) = active_pane_item + && let Some(acp_tools) = item.downcast::() + { + self.acp_tools = Some(acp_tools); + cx.notify(); + return ToolbarItemLocation::PrimaryRight; + } + if self.acp_tools.take().is_some() { + cx.notify(); + } + ToolbarItemLocation::Hidden + } +} diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index 1a389e8859b24a320720ecfc3fa6cf2a13f274ad..a8395a943a2ce4e06d4971548c32bf765adb492d 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -23,7 +23,6 @@ project.workspace = true text.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true [dev-dependencies] diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index c4eaffc2281de30cf0274539897d5fd70cda1351..b7722f211afda3a77bc96292a50acf869e7424d6 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -8,10 +8,7 @@ use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; use std::{cmp, ops::Range, sync::Arc}; use text::{Edit, Patch, Rope}; -use util::{ - RangeExt, ResultExt as _, - paths::{PathStyle, RemotePathBuf}, -}; +use util::{RangeExt, ResultExt as _}; /// Tracks actions performed by tools in a thread pub struct ActionLog { @@ -62,7 +59,13 @@ impl ActionLog { let file_path = buffer .read(cx) .file() - .map(|file| RemotePathBuf::new(file.full_path(cx), PathStyle::Posix).to_proto()) + .map(|file| { + let mut path = file.full_path(cx).to_string_lossy().into_owned(); + if file.path_style(cx).is_windows() { + path = path.replace('\\', "/"); + } + path + }) .unwrap_or_else(|| format!("buffer_{}", buffer.entity_id())); let mut result = String::new(); @@ -116,7 +119,7 @@ impl ActionLog { } else if buffer .read(cx) .file() - .map_or(false, |file| file.disk_state().exists()) + .is_some_and(|file| file.disk_state().exists()) { TrackedBufferStatus::Created { existing_file_content: Some(buffer.read(cx).as_rope().clone()), @@ -161,7 +164,7 @@ impl ActionLog { diff_base, last_seen_base, unreviewed_edits, - snapshot: text_snapshot.clone(), + snapshot: text_snapshot, status, version: buffer.read(cx).version(), diff, @@ -190,7 +193,7 @@ impl ActionLog { cx: &mut Context, ) { match event { - BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx), + BufferEvent::Edited => self.handle_buffer_edited(buffer, cx), BufferEvent::FileHandleChanged => { self.handle_buffer_file_changed(buffer, cx); } @@ -215,7 +218,7 @@ impl ActionLog { if buffer .read(cx) .file() - .map_or(false, |file| file.disk_state() == DiskState::Deleted) + .is_some_and(|file| file.disk_state() == DiskState::Deleted) { // If the buffer had been edited by a tool, but it got // deleted externally, we want to stop tracking it. @@ -227,7 +230,7 @@ impl ActionLog { if buffer .read(cx) .file() - .map_or(false, |file| file.disk_state() != DiskState::Deleted) + .is_some_and(|file| file.disk_state() != DiskState::Deleted) { // If the buffer had been deleted by a tool, but it got // resurrected externally, we want to clear the edits we @@ -264,15 +267,14 @@ impl ActionLog { if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) { cx.update(|cx| { let mut old_head = buffer_repo.read(cx).head_commit.clone(); - Some(cx.subscribe(git_diff, move |_, event, cx| match event { - buffer_diff::BufferDiffEvent::DiffChanged { .. } => { + Some(cx.subscribe(git_diff, move |_, event, cx| { + if let buffer_diff::BufferDiffEvent::DiffChanged { .. } = event { let new_head = buffer_repo.read(cx).head_commit.clone(); if new_head != old_head { old_head = new_head; git_diff_updates_tx.send(()).ok(); } } - _ => {} })) })? } else { @@ -290,7 +292,7 @@ impl ActionLog { } _ = git_diff_updates_rx.changed().fuse() => { if let Some(git_diff) = git_diff.as_ref() { - Self::keep_committed_edits(&this, &buffer, &git_diff, cx).await?; + Self::keep_committed_edits(&this, &buffer, git_diff, cx).await?; } } } @@ -462,7 +464,7 @@ impl ActionLog { anyhow::Ok(( tracked_buffer.diff.clone(), buffer.read(cx).language().cloned(), - buffer.read(cx).language_registry().clone(), + buffer.read(cx).language_registry(), )) })??; let diff_snapshot = BufferDiff::update_diff( @@ -498,7 +500,7 @@ impl ActionLog { new: new_range, }, &new_diff_base, - &buffer_snapshot.as_rope(), + buffer_snapshot.as_rope(), )); } unreviewed_edits @@ -530,12 +532,12 @@ impl ActionLog { /// Mark a buffer as created by agent, so we can refresh it in the context pub fn buffer_created(&mut self, buffer: Entity, cx: &mut Context) { - self.track_buffer_internal(buffer.clone(), true, cx); + self.track_buffer_internal(buffer, true, cx); } /// Mark a buffer as edited by agent, so we can refresh it in the context pub fn buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { - let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); + let tracked_buffer = self.track_buffer_internal(buffer, false, cx); if let TrackedBufferStatus::Deleted = tracked_buffer.status { tracked_buffer.status = TrackedBufferStatus::Modified; } @@ -614,10 +616,10 @@ impl ActionLog { false } }); - if tracked_buffer.unreviewed_edits.is_empty() { - if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status { - tracked_buffer.status = TrackedBufferStatus::Modified; - } + if tracked_buffer.unreviewed_edits.is_empty() + && let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status + { + tracked_buffer.status = TrackedBufferStatus::Modified; } tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); } @@ -811,7 +813,7 @@ impl ActionLog { tracked.version != buffer.version && buffer .file() - .map_or(false, |file| file.disk_state() != DiskState::Deleted) + .is_some_and(|file| file.disk_state() != DiskState::Deleted) }) .map(|(buffer, _)| buffer) } @@ -847,7 +849,7 @@ fn apply_non_conflicting_edits( conflict = true; if new_edits .peek() - .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new)) + .is_some_and(|next_edit| next_edit.old.overlaps(&old_edit.new)) { new_edit = new_edits.next().unwrap(); } else { @@ -964,7 +966,7 @@ impl TrackedBuffer { fn has_edits(&self, cx: &App) -> bool { self.diff .read(cx) - .hunks(&self.buffer.read(cx), cx) + .hunks(self.buffer.read(cx), cx) .next() .is_some() } @@ -2219,7 +2221,7 @@ mod tests { action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); for _ in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..25 => { action_log.update(cx, |log, cx| { let range = buffer.read(cx).random_byte_range(0, &mut rng); @@ -2238,7 +2240,7 @@ mod tests { .unwrap(); } _ => { - let is_agent_edit = rng.gen_bool(0.5); + let is_agent_edit = rng.random_bool(0.5); if is_agent_edit { log::info!("agent edit"); } else { @@ -2253,7 +2255,7 @@ mod tests { } } - if rng.gen_bool(0.2) { + if rng.random_bool(0.2) { quiesce(&action_log, &buffer, cx); } } @@ -2268,7 +2270,7 @@ mod tests { log::info!("quiescing..."); cx.run_until_parked(); action_log.update(cx, |log, cx| { - let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap(); + let tracked_buffer = log.tracked_buffers.get(buffer).unwrap(); let mut old_text = tracked_buffer.diff_base.clone(); let new_text = buffer.read(cx).as_rope(); for edit in tracked_buffer.unreviewed_edits.edits() { @@ -2302,7 +2304,7 @@ mod tests { .await; fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[("file.txt".into(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())], + &[("file.txt", "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())], "0000000", ); cx.run_until_parked(); @@ -2385,7 +2387,7 @@ mod tests { // - Ignores the last line edit (j stays as j) fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[("file.txt".into(), "A\nb\nc\nf\nG\nh\ni\nj".into())], + &[("file.txt", "A\nb\nc\nf\nG\nh\ni\nj".into())], "0000001", ); cx.run_until_parked(); @@ -2416,17 +2418,14 @@ mod tests { // Make another commit that accepts the NEW line but with different content fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[( - "file.txt".into(), - "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into(), - )], + &[("file.txt", "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into())], "0000002", ); cx.run_until_parked(); assert_eq!( unreviewed_hunks(&action_log, cx), vec![( - buffer.clone(), + buffer, vec![ HunkStatus { range: Point::new(6, 0)..Point::new(7, 0), @@ -2445,7 +2444,7 @@ mod tests { // Final commit that accepts all remaining edits fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[("file.txt".into(), "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())], + &[("file.txt", "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())], "0000003", ); cx.run_until_parked(); diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index 3a80f012f9fb0e5b056a7b2f8763a2019dfcdf2b..4e604b452122c5a8e38b2d02b54f4ee639817ab4 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -25,7 +25,6 @@ proto.workspace = true smallvec.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 7c562aaba4f494d044b3efd4c53344365011257f..09cc2fb9568ca01748435c73fd8834efdbb50839 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -1,19 +1,17 @@ -use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType}; +use auto_update::{AutoUpdateStatus, AutoUpdater, DismissMessage, VersionCheckType}; use editor::Editor; -use extension_host::ExtensionStore; +use extension_host::{ExtensionOperation, ExtensionStore}; use futures::StreamExt; use gpui::{ - Animation, AnimationExt as _, App, Context, CursorStyle, Entity, EventEmitter, - InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement, - Styled, Transformation, Window, actions, percentage, + App, Context, CursorStyle, Entity, EventEmitter, InteractiveElement as _, ParentElement as _, + Render, SharedString, StatefulInteractiveElement, Styled, Window, actions, }; use language::{ BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName, LanguageServerStatusUpdate, ServerHealth, }; use project::{ - EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project, - ProjectEnvironmentEvent, + LanguageServerProgress, LspStoreEvent, ProgressToken, Project, ProjectEnvironmentEvent, git_store::{GitStoreEvent, Repository}, }; use smallvec::SmallVec; @@ -21,11 +19,13 @@ use std::{ cmp::Reverse, collections::HashSet, fmt::Write, - path::Path, sync::Arc, time::{Duration, Instant}, }; -use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ + ButtonLike, CommonAnimationExt, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, + prelude::*, +}; use util::truncate_and_trailoff; use workspace::{StatusItemView, Workspace, item::ItemHandle}; @@ -61,7 +61,7 @@ struct ServerStatus { struct PendingWork<'a> { language_server_id: LanguageServerId, - progress_token: &'a str, + progress_token: &'a ProgressToken, progress: &'a LanguageServerProgress, } @@ -82,7 +82,6 @@ impl ActivityIndicator { ) -> Entity { let project = workspace.project().clone(); let auto_updater = AutoUpdater::get(cx); - let workspace_handle = cx.entity(); let this = cx.new(|cx| { let mut status_events = languages.language_server_binary_statuses(); cx.spawn(async move |this, cx| { @@ -100,29 +99,10 @@ impl ActivityIndicator { }) .detach(); - cx.subscribe_in( - &workspace_handle, - window, - |activity_indicator, _, event, window, cx| match event { - workspace::Event::ClearActivityIndicator { .. } => { - if activity_indicator.statuses.pop().is_some() { - activity_indicator.dismiss_error_message( - &DismissErrorMessage, - window, - cx, - ); - cx.notify(); - } - } - _ => {} - }, - ) - .detach(); - cx.subscribe( &project.read(cx).lsp_store(), - |activity_indicator, _, event, cx| match event { - LspStoreEvent::LanguageServerUpdate { name, message, .. } => { + |activity_indicator, _, event, cx| { + if let LspStoreEvent::LanguageServerUpdate { name, message, .. } = event { if let proto::update_language_server::Variant::StatusUpdate(status_update) = message { @@ -191,7 +171,6 @@ impl ActivityIndicator { } cx.notify() } - _ => {} }, ) .detach(); @@ -206,9 +185,10 @@ impl ActivityIndicator { cx.subscribe( &project.read(cx).git_store().clone(), - |_, _, event: &GitStoreEvent, cx| match event { - project::git_store::GitStoreEvent::JobsUpdated => cx.notify(), - _ => {} + |_, _, event: &GitStoreEvent, cx| { + if let project::git_store::GitStoreEvent::JobsUpdated = event { + cx.notify() + } }, ) .detach(); @@ -230,7 +210,8 @@ impl ActivityIndicator { server_name, status, } => { - let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); + let create_buffer = + project.update(cx, |project, cx| project.create_buffer(false, cx)); let status = status.clone(); let server_name = server_name.clone(); cx.spawn_in(window, async move |workspace, cx| { @@ -297,18 +278,13 @@ impl ActivityIndicator { }); } - fn dismiss_error_message( - &mut self, - _: &DismissErrorMessage, - _: &mut Window, - cx: &mut Context, - ) { - let error_dismissed = if let Some(updater) = &self.auto_updater { - updater.update(cx, |updater, cx| updater.dismiss_error(cx)) + fn dismiss_message(&mut self, _: &DismissMessage, _: &mut Window, cx: &mut Context) { + let dismissed = if let Some(updater) = &self.auto_updater { + updater.update(cx, |updater, cx| updater.dismiss(cx)) } else { false }; - if error_dismissed { + if dismissed { return; } @@ -337,9 +313,9 @@ impl ActivityIndicator { let mut pending_work = status .pending_work .iter() - .map(|(token, progress)| PendingWork { + .map(|(progress_token, progress)| PendingWork { language_server_id: server_id, - progress_token: token.as_str(), + progress_token, progress, }) .collect::>(); @@ -350,27 +326,23 @@ impl ActivityIndicator { .flatten() } - fn pending_environment_errors<'a>( - &'a self, - cx: &'a App, - ) -> impl Iterator, &'a EnvironmentErrorMessage)> { - self.project.read(cx).shell_environment_errors(cx) + fn pending_environment_error<'a>(&'a self, cx: &'a App) -> Option<&'a String> { + self.project.read(cx).peek_environment_error(cx) } fn content_to_render(&mut self, cx: &mut Context) -> Option { // Show if any direnv calls failed - if let Some((abs_path, error)) = self.pending_environment_errors(cx).next() { - let abs_path = abs_path.clone(); + if let Some(message) = self.pending_environment_error(cx) { return Some(Content { icon: Some( Icon::new(IconName::Warning) .size(IconSize::Small) .into_any_element(), ), - message: error.0.clone(), + message: message.clone(), on_click: Some(Arc::new(move |this, window, cx| { this.project.update(cx, |project, cx| { - project.remove_environment_error(&abs_path, cx); + project.pop_environment_error(cx); }); window.dispatch_action(Box::new(workspace::OpenLog), cx); })), @@ -386,11 +358,7 @@ impl ActivityIndicator { .. }) = pending_work.next() { - let mut message = progress - .title - .as_deref() - .unwrap_or(progress_token) - .to_string(); + let mut message = progress.title.clone().unwrap_or(progress_token.to_string()); if let Some(percentage) = progress.percentage { write!(&mut message, " ({}%)", percentage).unwrap(); @@ -410,13 +378,7 @@ impl ActivityIndicator { icon: Some( Icon::new(IconName::ArrowCircle) .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ) + .with_rotate_animation(2) .into_any_element(), ), message, @@ -438,11 +400,7 @@ impl ActivityIndicator { icon: Some( Icon::new(IconName::ArrowCircle) .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) + .with_rotate_animation(2) .into_any_element(), ), message: format!("Debug: {}", session.read(cx).adapter()), @@ -458,26 +416,20 @@ impl ActivityIndicator { .map(|r| r.read(cx)) .and_then(Repository::current_job); // Show any long-running git command - if let Some(job_info) = current_job { - if Instant::now() - job_info.start >= GIT_OPERATION_DELAY { - return Some(Content { - icon: Some( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ) - .into_any_element(), - ), - message: job_info.message.into(), - on_click: None, - tooltip_message: None, - }); - } + if let Some(job_info) = current_job + && Instant::now() - job_info.start >= GIT_OPERATION_DELAY + { + return Some(Content { + icon: Some( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ), + message: job_info.message.into(), + on_click: None, + tooltip_message: None, + }); } // Show any language server installation info. @@ -546,7 +498,7 @@ impl ActivityIndicator { on_click: Some(Arc::new(move |this, window, cx| { this.statuses .retain(|status| !downloading.contains(&status.name)); - this.dismiss_error_message(&DismissErrorMessage, window, cx) + this.dismiss_message(&DismissMessage, window, cx) })), tooltip_message: None, }); @@ -575,7 +527,7 @@ impl ActivityIndicator { on_click: Some(Arc::new(move |this, window, cx| { this.statuses .retain(|status| !checking_for_update.contains(&status.name)); - this.dismiss_error_message(&DismissErrorMessage, window, cx) + this.dismiss_message(&DismissMessage, window, cx) })), tooltip_message: None, }); @@ -678,17 +630,19 @@ impl ActivityIndicator { } // Show any application auto-update info. - if let Some(updater) = &self.auto_updater { - return match &updater.read(cx).status() { + self.auto_updater + .as_ref() + .and_then(|updater| match &updater.read(cx).status() { AutoUpdateStatus::Checking => Some(Content { icon: Some( - Icon::new(IconName::Download) + Icon::new(IconName::LoadCircle) .size(IconSize::Small) + .with_rotate_animation(3) .into_any_element(), ), message: "Checking for Zed updates…".to_string(), on_click: Some(Arc::new(|this, window, cx| { - this.dismiss_error_message(&DismissErrorMessage, window, cx) + this.dismiss_message(&DismissMessage, window, cx) })), tooltip_message: None, }), @@ -700,64 +654,86 @@ impl ActivityIndicator { ), message: "Downloading Zed update…".to_string(), on_click: Some(Arc::new(|this, window, cx| { - this.dismiss_error_message(&DismissErrorMessage, window, cx) + this.dismiss_message(&DismissMessage, window, cx) })), - tooltip_message: Some(Self::version_tooltip_message(&version)), + tooltip_message: Some(Self::version_tooltip_message(version)), }), AutoUpdateStatus::Installing { version } => Some(Content { icon: Some( - Icon::new(IconName::Download) + Icon::new(IconName::LoadCircle) .size(IconSize::Small) + .with_rotate_animation(3) .into_any_element(), ), message: "Installing Zed update…".to_string(), on_click: Some(Arc::new(|this, window, cx| { - this.dismiss_error_message(&DismissErrorMessage, window, cx) + this.dismiss_message(&DismissMessage, window, cx) })), - tooltip_message: Some(Self::version_tooltip_message(&version)), + tooltip_message: Some(Self::version_tooltip_message(version)), }), AutoUpdateStatus::Updated { version } => Some(Content { icon: None, message: "Click to restart and update Zed".to_string(), on_click: Some(Arc::new(move |_, _, cx| workspace::reload(cx))), - tooltip_message: Some(Self::version_tooltip_message(&version)), + tooltip_message: Some(Self::version_tooltip_message(version)), }), - AutoUpdateStatus::Errored => Some(Content { + AutoUpdateStatus::Errored { error } => Some(Content { icon: Some( Icon::new(IconName::Warning) .size(IconSize::Small) .into_any_element(), ), - message: "Auto update failed".to_string(), + message: "Failed to update Zed".to_string(), on_click: Some(Arc::new(|this, window, cx| { - this.dismiss_error_message(&DismissErrorMessage, window, cx) + window.dispatch_action(Box::new(workspace::OpenLog), cx); + this.dismiss_message(&DismissMessage, window, cx); })), - tooltip_message: None, + tooltip_message: Some(format!("{error}")), }), AutoUpdateStatus::Idle => None, - }; - } - - if let Some(extension_store) = - ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx)) - { - if let Some(extension_id) = extension_store.outstanding_operations().keys().next() { - return Some(Content { - icon: Some( - Icon::new(IconName::Download) - .size(IconSize::Small) - .into_any_element(), - ), - message: format!("Updating {extension_id} extension…"), - on_click: Some(Arc::new(|this, window, cx| { - this.dismiss_error_message(&DismissErrorMessage, window, cx) - })), - tooltip_message: None, - }); - } - } + }) + .or_else(|| { + if let Some(extension_store) = + ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx)) + && let Some((extension_id, operation)) = + extension_store.outstanding_operations().iter().next() + { + let (message, icon, rotate) = match operation { + ExtensionOperation::Install => ( + format!("Installing {extension_id} extension…"), + IconName::LoadCircle, + true, + ), + ExtensionOperation::Upgrade => ( + format!("Updating {extension_id} extension…"), + IconName::Download, + false, + ), + ExtensionOperation::Remove => ( + format!("Removing {extension_id} extension…"), + IconName::LoadCircle, + true, + ), + }; - None + Some(Content { + icon: Some(Icon::new(icon).size(IconSize::Small).map(|this| { + if rotate { + this.with_rotate_animation(3).into_any_element() + } else { + this.into_any_element() + } + })), + message, + on_click: Some(Arc::new(|this, window, cx| { + this.dismiss_message(&Default::default(), window, cx) + })), + tooltip_message: None, + }) + } else { + None + } + }) } fn version_tooltip_message(version: &VersionCheckType) -> String { @@ -789,11 +765,11 @@ impl Render for ActivityIndicator { let result = h_flex() .id("activity-indicator") .on_action(cx.listener(Self::show_error_message)) - .on_action(cx.listener(Self::dismiss_error_message)); + .on_action(cx.listener(Self::dismiss_message)); let Some(content) = self.content_to_render(cx) else { return result; }; - let this = cx.entity().downgrade(); + let activity_indicator = cx.entity().downgrade(); let truncate_content = content.message.len() > MAX_MESSAGE_LEN; result.gap_2().child( PopoverMenu::new("activity-indicator-popover") @@ -835,22 +811,21 @@ impl Render for ActivityIndicator { ) .anchor(gpui::Corner::BottomLeft) .menu(move |window, cx| { - let strong_this = this.upgrade()?; + let strong_this = activity_indicator.upgrade()?; let mut has_work = false; let menu = ContextMenu::build(window, cx, |mut menu, _, cx| { for work in strong_this.read(cx).pending_language_server_work(cx) { has_work = true; - let this = this.clone(); + let activity_indicator = activity_indicator.clone(); let mut title = work .progress .title - .as_deref() - .unwrap_or(work.progress_token) - .to_owned(); + .clone() + .unwrap_or(work.progress_token.to_string()); if work.progress.is_cancellable { let language_server_id = work.language_server_id; - let token = work.progress_token.to_string(); + let token = work.progress_token.clone(); let title = SharedString::from(title); menu = menu.custom_entry( move |_, _| { @@ -862,18 +837,23 @@ impl Render for ActivityIndicator { .into_any_element() }, move |_, cx| { - this.update(cx, |this, cx| { - this.project.update(cx, |project, cx| { - project.cancel_language_server_work( - language_server_id, - Some(token.clone()), + let token = token.clone(); + activity_indicator + .update(cx, |activity_indicator, cx| { + activity_indicator.project.update( cx, + |project, cx| { + project.cancel_language_server_work( + language_server_id, + Some(token), + cx, + ); + }, ); - }); - this.context_menu_handle.hide(cx); - cx.notify(); - }) - .ok(); + activity_indicator.context_menu_handle.hide(cx); + cx.notify(); + }) + .ok(); }, ); } else { diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 53ad2f496758bfc288a5c9dc25f8e2e99851d5b2..e962c876a38f788607706aad4e53ee5e0488b08d 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -5,75 +5,101 @@ edition.workspace = true publish.workspace = true license = "GPL-3.0-or-later" -[lints] -workspace = true - [lib] path = "src/agent.rs" -doctest = false [features] -test-support = [ - "gpui/test-support", - "language/test-support", -] +test-support = ["db/test-support"] +eval = [] +unit-eval = [] +e2e = [] + +[lints] +workspace = true [dependencies] +acp_thread.workspace = true action_log.workspace = true +agent-client-protocol.workspace = true +agent_servers.workspace = true agent_settings.workspace = true anyhow.workspace = true -assistant_context.workspace = true -assistant_tool.workspace = true +assistant_text_thread.workspace = true chrono.workspace = true client.workspace = true cloud_llm_client.workspace = true collections.workspace = true -component.workspace = true context_server.workspace = true -convert_case.workspace = true -feature_flags.workspace = true +db.workspace = true +derive_more.workspace = true fs.workspace = true futures.workspace = true git.workspace = true gpui.workspace = true -heed.workspace = true +handlebars = { workspace = true, features = ["rust-embed"] } +html_to_markdown.workspace = true http_client.workspace = true -icons.workspace = true indoc.workspace = true itertools.workspace = true language.workspace = true language_model.workspace = true +language_models.workspace = true log.workspace = true +open.workspace = true +parking_lot.workspace = true paths.workspace = true -postage.workspace = true project.workspace = true prompt_store.workspace = true -ref-cast.workspace = true -rope.workspace = true +regex.workspace = true +rust-embed.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smallvec.workspace = true smol.workspace = true sqlez.workspace = true +streaming_diff.workspace = true +strsim.workspace = true +task.workspace = true telemetry.workspace = true +terminal.workspace = true text.workspace = true -theme.workspace = true thiserror.workspace = true -time.workspace = true +ui.workspace = true util.workspace = true uuid.workspace = true -workspace-hack.workspace = true +watch.workspace = true +web_search.workspace = true +zed_env_vars.workspace = true zstd.workspace = true [dev-dependencies] -assistant_tools.workspace = true +agent_servers = { workspace = true, "features" = ["test-support"] } +assistant_text_thread = { workspace = true, "features" = ["test-support"] } +client = { workspace = true, "features" = ["test-support"] } +clock = { workspace = true, "features" = ["test-support"] } +context_server = { workspace = true, "features" = ["test-support"] } +ctor.workspace = true +db = { workspace = true, "features" = ["test-support"] } +editor = { workspace = true, "features" = ["test-support"] } +env_logger.workspace = true +fs = { workspace = true, "features" = ["test-support"] } +git = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } -indoc.workspace = true +gpui_tokio.workspace = true language = { workspace = true, "features" = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } -parking_lot.workspace = true +lsp = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } +project = { workspace = true, "features" = ["test-support"] } rand.workspace = true +reqwest_client.workspace = true +settings = { workspace = true, "features" = ["test-support"] } +tempfile.workspace = true +terminal = { workspace = true, "features" = ["test-support"] } +theme = { workspace = true, "features" = ["test-support"] } +tree-sitter-rust.workspace = true +unindent = { workspace = true } +worktree = { workspace = true, "features" = ["test-support"] } +zlog.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 7e3590f05df18d258fae91fd8aa596c07c5fb516..631c1122f85421e8f4f19a7a64efd82da0528162 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1,20 +1,1636 @@ -pub mod agent_profile; -pub mod context; -pub mod context_server_tool; -pub mod context_store; -pub mod history_store; -pub mod thread; -pub mod thread_store; -pub mod tool_use; - -pub use context::{AgentContext, ContextId, ContextLoadResult}; -pub use context_store::ContextStore; -pub use thread::{ - LastRestoreCheckpoint, Message, MessageCrease, MessageId, MessageSegment, Thread, ThreadError, - ThreadEvent, ThreadFeedback, ThreadId, ThreadSummary, TokenUsageRatio, +mod db; +mod edit_agent; +mod history_store; +mod legacy_thread; +mod native_agent_server; +pub mod outline; +mod templates; +mod thread; +mod tool_schema; +mod tools; + +#[cfg(test)] +mod tests; + +pub use db::*; +pub use history_store::*; +pub use native_agent_server::NativeAgentServer; +pub use templates::*; +pub use thread::*; +pub use tools::*; + +use acp_thread::{AcpThread, AgentModelSelector}; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use chrono::{DateTime, Utc}; +use collections::{HashSet, IndexMap}; +use fs::Fs; +use futures::channel::{mpsc, oneshot}; +use futures::future::Shared; +use futures::{StreamExt, future}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -pub use thread_store::{SerializedThread, TextThreadStore, ThreadStore}; +use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; +use project::{Project, ProjectItem, ProjectPath, Worktree}; +use prompt_store::{ + ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, +}; +use serde::{Deserialize, Serialize}; +use settings::{LanguageModelSelection, update_settings_file}; +use std::any::Any; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; +use util::ResultExt; +use util::rel_path::RelPath; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectSnapshot { + pub worktree_snapshots: Vec, + pub timestamp: DateTime, +} + +const RULES_FILE_NAMES: [&str; 9] = [ + ".rules", + ".cursorrules", + ".windsurfrules", + ".clinerules", + ".github/copilot-instructions.md", + "CLAUDE.md", + "AGENT.md", + "AGENTS.md", + "GEMINI.md", +]; + +pub struct RulesLoadingError { + pub message: SharedString, +} + +/// Holds both the internal Thread and the AcpThread for a session +struct Session { + /// The internal thread that processes messages + thread: Entity, + /// The ACP thread that handles protocol communication + acp_thread: WeakEntity, + pending_save: Task<()>, + _subscriptions: Vec, +} + +pub struct LanguageModels { + /// Access language model by ID + models: HashMap>, + /// Cached list for returning language model information + model_list: acp_thread::AgentModelList, + refresh_models_rx: watch::Receiver<()>, + refresh_models_tx: watch::Sender<()>, + _authenticate_all_providers_task: Task<()>, +} + +impl LanguageModels { + fn new(cx: &mut App) -> Self { + let (refresh_models_tx, refresh_models_rx) = watch::channel(()); + + let mut this = Self { + models: HashMap::default(), + model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), + refresh_models_rx, + refresh_models_tx, + _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx), + }; + this.refresh_list(cx); + this + } + + fn refresh_list(&mut self, cx: &App) { + let providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .into_iter() + .filter(|provider| provider.is_authenticated(cx)) + .collect::>(); + + let mut language_model_list = IndexMap::default(); + let mut recommended_models = HashSet::default(); + + let mut recommended = Vec::new(); + for provider in &providers { + for model in provider.recommended_models(cx) { + recommended_models.insert((model.provider_id(), model.id())); + recommended.push(Self::map_language_model_to_info(&model, provider)); + } + } + if !recommended.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName("Recommended".into()), + recommended, + ); + } + + let mut models = HashMap::default(); + for provider in providers { + let mut provider_models = Vec::new(); + for model in provider.provided_models(cx) { + let model_info = Self::map_language_model_to_info(&model, &provider); + let model_id = model_info.id.clone(); + if !recommended_models.contains(&(model.provider_id(), model.id())) { + provider_models.push(model_info); + } + models.insert(model_id, model); + } + if !provider_models.is_empty() { + language_model_list.insert( + acp_thread::AgentModelGroupName(provider.name().0.clone()), + provider_models, + ); + } + } + + self.models = models; + self.model_list = acp_thread::AgentModelList::Grouped(language_model_list); + self.refresh_models_tx.send(()).ok(); + } + + fn watch(&self) -> watch::Receiver<()> { + self.refresh_models_rx.clone() + } + + pub fn model_from_id(&self, model_id: &acp::ModelId) -> Option> { + self.models.get(model_id).cloned() + } + + fn map_language_model_to_info( + model: &Arc, + provider: &Arc, + ) -> acp_thread::AgentModelInfo { + acp_thread::AgentModelInfo { + id: Self::model_id(model), + name: model.name().0, + description: None, + icon: Some(provider.icon()), + } + } + + fn model_id(model: &Arc) -> acp::ModelId { + acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) + } + + fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.background_spawn(async move { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + match err { + language_model::AuthenticateError::CredentialsNotFound => { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } + language_model::AuthenticateError::ConnectionRefused => { + // Not logging connection refused errors as they are mostly from LM Studio's noisy auth failures. + // LM Studio only has one auth method (endpoint call) which fails for users who haven't enabled it. + // TODO: Better manage LM Studio auth logic to avoid these noisy failures. + } + _ => { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + } + } + }) + } +} + +pub struct NativeAgent { + /// Session ID -> Session mapping + sessions: HashMap, + history: Entity, + /// Shared project context for all threads + project_context: Entity, + project_context_needs_refresh: watch::Sender<()>, + _maintain_project_context: Task>, + context_server_registry: Entity, + /// Shared templates for all threads + templates: Arc, + /// Cached model information + models: LanguageModels, + project: Entity, + prompt_store: Option>, + fs: Arc, + _subscriptions: Vec, +} + +impl NativeAgent { + pub async fn new( + project: Entity, + history: Entity, + templates: Arc, + prompt_store: Option>, + fs: Arc, + cx: &mut AsyncApp, + ) -> Result> { + log::debug!("Creating new NativeAgent"); + + let project_context = cx + .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? + .await; + + cx.new(|cx| { + let mut subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + ), + ]; + if let Some(prompt_store) = prompt_store.as_ref() { + subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) + } + + let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = + watch::channel(()); + Self { + sessions: HashMap::new(), + history, + project_context: cx.new(|_| project_context), + project_context_needs_refresh: project_context_needs_refresh_tx, + _maintain_project_context: cx.spawn(async move |this, cx| { + Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await + }), + context_server_registry: cx.new(|cx| { + ContextServerRegistry::new(project.read(cx).context_server_store(), cx) + }), + templates, + models: LanguageModels::new(cx), + project, + prompt_store, + fs, + _subscriptions: subscriptions, + } + }) + } + + fn register_session( + &mut self, + thread_handle: Entity, + cx: &mut Context, + ) -> Entity { + let connection = Rc::new(NativeAgentConnection(cx.entity())); + + let thread = thread_handle.read(cx); + let session_id = thread.id().clone(); + let title = thread.title(); + let project = thread.project.clone(); + let action_log = thread.action_log.clone(); + let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); + let acp_thread = cx.new(|cx| { + acp_thread::AcpThread::new( + title, + connection, + project.clone(), + action_log.clone(), + session_id.clone(), + prompt_capabilities_rx, + cx, + ) + }); + + let registry = LanguageModelRegistry::read_global(cx); + let summarization_model = registry.thread_summary_model().map(|c| c.model); + + thread_handle.update(cx, |thread, cx| { + thread.set_summarization_model(summarization_model, cx); + thread.add_default_tools( + Rc::new(AcpThreadEnvironment { + acp_thread: acp_thread.downgrade(), + }) as _, + cx, + ) + }); + + let subscriptions = vec![ + cx.observe_release(&acp_thread, |this, acp_thread, _cx| { + this.sessions.remove(acp_thread.session_id()); + }), + cx.subscribe(&thread_handle, Self::handle_thread_title_updated), + cx.subscribe(&thread_handle, Self::handle_thread_token_usage_updated), + cx.observe(&thread_handle, move |this, thread, cx| { + this.save_thread(thread, cx) + }), + ]; + + self.sessions.insert( + session_id, + Session { + thread: thread_handle, + acp_thread: acp_thread.downgrade(), + _subscriptions: subscriptions, + pending_save: Task::ready(()), + }, + ); + acp_thread + } + + pub fn models(&self) -> &LanguageModels { + &self.models + } + + async fn maintain_project_context( + this: WeakEntity, + mut needs_refresh: watch::Receiver<()>, + cx: &mut AsyncApp, + ) -> Result<()> { + while needs_refresh.changed().await.is_ok() { + let project_context = this + .update(cx, |this, cx| { + Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) + })? + .await; + this.update(cx, |this, cx| { + this.project_context = cx.new(|_| project_context); + })?; + } + + Ok(()) + } + + fn build_project_context( + project: &Entity, + prompt_store: Option<&Entity>, + cx: &mut App, + ) -> Task { + let worktrees = project.read(cx).visible_worktrees(cx).collect::>(); + let worktree_tasks = worktrees + .into_iter() + .map(|worktree| { + Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx) + }) + .collect::>(); + let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { + prompt_store.read_with(cx, |prompt_store, cx| { + let prompts = prompt_store.default_prompt_metadata(); + let load_tasks = prompts.into_iter().map(|prompt_metadata| { + let contents = prompt_store.load(prompt_metadata.id, cx); + async move { (contents.await, prompt_metadata) } + }); + cx.background_spawn(future::join_all(load_tasks)) + }) + } else { + Task::ready(vec![]) + }; + + cx.spawn(async move |_cx| { + let (worktrees, default_user_rules) = + future::join(future::join_all(worktree_tasks), default_user_rules_task).await; + + let worktrees = worktrees + .into_iter() + .map(|(worktree, _rules_error)| { + // TODO: show error message + // if let Some(rules_error) = rules_error { + // this.update(cx, |_, cx| cx.emit(rules_error)).ok(); + // } + worktree + }) + .collect::>(); + + let default_user_rules = default_user_rules + .into_iter() + .flat_map(|(contents, prompt_metadata)| match contents { + Ok(contents) => Some(UserRulesContext { + uuid: match prompt_metadata.id { + prompt_store::PromptId::User { uuid } => uuid, + prompt_store::PromptId::EditWorkflow => return None, + }, + title: prompt_metadata.title.map(|title| title.to_string()), + contents, + }), + Err(_err) => { + // TODO: show error message + // this.update(cx, |_, cx| { + // cx.emit(RulesLoadingError { + // message: format!("{err:?}").into(), + // }); + // }) + // .ok(); + None + } + }) + .collect::>(); + + ProjectContext::new(worktrees, default_user_rules) + }) + } + + fn load_worktree_info_for_system_prompt( + worktree: Entity, + project: Entity, + cx: &mut App, + ) -> Task<(WorktreeContext, Option)> { + let tree = worktree.read(cx); + let root_name = tree.root_name_str().into(); + let abs_path = tree.abs_path(); + + let mut context = WorktreeContext { + root_name, + abs_path, + rules_file: None, + }; + + let rules_task = Self::load_worktree_rules_file(worktree, project, cx); + let Some(rules_task) = rules_task else { + return Task::ready((context, None)); + }; + + cx.spawn(async move |_| { + let (rules_file, rules_file_error) = match rules_task.await { + Ok(rules_file) => (Some(rules_file), None), + Err(err) => ( + None, + Some(RulesLoadingError { + message: format!("{err}").into(), + }), + ), + }; + context.rules_file = rules_file; + (context, rules_file_error) + }) + } + + fn load_worktree_rules_file( + worktree: Entity, + project: Entity, + cx: &mut App, + ) -> Option>> { + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); + let selected_rules_file = RULES_FILE_NAMES + .into_iter() + .filter_map(|name| { + worktree + .entry_for_path(RelPath::unix(name).unwrap()) + .filter(|entry| entry.is_file()) + .map(|entry| entry.path.clone()) + }) + .next(); + + // Note that Cline supports `.clinerules` being a directory, but that is not currently + // supported. This doesn't seem to occur often in GitHub repositories. + selected_rules_file.map(|path_in_worktree| { + let project_path = ProjectPath { + worktree_id, + path: path_in_worktree.clone(), + }; + let buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + let rope_task = cx.spawn(async move |cx| { + buffer_task.await?.read_with(cx, |buffer, cx| { + let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; + anyhow::Ok((project_entry_id, buffer.as_rope().clone())) + })? + }); + // Build a string from the rope on a background thread. + cx.background_spawn(async move { + let (project_entry_id, rope) = rope_task.await?; + anyhow::Ok(RulesFileContext { + path_in_worktree, + text: rope.to_string().trim().to_string(), + project_entry_id: project_entry_id.to_usize(), + }) + }) + }) + } + + fn handle_thread_title_updated( + &mut self, + thread: Entity, + _: &TitleUpdated, + cx: &mut Context, + ) { + let session_id = thread.read(cx).id(); + let Some(session) = self.sessions.get(session_id) else { + return; + }; + let thread = thread.downgrade(); + let acp_thread = session.acp_thread.clone(); + cx.spawn(async move |_, cx| { + let title = thread.read_with(cx, |thread, _| thread.title())?; + let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; + task.await + }) + .detach_and_log_err(cx); + } + + fn handle_thread_token_usage_updated( + &mut self, + thread: Entity, + usage: &TokenUsageUpdated, + cx: &mut Context, + ) { + let Some(session) = self.sessions.get(thread.read(cx).id()) else { + return; + }; + session + .acp_thread + .update(cx, |acp_thread, cx| { + acp_thread.update_token_usage(usage.0.clone(), cx); + }) + .ok(); + } + + fn handle_project_event( + &mut self, + _project: Entity, + event: &project::Event, + _cx: &mut Context, + ) { + match event { + project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { + self.project_context_needs_refresh.send(()).ok(); + } + project::Event::WorktreeUpdatedEntries(_, items) => { + if items.iter().any(|(path, _, _)| { + RULES_FILE_NAMES + .iter() + .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) + }) { + self.project_context_needs_refresh.send(()).ok(); + } + } + _ => {} + } + } + + fn handle_prompts_updated_event( + &mut self, + _prompt_store: Entity, + _event: &prompt_store::PromptsUpdatedEvent, + _cx: &mut Context, + ) { + self.project_context_needs_refresh.send(()).ok(); + } + + fn handle_models_updated_event( + &mut self, + _registry: Entity, + _event: &language_model::Event, + cx: &mut Context, + ) { + self.models.refresh_list(cx); + + let registry = LanguageModelRegistry::read_global(cx); + let default_model = registry.default_model().map(|m| m.model); + let summarization_model = registry.thread_summary_model().map(|m| m.model); + + for session in self.sessions.values_mut() { + session.thread.update(cx, |thread, cx| { + if thread.model().is_none() + && let Some(model) = default_model.clone() + { + thread.set_model(model, cx); + cx.notify(); + } + thread.set_summarization_model(summarization_model.clone(), cx); + }); + } + } + + pub fn load_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + let db_thread = database + .load_thread(id.clone()) + .await? + .with_context(|| format!("no thread found with ID: {id:?}"))?; + + this.update(cx, |this, cx| { + let summarization_model = LanguageModelRegistry::read_global(cx) + .thread_summary_model() + .map(|c| c.model); + + cx.new(|cx| { + let mut thread = Thread::from_db( + id.clone(), + db_thread, + this.project.clone(), + this.project_context.clone(), + this.context_server_registry.clone(), + this.templates.clone(), + cx, + ); + thread.set_summarization_model(summarization_model, cx); + thread + }) + }) + }) + } + + pub fn open_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let task = self.load_thread(id, cx); + cx.spawn(async move |this, cx| { + let thread = task.await?; + let acp_thread = + this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; + let events = thread.update(cx, |thread, cx| thread.replay(cx))?; + cx.update(|cx| { + NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) + })? + .await?; + Ok(acp_thread) + }) + } + + pub fn thread_summary( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let thread = self.open_thread(id.clone(), cx); + cx.spawn(async move |this, cx| { + let acp_thread = thread.await?; + let result = this + .update(cx, |this, cx| { + this.sessions + .get(&id) + .unwrap() + .thread + .update(cx, |thread, cx| thread.summary(cx)) + })? + .await + .context("Failed to generate summary")?; + drop(acp_thread); + Ok(result) + }) + } + + fn save_thread(&mut self, thread: Entity, cx: &mut Context) { + if thread.read(cx).is_empty() { + return; + } + + let database_future = ThreadsDatabase::connect(cx); + let (id, db_thread) = + thread.update(cx, |thread, cx| (thread.id().clone(), thread.to_db(cx))); + let Some(session) = self.sessions.get_mut(&id) else { + return; + }; + let history = self.history.clone(); + session.pending_save = cx.spawn(async move |_, cx| { + let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { + return; + }; + let db_thread = db_thread.await; + database.save_thread(id, db_thread).await.log_err(); + history.update(cx, |history, cx| history.reload(cx)).ok(); + }); + } +} + +/// Wrapper struct that implements the AgentConnection trait +#[derive(Clone)] +pub struct NativeAgentConnection(pub Entity); + +impl NativeAgentConnection { + pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option> { + self.0 + .read(cx) + .sessions + .get(session_id) + .map(|session| session.thread.clone()) + } + + pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task>> { + self.0.update(cx, |this, cx| this.load_thread(id, cx)) + } + + fn run_turn( + &self, + session_id: acp::SessionId, + cx: &mut App, + f: impl 'static + + FnOnce(Entity, &mut App) -> Result>>, + ) -> Task> { + let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { + agent + .sessions + .get_mut(&session_id) + .map(|s| (s.thread.clone(), s.acp_thread.clone())) + }) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + log::debug!("Found session for: {}", session_id); + + let response_stream = match f(thread, cx) { + Ok(stream) => stream, + Err(err) => return Task::ready(Err(err)), + }; + Self::handle_thread_events(response_stream, acp_thread, cx) + } + + fn handle_thread_events( + mut events: mpsc::UnboundedReceiver>, + acp_thread: WeakEntity, + cx: &App, + ) -> Task> { + cx.spawn(async move |cx| { + // Handle response stream and forward to session.acp_thread + while let Some(result) = events.next().await { + match result { + Ok(event) => { + log::trace!("Received completion event: {:?}", event); + + match event { + ThreadEvent::UserMessage(message) => { + acp_thread.update(cx, |thread, cx| { + for content in message.content { + thread.push_user_content_block( + Some(message.id.clone()), + content.into(), + cx, + ); + } + })?; + } + ThreadEvent::AgentText(text) => { + acp_thread.update(cx, |thread, cx| { + thread.push_assistant_content_block( + acp::ContentBlock::Text(acp::TextContent { + text, + annotations: None, + meta: None, + }), + false, + cx, + ) + })?; + } + ThreadEvent::AgentThinking(text) => { + acp_thread.update(cx, |thread, cx| { + thread.push_assistant_content_block( + acp::ContentBlock::Text(acp::TextContent { + text, + annotations: None, + meta: None, + }), + true, + cx, + ) + })?; + } + ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { + tool_call, + options, + response, + }) => { + let outcome_task = acp_thread.update(cx, |thread, cx| { + thread.request_tool_call_authorization( + tool_call, options, true, cx, + ) + })??; + cx.background_spawn(async move { + if let acp::RequestPermissionOutcome::Selected { option_id } = + outcome_task.await + { + response + .send(option_id) + .map(|_| anyhow!("authorization receiver was dropped")) + .log_err(); + } + }) + .detach(); + } + ThreadEvent::ToolCall(tool_call) => { + acp_thread.update(cx, |thread, cx| { + thread.upsert_tool_call(tool_call, cx) + })??; + } + ThreadEvent::ToolCallUpdate(update) => { + acp_thread.update(cx, |thread, cx| { + thread.update_tool_call(update, cx) + })??; + } + ThreadEvent::Retry(status) => { + acp_thread.update(cx, |thread, cx| { + thread.update_retry_status(status, cx) + })?; + } + ThreadEvent::Stop(stop_reason) => { + log::debug!("Assistant message complete: {:?}", stop_reason); + return Ok(acp::PromptResponse { + stop_reason, + meta: None, + }); + } + } + } + Err(e) => { + log::error!("Error in model response stream: {:?}", e); + return Err(e); + } + } + } + + log::debug!("Response stream completed"); + anyhow::Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + meta: None, + }) + }) + } +} + +struct NativeAgentModelSelector { + session_id: acp::SessionId, + connection: NativeAgentConnection, +} + +impl acp_thread::AgentModelSelector for NativeAgentModelSelector { + fn list_models(&self, cx: &mut App) -> Task> { + log::debug!("NativeAgentConnection::list_models called"); + let list = self.connection.0.read(cx).models.model_list.clone(); + Task::ready(if list.is_empty() { + Err(anyhow::anyhow!("No models available")) + } else { + Ok(list) + }) + } + + fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task> { + log::debug!( + "Setting model for session {}: {}", + self.session_id, + model_id + ); + let Some(thread) = self + .connection + .0 + .read(cx) + .sessions + .get(&self.session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let Some(model) = self.connection.0.read(cx).models.model_from_id(&model_id) else { + return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); + }; + + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + }); + + update_settings_file( + self.connection.0.read(cx).fs.clone(), + cx, + move |settings, _cx| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings + .agent + .get_or_insert_default() + .set_model(LanguageModelSelection { + provider: provider.into(), + model, + }); + }, + ); + + Task::ready(Ok(())) + } + + fn selected_model(&self, cx: &mut App) -> Task> { + let Some(thread) = self + .connection + .0 + .read(cx) + .sessions + .get(&self.session_id) + .map(|session| session.thread.clone()) + else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + let Some(model) = thread.read(cx).model() else { + return Task::ready(Err(anyhow!("Model not found"))); + }; + let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) + else { + return Task::ready(Err(anyhow!("Provider not found"))); + }; + Task::ready(Ok(LanguageModels::map_language_model_to_info( + model, &provider, + ))) + } + + fn watch(&self, cx: &mut App) -> Option> { + Some(self.connection.0.read(cx).models.watch()) + } +} + +impl acp_thread::AgentConnection for NativeAgentConnection { + fn new_thread( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut App, + ) -> Task>> { + let agent = self.0.clone(); + log::debug!("Creating new thread for project at: {:?}", cwd); + + cx.spawn(async move |cx| { + log::debug!("Starting thread creation in async context"); + + // Create Thread + let thread = agent.update( + cx, + |agent, cx: &mut gpui::Context| -> Result<_> { + // Fetch default model from registry settings + let registry = LanguageModelRegistry::read_global(cx); + // Log available models for debugging + let available_count = registry.available_models(cx).count(); + log::debug!("Total available models: {}", available_count); + + let default_model = registry.default_model().and_then(|default_model| { + agent + .models + .model_from_id(&LanguageModels::model_id(&default_model.model)) + }); + Ok(cx.new(|cx| { + Thread::new( + project.clone(), + agent.project_context.clone(), + agent.context_server_registry.clone(), + agent.templates.clone(), + default_model, + cx, + ) + })) + }, + )??; + agent.update(cx, |agent, cx| agent.register_session(thread, cx)) + }) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] // No auth for in-process + } + + fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + + fn model_selector(&self, session_id: &acp::SessionId) -> Option> { + Some(Rc::new(NativeAgentModelSelector { + session_id: session_id.clone(), + connection: self.clone(), + }) as Rc) + } + + fn prompt( + &self, + id: Option, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + let id = id.expect("UserMessageId is required"); + let session_id = params.session_id.clone(); + log::info!("Received prompt request for session: {}", session_id); + log::debug!("Prompt blocks count: {}", params.prompt.len()); + let path_style = self.0.read(cx).project.read(cx).path_style(cx); + + self.run_turn(session_id, cx, move |thread, cx| { + let content: Vec = params + .prompt + .into_iter() + .map(|block| UserMessageContent::from_content_block(block, path_style)) + .collect::>(); + log::debug!("Converted prompt to message: {} chars", content.len()); + log::debug!("Message id: {:?}", id); + log::debug!("Message content: {:?}", content); + + thread.update(cx, |thread, cx| thread.send(id, content, cx)) + }) + } + + fn resume( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionResume { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + + fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + log::info!("Cancelling on session: {}", session_id); + self.0.update(cx, |agent, cx| { + if let Some(agent) = agent.sessions.get(session_id) { + agent.thread.update(cx, |thread, cx| thread.cancel(cx)); + } + }); + } + + fn truncate( + &self, + session_id: &agent_client_protocol::SessionId, + cx: &App, + ) -> Option> { + self.0.read_with(cx, |agent, _cx| { + agent.sessions.get(session_id).map(|session| { + Rc::new(NativeAgentSessionTruncate { + thread: session.thread.clone(), + acp_thread: session.acp_thread.clone(), + }) as _ + }) + }) + } + + fn set_title( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(NativeAgentSessionSetTitle { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) + } + + fn telemetry(&self) -> Option> { + Some(Rc::new(self.clone()) as Rc) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +impl acp_thread::AgentTelemetry for NativeAgentConnection { + fn agent_name(&self) -> String { + "Zed".into() + } + + fn thread_data( + &self, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + let Some(session) = self.0.read(cx).sessions.get(session_id) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let task = session.thread.read(cx).to_db(cx); + cx.background_spawn(async move { + serde_json::to_value(task.await).context("Failed to serialize thread") + }) + } +} + +struct NativeAgentSessionTruncate { + thread: Entity, + acp_thread: WeakEntity, +} + +impl acp_thread::AgentSessionTruncate for NativeAgentSessionTruncate { + fn run(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { + match self.thread.update(cx, |thread, cx| { + thread.truncate(message_id.clone(), cx)?; + Ok(thread.latest_token_usage()) + }) { + Ok(usage) => { + self.acp_thread + .update(cx, |thread, cx| { + thread.update_token_usage(usage, cx); + }) + .ok(); + Task::ready(Ok(())) + } + Err(error) => Task::ready(Err(error)), + } + } +} + +struct NativeAgentSessionResume { + connection: NativeAgentConnection, + session_id: acp::SessionId, +} + +impl acp_thread::AgentSessionResume for NativeAgentSessionResume { + fn run(&self, cx: &mut App) -> Task> { + self.connection + .run_turn(self.session_id.clone(), cx, |thread, cx| { + thread.update(cx, |thread, cx| thread.resume(cx)) + }) + } +} + +struct NativeAgentSessionSetTitle { + connection: NativeAgentConnection, + session_id: acp::SessionId, +} + +impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { + fn run(&self, title: SharedString, cx: &mut App) -> Task> { + let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { + return Task::ready(Err(anyhow!("session not found"))); + }; + let thread = session.thread.clone(); + thread.update(cx, |thread, cx| thread.set_title(title, cx)); + Task::ready(Ok(())) + } +} + +pub struct AcpThreadEnvironment { + acp_thread: WeakEntity, +} + +impl ThreadEnvironment for AcpThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>> { + let task = self.acp_thread.update(cx, |thread, cx| { + thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx) + }); + + let acp_thread = self.acp_thread.clone(); + cx.spawn(async move |cx| { + let terminal = task?.await?; + + let (drop_tx, drop_rx) = oneshot::channel(); + let terminal_id = terminal.read_with(cx, |terminal, _cx| terminal.id().clone())?; + + cx.spawn(async move |cx| { + drop_rx.await.ok(); + acp_thread.update(cx, |thread, cx| thread.release_terminal(terminal_id, cx)) + }) + .detach(); + + let handle = AcpTerminalHandle { + terminal, + _drop_tx: Some(drop_tx), + }; + + Ok(Rc::new(handle) as _) + }) + } +} + +pub struct AcpTerminalHandle { + terminal: Entity, + _drop_tx: Option>, +} + +impl TerminalHandle for AcpTerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result { + self.terminal.read_with(cx, |term, _cx| term.id().clone()) + } + + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { + self.terminal + .read_with(cx, |term, _cx| term.wait_for_exit()) + } + + fn current_output(&self, cx: &AsyncApp) -> Result { + self.terminal + .read_with(cx, |term, cx| term.current_output(cx)) + } +} + +#[cfg(test)] +mod internal_tests { + use crate::HistoryEntryId; + + use super::*; + use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; + use fs::FakeFs; + use gpui::TestAppContext; + use indoc::formatdoc; + use language_model::fake_provider::FakeLanguageModel; + use serde_json::json; + use settings::SettingsStore; + use util::{path, rel_path::rel_path}; + + #[gpui::test] + async fn test_maintaining_project_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": {} + }), + ) + .await; + let project = Project::test(fs.clone(), [], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let agent = NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + agent.read_with(cx, |agent, cx| { + assert_eq!(agent.project_context.read(cx).worktrees, vec![]) + }); + + let worktree = project + .update(cx, |project, cx| project.create_worktree("/a", true, cx)) + .await + .unwrap(); + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { + assert_eq!( + agent.project_context.read(cx).worktrees, + vec![WorktreeContext { + root_name: "a".into(), + abs_path: Path::new("/a").into(), + rules_file: None + }] + ) + }); + + // Creating `/a/.rules` updates the project context. + fs.insert_file("/a/.rules", Vec::new()).await; + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { + let rules_entry = worktree + .read(cx) + .entry_for_path(rel_path(".rules")) + .unwrap(); + assert_eq!( + agent.project_context.read(cx).worktrees, + vec![WorktreeContext { + root_name: "a".into(), + abs_path: Path::new("/a").into(), + rules_file: Some(RulesFileContext { + path_in_worktree: rel_path(".rules").into(), + text: "".into(), + project_entry_id: rules_entry.id.to_usize() + }) + }] + ) + }); + } + + #[gpui::test] + async fn test_listing_models(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let connection = NativeAgentConnection( + NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(), + ); + + // Create a thread/session + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) + }) + .await + .unwrap(); + + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + + let models = cx + .update(|cx| { + connection + .model_selector(&session_id) + .unwrap() + .list_models(cx) + }) + .await + .unwrap(); + + let acp_thread::AgentModelList::Grouped(models) = models else { + panic!("Unexpected model group"); + }; + assert_eq!( + models, + IndexMap::from_iter([( + AgentModelGroupName("Fake".into()), + vec![AgentModelInfo { + id: acp::ModelId("fake/fake".into()), + name: "Fake".into(), + description: None, + icon: Some(ui::IconName::ZedAssistant), + }] + )]) + ); + } + + #[gpui::test] + async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.create_dir(paths::settings_file().parent().unwrap()) + .await + .unwrap(); + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "default_model": { + "provider": "foo", + "model": "bar" + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + let project = Project::test(fs.clone(), [], cx).await; + + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create the agent and connection + let agent = NativeAgent::new( + project.clone(), + history_store, + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = NativeAgentConnection(agent.clone()); + + // Create a thread/session + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) + }) + .await + .unwrap(); + + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + + // Select a model + let selector = connection.model_selector(&session_id).unwrap(); + let model_id = acp::ModelId("fake/fake".into()); + cx.update(|cx| selector.select_model(model_id.clone(), cx)) + .await + .unwrap(); + + // Verify the thread has the selected model + agent.read_with(cx, |agent, _| { + let session = agent.sessions.get(&session_id).unwrap(); + session.thread.read_with(cx, |thread, _| { + assert_eq!(thread.model().unwrap().id().0, "fake"); + }); + }); + + cx.run_until_parked(); + + // Verify settings file was updated + let settings_content = fs.load(paths::settings_file()).await.unwrap(); + let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); + + // Check that the agent settings contain the selected model + assert_eq!( + settings_json["agent"]["default_model"]["model"], + json!("fake") + ); + assert_eq!( + settings_json["agent"]["default_model"]["provider"], + json!("fake") + ); + } + + #[gpui::test] + async fn test_save_load_thread(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "b.md": "Lorem" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let agent = NativeAgent::new( + project.clone(), + history_store.clone(), + Templates::new(), + None, + fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_thread(project.clone(), Path::new(""), cx) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + + // Ensure empty threads are not saved, even if they get mutated. + let model = Arc::new(FakeLanguageModel::default()); + let summary_model = Arc::new(FakeLanguageModel::default()); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread.set_summarization_model(Some(summary_model.clone()), cx); + }); + cx.run_until_parked(); + assert_eq!(history_entries(&history_store, cx), vec![]); + + let send = acp_thread.update(cx, |thread, cx| { + thread.send( + vec![ + "What does ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: "b.md".into(), + uri: MentionUri::File { + abs_path: path!("/a/b.md").into(), + } + .to_uri() + .to_string(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + meta: None, + }), + " mean?".into(), + ], + cx, + ) + }); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("Lorem."); + model.end_last_completion_stream(); + cx.run_until_parked(); + summary_model + .send_last_completion_stream_text_chunk(&format!("Explaining {}", path!("/a/b.md"))); + summary_model.end_last_completion_stream(); + + send.await.unwrap(); + let uri = MentionUri::File { + abs_path: path!("/a/b.md").into(), + } + .to_uri(); + acp_thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + formatdoc! {" + ## User + + What does [@b.md]({uri}) mean? + + ## Assistant + + Lorem. + + "} + ) + }); + + cx.run_until_parked(); + + // Drop the ACP thread, which should cause the session to be dropped as well. + cx.update(|_| { + drop(thread); + drop(acp_thread); + }); + agent.read_with(cx, |agent, _| { + assert_eq!(agent.sessions.keys().cloned().collect::>(), []); + }); + + // Ensure the thread can be reloaded from disk. + assert_eq!( + history_entries(&history_store, cx), + vec![( + HistoryEntryId::AcpThread(session_id.clone()), + format!("Explaining {}", path!("/a/b.md")) + )] + ); + let acp_thread = agent + .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .await + .unwrap(); + acp_thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + formatdoc! {" + ## User + + What does [@b.md]({uri}) mean? + + ## Assistant + + Lorem. + + "} + ) + }); + } + + fn history_entries( + history: &Entity, + cx: &mut TestAppContext, + ) -> Vec<(HistoryEntryId, String)> { + history.read_with(cx, |history, _| { + history + .entries() + .map(|e| (e.id(), e.title().to_string())) + .collect::>() + }) + } -pub fn init(cx: &mut gpui::App) { - thread_store::init(cx); + fn init_test(cx: &mut TestAppContext) { + env_logger::try_init().ok(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + Project::init_settings(cx); + agent_settings::init(cx); + language::init(cx); + LanguageModelRegistry::test(cx); + }); + } } diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs deleted file mode 100644 index 38e697dd9bbd5ede89ad23575bb1e123dfb2c350..0000000000000000000000000000000000000000 --- a/crates/agent/src/agent_profile.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::sync::Arc; - -use agent_settings::{AgentProfileId, AgentProfileSettings, AgentSettings}; -use assistant_tool::{Tool, ToolSource, ToolWorkingSet, UniqueToolName}; -use collections::IndexMap; -use convert_case::{Case, Casing}; -use fs::Fs; -use gpui::{App, Entity, SharedString}; -use settings::{Settings, update_settings_file}; -use util::ResultExt; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AgentProfile { - id: AgentProfileId, - tool_set: Entity, -} - -pub type AvailableProfiles = IndexMap; - -impl AgentProfile { - pub fn new(id: AgentProfileId, tool_set: Entity) -> Self { - Self { id, tool_set } - } - - /// Saves a new profile to the settings. - pub fn create( - name: String, - base_profile_id: Option, - fs: Arc, - cx: &App, - ) -> AgentProfileId { - let id = AgentProfileId(name.to_case(Case::Kebab).into()); - - let base_profile = - base_profile_id.and_then(|id| AgentSettings::get_global(cx).profiles.get(&id).cloned()); - - let profile_settings = AgentProfileSettings { - name: name.into(), - tools: base_profile - .as_ref() - .map(|profile| profile.tools.clone()) - .unwrap_or_default(), - enable_all_context_servers: base_profile - .as_ref() - .map(|profile| profile.enable_all_context_servers) - .unwrap_or_default(), - context_servers: base_profile - .map(|profile| profile.context_servers) - .unwrap_or_default(), - }; - - update_settings_file::(fs, cx, { - let id = id.clone(); - move |settings, _cx| { - settings.create_profile(id, profile_settings).log_err(); - } - }); - - id - } - - /// Returns a map of AgentProfileIds to their names - pub fn available_profiles(cx: &App) -> AvailableProfiles { - let mut profiles = AvailableProfiles::default(); - for (id, profile) in AgentSettings::get_global(cx).profiles.iter() { - profiles.insert(id.clone(), profile.name.clone()); - } - profiles - } - - pub fn id(&self) -> &AgentProfileId { - &self.id - } - - pub fn enabled_tools(&self, cx: &App) -> Vec<(UniqueToolName, Arc)> { - let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else { - return Vec::new(); - }; - - self.tool_set - .read(cx) - .tools(cx) - .into_iter() - .filter(|(_, tool)| Self::is_enabled(settings, tool.source(), tool.name())) - .collect() - } - - pub fn is_tool_enabled(&self, source: ToolSource, tool_name: String, cx: &App) -> bool { - let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else { - return false; - }; - - return Self::is_enabled(settings, source, tool_name); - } - - fn is_enabled(settings: &AgentProfileSettings, source: ToolSource, name: String) -> bool { - match source { - ToolSource::Native => *settings.tools.get(name.as_str()).unwrap_or(&false), - ToolSource::ContextServer { id } => settings - .context_servers - .get(id.as_ref()) - .and_then(|preset| preset.tools.get(name.as_str()).copied()) - .unwrap_or(settings.enable_all_context_servers), - } - } -} - -#[cfg(test)] -mod tests { - use agent_settings::ContextServerPreset; - use assistant_tool::ToolRegistry; - use collections::IndexMap; - use gpui::SharedString; - use gpui::{AppContext, TestAppContext}; - use http_client::FakeHttpClient; - use project::Project; - use settings::{Settings, SettingsStore}; - - use super::*; - - #[gpui::test] - async fn test_enabled_built_in_tools_for_profile(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId::default(); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id.clone(), tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings - .tools - .into_iter() - .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string())) - // Provider dependent - .filter(|tool| tool != "web_search") - .collect::>(); - // Plus all registered MCP tools - expected_tools.extend(["enabled_mcp_tool".into(), "disabled_mcp_tool".into()]); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - #[gpui::test] - async fn test_custom_mcp_settings(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId("custom_mcp".into()); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id.clone(), tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings.context_servers["mcp"] - .tools - .iter() - .filter_map(|(key, enabled)| enabled.then(|| key.to_string())) - .collect::>(); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - #[gpui::test] - async fn test_only_built_in(cx: &mut TestAppContext) { - init_test_settings(cx); - - let id = AgentProfileId("write_minus_mcp".into()); - let profile_settings = cx.read(|cx| { - AgentSettings::get_global(cx) - .profiles - .get(&id) - .unwrap() - .clone() - }); - let tool_set = default_tool_set(cx); - - let profile = AgentProfile::new(id.clone(), tool_set); - - let mut enabled_tools = cx - .read(|cx| profile.enabled_tools(cx)) - .into_iter() - .map(|(_, tool)| tool.name()) - .collect::>(); - enabled_tools.sort(); - - let mut expected_tools = profile_settings - .tools - .into_iter() - .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string())) - // Provider dependent - .filter(|tool| tool != "web_search") - .collect::>(); - expected_tools.sort(); - - assert_eq!(enabled_tools, expected_tools); - } - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - Project::init_settings(cx); - AgentSettings::register(cx); - language_model::init_settings(cx); - ToolRegistry::default_global(cx); - assistant_tools::init(FakeHttpClient::with_404_response(), cx); - }); - - cx.update(|cx| { - let mut agent_settings = AgentSettings::get_global(cx).clone(); - agent_settings.profiles.insert( - AgentProfileId("write_minus_mcp".into()), - AgentProfileSettings { - name: "write_minus_mcp".into(), - enable_all_context_servers: false, - ..agent_settings.profiles[&AgentProfileId::default()].clone() - }, - ); - agent_settings.profiles.insert( - AgentProfileId("custom_mcp".into()), - AgentProfileSettings { - name: "mcp".into(), - tools: IndexMap::default(), - enable_all_context_servers: false, - context_servers: IndexMap::from_iter([("mcp".into(), context_server_preset())]), - }, - ); - AgentSettings::override_global(agent_settings, cx); - }) - } - - fn context_server_preset() -> ContextServerPreset { - ContextServerPreset { - tools: IndexMap::from_iter([ - ("enabled_mcp_tool".into(), true), - ("disabled_mcp_tool".into(), false), - ]), - } - } - - fn default_tool_set(cx: &mut TestAppContext) -> Entity { - cx.new(|cx| { - let mut tool_set = ToolWorkingSet::default(); - tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp")), cx); - tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp")), cx); - tool_set - }) - } - - struct FakeTool { - name: String, - source: SharedString, - } - - impl FakeTool { - fn new(name: impl Into, source: impl Into) -> Self { - Self { - name: name.into(), - source: source.into(), - } - } - } - - impl Tool for FakeTool { - fn name(&self) -> String { - self.name.clone() - } - - fn source(&self) -> ToolSource { - ToolSource::ContextServer { - id: self.source.clone(), - } - } - - fn description(&self) -> String { - unimplemented!() - } - - fn icon(&self) -> icons::IconName { - unimplemented!() - } - - fn needs_confirmation( - &self, - _input: &serde_json::Value, - _project: &Entity, - _cx: &App, - ) -> bool { - unimplemented!() - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - unimplemented!() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> assistant_tool::ToolResult { - unimplemented!() - } - - fn may_perform_edits(&self) -> bool { - unimplemented!() - } - } -} diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs deleted file mode 100644 index 22d1a72bf5f833a6594f34fd8f5d7b9102740740..0000000000000000000000000000000000000000 --- a/crates/agent/src/context_server_tool.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::sync::Arc; - -use action_log::ActionLog; -use anyhow::{Result, anyhow, bail}; -use assistant_tool::{Tool, ToolResult, ToolSource}; -use context_server::{ContextServerId, types}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use icons::IconName; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, context_server_store::ContextServerStore}; - -pub struct ContextServerTool { - store: Entity, - server_id: ContextServerId, - tool: types::Tool, -} - -impl ContextServerTool { - pub fn new( - store: Entity, - server_id: ContextServerId, - tool: types::Tool, - ) -> Self { - Self { - store, - server_id, - tool, - } - } -} - -impl Tool for ContextServerTool { - fn name(&self) -> String { - self.tool.name.clone() - } - - fn description(&self) -> String { - self.tool.description.clone().unwrap_or_default() - } - - fn icon(&self) -> IconName { - IconName::ToolHammer - } - - fn source(&self) -> ToolSource { - ToolSource::ContextServer { - id: self.server_id.clone().0.into(), - } - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - let mut schema = self.tool.input_schema.clone(); - assistant_tool::adapt_schema_to_format(&mut schema, format)?; - Ok(match schema { - serde_json::Value::Null => { - serde_json::json!({ "type": "object", "properties": [] }) - } - serde_json::Value::Object(map) if map.is_empty() => { - serde_json::json!({ "type": "object", "properties": [] }) - } - _ => schema, - }) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - format!("Run MCP tool `{}`", self.tool.name) - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - if let Some(server) = self.store.read(cx).get_running_server(&self.server_id) { - let tool_name = self.tool.name.clone(); - let server_clone = server.clone(); - let input_clone = input.clone(); - - cx.spawn(async move |_cx| { - let Some(protocol) = server_clone.client() else { - bail!("Context server not initialized"); - }; - - let arguments = if let serde_json::Value::Object(map) = input_clone { - Some(map.into_iter().collect()) - } else { - None - }; - - log::trace!( - "Running tool: {} with arguments: {:?}", - tool_name, - arguments - ); - let response = protocol - .request::( - context_server::types::CallToolParams { - name: tool_name, - arguments, - meta: None, - }, - ) - .await?; - - let mut result = String::new(); - for content in response.content { - match content { - types::ToolResponseContent::Text { text } => { - result.push_str(&text); - } - types::ToolResponseContent::Image { .. } => { - log::warn!("Ignoring image content from tool response"); - } - types::ToolResponseContent::Audio { .. } => { - log::warn!("Ignoring audio content from tool response"); - } - types::ToolResponseContent::Resource { .. } => { - log::warn!("Ignoring resource content from tool response"); - } - } - } - Ok(result.into()) - }) - .into() - } else { - Task::ready(Err(anyhow!("Context server not found"))).into() - } - } -} diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs new file mode 100644 index 0000000000000000000000000000000000000000..c72e20571e2761788157a5fd10df147c2b414e4a --- /dev/null +++ b/crates/agent/src/db.rs @@ -0,0 +1,425 @@ +use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; +use acp_thread::UserMessageId; +use agent_client_protocol as acp; +use agent_settings::{AgentProfileId, CompletionMode}; +use anyhow::{Result, anyhow}; +use chrono::{DateTime, Utc}; +use collections::{HashMap, IndexMap}; +use futures::{FutureExt, future::Shared}; +use gpui::{BackgroundExecutor, Global, Task}; +use indoc::indoc; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use sqlez::{ + bindable::{Bind, Column}, + connection::Connection, + statement::Statement, +}; +use std::sync::Arc; +use ui::{App, SharedString}; +use zed_env_vars::ZED_STATELESS; + +pub type DbMessage = crate::Message; +pub type DbSummary = crate::legacy_thread::DetailedSummaryState; +pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DbThreadMetadata { + pub id: acp::SessionId, + #[serde(alias = "summary")] + pub title: SharedString, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DbThread { + pub title: SharedString, + pub messages: Vec, + pub updated_at: DateTime, + #[serde(default)] + pub detailed_summary: Option, + #[serde(default)] + pub initial_project_snapshot: Option>, + #[serde(default)] + pub cumulative_token_usage: language_model::TokenUsage, + #[serde(default)] + pub request_token_usage: HashMap, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub completion_mode: Option, + #[serde(default)] + pub profile: Option, +} + +impl DbThread { + pub const VERSION: &'static str = "0.3.0"; + + pub fn from_json(json: &[u8]) -> Result { + let saved_thread_json = serde_json::from_slice::(json)?; + match saved_thread_json.get("version") { + Some(serde_json::Value::String(version)) => match version.as_str() { + Self::VERSION => Ok(serde_json::from_value(saved_thread_json)?), + _ => Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json( + json, + )?), + }, + _ => { + Self::upgrade_from_agent_1(crate::legacy_thread::SerializedThread::from_json(json)?) + } + } + } + + fn upgrade_from_agent_1(thread: crate::legacy_thread::SerializedThread) -> Result { + let mut messages = Vec::new(); + let mut request_token_usage = HashMap::default(); + + let mut last_user_message_id = None; + for (ix, msg) in thread.messages.into_iter().enumerate() { + let message = match msg.role { + language_model::Role::User => { + let mut content = Vec::new(); + + // Convert segments to content + for segment in msg.segments { + match segment { + crate::legacy_thread::SerializedMessageSegment::Text { text } => { + content.push(UserMessageContent::Text(text)); + } + crate::legacy_thread::SerializedMessageSegment::Thinking { + text, + .. + } => { + // User messages don't have thinking segments, but handle gracefully + content.push(UserMessageContent::Text(text)); + } + crate::legacy_thread::SerializedMessageSegment::RedactedThinking { + .. + } => { + // User messages don't have redacted thinking, skip. + } + } + } + + // If no content was added, add context as text if available + if content.is_empty() && !msg.context.is_empty() { + content.push(UserMessageContent::Text(msg.context)); + } + + let id = UserMessageId::new(); + last_user_message_id = Some(id.clone()); + + crate::Message::User(UserMessage { + // MessageId from old format can't be meaningfully converted, so generate a new one + id, + content, + }) + } + language_model::Role::Assistant => { + let mut content = Vec::new(); + + // Convert segments to content + for segment in msg.segments { + match segment { + crate::legacy_thread::SerializedMessageSegment::Text { text } => { + content.push(AgentMessageContent::Text(text)); + } + crate::legacy_thread::SerializedMessageSegment::Thinking { + text, + signature, + } => { + content.push(AgentMessageContent::Thinking { text, signature }); + } + crate::legacy_thread::SerializedMessageSegment::RedactedThinking { + data, + } => { + content.push(AgentMessageContent::RedactedThinking(data)); + } + } + } + + // Convert tool uses + let mut tool_names_by_id = HashMap::default(); + for tool_use in msg.tool_uses { + tool_names_by_id.insert(tool_use.id.clone(), tool_use.name.clone()); + content.push(AgentMessageContent::ToolUse( + language_model::LanguageModelToolUse { + id: tool_use.id, + name: tool_use.name.into(), + raw_input: serde_json::to_string(&tool_use.input) + .unwrap_or_default(), + input: tool_use.input, + is_input_complete: true, + }, + )); + } + + // Convert tool results + let mut tool_results = IndexMap::default(); + for tool_result in msg.tool_results { + let name = tool_names_by_id + .remove(&tool_result.tool_use_id) + .unwrap_or_else(|| SharedString::from("unknown")); + tool_results.insert( + tool_result.tool_use_id.clone(), + language_model::LanguageModelToolResult { + tool_use_id: tool_result.tool_use_id, + tool_name: name.into(), + is_error: tool_result.is_error, + content: tool_result.content, + output: tool_result.output, + }, + ); + } + + if let Some(last_user_message_id) = &last_user_message_id + && let Some(token_usage) = thread.request_token_usage.get(ix).copied() + { + request_token_usage.insert(last_user_message_id.clone(), token_usage); + } + + crate::Message::Agent(AgentMessage { + content, + tool_results, + }) + } + language_model::Role::System => { + // Skip system messages as they're not supported in the new format + continue; + } + }; + + messages.push(message); + } + + Ok(Self { + title: thread.summary, + messages, + updated_at: thread.updated_at, + detailed_summary: match thread.detailed_summary_state { + crate::legacy_thread::DetailedSummaryState::NotGenerated + | crate::legacy_thread::DetailedSummaryState::Generating => None, + crate::legacy_thread::DetailedSummaryState::Generated { text, .. } => Some(text), + }, + initial_project_snapshot: thread.initial_project_snapshot, + cumulative_token_usage: thread.cumulative_token_usage, + request_token_usage, + model: thread.model, + completion_mode: thread.completion_mode, + profile: thread.profile, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataType { + #[serde(rename = "json")] + Json, + #[serde(rename = "zstd")] + Zstd, +} + +impl Bind for DataType { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + let value = match self { + DataType::Json => "json", + DataType::Zstd => "zstd", + }; + value.bind(statement, start_index) + } +} + +impl Column for DataType { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let (value, next_index) = String::column(statement, start_index)?; + let data_type = match value.as_str() { + "json" => DataType::Json, + "zstd" => DataType::Zstd, + _ => anyhow::bail!("Unknown data type: {}", value), + }; + Ok((data_type, next_index)) + } +} + +pub(crate) struct ThreadsDatabase { + executor: BackgroundExecutor, + connection: Arc>, +} + +struct GlobalThreadsDatabase(Shared, Arc>>>); + +impl Global for GlobalThreadsDatabase {} + +impl ThreadsDatabase { + pub fn connect(cx: &mut App) -> Shared, Arc>>> { + if cx.has_global::() { + return cx.global::().0.clone(); + } + let executor = cx.background_executor().clone(); + let task = executor + .spawn({ + let executor = executor.clone(); + async move { + match ThreadsDatabase::new(executor) { + Ok(db) => Ok(Arc::new(db)), + Err(err) => Err(Arc::new(err)), + } + } + }) + .shared(); + + cx.set_global(GlobalThreadsDatabase(task.clone())); + task + } + + pub fn new(executor: BackgroundExecutor) -> Result { + let connection = if *ZED_STATELESS { + Connection::open_memory(Some("THREAD_FALLBACK_DB")) + } else if cfg!(any(feature = "test-support", test)) { + // rust stores the name of the test on the current thread. + // We use this to automatically create a database that will + // be shared within the test (for the test_retrieve_old_thread) + // but not with concurrent tests. + let thread = std::thread::current(); + let test_name = thread.name(); + Connection::open_memory(Some(&format!( + "THREAD_FALLBACK_{}", + test_name.unwrap_or_default() + ))) + } else { + let threads_dir = paths::data_dir().join("threads"); + std::fs::create_dir_all(&threads_dir)?; + let sqlite_path = threads_dir.join("threads.db"); + Connection::open_file(&sqlite_path.to_string_lossy()) + }; + + connection.exec(indoc! {" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + summary TEXT NOT NULL, + updated_at TEXT NOT NULL, + data_type TEXT NOT NULL, + data BLOB NOT NULL + ) + "})?() + .map_err(|e| anyhow!("Failed to create threads table: {}", e))?; + + let db = Self { + executor, + connection: Arc::new(Mutex::new(connection)), + }; + + Ok(db) + } + + fn save_thread_sync( + connection: &Arc>, + id: acp::SessionId, + thread: DbThread, + ) -> Result<()> { + const COMPRESSION_LEVEL: i32 = 3; + + #[derive(Serialize)] + struct SerializedThread { + #[serde(flatten)] + thread: DbThread, + version: &'static str, + } + + let title = thread.title.to_string(); + let updated_at = thread.updated_at.to_rfc3339(); + let json_data = serde_json::to_string(&SerializedThread { + thread, + version: DbThread::VERSION, + })?; + + let connection = connection.lock(); + + let compressed = zstd::encode_all(json_data.as_bytes(), COMPRESSION_LEVEL)?; + let data_type = DataType::Zstd; + let data = compressed; + + let mut insert = connection.exec_bound::<(Arc, String, String, DataType, Vec)>(indoc! {" + INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?) + "})?; + + insert((id.0, title, updated_at, data_type, data))?; + + Ok(()) + } + + pub fn list_threads(&self) -> Task>> { + let connection = self.connection.clone(); + + self.executor.spawn(async move { + let connection = connection.lock(); + + let mut select = + connection.select_bound::<(), (Arc, String, String)>(indoc! {" + SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC + "})?; + + let rows = select(())?; + let mut threads = Vec::new(); + + for (id, summary, updated_at) in rows { + threads.push(DbThreadMetadata { + id: acp::SessionId(id), + title: summary.into(), + updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), + }); + } + + Ok(threads) + }) + } + + pub fn load_thread(&self, id: acp::SessionId) -> Task>> { + let connection = self.connection.clone(); + + self.executor.spawn(async move { + let connection = connection.lock(); + let mut select = connection.select_bound::, (DataType, Vec)>(indoc! {" + SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 + "})?; + + let rows = select(id.0)?; + if let Some((data_type, data)) = rows.into_iter().next() { + let json_data = match data_type { + DataType::Zstd => { + let decompressed = zstd::decode_all(&data[..])?; + String::from_utf8(decompressed)? + } + DataType::Json => String::from_utf8(data)?, + }; + let thread = DbThread::from_json(json_data.as_bytes())?; + Ok(Some(thread)) + } else { + Ok(None) + } + }) + } + + pub fn save_thread(&self, id: acp::SessionId, thread: DbThread) -> Task> { + let connection = self.connection.clone(); + + self.executor + .spawn(async move { Self::save_thread_sync(&connection, id, thread) }) + } + + pub fn delete_thread(&self, id: acp::SessionId) -> Task> { + let connection = self.connection.clone(); + + self.executor.spawn(async move { + let connection = connection.lock(); + + let mut delete = connection.exec_bound::>(indoc! {" + DELETE FROM threads WHERE id = ? + "})?; + + delete(id.0)?; + + Ok(()) + }) + } +} diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/agent/src/edit_agent.rs similarity index 96% rename from crates/assistant_tools/src/edit_agent.rs rename to crates/agent/src/edit_agent.rs index aa321aa8f30117e21a04e4acb52b5c5cdbfedfaa..829287f65478d56d793ed506c44a8331580cc4c5 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -26,13 +26,13 @@ use language_model::{ use project::{AgentLocation, Project}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::Poll}; +use std::{cmp, iter, mem, ops::Range, pin::Pin, sync::Arc, task::Poll}; use streaming_diff::{CharOperation, StreamingDiff}; use streaming_fuzzy_matcher::StreamingFuzzyMatcher; #[derive(Serialize)] struct CreateFilePromptTemplate { - path: Option, + path: Option, edit_description: String, } @@ -42,7 +42,7 @@ impl Template for CreateFilePromptTemplate { #[derive(Serialize)] struct EditFileXmlPromptTemplate { - path: Option, + path: Option, edit_description: String, } @@ -52,7 +52,7 @@ impl Template for EditFileXmlPromptTemplate { #[derive(Serialize)] struct EditFileDiffFencedPromptTemplate { - path: Option, + path: Option, edit_description: String, } @@ -115,7 +115,7 @@ impl EditAgent { let conversation = conversation.clone(); let output = cx.spawn(async move |cx| { let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let path = cx.update(|cx| snapshot.resolve_file_path(cx, true))?; + let path = cx.update(|cx| snapshot.resolve_file_path(true, cx))?; let prompt = CreateFilePromptTemplate { path, edit_description, @@ -229,7 +229,7 @@ impl EditAgent { let edit_format = self.edit_format; let output = cx.spawn(async move |cx| { let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let path = cx.update(|cx| snapshot.resolve_file_path(cx, true))?; + let path = cx.update(|cx| snapshot.resolve_file_path(true, cx))?; let prompt = match edit_format { EditFormat::XmlTags => EditFileXmlPromptTemplate { path, @@ -672,29 +672,30 @@ impl EditAgent { cx: &mut AsyncApp, ) -> Result>> { let mut messages_iter = conversation.messages.iter_mut(); - if let Some(last_message) = messages_iter.next_back() { - if last_message.role == Role::Assistant { - let old_content_len = last_message.content.len(); - last_message - .content - .retain(|content| !matches!(content, MessageContent::ToolUse(_))); - let new_content_len = last_message.content.len(); - - // We just removed pending tool uses from the content of the - // last message, so it doesn't make sense to cache it anymore - // (e.g., the message will look very different on the next - // request). Thus, we move the flag to the message prior to it, - // as it will still be a valid prefix of the conversation. - if old_content_len != new_content_len && last_message.cache { - if let Some(prev_message) = messages_iter.next_back() { - last_message.cache = false; - prev_message.cache = true; - } - } + if let Some(last_message) = messages_iter.next_back() + && last_message.role == Role::Assistant + { + let old_content_len = last_message.content.len(); + last_message + .content + .retain(|content| !matches!(content, MessageContent::ToolUse(_))); + let new_content_len = last_message.content.len(); + + // We just removed pending tool uses from the content of the + // last message, so it doesn't make sense to cache it anymore + // (e.g., the message will look very different on the next + // request). Thus, we move the flag to the message prior to it, + // as it will still be a valid prefix of the conversation. + if old_content_len != new_content_len + && last_message.cache + && let Some(prev_message) = messages_iter.next_back() + { + last_message.cache = false; + prev_message.cache = true; + } - if last_message.content.is_empty() { - conversation.messages.pop(); - } + if last_message.content.is_empty() { + conversation.messages.pop(); } } @@ -1314,17 +1315,17 @@ mod tests { #[gpui::test(iterations = 100)] async fn test_random_indents(mut rng: StdRng) { - let len = rng.gen_range(1..=100); + let len = rng.random_range(1..=100); let new_text = util::RandomCharIter::new(&mut rng) .with_simple_text() .take(len) .collect::(); let new_text = new_text .split('\n') - .map(|line| format!("{}{}", " ".repeat(rng.gen_range(0..=8)), line)) + .map(|line| format!("{}{}", " ".repeat(rng.random_range(0..=8)), line)) .collect::>() .join("\n"); - let delta = IndentDelta::Spaces(rng.gen_range(-4..=4)); + let delta = IndentDelta::Spaces(rng.random_range(-4i8..=4i8) as isize); let chunks = to_random_chunks(&mut rng, &new_text); let new_text_chunks = stream::iter(chunks.iter().enumerate().map(|(index, chunk)| { @@ -1356,7 +1357,7 @@ mod tests { } fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { - let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50)); + let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); chunk_indices.sort(); chunk_indices.push(input.len()); diff --git a/crates/assistant_tools/src/edit_agent/create_file_parser.rs b/crates/agent/src/edit_agent/create_file_parser.rs similarity index 96% rename from crates/assistant_tools/src/edit_agent/create_file_parser.rs rename to crates/agent/src/edit_agent/create_file_parser.rs index 0aad9ecb87c1426486b531ac4291913cd0d74092..2272434d796a92e53b741f8ed5f4303d94f88489 100644 --- a/crates/assistant_tools/src/edit_agent/create_file_parser.rs +++ b/crates/agent/src/edit_agent/create_file_parser.rs @@ -160,7 +160,7 @@ mod tests { &mut parser, &mut rng ), - // This output is marlformed, so we're doing our best effort + // This output is malformed, so we're doing our best effort "Hello world\n```\n\nThe end\n".to_string() ); } @@ -182,7 +182,7 @@ mod tests { &mut parser, &mut rng ), - // This output is marlformed, so we're doing our best effort + // This output is malformed, so we're doing our best effort "```\nHello world\n```\n".to_string() ); } @@ -204,7 +204,7 @@ mod tests { } fn parse_random_chunks(input: &str, parser: &mut CreateFileParser, rng: &mut StdRng) -> String { - let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50)); + let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); chunk_indices.sort(); chunk_indices.push(input.len()); diff --git a/crates/assistant_tools/src/edit_agent/edit_parser.rs b/crates/agent/src/edit_agent/edit_parser.rs similarity index 99% rename from crates/assistant_tools/src/edit_agent/edit_parser.rs rename to crates/agent/src/edit_agent/edit_parser.rs index db58c2bf3685030abfa6cfdd506c068c6643dce8..8411171ba4ea491d2603014a0715ce471b34e36f 100644 --- a/crates/assistant_tools/src/edit_agent/edit_parser.rs +++ b/crates/agent/src/edit_agent/edit_parser.rs @@ -996,7 +996,7 @@ mod tests { } fn parse_random_chunks(input: &str, parser: &mut EditParser, rng: &mut StdRng) -> Vec { - let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50)); + let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); chunk_indices.sort(); chunk_indices.push(input.len()); diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs similarity index 94% rename from crates/assistant_tools/src/edit_agent/evals.rs rename to crates/agent/src/edit_agent/evals.rs index 9a8e7624559e9a1284ace7c932f428c7389b6254..84cdd101f57546a0bfbc86a290bf1f453e69a979 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1,12 +1,8 @@ use super::*; use crate::{ - ReadFileToolInput, - edit_file_tool::{EditFileMode, EditFileToolInput}, - grep_tool::GrepToolInput, - list_directory_tool::ListDirectoryToolInput, + EditFileMode, EditFileToolInput, GrepToolInput, ListDirectoryToolInput, ReadFileToolInput, }; use Role::*; -use assistant_tool::ToolRegistry; use client::{Client, UserStore}; use collections::HashMap; use fs::FakeFs; @@ -15,11 +11,11 @@ use gpui::{AppContext, TestAppContext, Timer}; use http_client::StatusCode; use indoc::{formatdoc, indoc}; use language_model::{ - LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, SelectedModel, + LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolResultContent, + LanguageModelToolUse, LanguageModelToolUseId, SelectedModel, }; use project::Project; -use prompt_store::{ModelContext, ProjectContext, PromptBuilder, WorktreeContext}; +use prompt_store::{ProjectContext, WorktreeContext}; use rand::prelude::*; use reqwest_client::ReqwestClient; use serde_json::json; @@ -35,7 +31,7 @@ use std::{ use util::path; #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_extract_handle_command_output() { // Test how well agent generates multiple edit hunks. // @@ -112,7 +108,7 @@ fn eval_extract_handle_command_output() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_delete_run_git_blame() { // Model | Pass rate // ----------------------------|---------- @@ -121,6 +117,7 @@ fn eval_delete_run_git_blame() { // gemini-2.5-pro-06-05 | 1.0 (2025-06-16) // gemini-2.5-flash | // gpt-4.1 | + let input_file_path = "root/blame.rs"; let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); @@ -174,7 +171,7 @@ fn eval_delete_run_git_blame() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_translate_doc_comments() { // Model | Pass rate // ============================================ @@ -184,6 +181,7 @@ fn eval_translate_doc_comments() { // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); let edit_description = "Translate all doc comments to Italian"; @@ -236,7 +234,7 @@ fn eval_translate_doc_comments() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { // Model | Pass rate // ============================================ @@ -246,6 +244,7 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { // gemini-2.5-pro-preview-latest | 0.99 (2025-06-16) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/lib.rs"; let input_file_content = include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); @@ -361,7 +360,7 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_disable_cursor_blinking() { // Model | Pass rate // ============================================ @@ -371,6 +370,7 @@ fn eval_disable_cursor_blinking() { // gemini-2.5-pro | 0.95 (2025-07-14) // gemini-2.5-flash-preview-04-17 | 0.78 (2025-07-14) // gpt-4.1 | 0.00 (2025-07-14) (follows edit_description too literally) + let input_file_path = "root/editor.rs"; let input_file_content = include_str!("evals/fixtures/disable_cursor_blinking/before.rs"); let edit_description = "Comment out the call to `BlinkManager::enable`"; @@ -446,7 +446,7 @@ fn eval_disable_cursor_blinking() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_from_pixels_constructor() { // Results for 2025-06-13 // @@ -463,6 +463,7 @@ fn eval_from_pixels_constructor() { // claude-3.7-sonnet | 2025-06-14 | 0.88 // gemini-2.5-pro-preview-06-05 | 2025-06-16 | 0.98 // gpt-4.1 | + let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); let edit_description = "Implement from_pixels constructor and add tests."; @@ -655,7 +656,7 @@ fn eval_from_pixels_constructor() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_zode() { // Model | Pass rate // ============================================ @@ -665,6 +666,7 @@ fn eval_zode() { // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | 1.0 (2025-05-22) // gpt-4.1 | 1.0 (2025-05-22) + let input_file_path = "root/zode.py"; let input_content = None; let edit_description = "Create the main Zode CLI script"; @@ -761,7 +763,7 @@ fn eval_zode() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_add_overwrite_test() { // Model | Pass rate // ============================================ @@ -771,6 +773,7 @@ fn eval_add_overwrite_test() { // gemini-2.5-pro-preview-03-25 | 0.35 (2025-05-22) // gemini-2.5-flash-preview-04-17 | // gpt-4.1 | + let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); let edit_description = "Add a new test for overwriting a file in action_log.rs"; @@ -992,7 +995,7 @@ fn eval_add_overwrite_test() { } #[test] -#[cfg_attr(not(feature = "eval"), ignore)] +#[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_create_empty_file() { // Check that Edit Agent can create a file without writing its // thoughts into it. This issue is not specific to empty files, but @@ -1010,7 +1013,7 @@ fn eval_create_empty_file() { // // TODO: gpt-4.1-mini errored 38 times: // "data did not match any variant of untagged enum ResponseStreamResult" - // + let input_file_content = None; let expected_output_content = String::new(); eval( @@ -1153,8 +1156,7 @@ impl EvalInput { .expect("Conversation must end with an edit_file tool use") .clone(); - let edit_file_input: EditFileToolInput = - serde_json::from_value(tool_use.input.clone()).unwrap(); + let edit_file_input: EditFileToolInput = serde_json::from_value(tool_use.input).unwrap(); EvalInput { conversation, @@ -1283,14 +1285,14 @@ impl EvalAssertion { // Parse the score from the response let re = regex::Regex::new(r"(\d+)").unwrap(); - if let Some(captures) = re.captures(&output) { - if let Some(score_match) = captures.get(1) { - let score = score_match.as_str().parse().unwrap_or(0); - return Ok(EvalAssertionOutcome { - score, - message: Some(output), - }); - } + if let Some(captures) = re.captures(&output) + && let Some(score_match) = captures.get(1) + { + let score = score_match.as_str().parse().unwrap_or(0); + return Ok(EvalAssertionOutcome { + score, + message: Some(output), + }); } anyhow::bail!("No score found in response. Raw output: {output}"); @@ -1400,7 +1402,7 @@ fn eval( } fn run_eval(eval: EvalInput, tx: mpsc::Sender>) { - let dispatcher = gpui::TestDispatcher::new(StdRng::from_entropy()); + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); let mut cx = TestAppContext::build(dispatcher, None); let output = cx.executor().block_test(async { let test = EditAgentTest::new(&mut cx).await; @@ -1460,7 +1462,7 @@ impl EditAgentTest { async fn new(cx: &mut TestAppContext) -> Self { cx.executor().allow_parking(); - let fs = FakeFs::new(cx.executor().clone()); + let fs = FakeFs::new(cx.executor()); cx.update(|cx| { settings::init(cx); gpui_tokio::init(cx); @@ -1475,25 +1477,33 @@ impl EditAgentTest { Project::init_settings(cx); language::init(cx); language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); - crate::init(client.http_client(), cx); + language_models::init(user_store, client.clone(), cx); }); fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let agent_model = SelectedModel::from_str( - &std::env::var("ZED_AGENT_MODEL") - .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()), + &std::env::var("ZED_AGENT_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), ) .unwrap(); let judge_model = SelectedModel::from_str( - &std::env::var("ZED_JUDGE_MODEL") - .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()), + &std::env::var("ZED_JUDGE_MODEL").unwrap_or("anthropic/claude-sonnet-4-latest".into()), ) .unwrap(); + + let authenticate_provider_tasks = cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }) + }); let (agent_model, judge_model) = cx .update(|cx| { cx.spawn(async move |cx| { + futures::future::join_all(authenticate_provider_tasks).await; let agent_model = Self::load_model(&agent_model, cx).await; let judge_model = Self::load_model(&judge_model, cx).await; (agent_model.unwrap(), judge_model.unwrap()) @@ -1521,7 +1531,15 @@ impl EditAgentTest { selected_model: &SelectedModel, cx: &mut AsyncApp, ) -> Result> { - let (provider, model) = cx.update(|cx| { + cx.update(|cx| { + let registry = LanguageModelRegistry::read_global(cx); + let provider = registry + .provider(&selected_model.provider) + .expect("Provider not found"); + provider.authenticate(cx) + })? + .await?; + cx.update(|cx| { let models = LanguageModelRegistry::read_global(cx); let model = models .available_models(cx) @@ -1529,12 +1547,9 @@ impl EditAgentTest { model.provider_id() == selected_model.provider && model.id() == selected_model.model }) - .expect("Model not found"); - let provider = models.provider(&model.provider_id()).unwrap(); - (provider, model) - })?; - cx.update(|cx| provider.authenticate(cx))?.await?; - Ok(model) + .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)); + model + }) } async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result { @@ -1549,44 +1564,32 @@ impl EditAgentTest { .update(cx, |project, cx| project.open_buffer(path, cx)) .await .unwrap(); - let tools = cx.update(|cx| { - ToolRegistry::default_global(cx) - .tools() - .into_iter() - .filter_map(|tool| { - let input_schema = tool - .input_schema(self.agent.model.tool_input_format()) - .ok()?; - Some(LanguageModelRequestTool { - name: tool.name(), - description: tool.description(), - input_schema, - }) - }) - .collect::>() - }); - let tool_names = tools - .iter() - .map(|tool| tool.name.clone()) - .collect::>(); - let worktrees = vec![WorktreeContext { - root_name: "root".to_string(), - abs_path: Path::new("/path/to/root").into(), - rules_file: None, - }]; - let prompt_builder = PromptBuilder::new(None)?; - let project_context = ProjectContext::new(worktrees, Vec::default()); - let system_prompt = prompt_builder.generate_assistant_system_prompt( - &project_context, - &ModelContext { + + let tools = crate::built_in_tools().collect::>(); + + let system_prompt = { + let worktrees = vec![WorktreeContext { + root_name: "root".to_string(), + abs_path: Path::new("/path/to/root").into(), + rules_file: None, + }]; + let project_context = ProjectContext::new(worktrees, Vec::default()); + let tool_names = tools + .iter() + .map(|tool| tool.name.clone().into()) + .collect::>(); + let template = crate::SystemPromptTemplate { + project: &project_context, available_tools: tool_names, - }, - )?; + }; + let templates = Templates::new(); + template.render(&templates).unwrap() + }; let has_system_prompt = eval .conversation .first() - .map_or(false, |msg| msg.role == Role::System); + .is_some_and(|msg| msg.role == Role::System); let messages = if has_system_prompt { eval.conversation } else { @@ -1708,7 +1711,7 @@ async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> }; if let Some(retry_after) = retry_delay { - let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0)); + let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0)); eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}"); Timer::after(retry_after + jitter).await; } else { diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs b/crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs rename to crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs b/crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-01.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-02.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-03.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff rename to crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/possible-04.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff b/crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff rename to crates/agent/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs b/crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/from_pixels_constructor/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs b/crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/translate_doc_comments/before.rs diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs b/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs similarity index 99% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs rename to crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs index b51c74c798d88b3f84303ffe41f4ac2590e7f236..cfa28fe1ad6091c9adda22f610e1cf13166f8dfb 100644 --- a/crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs +++ b/crates/agent/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs @@ -916,7 +916,7 @@ impl Loader { if !found_non_static { found_non_static = true; eprintln!( - "Warning: Found non-static non-tree-sitter functions in the external scannner" + "Warning: Found non-static non-tree-sitter functions in the external scanner" ); } eprintln!(" `{function_name}`"); diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md rename to crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react.py similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react.py rename to crates/agent/src/edit_agent/evals/fixtures/zode/react.py diff --git a/crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react_test.py b/crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py similarity index 100% rename from crates/assistant_tools/src/edit_agent/evals/fixtures/zode/react_test.py rename to crates/agent/src/edit_agent/evals/fixtures/zode/react_test.py diff --git a/crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs similarity index 95% rename from crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs rename to crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs index 092bdce8b347ee5bcb5849703533710652b5b01c..904ec05a8c7565d5052cd546fc0bf6d723ffa375 100644 --- a/crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs @@ -308,18 +308,19 @@ mod tests { use indoc::indoc; use language::{BufferId, TextBuffer}; use rand::prelude::*; + use text::ReplicaId; use util::test::{generate_marked_text, marked_text_ranges}; #[test] fn test_empty_query() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Hello world\nThis is a test\nFoo bar baz", ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); assert_eq!(push(&mut finder, ""), None); assert_eq!(finish(finder), None); } @@ -327,13 +328,13 @@ mod tests { #[test] fn test_streaming_exact_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Hello world\nThis is a test\nFoo bar baz", ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); // Push partial query assert_eq!(push(&mut finder, "This"), None); @@ -351,7 +352,7 @@ mod tests { #[test] fn test_streaming_fuzzy_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {" function foo(a, b) { @@ -365,7 +366,7 @@ mod tests { ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); // Push a fuzzy query that should match the first function assert_eq!( @@ -385,13 +386,13 @@ mod tests { #[test] fn test_incremental_improvement() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); // No match initially assert_eq!(push(&mut finder, "Lin"), None); @@ -410,7 +411,7 @@ mod tests { #[test] fn test_incomplete_lines_buffering() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {" The quick brown fox @@ -420,7 +421,7 @@ mod tests { ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); // Push text in small chunks across line boundaries assert_eq!(push(&mut finder, "jumps "), None); // No newline yet @@ -437,7 +438,7 @@ mod tests { #[test] fn test_multiline_fuzzy_match() { let buffer = TextBuffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), indoc! {r#" impl Display for User { @@ -458,7 +459,7 @@ mod tests { ); let snapshot = buffer.snapshot(); - let mut finder = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut finder = StreamingFuzzyMatcher::new(snapshot); assert_eq!( push(&mut finder, "impl Debug for User {\n"), @@ -691,7 +692,11 @@ mod tests { } "#}; - let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.to_string()); + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); let snapshot = buffer.snapshot(); let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); @@ -711,7 +716,7 @@ mod tests { "Expected to match `second_function` based on the line hint" ); - let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut matcher = StreamingFuzzyMatcher::new(snapshot); matcher.push(query, None); matcher.finish(); let best_match = matcher.select_best_match(); @@ -724,10 +729,10 @@ mod tests { #[track_caller] fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) { let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false); - let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.clone()); + let buffer = TextBuffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text.clone()); let snapshot = buffer.snapshot(); - let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + let mut matcher = StreamingFuzzyMatcher::new(snapshot); // Split query into random chunks let chunks = to_random_chunks(rng, query); @@ -771,7 +776,7 @@ mod tests { } fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec { - let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50)); + let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50)); let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count); chunk_indices.sort(); chunk_indices.push(input.len()); @@ -794,10 +799,8 @@ mod tests { fn finish(mut finder: StreamingFuzzyMatcher) -> Option { let snapshot = finder.snapshot.clone(); let matches = finder.finish(); - if let Some(range) = matches.first() { - Some(snapshot.text_for_range(range.clone()).collect::()) - } else { - None - } + matches + .first() + .map(|range| snapshot.text_for_range(range.clone()).collect::()) } } diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index eb39c3e454c25fdc87baeffd550ea5cb29155aab..3bfbd99677feed5db53d96d2fa96316ac49abce4 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -1,68 +1,128 @@ -use crate::{ - ThreadId, - thread_store::{SerializedThreadMetadata, ThreadStore}, -}; -use anyhow::{Context as _, Result}; -use assistant_context::SavedContextMetadata; +use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; +use acp_thread::MentionUri; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use assistant_text_thread::{SavedTextThreadMetadata, TextThread}; use chrono::{DateTime, Utc}; +use db::kvp::KEY_VALUE_STORE; use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*}; use itertools::Itertools; -use paths::contexts_dir; +use paths::text_threads_dir; +use project::Project; use serde::{Deserialize, Serialize}; -use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration}; +use std::{collections::VecDeque, path::Path, rc::Rc, sync::Arc, time::Duration}; +use ui::ElementId; use util::ResultExt as _; const MAX_RECENTLY_OPENED_ENTRIES: usize = 6; -const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json"; +const RECENTLY_OPENED_THREADS_KEY: &str = "recent-agent-threads"; const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50); +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +//todo: We should remove this function once we support loading all acp thread +pub fn load_agent_thread( + session_id: acp::SessionId, + history_store: Entity, + project: Entity, + cx: &mut App, +) -> Task>> { + use agent_servers::{AgentServer, AgentServerDelegate}; + + let server = Rc::new(crate::NativeAgentServer::new( + project.read(cx).fs().clone(), + history_store, + )); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + cx.update(|cx| agent.load_thread(session_id, cx))?.await + }) +} + #[derive(Clone, Debug)] pub enum HistoryEntry { - Thread(SerializedThreadMetadata), - Context(SavedContextMetadata), + AcpThread(DbThreadMetadata), + TextThread(SavedTextThreadMetadata), } impl HistoryEntry { pub fn updated_at(&self) -> DateTime { match self { - HistoryEntry::Thread(thread) => thread.updated_at, - HistoryEntry::Context(context) => context.mtime.to_utc(), + HistoryEntry::AcpThread(thread) => thread.updated_at, + HistoryEntry::TextThread(text_thread) => text_thread.mtime.to_utc(), } } pub fn id(&self) -> HistoryEntryId { match self { - HistoryEntry::Thread(thread) => HistoryEntryId::Thread(thread.id.clone()), - HistoryEntry::Context(context) => HistoryEntryId::Context(context.path.clone()), + HistoryEntry::AcpThread(thread) => HistoryEntryId::AcpThread(thread.id.clone()), + HistoryEntry::TextThread(text_thread) => { + HistoryEntryId::TextThread(text_thread.path.clone()) + } + } + } + + pub fn mention_uri(&self) -> MentionUri { + match self { + HistoryEntry::AcpThread(thread) => MentionUri::Thread { + id: thread.id.clone(), + name: thread.title.to_string(), + }, + HistoryEntry::TextThread(text_thread) => MentionUri::TextThread { + path: text_thread.path.as_ref().to_owned(), + name: text_thread.title.to_string(), + }, } } pub fn title(&self) -> &SharedString { match self { - HistoryEntry::Thread(thread) => &thread.summary, - HistoryEntry::Context(context) => &context.title, + HistoryEntry::AcpThread(thread) => { + if thread.title.is_empty() { + DEFAULT_TITLE + } else { + &thread.title + } + } + HistoryEntry::TextThread(text_thread) => &text_thread.title, } } } /// Generic identifier for a history entry. -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Hash)] pub enum HistoryEntryId { - Thread(ThreadId), - Context(Arc), + AcpThread(acp::SessionId), + TextThread(Arc), } -#[derive(Serialize, Deserialize)] +impl Into for HistoryEntryId { + fn into(self) -> ElementId { + match self { + HistoryEntryId::AcpThread(session_id) => ElementId::Name(session_id.0.into()), + HistoryEntryId::TextThread(path) => ElementId::Path(path), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] enum SerializedRecentOpen { - Thread(String), - ContextName(String), - /// Old format which stores the full path - Context(String), + AcpThread(String), + TextThread(String), } pub struct HistoryStore { - thread_store: Entity, - context_store: Entity, + threads: Vec, + entries: Vec, + text_thread_store: Entity, recently_opened_entries: VecDeque, _subscriptions: Vec, _save_recently_opened_entries_task: Task<()>, @@ -70,69 +130,133 @@ pub struct HistoryStore { impl HistoryStore { pub fn new( - thread_store: Entity, - context_store: Entity, - initial_recent_entries: impl IntoIterator, + text_thread_store: Entity, cx: &mut Context, ) -> Self { - let subscriptions = vec![ - cx.observe(&thread_store, |_, _, cx| cx.notify()), - cx.observe(&context_store, |_, _, cx| cx.notify()), - ]; + let subscriptions = + vec![cx.observe(&text_thread_store, |this, _, cx| this.update_entries(cx))]; cx.spawn(async move |this, cx| { - let entries = Self::load_recently_opened_entries(cx).await.log_err()?; - this.update(cx, |this, _| { - this.recently_opened_entries - .extend( - entries.into_iter().take( - MAX_RECENTLY_OPENED_ENTRIES - .saturating_sub(this.recently_opened_entries.len()), - ), - ); + let entries = Self::load_recently_opened_entries(cx).await; + this.update(cx, |this, cx| { + if let Some(entries) = entries.log_err() { + this.recently_opened_entries = entries; + } + + this.reload(cx); }) - .ok() + .ok(); }) .detach(); Self { - thread_store, - context_store, - recently_opened_entries: initial_recent_entries.into_iter().collect(), + text_thread_store, + recently_opened_entries: VecDeque::default(), + threads: Vec::default(), + entries: Vec::default(), _subscriptions: subscriptions, _save_recently_opened_entries_task: Task::ready(()), } } - pub fn entries(&self, cx: &mut Context) -> Vec { - let mut history_entries = Vec::new(); + pub fn thread_from_session_id(&self, session_id: &acp::SessionId) -> Option<&DbThreadMetadata> { + self.threads.iter().find(|thread| &thread.id == session_id) + } + + pub fn load_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task>> { + let database_future = ThreadsDatabase::connect(cx); + cx.background_spawn(async move { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.load_thread(id).await + }) + } + + pub fn delete_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let database = database_future.await.map_err(|err| anyhow!(err))?; + database.delete_thread(id.clone()).await?; + this.update(cx, |this, cx| this.reload(cx)) + }) + } + + pub fn delete_text_thread( + &mut self, + path: Arc, + cx: &mut Context, + ) -> Task> { + self.text_thread_store + .update(cx, |store, cx| store.delete_local(path, cx)) + } + + pub fn load_text_thread( + &self, + path: Arc, + cx: &mut Context, + ) -> Task>> { + self.text_thread_store + .update(cx, |store, cx| store.open_local(path, cx)) + } + pub fn reload(&self, cx: &mut Context) { + let database_future = ThreadsDatabase::connect(cx); + cx.spawn(async move |this, cx| { + let threads = database_future + .await + .map_err(|err| anyhow!(err))? + .list_threads() + .await?; + + this.update(cx, |this, cx| { + if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { + for thread in threads + .iter() + .take(MAX_RECENTLY_OPENED_ENTRIES - this.recently_opened_entries.len()) + .rev() + { + this.push_recently_opened_entry( + HistoryEntryId::AcpThread(thread.id.clone()), + cx, + ) + } + } + this.threads = threads; + this.update_entries(cx); + }) + }) + .detach_and_log_err(cx); + } + + fn update_entries(&mut self, cx: &mut Context) { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() { - return history_entries; + return; } - - history_entries.extend( - self.thread_store - .read(cx) - .reverse_chronological_threads() - .cloned() - .map(HistoryEntry::Thread), - ); + let mut history_entries = Vec::new(); + history_entries.extend(self.threads.iter().cloned().map(HistoryEntry::AcpThread)); history_entries.extend( - self.context_store + self.text_thread_store .read(cx) - .unordered_contexts() + .unordered_text_threads() .cloned() - .map(HistoryEntry::Context), + .map(HistoryEntry::TextThread), ); history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at())); - history_entries + self.entries = history_entries; + cx.notify() } - pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec { - self.entries(cx).into_iter().take(limit).collect() + pub fn is_empty(&self, _cx: &App) -> bool { + self.entries.is_empty() } pub fn recently_opened_entries(&self, cx: &App) -> Vec { @@ -141,38 +265,34 @@ impl HistoryStore { return Vec::new(); } - let thread_entries = self - .thread_store + let thread_entries = self.threads.iter().flat_map(|thread| { + self.recently_opened_entries + .iter() + .enumerate() + .flat_map(|(index, entry)| match entry { + HistoryEntryId::AcpThread(id) if &thread.id == id => { + Some((index, HistoryEntry::AcpThread(thread.clone()))) + } + _ => None, + }) + }); + + let context_entries = self + .text_thread_store .read(cx) - .reverse_chronological_threads() - .flat_map(|thread| { + .unordered_text_threads() + .flat_map(|text_thread| { self.recently_opened_entries .iter() .enumerate() .flat_map(|(index, entry)| match entry { - HistoryEntryId::Thread(id) if &thread.id == id => { - Some((index, HistoryEntry::Thread(thread.clone()))) + HistoryEntryId::TextThread(path) if &text_thread.path == path => { + Some((index, HistoryEntry::TextThread(text_thread.clone()))) } _ => None, }) }); - let context_entries = - self.context_store - .read(cx) - .unordered_contexts() - .flat_map(|context| { - self.recently_opened_entries - .iter() - .enumerate() - .flat_map(|(index, entry)| match entry { - HistoryEntryId::Context(path) if &context.path == path => { - Some((index, HistoryEntry::Context(context.clone()))) - } - _ => None, - }) - }); - thread_entries .chain(context_entries) // optimization to halt iteration early @@ -187,59 +307,52 @@ impl HistoryStore { .recently_opened_entries .iter() .filter_map(|entry| match entry { - HistoryEntryId::Context(path) => path.file_name().map(|file| { - SerializedRecentOpen::ContextName(file.to_string_lossy().to_string()) + HistoryEntryId::TextThread(path) => path.file_name().map(|file| { + SerializedRecentOpen::TextThread(file.to_string_lossy().into_owned()) }), - HistoryEntryId::Thread(id) => Some(SerializedRecentOpen::Thread(id.to_string())), + HistoryEntryId::AcpThread(id) => { + Some(SerializedRecentOpen::AcpThread(id.to_string())) + } }) .collect::>(); self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| { + let content = serde_json::to_string(&serialized_entries).unwrap(); cx.background_executor() .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE) .await; - cx.background_spawn(async move { - let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); - let content = serde_json::to_string(&serialized_entries)?; - std::fs::write(path, content)?; - anyhow::Ok(()) - }) - .await - .log_err(); + + if cfg!(any(feature = "test-support", test)) { + return; + } + KEY_VALUE_STORE + .write_kvp(RECENTLY_OPENED_THREADS_KEY.to_owned(), content) + .await + .log_err(); }); } - fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { + fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { - let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH); - let contents = match smol::fs::read_to_string(path).await { - Ok(it) => it, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Ok(Vec::new()); - } - Err(e) => { - return Err(e) - .context("deserializing persisted agent panel navigation history"); - } - }; - let entries = serde_json::from_str::>(&contents) + if cfg!(any(feature = "test-support", test)) { + anyhow::bail!("history store does not persist in tests"); + } + let json = KEY_VALUE_STORE + .read_kvp(RECENTLY_OPENED_THREADS_KEY)? + .unwrap_or("[]".to_string()); + let entries = serde_json::from_str::>(&json) .context("deserializing persisted agent panel navigation history")? .into_iter() .take(MAX_RECENTLY_OPENED_ENTRIES) .flat_map(|entry| match entry { - SerializedRecentOpen::Thread(id) => { - Some(HistoryEntryId::Thread(id.as_str().into())) - } - SerializedRecentOpen::ContextName(file_name) => Some(HistoryEntryId::Context( - contexts_dir().join(file_name).into(), + SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread( + acp::SessionId(id.as_str().into()), )), - SerializedRecentOpen::Context(path) => { - Path::new(&path).file_name().map(|file_name| { - HistoryEntryId::Context(contexts_dir().join(file_name).into()) - }) - } + SerializedRecentOpen::TextThread(file_name) => Some( + HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), + ), }) - .collect::>(); + .collect(); Ok(entries) }) } @@ -253,11 +366,10 @@ impl HistoryStore { self.save_recently_opened_entries(cx); } - pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context) { - self.recently_opened_entries.retain(|entry| match entry { - HistoryEntryId::Thread(thread_id) if thread_id == &id => false, - _ => true, - }); + pub fn remove_recently_opened_thread(&mut self, id: acp::SessionId, cx: &mut Context) { + self.recently_opened_entries.retain( + |entry| !matches!(entry, HistoryEntryId::AcpThread(thread_id) if thread_id == &id), + ); self.save_recently_opened_entries(cx); } @@ -269,8 +381,8 @@ impl HistoryStore { ) { for entry in &mut self.recently_opened_entries { match entry { - HistoryEntryId::Context(path) if path.as_ref() == old_path => { - *entry = HistoryEntryId::Context(new_path.clone()); + HistoryEntryId::TextThread(path) if path.as_ref() == old_path => { + *entry = HistoryEntryId::TextThread(new_path.clone()); break; } _ => {} @@ -284,4 +396,8 @@ impl HistoryStore { .retain(|old_entry| old_entry != entry); self.save_recently_opened_entries(cx); } + + pub fn entries(&self) -> impl Iterator { + self.entries.iter().cloned() + } } diff --git a/crates/agent/src/legacy_thread.rs b/crates/agent/src/legacy_thread.rs new file mode 100644 index 0000000000000000000000000000000000000000..34babb800616e7a3d5390abdaccc0cafa24ff386 --- /dev/null +++ b/crates/agent/src/legacy_thread.rs @@ -0,0 +1,402 @@ +use crate::ProjectSnapshot; +use agent_settings::{AgentProfileId, CompletionMode}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use gpui::SharedString; +use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub enum DetailedSummaryState { + #[default] + NotGenerated, + Generating, + Generated { + text: SharedString, + }, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +pub struct MessageId(pub usize); + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SerializedThread { + pub version: String, + pub summary: SharedString, + pub updated_at: DateTime, + pub messages: Vec, + #[serde(default)] + pub initial_project_snapshot: Option>, + #[serde(default)] + pub cumulative_token_usage: TokenUsage, + #[serde(default)] + pub request_token_usage: Vec, + #[serde(default)] + pub detailed_summary_state: DetailedSummaryState, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub completion_mode: Option, + #[serde(default)] + pub tool_use_limit_reached: bool, + #[serde(default)] + pub profile: Option, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct SerializedLanguageModel { + pub provider: String, + pub model: String, +} + +impl SerializedThread { + pub const VERSION: &'static str = "0.2.0"; + + pub fn from_json(json: &[u8]) -> Result { + let saved_thread_json = serde_json::from_slice::(json)?; + match saved_thread_json.get("version") { + Some(serde_json::Value::String(version)) => match version.as_str() { + SerializedThreadV0_1_0::VERSION => { + let saved_thread = + serde_json::from_value::(saved_thread_json)?; + Ok(saved_thread.upgrade()) + } + SerializedThread::VERSION => Ok(serde_json::from_value::( + saved_thread_json, + )?), + _ => anyhow::bail!("unrecognized serialized thread version: {version:?}"), + }, + None => { + let saved_thread = + serde_json::from_value::(saved_thread_json)?; + Ok(saved_thread.upgrade()) + } + version => anyhow::bail!("unrecognized serialized thread version: {version:?}"), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedThreadV0_1_0( + // The structure did not change, so we are reusing the latest SerializedThread. + // When making the next version, make sure this points to SerializedThreadV0_2_0 + SerializedThread, +); + +impl SerializedThreadV0_1_0 { + pub const VERSION: &'static str = "0.1.0"; + + pub fn upgrade(self) -> SerializedThread { + debug_assert_eq!(SerializedThread::VERSION, "0.2.0"); + + let mut messages: Vec = Vec::with_capacity(self.0.messages.len()); + + for message in self.0.messages { + if message.role == Role::User + && !message.tool_results.is_empty() + && let Some(last_message) = messages.last_mut() + { + debug_assert!(last_message.role == Role::Assistant); + + last_message.tool_results = message.tool_results; + continue; + } + + messages.push(message); + } + + SerializedThread { + messages, + version: SerializedThread::VERSION.to_string(), + ..self.0 + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedMessage { + pub id: MessageId, + pub role: Role, + #[serde(default)] + pub segments: Vec, + #[serde(default)] + pub tool_uses: Vec, + #[serde(default)] + pub tool_results: Vec, + #[serde(default)] + pub context: String, + #[serde(default)] + pub creases: Vec, + #[serde(default)] + pub is_hidden: bool, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum SerializedMessageSegment { + #[serde(rename = "text")] + Text { + text: String, + }, + #[serde(rename = "thinking")] + Thinking { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + }, + RedactedThinking { + data: String, + }, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedToolUse { + pub id: LanguageModelToolUseId, + pub name: SharedString, + pub input: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedToolResult { + pub tool_use_id: LanguageModelToolUseId, + pub is_error: bool, + pub content: LanguageModelToolResultContent, + pub output: Option, +} + +#[derive(Serialize, Deserialize)] +struct LegacySerializedThread { + pub summary: SharedString, + pub updated_at: DateTime, + pub messages: Vec, + #[serde(default)] + pub initial_project_snapshot: Option>, +} + +impl LegacySerializedThread { + pub fn upgrade(self) -> SerializedThread { + SerializedThread { + version: SerializedThread::VERSION.to_string(), + summary: self.summary, + updated_at: self.updated_at, + messages: self.messages.into_iter().map(|msg| msg.upgrade()).collect(), + initial_project_snapshot: self.initial_project_snapshot, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: Vec::new(), + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct LegacySerializedMessage { + pub id: MessageId, + pub role: Role, + pub text: String, + #[serde(default)] + pub tool_uses: Vec, + #[serde(default)] + pub tool_results: Vec, +} + +impl LegacySerializedMessage { + fn upgrade(self) -> SerializedMessage { + SerializedMessage { + id: self.id, + role: self.role, + segments: vec![SerializedMessageSegment::Text { text: self.text }], + tool_uses: self.tool_uses, + tool_results: self.tool_results, + context: String::new(), + creases: Vec::new(), + is_hidden: false, + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct SerializedCrease { + pub start: usize, + pub end: usize, + pub icon_path: SharedString, + pub label: SharedString, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use language_model::{Role, TokenUsage}; + use pretty_assertions::assert_eq; + + #[test] + fn test_legacy_serialized_thread_upgrade() { + let updated_at = Utc::now(); + let legacy_thread = LegacySerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![LegacySerializedMessage { + id: MessageId(1), + role: Role::User, + text: "Hello, world!".to_string(), + tool_uses: vec![], + tool_results: vec![], + }], + initial_project_snapshot: None, + }; + + let upgraded = legacy_thread.upgrade(); + + assert_eq!( + upgraded, + SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Hello, world!".to_string() + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false + }], + version: SerializedThread::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None + } + ) + } + + #[test] + fn test_serialized_threadv0_1_0_upgrade() { + let updated_at = Utc::now(); + let thread_v0_1_0 = SerializedThreadV0_1_0(SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![ + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Use tool_1".to_string(), + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + SerializedMessage { + id: MessageId(2), + role: Role::Assistant, + segments: vec![SerializedMessageSegment::Text { + text: "I want to use a tool".to_string(), + }], + tool_uses: vec![SerializedToolUse { + id: "abc".into(), + name: "tool_1".into(), + input: serde_json::Value::Null, + }], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Here is the tool result".to_string(), + }], + tool_uses: vec![], + tool_results: vec![SerializedToolResult { + tool_use_id: "abc".into(), + is_error: false, + content: LanguageModelToolResultContent::Text("abcdef".into()), + output: Some(serde_json::Value::Null), + }], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + ], + version: SerializedThreadV0_1_0::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None, + }); + let upgraded = thread_v0_1_0.upgrade(); + + assert_eq!( + upgraded, + SerializedThread { + summary: "Test conversation".into(), + updated_at, + messages: vec![ + SerializedMessage { + id: MessageId(1), + role: Role::User, + segments: vec![SerializedMessageSegment::Text { + text: "Use tool_1".to_string() + }], + tool_uses: vec![], + tool_results: vec![], + context: "".to_string(), + creases: vec![], + is_hidden: false + }, + SerializedMessage { + id: MessageId(2), + role: Role::Assistant, + segments: vec![SerializedMessageSegment::Text { + text: "I want to use a tool".to_string(), + }], + tool_uses: vec![SerializedToolUse { + id: "abc".into(), + name: "tool_1".into(), + input: serde_json::Value::Null, + }], + tool_results: vec![SerializedToolResult { + tool_use_id: "abc".into(), + is_error: false, + content: LanguageModelToolResultContent::Text("abcdef".into()), + output: Some(serde_json::Value::Null), + }], + context: "".to_string(), + creases: vec![], + is_hidden: false, + }, + ], + version: SerializedThread::VERSION.to_string(), + initial_project_snapshot: None, + cumulative_token_usage: TokenUsage::default(), + request_token_usage: vec![], + detailed_summary_state: DetailedSummaryState::default(), + model: None, + completion_mode: None, + tool_use_limit_reached: false, + profile: None + } + ) + } +} diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..b28009223b7a7f2232b440282a0d6f61907f442c --- /dev/null +++ b/crates/agent/src/native_agent_server.rs @@ -0,0 +1,128 @@ +use std::{any::Any, path::Path, rc::Rc, sync::Arc}; + +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::Result; +use fs::Fs; +use gpui::{App, Entity, SharedString, Task}; +use prompt_store::PromptStore; + +use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates}; + +#[derive(Clone)] +pub struct NativeAgentServer { + fs: Arc, + history: Entity, +} + +impl NativeAgentServer { + pub fn new(fs: Arc, history: Entity) -> Self { + Self { fs, history } + } +} + +impl AgentServer for NativeAgentServer { + fn telemetry_id(&self) -> &'static str { + "zed" + } + + fn name(&self) -> SharedString { + "Zed Agent".into() + } + + fn logo(&self) -> ui::IconName { + ui::IconName::ZedAgent + } + + fn connect( + &self, + _root_dir: Option<&Path>, + delegate: AgentServerDelegate, + cx: &mut App, + ) -> Task< + Result<( + Rc, + Option, + )>, + > { + log::debug!( + "NativeAgentServer::connect called for path: {:?}", + _root_dir + ); + let project = delegate.project().clone(); + let fs = self.fs.clone(); + let history = self.history.clone(); + let prompt_store = PromptStore::global(cx); + cx.spawn(async move |cx| { + log::debug!("Creating templates for native agent"); + let templates = Templates::new(); + let prompt_store = prompt_store.await?; + + log::debug!("Creating native agent entity"); + let agent = + NativeAgent::new(project, history, templates, Some(prompt_store), fs, cx).await?; + + // Create the connection wrapper + let connection = NativeAgentConnection(agent); + log::debug!("NativeAgentServer connection established successfully"); + + Ok(( + Rc::new(connection) as Rc, + None, + )) + }) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use assistant_text_thread::TextThreadStore; + use gpui::AppContext; + + agent_servers::e2e_tests::common_e2e_tests!( + async |fs, project, cx| { + let auth = cx.update(|cx| { + prompt_store::init(cx); + terminal::init(cx); + + let registry = language_model::LanguageModelRegistry::read_global(cx); + let auth = registry + .provider(&language_model::ANTHROPIC_PROVIDER_ID) + .unwrap() + .authenticate(cx); + + cx.spawn(async move |_| auth.await) + }); + + auth.await.unwrap(); + + cx.update(|cx| { + let registry = language_model::LanguageModelRegistry::global(cx); + + registry.update(cx, |registry, cx| { + registry.select_default_model( + Some(&language_model::SelectedModel { + provider: language_model::ANTHROPIC_PROVIDER_ID, + model: language_model::LanguageModelId("claude-sonnet-4-latest".into()), + }), + cx, + ); + }); + }); + + let history = cx.update(|cx| { + let text_thread_store = + cx.new(move |cx| TextThreadStore::fake(project.clone(), cx)); + cx.new(move |cx| HistoryStore::new(text_thread_store, cx)) + }); + + NativeAgentServer::new(fs.clone(), history) + }, + allow_option_id = "allow" + ); +} diff --git a/crates/assistant_tool/src/outline.rs b/crates/agent/src/outline.rs similarity index 51% rename from crates/assistant_tool/src/outline.rs rename to crates/agent/src/outline.rs index 4f8bde5456073912185fe160d48363eac7601ef5..bc78290fb52ae208742b9dea0e6dbbe560022419 100644 --- a/crates/assistant_tool/src/outline.rs +++ b/crates/agent/src/outline.rs @@ -1,8 +1,6 @@ -use action_log::ActionLog; -use anyhow::{Context as _, Result}; +use anyhow::Result; use gpui::{AsyncApp, Entity}; -use language::{OutlineItem, ParseStatus}; -use project::Project; +use language::{Buffer, OutlineItem, ParseStatus}; use regex::Regex; use std::fmt::Write; use text::Point; @@ -11,53 +9,66 @@ use text::Point; /// we automatically provide the file's symbol outline instead, with line numbers. pub const AUTO_OUTLINE_SIZE: usize = 16384; -pub async fn file_outline( - project: Entity, - path: String, - action_log: Entity, - regex: Option, - cx: &mut AsyncApp, -) -> anyhow::Result { - let buffer = { - let project_path = project.read_with(cx, |project, cx| { - project - .find_project_path(&path, cx) - .with_context(|| format!("Path {path} not found in project")) - })??; - - project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await? - }; +/// Result of getting buffer content, which can be either full content or an outline. +pub struct BufferContent { + /// The actual content (either full text or outline) + pub text: String, + /// Whether this is an outline (true) or full content (false) + pub is_outline: bool, +} - action_log.update(cx, |action_log, cx| { - action_log.buffer_read(buffer.clone(), cx); - })?; +/// Returns either the full content of a buffer or its outline, depending on size. +/// For files larger than AUTO_OUTLINE_SIZE, returns an outline with a header. +/// For smaller files, returns the full content. +pub async fn get_buffer_content_or_outline( + buffer: Entity, + path: Option<&str>, + cx: &AsyncApp, +) -> Result { + let file_size = buffer.read_with(cx, |buffer, _| buffer.text().len())?; + + if file_size > AUTO_OUTLINE_SIZE { + // For large files, use outline instead of full content + // Wait until the buffer has been fully parsed, so we can read its outline + let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; + while *parse_status.borrow() != ParseStatus::Idle { + parse_status.changed().await?; + } - // Wait until the buffer has been fully parsed, so that we can read its outline. - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; + let outline_items = buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot + .outline(None) + .items + .into_iter() + .map(|item| item.to_point(&snapshot)) + .collect::>() + })?; + + let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?; + + let text = if let Some(path) = path { + format!( + "# File outline for {path} (file too large to show full content)\n\n{outline_text}", + ) + } else { + format!("# File outline (file too large to show full content)\n\n{outline_text}",) + }; + Ok(BufferContent { + text, + is_outline: true, + }) + } else { + // File is small enough, return full content + let text = buffer.read_with(cx, |buffer, _| buffer.text())?; + Ok(BufferContent { + text, + is_outline: false, + }) } - - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let outline = snapshot - .outline(None) - .context("No outline information available for this file at path {path}")?; - - render_outline( - outline - .items - .into_iter() - .map(|item| item.to_point(&snapshot)), - regex, - 0, - usize::MAX, - ) - .await } -pub async fn render_outline( +async fn render_outline( items: impl IntoIterator>, regex: Option, offset: usize, diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt deleted file mode 100644 index f743e239c883c7456f7bdc6e089185c6b994cb44..0000000000000000000000000000000000000000 --- a/crates/agent/src/prompts/stale_files_prompt_header.txt +++ /dev/null @@ -1,3 +0,0 @@ -[The following is an auto-generated notification; do not reply] - -These files have changed since the last read: diff --git a/crates/agent2/src/templates.rs b/crates/agent/src/templates.rs similarity index 98% rename from crates/agent2/src/templates.rs rename to crates/agent/src/templates.rs index a63f0ad206308130712b9481cfd7231eb0fd2696..72a8f6633cb7bb926580dbb4f9e65ec032162d93 100644 --- a/crates/agent2/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -62,7 +62,7 @@ fn contains( handlebars::RenderError::new("contains: missing or invalid query parameter") })?; - if list.contains(&query) { + if list.contains(query) { out.write("true")?; } diff --git a/crates/assistant_tools/src/templates/create_file_prompt.hbs b/crates/agent/src/templates/create_file_prompt.hbs similarity index 100% rename from crates/assistant_tools/src/templates/create_file_prompt.hbs rename to crates/agent/src/templates/create_file_prompt.hbs diff --git a/crates/assistant_tools/src/templates/diff_judge.hbs b/crates/agent/src/templates/diff_judge.hbs similarity index 100% rename from crates/assistant_tools/src/templates/diff_judge.hbs rename to crates/agent/src/templates/diff_judge.hbs diff --git a/crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs b/crates/agent/src/templates/edit_file_prompt_diff_fenced.hbs similarity index 100% rename from crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs rename to crates/agent/src/templates/edit_file_prompt_diff_fenced.hbs diff --git a/crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs b/crates/agent/src/templates/edit_file_prompt_xml.hbs similarity index 100% rename from crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs rename to crates/agent/src/templates/edit_file_prompt_xml.hbs diff --git a/crates/agent2/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs similarity index 93% rename from crates/agent2/src/templates/system_prompt.hbs rename to crates/agent/src/templates/system_prompt.hbs index a9f67460d81e79f03d0a0a9b60cd4d6c32fc3b20..ca324fad7acccb3e50f1140c8f99d52319d159d4 100644 --- a/crates/agent2/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -48,16 +48,15 @@ The one exception to this is if the user references something you don't know abo ## Code Block Formatting Whenever you mention a code block, you MUST use ONLY use the following format: + ```path/to/Something.blah#L123-456 (code goes here) ``` -The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah -is a path in the project. (If there is no valid path in the project, then you can use -/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser -does not understand the more common ```language syntax, or bare ``` blocks. It only -understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again. + +The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah is a path in the project. (If there is no valid path in the project, then you can use /dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser does not understand the more common ```language syntax, or bare ``` blocks. It only understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again. Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP! You have made a mistake. You can only ever put paths after triple backticks! + Based on all the information I've gathered, here's a summary of how this system works: 1. The README file is loaded into the system. @@ -74,6 +73,7 @@ This is the last header in the README. ``` 4. Finally, it passes this information on to the next process. + In Markdown, hash marks signify headings. For example: ```/dev/null/example.md#L1-3 @@ -82,6 +82,7 @@ In Markdown, hash marks signify headings. For example: ### Level 3 heading ``` + Here are examples of ways you must never render code blocks: In Markdown, hash marks signify headings. For example: @@ -91,7 +92,9 @@ In Markdown, hash marks signify headings. For example: ### Level 3 heading ``` + This example is unacceptable because it does not include the path. + In Markdown, hash marks signify headings. For example: ```markdown @@ -101,14 +104,15 @@ In Markdown, hash marks signify headings. For example: ``` This example is unacceptable because it has the language instead of the path. + In Markdown, hash marks signify headings. For example: # Level 1 heading ## Level 2 heading ### Level 3 heading -This example is unacceptable because it uses indentation to mark the code block -instead of backticks with a path. +This example is unacceptable because it uses indentation to mark the code block instead of backticks with a path. + In Markdown, hash marks signify headings. For example: ```markdown diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..20fc40f242831552630f1e15f59917fd80b1ecdb --- /dev/null +++ b/crates/agent/src/tests/mod.rs @@ -0,0 +1,2585 @@ +use super::*; +use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList, UserMessageId}; +use agent_client_protocol::{self as acp}; +use agent_settings::AgentProfileId; +use anyhow::Result; +use client::{Client, UserStore}; +use cloud_llm_client::CompletionIntent; +use collections::IndexMap; +use context_server::{ContextServer, ContextServerCommand, ContextServerId}; +use fs::{FakeFs, Fs}; +use futures::{ + StreamExt, + channel::{ + mpsc::{self, UnboundedReceiver}, + oneshot, + }, +}; +use gpui::{ + App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, +}; +use indoc::indoc; +use language_model::{ + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat, + LanguageModelToolUse, MessageContent, Role, StopReason, fake_provider::FakeLanguageModel, +}; +use pretty_assertions::assert_eq; +use project::{ + Project, context_server_store::ContextServerStore, project_settings::ProjectSettings, +}; +use prompt_store::ProjectContext; +use reqwest_client::ReqwestClient; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use settings::{Settings, SettingsStore}; +use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use util::path; + +mod test_tools; +use test_tools::*; + +#[gpui::test] +async fn test_echo(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hello"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let events = events.collect().await; + thread.update(cx, |thread, _cx| { + assert_eq!( + thread.last_message().unwrap().to_markdown(), + indoc! {" + ## Assistant + + Hello + "} + ) + }); + assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); +} + +#[gpui::test] +async fn test_thinking(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.send( + UserMessageId::new(), + [indoc! {" + Testing: + + Generate a thinking step where you just think the word 'Think', + and have your final answer be 'Hello' + "}], + cx, + ) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Thinking { + text: "Think".to_string(), + signature: None, + }); + fake_model.send_last_completion_stream_text_chunk("Hello"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let events = events.collect().await; + thread.update(cx, |thread, _cx| { + assert_eq!( + thread.last_message().unwrap().to_markdown(), + indoc! {" + ## Assistant + + Think + Hello + "} + ) + }); + assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); +} + +#[gpui::test] +async fn test_system_prompt(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + project_context, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + project_context.update(cx, |project_context, _cx| { + project_context.shell = "test-shell".into() + }); + thread.update(cx, |thread, _| thread.add_tool(EchoTool)); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let mut pending_completions = fake_model.pending_completions(); + assert_eq!( + pending_completions.len(), + 1, + "unexpected pending completions: {:?}", + pending_completions + ); + + let pending_completion = pending_completions.pop().unwrap(); + assert_eq!(pending_completion.messages[0].role, Role::System); + + let system_message = &pending_completion.messages[0]; + let system_prompt = system_message.content[0].to_str().unwrap(); + assert!( + system_prompt.contains("test-shell"), + "unexpected system message: {:?}", + system_message + ); + assert!( + system_prompt.contains("## Fixing Diagnostics"), + "unexpected system message: {:?}", + system_message + ); +} + +#[gpui::test] +async fn test_system_prompt_without_tools(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let mut pending_completions = fake_model.pending_completions(); + assert_eq!( + pending_completions.len(), + 1, + "unexpected pending completions: {:?}", + pending_completions + ); + + let pending_completion = pending_completions.pop().unwrap(); + assert_eq!(pending_completion.messages[0].role, Role::System); + + let system_message = &pending_completion.messages[0]; + let system_prompt = system_message.content[0].to_str().unwrap(); + assert!( + !system_prompt.contains("## Tool Use"), + "unexpected system message: {:?}", + system_message + ); + assert!( + !system_prompt.contains("## Fixing Diagnostics"), + "unexpected system message: {:?}", + system_message + ); +} + +#[gpui::test] +async fn test_prompt_caching(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Send initial user message and verify it's cached + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![LanguageModelRequestMessage { + role: Role::User, + content: vec!["Message 1".into()], + cache: true + }] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( + "Response to Message 1".into(), + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Send another user message and verify only the latest is cached + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Message 1".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec!["Response to Message 1".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Message 2".into()], + cache: true + } + ] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( + "Response to Message 2".into(), + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Simulate a tool call and verify that the latest tool result is cached + thread.update(cx, |thread, _| thread.add_tool(EchoTool)); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use = LanguageModelToolUse { + id: "tool_1".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + let tool_result = LanguageModelToolResult { + tool_use_id: "tool_1".into(), + tool_name: EchoTool::name().into(), + is_error: false, + content: "test".into(), + output: Some("test".into()), + }; + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Message 1".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec!["Response to Message 1".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Message 2".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec!["Response to Message 2".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Use the echo tool".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::ToolUse(tool_use)], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(tool_result)], + cache: true + } + ] + ); +} + +#[gpui::test] +#[cfg_attr(not(feature = "e2e"), ignore)] +async fn test_basic_tool_calls(cx: &mut TestAppContext) { + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; + + // Test a tool call that's likely to complete *before* streaming stops. + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send( + UserMessageId::new(), + ["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."], + cx, + ) + }) + .unwrap() + .collect() + .await; + assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); + + // Test a tool calls that's likely to complete *after* streaming stops. + let events = thread + .update(cx, |thread, cx| { + thread.remove_tool(&EchoTool::name()); + thread.add_tool(DelayTool); + thread.send( + UserMessageId::new(), + [ + "Now call the delay tool with 200ms.", + "When the timer goes off, then you echo the output of the tool.", + ], + cx, + ) + }) + .unwrap() + .collect() + .await; + assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); + thread.update(cx, |thread, _cx| { + assert!( + thread + .last_message() + .unwrap() + .as_agent_message() + .unwrap() + .content + .iter() + .any(|content| { + if let AgentMessageContent::Text(text) = content { + text.contains("Ding") + } else { + false + } + }), + "{}", + thread.to_markdown() + ); + }); +} + +#[gpui::test] +#[cfg_attr(not(feature = "e2e"), ignore)] +async fn test_streaming_tool_calls(cx: &mut TestAppContext) { + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; + + // Test a tool call that's likely to complete *before* streaming stops. + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(WordListTool); + thread.send(UserMessageId::new(), ["Test the word_list tool."], cx) + }) + .unwrap(); + + let mut saw_partial_tool_use = false; + while let Some(event) = events.next().await { + if let Ok(ThreadEvent::ToolCall(tool_call)) = event { + thread.update(cx, |thread, _cx| { + // Look for a tool use in the thread's last message + let message = thread.last_message().unwrap(); + let agent_message = message.as_agent_message().unwrap(); + let last_content = agent_message.content.last().unwrap(); + if let AgentMessageContent::ToolUse(last_tool_use) = last_content { + assert_eq!(last_tool_use.name.as_ref(), "word_list"); + if tool_call.status == acp::ToolCallStatus::Pending { + if !last_tool_use.is_input_complete + && last_tool_use.input.get("g").is_none() + { + saw_partial_tool_use = true; + } + } else { + last_tool_use + .input + .get("a") + .expect("'a' has streamed because input is now complete"); + last_tool_use + .input + .get("g") + .expect("'g' has streamed because input is now complete"); + } + } else { + panic!("last content should be a tool use"); + } + }); + } + } + + assert!( + saw_partial_tool_use, + "should see at least one partially streamed tool use in the history" + ); +} + +#[gpui::test] +async fn test_tool_authorization(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(ToolRequiringPermission); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_1".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_2".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + let tool_call_auth_1 = next_tool_call_authorization(&mut events).await; + let tool_call_auth_2 = next_tool_call_authorization(&mut events).await; + + // Approve the first + tool_call_auth_1 + .response + .send(tool_call_auth_1.options[1].id.clone()) + .unwrap(); + cx.run_until_parked(); + + // Reject the second + tool_call_auth_2 + .response + .send(tool_call_auth_1.options[2].id.clone()) + .unwrap(); + cx.run_until_parked(); + + let completion = fake_model.pending_completions().pop().unwrap(); + let message = completion.messages.last().unwrap(); + assert_eq!( + message.content, + vec![ + language_model::MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), + tool_name: ToolRequiringPermission::name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + }), + language_model::MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), + tool_name: ToolRequiringPermission::name().into(), + is_error: true, + content: "Permission to run tool denied by user".into(), + output: Some("Permission to run tool denied by user".into()) + }) + ] + ); + + // Simulate yet another tool call. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_3".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + + // Respond by always allowing tools. + let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; + tool_call_auth_3 + .response + .send(tool_call_auth_3.options[0].id.clone()) + .unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + let message = completion.messages.last().unwrap(); + assert_eq!( + message.content, + vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_name: ToolRequiringPermission::name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + } + )] + ); + + // Simulate a final tool call, ensuring we don't trigger authorization. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_4".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + let message = completion.messages.last().unwrap(); + assert_eq!( + message.content, + vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: "tool_id_4".into(), + tool_name: ToolRequiringPermission::name().into(), + is_error: false, + content: "Allowed".into(), + output: Some("Allowed".into()) + } + )] + ); +} + +#[gpui::test] +async fn test_tool_hallucination(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_id_1".into(), + name: "nonexistent_tool".into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!(tool_call.title, "nonexistent_tool"); + assert_eq!(tool_call.status, acp::ToolCallStatus::Pending); + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); +} + +#[gpui::test] +async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let tool_use = LanguageModelToolUse { + id: "tool_id_1".into(), + name: EchoTool::name().into(), + raw_input: "{}".into(), + input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), + is_input_complete: true, + }; + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); + fake_model.end_last_completion_stream(); + + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + let tool_result = LanguageModelToolResult { + tool_use_id: "tool_id_1".into(), + tool_name: EchoTool::name().into(), + is_error: false, + content: "def".into(), + output: Some("def".into()), + }; + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["abc".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::ToolUse(tool_use.clone())], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(tool_result.clone())], + cache: true + }, + ] + ); + + // Simulate reaching tool use limit. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( + cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, + )); + fake_model.end_last_completion_stream(); + let last_event = events.collect::>().await.pop().unwrap(); + assert!( + last_event + .unwrap_err() + .is::() + ); + + let events = thread.update(cx, |thread, cx| thread.resume(cx)).unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["abc".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::ToolUse(tool_use)], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(tool_result)], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Continue where you left off".into()], + cache: true + } + ] + ); + + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text("Done".into())); + fake_model.end_last_completion_stream(); + events.collect::>().await; + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.last_message().unwrap().to_markdown(), + indoc! {" + ## Assistant + + Done + "} + ) + }); +} + +#[gpui::test] +async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use = LanguageModelToolUse { + id: "tool_id_1".into(), + name: EchoTool::name().into(), + raw_input: "{}".into(), + input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), + is_input_complete: true, + }; + let tool_result = LanguageModelToolResult { + tool_use_id: "tool_id_1".into(), + tool_name: EchoTool::name().into(), + is_error: false, + content: "def".into(), + output: Some("def".into()), + }; + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( + cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, + )); + fake_model.end_last_completion_stream(); + let last_event = events.collect::>().await.pop().unwrap(); + assert!( + last_event + .unwrap_err() + .is::() + ); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), vec!["ghi"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["abc".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::ToolUse(tool_use)], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(tool_result)], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec!["ghi".into()], + cache: true + } + ] + ); +} + +async fn expect_tool_call(events: &mut UnboundedReceiver>) -> acp::ToolCall { + let event = events + .next() + .await + .expect("no tool call authorization event received") + .unwrap(); + match event { + ThreadEvent::ToolCall(tool_call) => tool_call, + event => { + panic!("Unexpected event {event:?}"); + } + } +} + +async fn expect_tool_call_update_fields( + events: &mut UnboundedReceiver>, +) -> acp::ToolCallUpdate { + let event = events + .next() + .await + .expect("no tool call authorization event received") + .unwrap(); + match event { + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => update, + event => { + panic!("Unexpected event {event:?}"); + } + } +} + +async fn next_tool_call_authorization( + events: &mut UnboundedReceiver>, +) -> ToolCallAuthorization { + loop { + let event = events + .next() + .await + .expect("no tool call authorization event received") + .unwrap(); + if let ThreadEvent::ToolCallAuthorization(tool_call_authorization) = event { + let permission_kinds = tool_call_authorization + .options + .iter() + .map(|o| o.kind) + .collect::>(); + assert_eq!( + permission_kinds, + vec![ + acp::PermissionOptionKind::AllowAlways, + acp::PermissionOptionKind::AllowOnce, + acp::PermissionOptionKind::RejectOnce, + ] + ); + return tool_call_authorization; + } + } +} + +#[gpui::test] +#[cfg_attr(not(feature = "e2e"), ignore)] +async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; + + // Test concurrent tool calls with different delay times + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(DelayTool); + thread.send( + UserMessageId::new(), + [ + "Call the delay tool twice in the same message.", + "Once with 100ms. Once with 300ms.", + "When both timers are complete, describe the outputs.", + ], + cx, + ) + }) + .unwrap() + .collect() + .await; + + let stop_reasons = stop_events(events); + assert_eq!(stop_reasons, vec![acp::StopReason::EndTurn]); + + thread.update(cx, |thread, _cx| { + let last_message = thread.last_message().unwrap(); + let agent_message = last_message.as_agent_message().unwrap(); + let text = agent_message + .content + .iter() + .filter_map(|content| { + if let AgentMessageContent::Text(text) = content { + Some(text.as_str()) + } else { + None + } + }) + .collect::(); + + assert!(text.contains("Ding")); + }); +} + +#[gpui::test] +async fn test_profiles(cx: &mut TestAppContext) { + let ThreadTest { + model, thread, fs, .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread.update(cx, |thread, _cx| { + thread.add_tool(DelayTool); + thread.add_tool(EchoTool); + thread.add_tool(InfiniteTool); + }); + + // Override profiles and wait for settings to be loaded. + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "profiles": { + "test-1": { + "name": "Test Profile 1", + "tools": { + EchoTool::name(): true, + DelayTool::name(): true, + } + }, + "test-2": { + "name": "Test Profile 2", + "tools": { + InfiniteTool::name(): true, + } + } + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + + // Test that test-1 profile (default) has echo and delay tools + thread + .update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-1".into())); + thread.send(UserMessageId::new(), ["test"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let mut pending_completions = fake_model.pending_completions(); + assert_eq!(pending_completions.len(), 1); + let completion = pending_completions.pop().unwrap(); + let tool_names: Vec = completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert_eq!(tool_names, vec![DelayTool::name(), EchoTool::name()]); + fake_model.end_last_completion_stream(); + + // Switch to test-2 profile, and verify that it has only the infinite tool. + thread + .update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-2".into())); + thread.send(UserMessageId::new(), ["test2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let mut pending_completions = fake_model.pending_completions(); + assert_eq!(pending_completions.len(), 1); + let completion = pending_completions.pop().unwrap(); + let tool_names: Vec = completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert_eq!(tool_names, vec![InfiniteTool::name()]); +} + +#[gpui::test] +async fn test_mcp_tools(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Override profiles and wait for settings to be loaded. + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "always_allow_tool_actions": true, + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + "tools": { + EchoTool::name(): true, + } + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + thread.update(cx, |thread, _| { + thread.set_profile(AgentProfileId("test".into())) + }); + + let mut mcp_tool_calls = setup_context_server( + "test_server", + vec![context_server::types::Tool { + name: "echo".into(), + description: None, + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) + .unwrap(), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + + let events = thread.update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hey"], cx).unwrap() + }); + cx.run_until_parked(); + + // Simulate the model calling the MCP tool. + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!(tool_names_for_completion(&completion), vec!["echo"]); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "echo".into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "echo"); + assert_eq!(tool_call_params.arguments, Some(json!({"text": "test"}))); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { + text: "test".into(), + }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!(tool_names_for_completion(&completion), vec!["echo"]); + fake_model.send_last_completion_stream_text_chunk("Done!"); + fake_model.end_last_completion_stream(); + events.collect::>().await; + + // Send again after adding the echo tool, ensuring the name collision is resolved. + let events = thread.update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Go"], cx).unwrap() + }); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec!["echo", "test_server_echo"] + ); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_2".into(), + name: "test_server_echo".into(), + raw_input: json!({"text": "mcp"}).to_string(), + input: json!({"text": "mcp"}), + is_input_complete: true, + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_3".into(), + name: "echo".into(), + raw_input: json!({"text": "native"}).to_string(), + input: json!({"text": "native"}), + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "echo"); + assert_eq!(tool_call_params.arguments, Some(json!({"text": "mcp"}))); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { text: "mcp".into() }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + // Ensure the tool results were inserted with the correct names. + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages.last().unwrap().content, + vec![ + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_3".into(), + tool_name: "echo".into(), + is_error: false, + content: "native".into(), + output: Some("native".into()), + },), + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: "tool_2".into(), + tool_name: "test_server_echo".into(), + is_error: false, + content: "mcp".into(), + output: Some("mcp".into()), + },), + ] + ); + fake_model.end_last_completion_stream(); + events.collect::>().await; +} + +#[gpui::test] +async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Set up a profile with all tools enabled + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + "tools": { + EchoTool::name(): true, + DelayTool::name(): true, + WordListTool::name(): true, + ToolRequiringPermission::name(): true, + InfiniteTool::name(): true, + } + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + + thread.update(cx, |thread, _| { + thread.set_profile(AgentProfileId("test".into())); + thread.add_tool(EchoTool); + thread.add_tool(DelayTool); + thread.add_tool(WordListTool); + thread.add_tool(ToolRequiringPermission); + thread.add_tool(InfiniteTool); + }); + + // Set up multiple context servers with some overlapping tool names + let _server1_calls = setup_context_server( + "xxx", + vec![ + context_server::types::Tool { + name: "echo".into(), // Conflicts with native EchoTool + description: None, + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) + .unwrap(), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "unique_tool_1".into(), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + + let _server2_calls = setup_context_server( + "yyy", + vec![ + context_server::types::Tool { + name: "echo".into(), // Also conflicts with native EchoTool + description: None, + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) + .unwrap(), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "unique_tool_2".into(), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + let _server3_calls = setup_context_server( + "zzz", + vec![ + context_server::types::Tool { + name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + context_server::types::Tool { + name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1), + description: None, + input_schema: json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + }, + ], + &context_server_store, + cx, + ); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Go"], cx) + }) + .unwrap(); + cx.run_until_parked(); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec![ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "delay", + "echo", + "infinite", + "tool_requiring_permission", + "unique_tool_1", + "unique_tool_2", + "word_list", + "xxx_echo", + "y_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "yyy_echo", + "z_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] + ); +} + +#[gpui::test] +#[cfg_attr(not(feature = "e2e"), ignore)] +async fn test_cancellation(cx: &mut TestAppContext) { + let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; + + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(InfiniteTool); + thread.add_tool(EchoTool); + thread.send( + UserMessageId::new(), + ["Call the echo tool, then call the infinite tool, then explain their output"], + cx, + ) + }) + .unwrap(); + + // Wait until both tools are called. + let mut expected_tools = vec!["Echo", "Infinite Tool"]; + let mut echo_id = None; + let mut echo_completed = false; + while let Some(event) = events.next().await { + match event.unwrap() { + ThreadEvent::ToolCall(tool_call) => { + assert_eq!(tool_call.title, expected_tools.remove(0)); + if tool_call.title == "Echo" { + echo_id = Some(tool_call.id); + } + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + acp::ToolCallUpdate { + id, + fields: + acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::Completed), + .. + }, + meta: None, + }, + )) if Some(&id) == echo_id.as_ref() => { + echo_completed = true; + } + _ => {} + } + + if expected_tools.is_empty() && echo_completed { + break; + } + } + + // Cancel the current send and ensure that the event stream is closed, even + // if one of the tools is still running. + thread.update(cx, |thread, cx| thread.cancel(cx)); + let events = events.collect::>().await; + let last_event = events.last(); + assert!( + matches!( + last_event, + Some(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) + ), + "unexpected event {last_event:?}" + ); + + // Ensure we can still send a new message after cancellation. + let events = thread + .update(cx, |thread, cx| { + thread.send( + UserMessageId::new(), + ["Testing: reply with 'Hello' then stop."], + cx, + ) + }) + .unwrap() + .collect::>() + .await; + thread.update(cx, |thread, _cx| { + let message = thread.last_message().unwrap(); + let agent_message = message.as_agent_message().unwrap(); + assert_eq!( + agent_message.content, + vec![AgentMessageContent::Text("Hello".to_string())] + ); + }); + assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); +} + +#[gpui::test] +async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events_1 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey 1!"); + cx.run_until_parked(); + + let events_2 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey 2!"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + + let events_1 = events_1.collect::>().await; + assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]); + let events_2 = events_2.collect::>().await; + assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]); +} + +#[gpui::test] +async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events_1 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey 1!"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + let events_1 = events_1.collect::>().await; + + let events_2 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey 2!"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + let events_2 = events_2.collect::>().await; + + assert_eq!(stop_events(events_1), vec![acp::StopReason::EndTurn]); + assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]); +} + +#[gpui::test] +async fn test_refusal(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello + "} + ); + }); + + fake_model.send_last_completion_stream_text_chunk("Hey!"); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello + + ## Assistant + + Hey! + "} + ); + }); + + // If the model refuses to continue, the thread should remove all the messages after the last user message. + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::Refusal)); + let events = events.collect::>().await; + assert_eq!(stop_events(events), vec![acp::StopReason::Refusal]); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.to_markdown(), ""); + }); +} + +#[gpui::test] +async fn test_truncate_first_message(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let message_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_id.clone(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello + "} + ); + assert_eq!(thread.latest_token_usage(), None); + }); + + fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 32_000, + output_tokens: 16_000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello + + ## Assistant + + Hey! + "} + ); + assert_eq!( + thread.latest_token_usage(), + Some(acp_thread::TokenUsage { + used_tokens: 32_000 + 16_000, + max_tokens: 1_000_000, + }) + ); + }); + + thread + .update(cx, |thread, cx| thread.truncate(message_id, cx)) + .unwrap(); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.to_markdown(), ""); + assert_eq!(thread.latest_token_usage(), None); + }); + + // Ensure we can still send a new message after truncation. + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hi"], cx) + }) + .unwrap(); + thread.update(cx, |thread, _cx| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hi + "} + ); + }); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Ahoy!"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 40_000, + output_tokens: 20_000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hi + + ## Assistant + + Ahoy! + "} + ); + + assert_eq!( + thread.latest_token_usage(), + Some(acp_thread::TokenUsage { + used_tokens: 40_000 + 20_000, + max_tokens: 1_000_000, + }) + ); + }); +} + +#[gpui::test] +async fn test_truncate_second_message(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Message 1 response"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 32_000, + output_tokens: 16_000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let assert_first_message_state = |cx: &mut TestAppContext| { + thread.clone().read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + "} + ); + + assert_eq!( + thread.latest_token_usage(), + Some(acp_thread::TokenUsage { + used_tokens: 32_000 + 16_000, + max_tokens: 1_000_000, + }) + ); + }); + }; + + assert_first_message_state(cx); + + let second_message_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(second_message_id.clone(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Message 2 response"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 40_000, + output_tokens: 20_000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + + ## User + + Message 2 + + ## Assistant + + Message 2 response + "} + ); + + assert_eq!( + thread.latest_token_usage(), + Some(acp_thread::TokenUsage { + used_tokens: 40_000 + 20_000, + max_tokens: 1_000_000, + }) + ); + }); + + thread + .update(cx, |thread, cx| thread.truncate(second_message_id, cx)) + .unwrap(); + cx.run_until_parked(); + + assert_first_message_state(cx); +} + +#[gpui::test] +async fn test_title_generation(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let summary_model = Arc::new(FakeLanguageModel::default()); + thread.update(cx, |thread, cx| { + thread.set_summarization_model(Some(summary_model.clone()), cx) + }); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "New Thread")); + + // Ensure the summary model has been invoked to generate a title. + summary_model.send_last_completion_stream_text_chunk("Hello "); + summary_model.send_last_completion_stream_text_chunk("world\nG"); + summary_model.send_last_completion_stream_text_chunk("oodnight Moon"); + summary_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); + + // Send another message, ensuring no title is generated this time. + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello again"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey again!"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + assert_eq!(summary_model.pending_completions(), Vec::new()); + send.collect::>().await; + thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); +} + +#[gpui::test] +async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let _events = thread + .update(cx, |thread, cx| { + thread.add_tool(ToolRequiringPermission); + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Hey!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let permission_tool_use = LanguageModelToolUse { + id: "tool_id_1".into(), + name: ToolRequiringPermission::name().into(), + raw_input: "{}".into(), + input: json!({}), + is_input_complete: true, + }; + let echo_tool_use = LanguageModelToolUse { + id: "tool_id_2".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model.send_last_completion_stream_text_chunk("Hi!"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + permission_tool_use, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + echo_tool_use.clone(), + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Ensure pending tools are skipped when building a request. + let request = thread + .read_with(cx, |thread, cx| { + thread.build_completion_request(CompletionIntent::EditFile, cx) + }) + .unwrap(); + assert_eq!( + request.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Hey!".into()], + cache: true + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![ + MessageContent::Text("Hi!".into()), + MessageContent::ToolUse(echo_tool_use.clone()) + ], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: echo_tool_use.id.clone(), + tool_name: echo_tool_use.name, + is_error: false, + content: "test".into(), + output: Some("test".into()) + })], + cache: false + }, + ], + ); +} + +#[gpui::test] +async fn test_agent_connection(cx: &mut TestAppContext) { + cx.update(settings::init); + let templates = Templates::new(); + + // Initialize language model system with test provider + cx.update(|cx| { + gpui_tokio::init(cx); + client::init_settings(cx); + + let http_client = FakeHttpClient::with_404_response(); + let clock = Arc::new(clock::FakeSystemClock::new()); + let client = Client::new(clock, http_client, cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + Project::init_settings(cx); + LanguageModelRegistry::test(cx); + agent_settings::init(cx); + }); + cx.executor().forbid_parking(); + + // Create a project for new_thread + let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); + fake_fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; + let cwd = Path::new("/test"); + let text_thread_store = + cx.new(|cx| assistant_text_thread::TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create agent and connection + let agent = NativeAgent::new( + project.clone(), + history_store, + templates.clone(), + None, + fake_fs.clone(), + &mut cx.to_async(), + ) + .await + .unwrap(); + let connection = NativeAgentConnection(agent.clone()); + + // Create a thread using new_thread + let connection_rc = Rc::new(connection.clone()); + let acp_thread = cx + .update(|cx| connection_rc.new_thread(project, cwd, cx)) + .await + .expect("new_thread should succeed"); + + // Get the session_id from the AcpThread + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + + // Test model_selector returns Some + let selector_opt = connection.model_selector(&session_id); + assert!( + selector_opt.is_some(), + "agent should always support ModelSelector" + ); + let selector = selector_opt.unwrap(); + + // Test list_models + let listed_models = cx + .update(|cx| selector.list_models(cx)) + .await + .expect("list_models should succeed"); + let AgentModelList::Grouped(listed_models) = listed_models else { + panic!("Unexpected model list type"); + }; + assert!(!listed_models.is_empty(), "should have at least one model"); + assert_eq!( + listed_models[&AgentModelGroupName("Fake".into())][0] + .id + .0 + .as_ref(), + "fake/fake" + ); + + // Test selected_model returns the default + let model = cx + .update(|cx| selector.selected_model(cx)) + .await + .expect("selected_model should succeed"); + let model = cx + .update(|cx| agent.read(cx).models().model_from_id(&model.id)) + .unwrap(); + let model = model.as_fake(); + assert_eq!(model.id().0, "fake", "should return default model"); + + let request = acp_thread.update(cx, |thread, cx| thread.send(vec!["abc".into()], cx)); + cx.run_until_parked(); + model.send_last_completion_stream_text_chunk("def"); + cx.run_until_parked(); + acp_thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc! {" + ## User + + abc + + ## Assistant + + def + + "} + ) + }); + + // Test cancel + cx.update(|cx| connection.cancel(&session_id, cx)); + request.await.expect("prompt should fail gracefully"); + + // Ensure that dropping the ACP thread causes the native thread to be + // dropped as well. + cx.update(|_| drop(acp_thread)); + let result = cx + .update(|cx| { + connection.prompt( + Some(acp_thread::UserMessageId::new()), + acp::PromptRequest { + session_id: session_id.clone(), + prompt: vec!["ghi".into()], + meta: None, + }, + cx, + ) + }) + .await; + assert_eq!( + result.as_ref().unwrap_err().to_string(), + "Session not found", + "unexpected result: {:?}", + result + ); +} + +#[gpui::test] +async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool)); + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Think"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Simulate streaming partial input. + let input = json!({}); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "1".into(), + name: ThinkingTool::name().into(), + raw_input: input.to_string(), + input, + is_input_complete: false, + }, + )); + + // Input streaming completed + let input = json!({ "content": "Thinking hard!" }); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "1".into(), + name: "thinking".into(), + raw_input: input.to_string(), + input, + is_input_complete: true, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let tool_call = expect_tool_call(&mut events).await; + assert_eq!( + tool_call, + acp::ToolCall { + id: acp::ToolCallId("1".into()), + title: "Thinking".into(), + kind: acp::ToolKind::Think, + status: acp::ToolCallStatus::Pending, + content: vec![], + locations: vec![], + raw_input: Some(json!({})), + raw_output: None, + meta: Some(json!({ "tool_name": "thinking" })), + } + ); + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + title: Some("Thinking".into()), + kind: Some(acp::ToolKind::Think), + raw_input: Some(json!({ "content": "Thinking hard!" })), + ..Default::default() + }, + meta: None, + } + ); + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }, + meta: None, + } + ); + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + content: Some(vec!["Thinking hard!".into()]), + ..Default::default() + }, + meta: None, + } + ); + let update = expect_tool_call_update_fields(&mut events).await; + assert_eq!( + update, + acp::ToolCallUpdate { + id: acp::ToolCallId("1".into()), + fields: acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::Completed), + raw_output: Some("Finished thinking.".into()), + ..Default::default() + }, + meta: None, + } + ); +} + +#[gpui::test] +async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.send(UserMessageId::new(), ["Hello!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.end_last_completion_stream(); + + let mut retry_events = Vec::new(); + while let Some(Ok(event)) = events.next().await { + match event { + ThreadEvent::Retry(retry_status) => { + retry_events.push(retry_status); + } + ThreadEvent::Stop(..) => break, + _ => {} + } + } + + assert_eq!(retry_events.len(), 0); + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello! + + ## Assistant + + Hey! + "} + ) + }); +} + +#[gpui::test] +async fn test_send_retry_on_error(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.send(UserMessageId::new(), ["Hello!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("Hey,"); + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { + provider: LanguageModelProviderName::new("Anthropic"), + retry_after: Some(Duration::from_secs(3)), + }); + fake_model.end_last_completion_stream(); + + cx.executor().advance_clock(Duration::from_secs(3)); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_text_chunk("there!"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let mut retry_events = Vec::new(); + while let Some(Ok(event)) = events.next().await { + match event { + ThreadEvent::Retry(retry_status) => { + retry_events.push(retry_status); + } + ThreadEvent::Stop(..) => break, + _ => {} + } + } + + assert_eq!(retry_events.len(), 1); + assert!(matches!( + retry_events[0], + acp_thread::RetryStatus { attempt: 1, .. } + )); + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.to_markdown(), + indoc! {" + ## User + + Hello! + + ## Assistant + + Hey, + + [resume] + + ## Assistant + + there! + "} + ) + }); +} + +#[gpui::test] +async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Call the echo tool!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use_1 = LanguageModelToolUse { + id: "tool_1".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + tool_use_1.clone(), + )); + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { + provider: LanguageModelProviderName::new("Anthropic"), + retry_after: Some(Duration::from_secs(3)), + }); + fake_model.end_last_completion_stream(); + + cx.executor().advance_clock(Duration::from_secs(3)); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Call the echo tool!".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: tool_use_1.id.clone(), + tool_name: tool_use_1.name.clone(), + is_error: false, + content: "test".into(), + output: Some("test".into()) + } + )], + cache: true + }, + ] + ); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + events.collect::>().await; + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.last_message(), + Some(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text("Done".into())], + tool_results: IndexMap::default() + })) + ); + }) +} + +#[gpui::test] +async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let mut events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.send(UserMessageId::new(), ["Hello!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + for _ in 0..crate::thread::MAX_RETRY_ATTEMPTS + 1 { + fake_model.send_last_completion_stream_error( + LanguageModelCompletionError::ServerOverloaded { + provider: LanguageModelProviderName::new("Anthropic"), + retry_after: Some(Duration::from_secs(3)), + }, + ); + fake_model.end_last_completion_stream(); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.run_until_parked(); + } + + let mut errors = Vec::new(); + let mut retry_events = Vec::new(); + while let Some(event) = events.next().await { + match event { + Ok(ThreadEvent::Retry(retry_status)) => { + retry_events.push(retry_status); + } + Ok(ThreadEvent::Stop(..)) => break, + Err(error) => errors.push(error), + _ => {} + } + } + + assert_eq!( + retry_events.len(), + crate::thread::MAX_RETRY_ATTEMPTS as usize + ); + for i in 0..crate::thread::MAX_RETRY_ATTEMPTS as usize { + assert_eq!(retry_events[i].attempt, i + 1); + } + assert_eq!(errors.len(), 1); + let error = errors[0] + .downcast_ref::() + .unwrap(); + assert!(matches!( + error, + LanguageModelCompletionError::ServerOverloaded { .. } + )); +} + +/// Filters out the stop events for asserting against in tests +fn stop_events(result_events: Vec>) -> Vec { + result_events + .into_iter() + .filter_map(|event| match event.unwrap() { + ThreadEvent::Stop(stop_reason) => Some(stop_reason), + _ => None, + }) + .collect() +} + +struct ThreadTest { + model: Arc, + thread: Entity, + project_context: Entity, + context_server_store: Entity, + fs: Arc, +} + +enum TestModel { + Sonnet4, + Fake, +} + +impl TestModel { + fn id(&self) -> LanguageModelId { + match self { + TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()), + TestModel::Fake => unreachable!(), + } + } +} + +async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.create_dir(paths::settings_file().parent().unwrap()) + .await + .unwrap(); + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "default_profile": "test-profile", + "profiles": { + "test-profile": { + "name": "Test Profile", + "tools": { + EchoTool::name(): true, + DelayTool::name(): true, + WordListTool::name(): true, + ToolRequiringPermission::name(): true, + InfiniteTool::name(): true, + ThinkingTool::name(): true, + } + } + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + + cx.update(|cx| { + settings::init(cx); + Project::init_settings(cx); + agent_settings::init(cx); + + match model { + TestModel::Fake => {} + TestModel::Sonnet4 => { + gpui_tokio::init(cx); + let http_client = ReqwestClient::user_agent("agent tests").unwrap(); + cx.set_http_client(Arc::new(http_client)); + client::init_settings(cx); + let client = Client::production(cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + } + }; + + watch_settings(fs.clone(), cx); + }); + + let templates = Templates::new(); + + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + + let model = cx + .update(|cx| { + if let TestModel::Fake = model { + Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>) + } else { + let model_id = model.id(); + let models = LanguageModelRegistry::read_global(cx); + let model = models + .available_models(cx) + .find(|model| model.id() == model_id) + .unwrap(); + + let provider = models.provider(&model.provider_id()).unwrap(); + let authenticated = provider.authenticate(cx); + + cx.spawn(async move |_cx| { + authenticated.await.unwrap(); + model + }) + } + }) + .await; + + let project_context = cx.new(|_cx| ProjectContext::default()); + let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let thread = cx.new(|cx| { + Thread::new( + project, + project_context.clone(), + context_server_registry, + templates, + Some(model.clone()), + cx, + ) + }); + ThreadTest { + model, + thread, + project_context, + context_server_store, + fs, + } +} + +#[cfg(test)] +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} + +fn watch_settings(fs: Arc, cx: &mut App) { + let fs = fs.clone(); + cx.spawn({ + async move |cx| { + let mut new_settings_content_rx = settings::watch_config_file( + cx.background_executor(), + fs, + paths::settings_file().clone(), + ); + + while let Some(new_settings_content) = new_settings_content_rx.next().await { + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.set_user_settings(&new_settings_content, cx) + }) + }) + .ok(); + } + } + }) + .detach(); +} + +fn tool_names_for_completion(completion: &LanguageModelRequest) -> Vec { + completion + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect() +} + +fn setup_context_server( + name: &'static str, + tools: Vec, + context_server_store: &Entity, + cx: &mut TestAppContext, +) -> mpsc::UnboundedReceiver<( + context_server::types::CallToolParams, + oneshot::Sender, +)> { + cx.update(|cx| { + let mut settings = ProjectSettings::get_global(cx).clone(); + settings.context_servers.insert( + name.into(), + project::project_settings::ContextServerSettings::Custom { + enabled: true, + command: ContextServerCommand { + path: "somebinary".into(), + args: Vec::new(), + env: None, + timeout: None, + }, + }, + ); + ProjectSettings::override_global(settings, cx); + }); + + let (mcp_tool_calls_tx, mcp_tool_calls_rx) = mpsc::unbounded(); + let fake_transport = context_server::test::create_fake_transport(name, cx.executor()) + .on_request::(move |_params| async move { + context_server::types::InitializeResponse { + protocol_version: context_server::types::ProtocolVersion( + context_server::types::LATEST_PROTOCOL_VERSION.to_string(), + ), + server_info: context_server::types::Implementation { + name: name.into(), + version: "1.0.0".to_string(), + }, + capabilities: context_server::types::ServerCapabilities { + tools: Some(context_server::types::ToolsCapabilities { + list_changed: Some(true), + }), + ..Default::default() + }, + meta: None, + } + }) + .on_request::(move |_params| { + let tools = tools.clone(); + async move { + context_server::types::ListToolsResponse { + tools, + next_cursor: None, + meta: None, + } + } + }) + .on_request::(move |params| { + let mcp_tool_calls_tx = mcp_tool_calls_tx.clone(); + async move { + let (response_tx, response_rx) = oneshot::channel(); + mcp_tool_calls_tx + .unbounded_send((params, response_tx)) + .unwrap(); + response_rx.await.unwrap() + } + }); + context_server_store.update(cx, |store, cx| { + store.start_server( + Arc::new(ContextServer::new( + ContextServerId(name.into()), + Arc::new(fake_transport), + )), + cx, + ); + }); + cx.run_until_parked(); + mcp_tool_calls_rx +} diff --git a/crates/agent2/src/tests/test_tools.rs b/crates/agent/src/tests/test_tools.rs similarity index 77% rename from crates/agent2/src/tests/test_tools.rs rename to crates/agent/src/tests/test_tools.rs index cbff44cedfc28a0d24ea4fa12e3ac71c9135c0d8..2275d23c2f8a924efce2d2d4d8bcf6a6f3a59def 100644 --- a/crates/agent2/src/tests/test_tools.rs +++ b/crates/agent/src/tests/test_tools.rs @@ -16,15 +16,19 @@ impl AgentTool for EchoTool { type Input = EchoToolInput; type Output = String; - fn name(&self) -> SharedString { - "echo".into() + fn name() -> &'static str { + "echo" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "Echo".into() } @@ -51,11 +55,15 @@ impl AgentTool for DelayTool { type Input = DelayToolInput; type Output = String; - fn name(&self) -> SharedString { - "delay".into() + fn name() -> &'static str { + "delay" } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { format!("Delay {}ms", input.ms).into() } else { @@ -63,7 +71,7 @@ impl AgentTool for DelayTool { } } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } @@ -92,15 +100,19 @@ impl AgentTool for ToolRequiringPermission { type Input = ToolRequiringPermissionInput; type Output = String; - fn name(&self) -> SharedString { - "tool_requiring_permission".into() + fn name() -> &'static str { + "tool_requiring_permission" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "This tool requires permission".into() } @@ -127,15 +139,19 @@ impl AgentTool for InfiniteTool { type Input = InfiniteToolInput; type Output = String; - fn name(&self) -> SharedString { - "infinite".into() + fn name() -> &'static str { + "infinite" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "Infinite Tool".into() } @@ -178,15 +194,19 @@ impl AgentTool for WordListTool { type Input = WordListInput; type Output = String; - fn name(&self) -> SharedString { - "word_list".into() + fn name() -> &'static str { + "word_list" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "List of random words".into() } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 549184218517a0b9e4915187dea30174021182b2..64e512690beeaebd4a343bc5f2df473c795aed3f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,93 +1,60 @@ use crate::{ - agent_profile::AgentProfile, - context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext}, - thread_store::{ - SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment, - SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext, - ThreadStore, - }, - tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState}, + ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, + DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, + ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, + SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, }; +use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; -use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_PROMPT}; -use anyhow::{Result, anyhow}; -use assistant_tool::{AnyToolCard, Tool, ToolWorkingSet}; + +use agent_client_protocol as acp; +use agent_settings::{ + AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, +}; +use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use client::{ModelRequestUsage, RequestUsage}; +use client::{ModelRequestUsage, RequestUsage, UserStore}; use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit}; -use collections::HashMap; -use feature_flags::{self, FeatureFlagAppExt}; -use futures::{FutureExt, StreamExt as _, future::Shared}; -use git::repository::DiffType; +use collections::{HashMap, HashSet, IndexMap}; +use fs::Fs; +use futures::stream; +use futures::{ + FutureExt, + channel::{mpsc, oneshot}, + future::Shared, + stream::FuturesUnordered, +}; use gpui::{ - AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, - WeakEntity, Window, + App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; -use http_client::StatusCode; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelExt as _, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelExt, + LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, - LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, - ModelRequestLimitReachedError, PaymentRequiredError, Role, SelectedModel, StopReason, - TokenUsage, + LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, + LanguageModelToolUseId, Role, SelectedModel, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; -use postage::stream::Stream as _; -use project::{ - Project, - git_store::{GitStore, GitStoreCheckpoint, RepositoryState}, -}; -use prompt_store::{ModelContext, PromptBuilder}; -use schemars::JsonSchema; +use project::Project; +use prompt_store::ProjectContext; +use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; -use settings::Settings; +use settings::{Settings, update_settings_file}; +use smol::stream::StreamExt; use std::{ - io::Write, - ops::Range, + collections::BTreeMap, + ops::RangeInclusive, + path::Path, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; -use thiserror::Error; -use util::{ResultExt as _, post_inc}; +use std::{fmt::Write, path::PathBuf}; +use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle}; use uuid::Uuid; -const MAX_RETRY_ATTEMPTS: u8 = 4; -const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone)] -enum RetryStrategy { - ExponentialBackoff { - initial_delay: Duration, - max_attempts: u8, - }, - Fixed { - delay: Duration, - max_attempts: u8, - }, -} - -#[derive( - Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema, -)] -pub struct ThreadId(Arc); - -impl ThreadId { - pub fn new() -> Self { - Self(Uuid::new_v4().to_string().into()) - } -} - -impl std::fmt::Display for ThreadId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&str> for ThreadId { - fn from(value: &str) -> Self { - Self(value.into()) - } -} +const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; +pub const MAX_TOOL_NAME_LENGTH: usize = 64; /// The ID of the user prompt that initiated a request. /// @@ -107,2086 +74,1908 @@ impl std::fmt::Display for PromptId { } } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] -pub struct MessageId(pub(crate) usize); - -impl MessageId { - fn post_inc(&mut self) -> Self { - Self(post_inc(&mut self.0)) - } - - pub fn as_usize(&self) -> usize { - self.0 - } -} +pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4; +pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); -/// Stored information that can be used to resurrect a context crease when creating an editor for a past message. -#[derive(Clone, Debug)] -pub struct MessageCrease { - pub range: Range, - pub icon_path: SharedString, - pub label: SharedString, - /// None for a deserialized message, Some otherwise. - pub context: Option, +#[derive(Debug, Clone)] +enum RetryStrategy { + ExponentialBackoff { + initial_delay: Duration, + max_attempts: u8, + }, + Fixed { + delay: Duration, + max_attempts: u8, + }, } -/// A message in a [`Thread`]. -#[derive(Debug, Clone)] -pub struct Message { - pub id: MessageId, - pub role: Role, - pub segments: Vec, - pub loaded_context: LoadedContext, - pub creases: Vec, - pub is_hidden: bool, - pub ui_only: bool, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Message { + User(UserMessage), + Agent(AgentMessage), + Resume, } impl Message { - /// Returns whether the message contains any meaningful text that should be displayed - /// The model sometimes runs tool without producing any text or just a marker ([`USING_TOOL_MARKER`]) - pub fn should_display_content(&self) -> bool { - self.segments.iter().all(|segment| segment.should_display()) + pub fn as_agent_message(&self) -> Option<&AgentMessage> { + match self { + Message::Agent(agent_message) => Some(agent_message), + _ => None, + } } - pub fn push_thinking(&mut self, text: &str, signature: Option) { - if let Some(MessageSegment::Thinking { - text: segment, - signature: current_signature, - }) = self.segments.last_mut() - { - if let Some(signature) = signature { - *current_signature = Some(signature); - } - segment.push_str(text); - } else { - self.segments.push(MessageSegment::Thinking { - text: text.to_string(), - signature, - }); + pub fn to_request(&self) -> Vec { + match self { + Message::User(message) => vec![message.to_request()], + Message::Agent(message) => message.to_request(), + Message::Resume => vec![LanguageModelRequestMessage { + role: Role::User, + content: vec!["Continue where you left off".into()], + cache: false, + }], } } - pub fn push_redacted_thinking(&mut self, data: String) { - self.segments.push(MessageSegment::RedactedThinking(data)); + pub fn to_markdown(&self) -> String { + match self { + Message::User(message) => message.to_markdown(), + Message::Agent(message) => message.to_markdown(), + Message::Resume => "[resume]\n".into(), + } } - pub fn push_text(&mut self, text: &str) { - if let Some(MessageSegment::Text(segment)) = self.segments.last_mut() { - segment.push_str(text); - } else { - self.segments.push(MessageSegment::Text(text.to_string())); + pub fn role(&self) -> Role { + match self { + Message::User(_) | Message::Resume => Role::User, + Message::Agent(_) => Role::Assistant, } } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserMessage { + pub id: UserMessageId, + pub content: Vec, +} - pub fn to_string(&self) -> String { - let mut result = String::new(); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum UserMessageContent { + Text(String), + Mention { uri: MentionUri, content: String }, + Image(LanguageModelImage), +} - if !self.loaded_context.text.is_empty() { - result.push_str(&self.loaded_context.text); - } +impl UserMessage { + pub fn to_markdown(&self) -> String { + let mut markdown = String::from("## User\n\n"); - for segment in &self.segments { - match segment { - MessageSegment::Text(text) => result.push_str(text), - MessageSegment::Thinking { text, .. } => { - result.push_str("\n"); - result.push_str(text); - result.push_str("\n"); + for content in &self.content { + match content { + UserMessageContent::Text(text) => { + markdown.push_str(text); + markdown.push('\n'); + } + UserMessageContent::Image(_) => { + markdown.push_str("\n"); + } + UserMessageContent::Mention { uri, content } => { + if !content.is_empty() { + let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content); + } else { + let _ = writeln!(&mut markdown, "{}", uri.as_link()); + } } - MessageSegment::RedactedThinking(_) => {} } } - result + markdown } -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MessageSegment { - Text(String), - Thinking { - text: String, - signature: Option, - }, - RedactedThinking(String), -} + fn to_request(&self) -> LanguageModelRequestMessage { + let mut message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::with_capacity(self.content.len()), + cache: false, + }; -impl MessageSegment { - pub fn should_display(&self) -> bool { - match self { - Self::Text(text) => text.is_empty(), - Self::Thinking { text, .. } => text.is_empty(), - Self::RedactedThinking(_) => false, + const OPEN_CONTEXT: &str = "\n\ + The following items were attached by the user. \ + They are up-to-date and don't need to be re-read.\n\n"; + + const OPEN_FILES_TAG: &str = ""; + const OPEN_DIRECTORIES_TAG: &str = ""; + const OPEN_SYMBOLS_TAG: &str = ""; + const OPEN_SELECTIONS_TAG: &str = ""; + const OPEN_THREADS_TAG: &str = ""; + const OPEN_FETCH_TAG: &str = ""; + const OPEN_RULES_TAG: &str = + "\nThe user has specified the following rules that should be applied:\n"; + + let mut file_context = OPEN_FILES_TAG.to_string(); + let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); + let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); + let mut selection_context = OPEN_SELECTIONS_TAG.to_string(); + let mut thread_context = OPEN_THREADS_TAG.to_string(); + let mut fetch_context = OPEN_FETCH_TAG.to_string(); + let mut rules_context = OPEN_RULES_TAG.to_string(); + + for chunk in &self.content { + let chunk = match chunk { + UserMessageContent::Text(text) => { + language_model::MessageContent::Text(text.clone()) + } + UserMessageContent::Image(value) => { + language_model::MessageContent::Image(value.clone()) + } + UserMessageContent::Mention { uri, content } => { + match uri { + MentionUri::File { abs_path } => { + write!( + &mut file_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag(abs_path, None), + text: &content.to_string(), + } + ) + .ok(); + } + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be used in mention content") + } + MentionUri::Directory { .. } => { + write!(&mut directory_context, "\n{}\n", content).ok(); + } + MentionUri::Symbol { + abs_path: path, + line_range, + .. + } => { + write!( + &mut symbol_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag(path, Some(line_range)), + text: content + } + ) + .ok(); + } + MentionUri::Selection { + abs_path: path, + line_range, + .. + } => { + write!( + &mut selection_context, + "\n{}", + MarkdownCodeBlock { + tag: &codeblock_tag( + path.as_deref().unwrap_or("Untitled".as_ref()), + Some(line_range) + ), + text: content + } + ) + .ok(); + } + MentionUri::Thread { .. } => { + write!(&mut thread_context, "\n{}\n", content).ok(); + } + MentionUri::TextThread { .. } => { + write!(&mut thread_context, "\n{}\n", content).ok(); + } + MentionUri::Rule { .. } => { + write!( + &mut rules_context, + "\n{}", + MarkdownCodeBlock { + tag: "", + text: content + } + ) + .ok(); + } + MentionUri::Fetch { url } => { + write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); + } + } + + language_model::MessageContent::Text(uri.as_link().to_string()) + } + }; + + message.content.push(chunk); } - } - pub fn text(&self) -> Option<&str> { - match self { - MessageSegment::Text(text) => Some(text), - _ => None, + let len_before_context = message.content.len(); + + if file_context.len() > OPEN_FILES_TAG.len() { + file_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(file_context)); } - } -} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProjectSnapshot { - pub worktree_snapshots: Vec, - pub unsaved_buffer_paths: Vec, - pub timestamp: DateTime, -} + if directory_context.len() > OPEN_DIRECTORIES_TAG.len() { + directory_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(directory_context)); + } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct WorktreeSnapshot { - pub worktree_path: String, - pub git_state: Option, -} + if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { + symbol_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(symbol_context)); + } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct GitState { - pub remote_url: Option, - pub head_sha: Option, - pub current_branch: Option, - pub diff: Option, -} + if selection_context.len() > OPEN_SELECTIONS_TAG.len() { + selection_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(selection_context)); + } -#[derive(Clone, Debug)] -pub struct ThreadCheckpoint { - message_id: MessageId, - git_checkpoint: GitStoreCheckpoint, -} + if thread_context.len() > OPEN_THREADS_TAG.len() { + thread_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(thread_context)); + } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum ThreadFeedback { - Positive, - Negative, -} + if fetch_context.len() > OPEN_FETCH_TAG.len() { + fetch_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(fetch_context)); + } -pub enum LastRestoreCheckpoint { - Pending { - message_id: MessageId, - }, - Error { - message_id: MessageId, - error: String, - }, -} + if rules_context.len() > OPEN_RULES_TAG.len() { + rules_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(rules_context)); + } -impl LastRestoreCheckpoint { - pub fn message_id(&self) -> MessageId { - match self { - LastRestoreCheckpoint::Pending { message_id } => *message_id, - LastRestoreCheckpoint::Error { message_id, .. } => *message_id, + if message.content.len() > len_before_context { + message.content.insert( + len_before_context, + language_model::MessageContent::Text(OPEN_CONTEXT.into()), + ); + message + .content + .push(language_model::MessageContent::Text("".into())); } + + message } } -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub enum DetailedSummaryState { - #[default] - NotGenerated, - Generating { - message_id: MessageId, - }, - Generated { - text: SharedString, - message_id: MessageId, - }, -} +fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive>) -> String { + let mut result = String::new(); + + if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { + let _ = write!(result, "{} ", extension); + } -impl DetailedSummaryState { - fn text(&self) -> Option { - if let Self::Generated { text, .. } = self { - Some(text.clone()) + let _ = write!(result, "{}", full_path.display()); + + if let Some(range) = line_range { + if range.start() == range.end() { + let _ = write!(result, ":{}", range.start() + 1); } else { - None + let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1); } } -} -#[derive(Default, Debug)] -pub struct TotalTokenUsage { - pub total: u64, - pub max: u64, + result } -impl TotalTokenUsage { - pub fn ratio(&self) -> TokenUsageRatio { - #[cfg(debug_assertions)] - let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD") - .unwrap_or("0.8".to_string()) - .parse() - .unwrap(); - #[cfg(not(debug_assertions))] - let warning_threshold: f32 = 0.8; - - // When the maximum is unknown because there is no selected model, - // avoid showing the token limit warning. - if self.max == 0 { - TokenUsageRatio::Normal - } else if self.total >= self.max { - TokenUsageRatio::Exceeded - } else if self.total as f32 / self.max as f32 >= warning_threshold { - TokenUsageRatio::Warning - } else { - TokenUsageRatio::Normal +impl AgentMessage { + pub fn to_markdown(&self) -> String { + let mut markdown = String::from("## Assistant\n\n"); + + for content in &self.content { + match content { + AgentMessageContent::Text(text) => { + markdown.push_str(text); + markdown.push('\n'); + } + AgentMessageContent::Thinking { text, .. } => { + markdown.push_str(""); + markdown.push_str(text); + markdown.push_str("\n"); + } + AgentMessageContent::RedactedThinking(_) => { + markdown.push_str("\n") + } + AgentMessageContent::ToolUse(tool_use) => { + markdown.push_str(&format!( + "**Tool Use**: {} (ID: {})\n", + tool_use.name, tool_use.id + )); + markdown.push_str(&format!( + "{}\n", + MarkdownCodeBlock { + tag: "json", + text: &format!("{:#}", tool_use.input) + } + )); + } + } + } + + for tool_result in self.tool_results.values() { + markdown.push_str(&format!( + "**Tool Result**: {} (ID: {})\n\n", + tool_result.tool_name, tool_result.tool_use_id + )); + if tool_result.is_error { + markdown.push_str("**ERROR:**\n"); + } + + match &tool_result.content { + LanguageModelToolResultContent::Text(text) => { + writeln!(markdown, "{text}\n").ok(); + } + LanguageModelToolResultContent::Image(_) => { + writeln!(markdown, "\n").ok(); + } + } + + if let Some(output) = tool_result.output.as_ref() { + writeln!( + markdown, + "**Debug Output**:\n\n```json\n{}\n```\n", + serde_json::to_string_pretty(output).unwrap() + ) + .unwrap(); + } } + + markdown } - pub fn add(&self, tokens: u64) -> TotalTokenUsage { - TotalTokenUsage { - total: self.total + tokens, - max: self.max, + pub fn to_request(&self) -> Vec { + let mut assistant_message = LanguageModelRequestMessage { + role: Role::Assistant, + content: Vec::with_capacity(self.content.len()), + cache: false, + }; + for chunk in &self.content { + match chunk { + AgentMessageContent::Text(text) => { + assistant_message + .content + .push(language_model::MessageContent::Text(text.clone())); + } + AgentMessageContent::Thinking { text, signature } => { + assistant_message + .content + .push(language_model::MessageContent::Thinking { + text: text.clone(), + signature: signature.clone(), + }); + } + AgentMessageContent::RedactedThinking(value) => { + assistant_message.content.push( + language_model::MessageContent::RedactedThinking(value.clone()), + ); + } + AgentMessageContent::ToolUse(tool_use) => { + if self.tool_results.contains_key(&tool_use.id) { + assistant_message + .content + .push(language_model::MessageContent::ToolUse(tool_use.clone())); + } + } + }; + } + + let mut user_message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::new(), + cache: false, + }; + + for tool_result in self.tool_results.values() { + let mut tool_result = tool_result.clone(); + // Surprisingly, the API fails if we return an empty string here. + // It thinks we are sending a tool use without a tool result. + if tool_result.content.is_empty() { + tool_result.content = "".into(); + } + user_message + .content + .push(language_model::MessageContent::ToolResult(tool_result)); + } + + let mut messages = Vec::new(); + if !assistant_message.content.is_empty() { + messages.push(assistant_message); + } + if !user_message.content.is_empty() { + messages.push(user_message); } + messages } } -#[derive(Debug, Default, PartialEq, Eq)] -pub enum TokenUsageRatio { - #[default] - Normal, - Warning, - Exceeded, +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentMessage { + pub content: Vec, + pub tool_results: IndexMap, } -#[derive(Debug, Clone, Copy)] -pub enum QueueState { - Sending, - Queued { position: usize }, - Started, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AgentMessageContent { + Text(String), + Thinking { + text: String, + signature: Option, + }, + RedactedThinking(String), + ToolUse(LanguageModelToolUse), } -/// A thread of conversation with the LLM. -pub struct Thread { - id: ThreadId, - updated_at: DateTime, - summary: ThreadSummary, - pending_summary: Task>, - detailed_summary_task: Task>, - detailed_summary_tx: postage::watch::Sender, - detailed_summary_rx: postage::watch::Receiver, - completion_mode: agent_settings::CompletionMode, - messages: Vec, - next_message_id: MessageId, - last_prompt_id: PromptId, - project_context: SharedProjectContext, - checkpoints_by_message: HashMap, - completion_count: usize, - pending_completions: Vec, - project: Entity, - prompt_builder: Arc, - tools: Entity, - tool_use: ToolUseState, - action_log: Entity, - last_restore_checkpoint: Option, - pending_checkpoint: Option, - initial_project_snapshot: Shared>>>, - request_token_usage: Vec, - cumulative_token_usage: TokenUsage, - exceeded_window_error: Option, - tool_use_limit_reached: bool, - feedback: Option, - retry_state: Option, - message_feedback: HashMap, - last_auto_capture_at: Option, - last_received_chunk_at: Option, - request_callback: Option< - Box])>, - >, - remaining_turns: u32, - configured_model: Option, - profile: AgentProfile, - last_error_context: Option<(Arc, CompletionIntent)>, +pub trait TerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result; + fn current_output(&self, cx: &AsyncApp) -> Result; + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; } -#[derive(Clone, Debug)] -struct RetryState { - attempt: u8, - max_attempts: u8, - intent: CompletionIntent, +pub trait ThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>>; } -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ThreadSummary { - Pending, - Generating, - Ready(SharedString), - Error, +#[derive(Debug)] +pub enum ThreadEvent { + UserMessage(UserMessage), + AgentText(String), + AgentThinking(String), + ToolCall(acp::ToolCall), + ToolCallUpdate(acp_thread::ToolCallUpdate), + ToolCallAuthorization(ToolCallAuthorization), + Retry(acp_thread::RetryStatus), + Stop(acp::StopReason), } -impl ThreadSummary { - pub const DEFAULT: SharedString = SharedString::new_static("New Thread"); - - pub fn or_default(&self) -> SharedString { - self.unwrap_or(Self::DEFAULT) - } +#[derive(Debug)] +pub struct NewTerminal { + pub command: String, + pub output_byte_limit: Option, + pub cwd: Option, + pub response: oneshot::Sender>>, +} - pub fn unwrap_or(&self, message: impl Into) -> SharedString { - self.ready().unwrap_or_else(|| message.into()) - } +#[derive(Debug)] +pub struct ToolCallAuthorization { + pub tool_call: acp::ToolCallUpdate, + pub options: Vec, + pub response: oneshot::Sender, +} - pub fn ready(&self) -> Option { - match self { - ThreadSummary::Ready(summary) => Some(summary.clone()), - ThreadSummary::Pending | ThreadSummary::Generating | ThreadSummary::Error => None, - } - } +#[derive(Debug, thiserror::Error)] +enum CompletionError { + #[error("max tokens")] + MaxTokens, + #[error("refusal")] + Refusal, + #[error(transparent)] + Other(#[from] anyhow::Error), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ExceededWindowError { - /// Model used when last message exceeded context window - model_id: LanguageModelId, - /// Token count including last message - token_count: u64, +pub struct Thread { + id: acp::SessionId, + prompt_id: PromptId, + updated_at: DateTime, + title: Option, + pending_title_generation: Option>, + pending_summary_generation: Option>>>, + summary: Option, + messages: Vec, + user_store: Entity, + completion_mode: CompletionMode, + /// Holds the task that handles agent interaction until the end of the turn. + /// Survives across multiple requests as the model performs tool calls and + /// we run tools, report their results. + running_turn: Option, + pending_message: Option, + tools: BTreeMap>, + tool_use_limit_reached: bool, + request_token_usage: HashMap, + #[allow(unused)] + cumulative_token_usage: TokenUsage, + #[allow(unused)] + initial_project_snapshot: Shared>>>, + context_server_registry: Entity, + profile_id: AgentProfileId, + project_context: Entity, + templates: Arc, + model: Option>, + summarization_model: Option>, + prompt_capabilities_tx: watch::Sender, + pub(crate) prompt_capabilities_rx: watch::Receiver, + pub(crate) project: Entity, + pub(crate) action_log: Entity, } impl Thread { + fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { + let image = model.map_or(true, |model| model.supports_images()); + acp::PromptCapabilities { + meta: None, + image, + audio: false, + embedded_context: true, + } + } + pub fn new( project: Entity, - tools: Entity, - prompt_builder: Arc, - system_prompt: SharedProjectContext, + project_context: Entity, + context_server_registry: Entity, + templates: Arc, + model: Option>, cx: &mut Context, ) -> Self { - let (detailed_summary_tx, detailed_summary_rx) = postage::watch::channel(); - let configured_model = LanguageModelRegistry::read_global(cx).default_model(); let profile_id = AgentSettings::get_global(cx).default_profile.clone(); - + let action_log = cx.new(|_cx| ActionLog::new(project.clone())); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { - id: ThreadId::new(), + id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), + prompt_id: PromptId::new(), updated_at: Utc::now(), - summary: ThreadSummary::Pending, - pending_summary: Task::ready(None), - detailed_summary_task: Task::ready(None), - detailed_summary_tx, - detailed_summary_rx, - completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, + title: None, + pending_title_generation: None, + pending_summary_generation: None, + summary: None, messages: Vec::new(), - next_message_id: MessageId(0), - last_prompt_id: PromptId::new(), - project_context: system_prompt, - checkpoints_by_message: HashMap::default(), - completion_count: 0, - pending_completions: Vec::new(), - project: project.clone(), - prompt_builder, - tools: tools.clone(), - last_restore_checkpoint: None, - pending_checkpoint: None, - tool_use: ToolUseState::new(tools.clone()), - action_log: cx.new(|_| ActionLog::new(project.clone())), + user_store: project.read(cx).user_store(), + completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, + running_turn: None, + pending_message: None, + tools: BTreeMap::default(), + tool_use_limit_reached: false, + request_token_usage: HashMap::default(), + cumulative_token_usage: TokenUsage::default(), initial_project_snapshot: { - let project_snapshot = Self::project_snapshot(project, cx); + let project_snapshot = Self::project_snapshot(project.clone(), cx); cx.foreground_executor() .spawn(async move { Some(project_snapshot.await) }) .shared() }, - request_token_usage: Vec::new(), - cumulative_token_usage: TokenUsage::default(), - exceeded_window_error: None, - tool_use_limit_reached: false, - feedback: None, - retry_state: None, - message_feedback: HashMap::default(), - last_auto_capture_at: None, - last_error_context: None, - last_received_chunk_at: None, - request_callback: None, - remaining_turns: u32::MAX, - configured_model: configured_model.clone(), - profile: AgentProfile::new(profile_id, tools), + context_server_registry, + profile_id, + project_context, + templates, + model, + summarization_model: None, + prompt_capabilities_tx, + prompt_capabilities_rx, + project, + action_log, } } - pub fn deserialize( - id: ThreadId, - serialized: SerializedThread, - project: Entity, - tools: Entity, - prompt_builder: Arc, - project_context: SharedProjectContext, - window: Option<&mut Window>, // None in headless mode - cx: &mut Context, - ) -> Self { - let next_message_id = MessageId( - serialized - .messages - .last() - .map(|message| message.id.0 + 1) - .unwrap_or(0), - ); - let tool_use = ToolUseState::from_serialized_messages( - tools.clone(), - &serialized.messages, - project.clone(), - window, - cx, - ); - let (detailed_summary_tx, detailed_summary_rx) = - postage::watch::channel_with(serialized.detailed_summary_state); - - let configured_model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - serialized - .model - .and_then(|model| { - let model = SelectedModel { - provider: model.provider.clone().into(), - model: model.model.clone().into(), - }; - registry.select_model(&model, cx) - }) - .or_else(|| registry.default_model()) - }); - - let completion_mode = serialized - .completion_mode - .unwrap_or_else(|| AgentSettings::get_global(cx).preferred_completion_mode); - let profile_id = serialized - .profile - .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone()); + pub fn id(&self) -> &acp::SessionId { + &self.id + } - Self { - id, - updated_at: serialized.updated_at, - summary: ThreadSummary::Ready(serialized.summary), - pending_summary: Task::ready(None), - detailed_summary_task: Task::ready(None), - detailed_summary_tx, - detailed_summary_rx, - completion_mode, - retry_state: None, - messages: serialized - .messages - .into_iter() - .map(|message| Message { - id: message.id, - role: message.role, - segments: message - .segments - .into_iter() - .map(|segment| match segment { - SerializedMessageSegment::Text { text } => MessageSegment::Text(text), - SerializedMessageSegment::Thinking { text, signature } => { - MessageSegment::Thinking { text, signature } + pub fn replay( + &mut self, + cx: &mut Context, + ) -> mpsc::UnboundedReceiver> { + let (tx, rx) = mpsc::unbounded(); + let stream = ThreadEventStream(tx); + for message in &self.messages { + match message { + Message::User(user_message) => stream.send_user_message(user_message), + Message::Agent(assistant_message) => { + for content in &assistant_message.content { + match content { + AgentMessageContent::Text(text) => stream.send_text(text), + AgentMessageContent::Thinking { text, .. } => { + stream.send_thinking(text) } - SerializedMessageSegment::RedactedThinking { data } => { - MessageSegment::RedactedThinking(data) + AgentMessageContent::RedactedThinking(_) => {} + AgentMessageContent::ToolUse(tool_use) => { + self.replay_tool_call( + tool_use, + assistant_message.tool_results.get(&tool_use.id), + &stream, + cx, + ); } - }) - .collect(), - loaded_context: LoadedContext { - contexts: Vec::new(), - text: message.context, - images: Vec::new(), - }, - creases: message - .creases - .into_iter() - .map(|crease| MessageCrease { - range: crease.start..crease.end, - icon_path: crease.icon_path, - label: crease.label, - context: None, - }) - .collect(), - is_hidden: message.is_hidden, - ui_only: false, // UI-only messages are not persisted - }) - .collect(), - next_message_id, - last_prompt_id: PromptId::new(), - project_context, - checkpoints_by_message: HashMap::default(), - completion_count: 0, - pending_completions: Vec::new(), - last_restore_checkpoint: None, - pending_checkpoint: None, - project: project.clone(), - prompt_builder, - tools: tools.clone(), - tool_use, - action_log: cx.new(|_| ActionLog::new(project)), - initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(), - request_token_usage: serialized.request_token_usage, - cumulative_token_usage: serialized.cumulative_token_usage, - exceeded_window_error: None, - tool_use_limit_reached: serialized.tool_use_limit_reached, - feedback: None, - message_feedback: HashMap::default(), - last_auto_capture_at: None, - last_error_context: None, - last_received_chunk_at: None, - request_callback: None, - remaining_turns: u32::MAX, - configured_model, - profile: AgentProfile::new(profile_id, tools), + } + } + } + Message::Resume => {} + } } + rx } - pub fn set_request_callback( - &mut self, - callback: impl 'static - + FnMut(&LanguageModelRequest, &[Result]), + fn replay_tool_call( + &self, + tool_use: &LanguageModelToolUse, + tool_result: Option<&LanguageModelToolResult>, + stream: &ThreadEventStream, + cx: &mut Context, ) { - self.request_callback = Some(Box::new(callback)); - } + let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { + self.context_server_registry + .read(cx) + .servers() + .find_map(|(_, tools)| { + if let Some(tool) = tools.get(tool_use.name.as_ref()) { + Some(tool.clone()) + } else { + None + } + }) + }); - pub fn id(&self) -> &ThreadId { - &self.id - } + let Some(tool) = tool else { + stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { + meta: None, + id: acp::ToolCallId(tool_use.id.to_string().into()), + title: tool_use.name.to_string(), + kind: acp::ToolKind::Other, + status: acp::ToolCallStatus::Failed, + content: Vec::new(), + locations: Vec::new(), + raw_input: Some(tool_use.input.clone()), + raw_output: None, + }))) + .ok(); + return; + }; + + let title = tool.initial_title(tool_use.input.clone(), cx); + let kind = tool.kind(); + stream.send_tool_call( + &tool_use.id, + &tool_use.name, + title, + kind, + tool_use.input.clone(), + ); - pub fn profile(&self) -> &AgentProfile { - &self.profile + let output = tool_result + .as_ref() + .and_then(|result| result.output.clone()); + if let Some(output) = output.clone() { + let tool_event_stream = ToolCallEventStream::new( + tool_use.id.clone(), + stream.clone(), + Some(self.project.read(cx).fs().clone()), + ); + tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) + .log_err(); + } + + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields { + status: Some( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ), + raw_output: output, + ..Default::default() + }, + ); } - pub fn set_profile(&mut self, id: AgentProfileId, cx: &mut Context) { - if &id != self.profile.id() { - self.profile = AgentProfile::new(id, self.tools.clone()); - cx.emit(ThreadEvent::ProfileChanged); + pub fn from_db( + id: acp::SessionId, + db_thread: DbThread, + project: Entity, + project_context: Entity, + context_server_registry: Entity, + templates: Arc, + cx: &mut Context, + ) -> Self { + let profile_id = db_thread + .profile + .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone()); + let model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + db_thread + .model + .and_then(|model| { + let model = SelectedModel { + provider: model.provider.clone().into(), + model: model.model.into(), + }; + registry.select_model(&model, cx) + }) + .or_else(|| registry.default_model()) + .map(|model| model.model) + }); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + Self { + id, + prompt_id: PromptId::new(), + title: if db_thread.title.is_empty() { + None + } else { + Some(db_thread.title.clone()) + }, + pending_title_generation: None, + pending_summary_generation: None, + summary: db_thread.detailed_summary, + messages: db_thread.messages, + user_store: project.read(cx).user_store(), + completion_mode: db_thread.completion_mode.unwrap_or_default(), + running_turn: None, + pending_message: None, + tools: BTreeMap::default(), + tool_use_limit_reached: false, + request_token_usage: db_thread.request_token_usage.clone(), + cumulative_token_usage: db_thread.cumulative_token_usage, + initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), + context_server_registry, + profile_id, + project_context, + templates, + model, + summarization_model: None, + project, + action_log, + updated_at: db_thread.updated_at, + prompt_capabilities_tx, + prompt_capabilities_rx, } } - pub fn is_empty(&self) -> bool { - self.messages.is_empty() - } + pub fn to_db(&self, cx: &App) -> Task { + let initial_project_snapshot = self.initial_project_snapshot.clone(); + let mut thread = DbThread { + title: self.title(), + messages: self.messages.clone(), + updated_at: self.updated_at, + detailed_summary: self.summary.clone(), + initial_project_snapshot: None, + cumulative_token_usage: self.cumulative_token_usage, + request_token_usage: self.request_token_usage.clone(), + model: self.model.as_ref().map(|model| DbLanguageModel { + provider: model.provider_id().to_string(), + model: model.name().0.to_string(), + }), + completion_mode: Some(self.completion_mode), + profile: Some(self.profile_id.clone()), + }; - pub fn updated_at(&self) -> DateTime { - self.updated_at + cx.background_spawn(async move { + let initial_project_snapshot = initial_project_snapshot.await; + thread.initial_project_snapshot = initial_project_snapshot; + thread + }) } - pub fn touch_updated_at(&mut self) { - self.updated_at = Utc::now(); - } + /// Create a snapshot of the current project state including git information and unsaved buffers. + fn project_snapshot( + project: Entity, + cx: &mut Context, + ) -> Task> { + let task = project::telemetry_snapshot::TelemetrySnapshot::new(&project, cx); + cx.spawn(async move |_, _| { + let snapshot = task.await; - pub fn advance_prompt_id(&mut self) { - self.last_prompt_id = PromptId::new(); + Arc::new(ProjectSnapshot { + worktree_snapshots: snapshot.worktree_snapshots, + timestamp: Utc::now(), + }) + }) } - pub fn project_context(&self) -> SharedProjectContext { - self.project_context.clone() + pub fn project_context(&self) -> &Entity { + &self.project_context } - pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() { - self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); - } - self.configured_model.clone() + pub fn project(&self) -> &Entity { + &self.project } - pub fn configured_model(&self) -> Option { - self.configured_model.clone() + pub fn action_log(&self) -> &Entity { + &self.action_log } - pub fn set_configured_model(&mut self, model: Option, cx: &mut Context) { - self.configured_model = model; - cx.notify(); + pub fn is_empty(&self) -> bool { + self.messages.is_empty() && self.title.is_none() } - pub fn summary(&self) -> &ThreadSummary { - &self.summary + pub fn model(&self) -> Option<&Arc> { + self.model.as_ref() } - pub fn set_summary(&mut self, new_summary: impl Into, cx: &mut Context) { - let current_summary = match &self.summary { - ThreadSummary::Pending | ThreadSummary::Generating => return, - ThreadSummary::Ready(summary) => summary, - ThreadSummary::Error => &ThreadSummary::DEFAULT, - }; - - let mut new_summary = new_summary.into(); - - if new_summary.is_empty() { - new_summary = ThreadSummary::DEFAULT; + pub fn set_model(&mut self, model: Arc, cx: &mut Context) { + let old_usage = self.latest_token_usage(); + self.model = Some(model); + let new_caps = Self::prompt_capabilities(self.model.as_deref()); + let new_usage = self.latest_token_usage(); + if old_usage != new_usage { + cx.emit(TokenUsageUpdated(new_usage)); } + self.prompt_capabilities_tx.send(new_caps).log_err(); + cx.notify() + } - if current_summary != &new_summary { - self.summary = ThreadSummary::Ready(new_summary); - cx.emit(ThreadEvent::SummaryChanged); - } + pub fn summarization_model(&self) -> Option<&Arc> { + self.summarization_model.as_ref() + } + + pub fn set_summarization_model( + &mut self, + model: Option>, + cx: &mut Context, + ) { + self.summarization_model = model; + cx.notify() } pub fn completion_mode(&self) -> CompletionMode { self.completion_mode } - pub fn set_completion_mode(&mut self, mode: CompletionMode) { + pub fn set_completion_mode(&mut self, mode: CompletionMode, cx: &mut Context) { + let old_usage = self.latest_token_usage(); self.completion_mode = mode; + let new_usage = self.latest_token_usage(); + if old_usage != new_usage { + cx.emit(TokenUsageUpdated(new_usage)); + } + cx.notify() } - pub fn message(&self, id: MessageId) -> Option<&Message> { - let index = self - .messages - .binary_search_by(|message| message.id.cmp(&id)) - .ok()?; - - self.messages.get(index) + #[cfg(any(test, feature = "test-support"))] + pub fn last_message(&self) -> Option { + if let Some(message) = self.pending_message.clone() { + Some(Message::Agent(message)) + } else { + self.messages.last().cloned() + } } - pub fn messages(&self) -> impl ExactSizeIterator { - self.messages.iter() + pub fn add_default_tools( + &mut self, + environment: Rc, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + self.add_tool(CopyPathTool::new(self.project.clone())); + self.add_tool(CreateDirectoryTool::new(self.project.clone())); + self.add_tool(DeletePathTool::new( + self.project.clone(), + self.action_log.clone(), + )); + self.add_tool(DiagnosticsTool::new(self.project.clone())); + self.add_tool(EditFileTool::new( + self.project.clone(), + cx.weak_entity(), + language_registry, + Templates::new(), + )); + self.add_tool(FetchTool::new(self.project.read(cx).client().http_client())); + self.add_tool(FindPathTool::new(self.project.clone())); + self.add_tool(GrepTool::new(self.project.clone())); + self.add_tool(ListDirectoryTool::new(self.project.clone())); + self.add_tool(MovePathTool::new(self.project.clone())); + self.add_tool(NowTool); + self.add_tool(OpenTool::new(self.project.clone())); + self.add_tool(ReadFileTool::new( + self.project.clone(), + self.action_log.clone(), + )); + self.add_tool(TerminalTool::new(self.project.clone(), environment)); + self.add_tool(ThinkingTool); + self.add_tool(WebSearchTool); } - pub fn is_generating(&self) -> bool { - !self.pending_completions.is_empty() || !self.all_tools_finished() + pub fn add_tool(&mut self, tool: T) { + self.tools.insert(T::name().into(), tool.erase()); } - /// Indicates whether streaming of language model events is stale. - /// When `is_generating()` is false, this method returns `None`. - pub fn is_generation_stale(&self) -> Option { - const STALE_THRESHOLD: u128 = 250; - - self.last_received_chunk_at - .map(|instant| instant.elapsed().as_millis() > STALE_THRESHOLD) + pub fn remove_tool(&mut self, name: &str) -> bool { + self.tools.remove(name).is_some() } - fn received_chunk(&mut self) { - self.last_received_chunk_at = Some(Instant::now()); + pub fn profile(&self) -> &AgentProfileId { + &self.profile_id } - pub fn queue_state(&self) -> Option { - self.pending_completions - .first() - .map(|pending_completion| pending_completion.queue_state) + pub fn set_profile(&mut self, profile_id: AgentProfileId) { + self.profile_id = profile_id; } - pub fn tools(&self) -> &Entity { - &self.tools + pub fn cancel(&mut self, cx: &mut Context) { + if let Some(running_turn) = self.running_turn.take() { + running_turn.cancel(); + } + self.flush_pending_message(cx); } - pub fn pending_tool(&self, id: &LanguageModelToolUseId) -> Option<&PendingToolUse> { - self.tool_use - .pending_tool_uses() - .into_iter() - .find(|tool_use| &tool_use.id == id) + fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context) { + let Some(last_user_message) = self.last_user_message() else { + return; + }; + + self.request_token_usage + .insert(last_user_message.id.clone(), update); + cx.emit(TokenUsageUpdated(self.latest_token_usage())); + cx.notify(); } - pub fn tools_needing_confirmation(&self) -> impl Iterator { - self.tool_use - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.needs_confirmation()) + pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context) -> Result<()> { + self.cancel(cx); + let Some(position) = self.messages.iter().position( + |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id), + ) else { + return Err(anyhow!("Message not found")); + }; + + for message in self.messages.drain(position..) { + match message { + Message::User(message) => { + self.request_token_usage.remove(&message.id); + } + Message::Agent(_) | Message::Resume => {} + } + } + self.clear_summary(); + cx.notify(); + Ok(()) } - pub fn has_pending_tool_uses(&self) -> bool { - !self.tool_use.pending_tool_uses().is_empty() + pub fn latest_request_token_usage(&self) -> Option { + let last_user_message = self.last_user_message()?; + let tokens = self.request_token_usage.get(&last_user_message.id)?; + Some(*tokens) } - pub fn checkpoint_for_message(&self, id: MessageId) -> Option { - self.checkpoints_by_message.get(&id).cloned() + pub fn latest_token_usage(&self) -> Option { + let usage = self.latest_request_token_usage()?; + let model = self.model.clone()?; + Some(acp_thread::TokenUsage { + max_tokens: model.max_token_count_for_mode(self.completion_mode.into()), + used_tokens: usage.total_tokens(), + }) } - pub fn restore_checkpoint( + pub fn resume( &mut self, - checkpoint: ThreadCheckpoint, cx: &mut Context, - ) -> Task> { - self.last_restore_checkpoint = Some(LastRestoreCheckpoint::Pending { - message_id: checkpoint.message_id, - }); - cx.emit(ThreadEvent::CheckpointChanged); + ) -> Result>> { + self.messages.push(Message::Resume); cx.notify(); - let git_store = self.project().read(cx).git_store().clone(); - let restore = git_store.update(cx, |git_store, cx| { - git_store.restore_checkpoint(checkpoint.git_checkpoint.clone(), cx) - }); - - cx.spawn(async move |this, cx| { - let result = restore.await; - this.update(cx, |this, cx| { - if let Err(err) = result.as_ref() { - this.last_restore_checkpoint = Some(LastRestoreCheckpoint::Error { - message_id: checkpoint.message_id, - error: err.to_string(), - }); - } else { - this.truncate(checkpoint.message_id, cx); - this.last_restore_checkpoint = None; - } - this.pending_checkpoint = None; - cx.emit(ThreadEvent::CheckpointChanged); - cx.notify(); - })?; - result - }) + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) } - fn finalize_pending_checkpoint(&mut self, cx: &mut Context) { - let pending_checkpoint = if self.is_generating() { - return; - } else if let Some(checkpoint) = self.pending_checkpoint.take() { - checkpoint - } else { - return; - }; + /// Sending a message results in the model streaming a response, which could include tool calls. + /// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent. + /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. + pub fn send( + &mut self, + id: UserMessageId, + content: impl IntoIterator, + cx: &mut Context, + ) -> Result>> + where + T: Into, + { + let model = self.model().context("No language model configured")?; + + log::info!("Thread::send called with model: {}", model.name().0); + self.advance_prompt_id(); + + let content = content.into_iter().map(Into::into).collect::>(); + log::debug!("Thread::send content: {:?}", content); + + self.messages + .push(Message::User(UserMessage { id, content })); + cx.notify(); - self.finalize_checkpoint(pending_checkpoint, cx); + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) } - fn finalize_checkpoint( + #[cfg(feature = "eval")] + pub fn proceed( &mut self, - pending_checkpoint: ThreadCheckpoint, cx: &mut Context, - ) { - let git_store = self.project.read(cx).git_store().clone(); - let final_checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx)); - cx.spawn(async move |this, cx| match final_checkpoint.await { - Ok(final_checkpoint) => { - let equal = git_store - .update(cx, |store, cx| { - store.compare_checkpoints( - pending_checkpoint.git_checkpoint.clone(), - final_checkpoint.clone(), - cx, - ) - })? - .await - .unwrap_or(false); + ) -> Result>> { + self.run_turn(cx) + } - this.update(cx, |this, cx| { - this.pending_checkpoint = if equal { - Some(pending_checkpoint) - } else { - this.insert_checkpoint(pending_checkpoint, cx); - Some(ThreadCheckpoint { - message_id: this.next_message_id, - git_checkpoint: final_checkpoint, - }) + fn run_turn( + &mut self, + cx: &mut Context, + ) -> Result>> { + self.cancel(cx); + + let model = self.model.clone().context("No language model configured")?; + let profile = AgentSettings::get_global(cx) + .profiles + .get(&self.profile_id) + .context("Profile not found")?; + let (events_tx, events_rx) = mpsc::unbounded::>(); + let event_stream = ThreadEventStream(events_tx); + let message_ix = self.messages.len().saturating_sub(1); + self.tool_use_limit_reached = false; + self.clear_summary(); + self.running_turn = Some(RunningTurn { + event_stream: event_stream.clone(), + tools: self.enabled_tools(profile, &model, cx), + _task: cx.spawn(async move |this, cx| { + log::debug!("Starting agent turn execution"); + + let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await; + _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); + + match turn_result { + Ok(()) => { + log::debug!("Turn execution completed"); + event_stream.send_stop(acp::StopReason::EndTurn); } - })?; + Err(error) => { + log::error!("Turn execution failed: {:?}", error); + match error.downcast::() { + Ok(CompletionError::Refusal) => { + event_stream.send_stop(acp::StopReason::Refusal); + _ = this.update(cx, |this, _| this.messages.truncate(message_ix)); + } + Ok(CompletionError::MaxTokens) => { + event_stream.send_stop(acp::StopReason::MaxTokens); + } + Ok(CompletionError::Other(error)) | Err(error) => { + event_stream.send_error(error); + } + } + } + } - Ok(()) - } - Err(_) => this.update(cx, |this, cx| { - this.insert_checkpoint(pending_checkpoint, cx) + _ = this.update(cx, |this, _| this.running_turn.take()); }), - }) - .detach(); + }); + Ok(events_rx) } - fn insert_checkpoint(&mut self, checkpoint: ThreadCheckpoint, cx: &mut Context) { - self.checkpoints_by_message - .insert(checkpoint.message_id, checkpoint); - cx.emit(ThreadEvent::CheckpointChanged); - cx.notify(); - } + async fn run_turn_internal( + this: &WeakEntity, + model: Arc, + event_stream: &ThreadEventStream, + cx: &mut AsyncApp, + ) -> Result<()> { + let mut attempt = 0; + let mut intent = CompletionIntent::UserPrompt; + loop { + let request = + this.update(cx, |this, cx| this.build_completion_request(intent, cx))??; - pub fn last_restore_checkpoint(&self) -> Option<&LastRestoreCheckpoint> { - self.last_restore_checkpoint.as_ref() - } + telemetry::event!( + "Agent Thread Completion", + thread_id = this.read_with(cx, |this, _| this.id.to_string())?, + prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?, + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + attempt + ); - pub fn truncate(&mut self, message_id: MessageId, cx: &mut Context) { - let Some(message_ix) = self - .messages - .iter() - .rposition(|message| message.id == message_id) - else { - return; - }; - for deleted_message in self.messages.drain(message_ix..) { - self.checkpoints_by_message.remove(&deleted_message.id); - } - cx.notify(); - } + log::debug!("Calling model.stream_completion, attempt {}", attempt); - pub fn context_for_message(&self, id: MessageId) -> impl Iterator { - self.messages - .iter() - .find(|message| message.id == id) - .into_iter() - .flat_map(|message| message.loaded_context.contexts.iter()) - } + let (mut events, mut error) = match model.stream_completion(request, cx).await { + Ok(events) => (events, None), + Err(err) => (stream::empty().boxed(), Some(err)), + }; + let mut tool_results = FuturesUnordered::new(); + while let Some(event) = events.next().await { + log::trace!("Received completion event: {:?}", event); + match event { + Ok(event) => { + tool_results.extend(this.update(cx, |this, cx| { + this.handle_completion_event(event, event_stream, cx) + })??); + } + Err(err) => { + error = Some(err); + break; + } + } + } - pub fn is_turn_end(&self, ix: usize) -> bool { - if self.messages.is_empty() { - return false; - } + let end_turn = tool_results.is_empty(); + while let Some(tool_result) = tool_results.next().await { + log::debug!("Tool finished {:?}", tool_result); - if !self.is_generating() && ix == self.messages.len() - 1 { - return true; - } + event_stream.update_tool_call_fields( + &tool_result.tool_use_id, + acp::ToolCallUpdateFields { + status: Some(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }), + raw_output: tool_result.output.clone(), + ..Default::default() + }, + ); + this.update(cx, |this, _cx| { + this.pending_message() + .tool_results + .insert(tool_result.tool_use_id.clone(), tool_result); + })?; + } - let Some(message) = self.messages.get(ix) else { - return false; - }; + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if this.title.is_none() && this.pending_title_generation.is_none() { + this.generate_title(cx); + } + })?; - if message.role != Role::Assistant { - return false; + if let Some(error) = error { + attempt += 1; + let retry = this.update(cx, |this, cx| { + let user_store = this.user_store.read(cx); + this.handle_completion_error(error, attempt, user_store.plan()) + })??; + let timer = cx.background_executor().timer(retry.duration); + event_stream.send_retry(retry); + timer.await; + this.update(cx, |this, _cx| { + if let Some(Message::Agent(message)) = this.messages.last() { + if message.tool_results.is_empty() { + intent = CompletionIntent::UserPrompt; + this.messages.push(Message::Resume); + } + } + })?; + } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { + return Err(language_model::ToolUseLimitReachedError.into()); + } else if end_turn { + return Ok(()); + } else { + intent = CompletionIntent::ToolResults; + attempt = 0; + } } - - self.messages - .get(ix + 1) - .and_then(|message| { - self.message(message.id) - .map(|next_message| next_message.role == Role::User && !next_message.is_hidden) - }) - .unwrap_or(false) } - pub fn tool_use_limit_reached(&self) -> bool { - self.tool_use_limit_reached - } + fn handle_completion_error( + &mut self, + error: LanguageModelCompletionError, + attempt: u8, + plan: Option, + ) -> Result { + let Some(model) = self.model.as_ref() else { + return Err(anyhow!(error)); + }; - /// Returns whether all of the tool uses have finished running. - pub fn all_tools_finished(&self) -> bool { - // If the only pending tool uses left are the ones with errors, then - // that means that we've finished running all of the pending tools. - self.tool_use - .pending_tool_uses() - .iter() - .all(|pending_tool_use| pending_tool_use.status.is_error()) - } + let auto_retry = if model.provider_id() == ZED_CLOUD_PROVIDER_ID { + match plan { + Some(Plan::V2(_)) => true, + Some(Plan::V1(_)) => self.completion_mode == CompletionMode::Burn, + None => false, + } + } else { + true + }; - /// Returns whether any pending tool uses may perform edits - pub fn has_pending_edit_tool_uses(&self) -> bool { - self.tool_use - .pending_tool_uses() - .iter() - .filter(|pending_tool_use| !pending_tool_use.status.is_error()) - .any(|pending_tool_use| pending_tool_use.may_perform_edits) - } + if !auto_retry { + return Err(anyhow!(error)); + } - pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { - self.tool_use.tool_uses_for_message(id, &self.project, cx) - } + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error)); + }; - pub fn tool_results_for_message( - &self, - assistant_message_id: MessageId, - ) -> Vec<&LanguageModelToolResult> { - self.tool_use.tool_results_for_message(assistant_message_id) - } + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + + if attempt > max_attempts { + return Err(anyhow!(error)); + } - pub fn tool_result(&self, id: &LanguageModelToolUseId) -> Option<&LanguageModelToolResult> { - self.tool_use.tool_result(id) + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) + } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); + + Ok(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, + }) } - pub fn output_for_tool(&self, id: &LanguageModelToolUseId) -> Option<&Arc> { - match &self.tool_use.tool_result(id)?.content { - LanguageModelToolResultContent::Text(text) => Some(text), - LanguageModelToolResultContent::Image(_) => { - // TODO: We should display image - None + /// A helper method that's called on every streamed completion event. + /// Returns an optional tool result task, which the main agentic loop will + /// send back to the model when it resolves. + fn handle_completion_event( + &mut self, + event: LanguageModelCompletionEvent, + event_stream: &ThreadEventStream, + cx: &mut Context, + ) -> Result>> { + log::trace!("Handling streamed completion event: {:?}", event); + use LanguageModelCompletionEvent::*; + + match event { + StartMessage { .. } => { + self.flush_pending_message(cx); + self.pending_message = Some(AgentMessage::default()); + } + Text(new_text) => self.handle_text_event(new_text, event_stream, cx), + Thinking { text, signature } => { + self.handle_thinking_event(text, signature, event_stream, cx) + } + RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), + ToolUse(tool_use) => { + return Ok(self.handle_tool_use_event(tool_use, event_stream, cx)); + } + ToolUseJsonParseError { + id, + tool_name, + raw_input, + json_parse_error, + } => { + return Ok(Some(Task::ready( + self.handle_tool_use_json_parse_error_event( + id, + tool_name, + raw_input, + json_parse_error, + ), + ))); + } + UsageUpdate(usage) => { + telemetry::event!( + "Agent Thread Completion Usage Updated", + thread_id = self.id.to_string(), + prompt_id = self.prompt_id.to_string(), + model = self.model.as_ref().map(|m| m.telemetry_id()), + model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), + input_tokens = usage.input_tokens, + output_tokens = usage.output_tokens, + cache_creation_input_tokens = usage.cache_creation_input_tokens, + cache_read_input_tokens = usage.cache_read_input_tokens, + ); + self.update_token_usage(usage, cx); + } + StatusUpdate(CompletionRequestStatus::UsageUpdated { amount, limit }) => { + self.update_model_request_usage(amount, limit, cx); + } + StatusUpdate( + CompletionRequestStatus::Started + | CompletionRequestStatus::Queued { .. } + | CompletionRequestStatus::Failed { .. }, + ) => {} + StatusUpdate(CompletionRequestStatus::ToolUseLimitReached) => { + self.tool_use_limit_reached = true; } + Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), + Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()), + Stop(StopReason::ToolUse | StopReason::EndTurn) => {} } - } - pub fn card_for_tool(&self, id: &LanguageModelToolUseId) -> Option { - self.tool_use.tool_result_card(id).cloned() + Ok(None) } - /// Return tools that are both enabled and supported by the model - pub fn available_tools( - &self, - cx: &App, - model: Arc, - ) -> Vec { - if model.supports_tools() { - self.profile - .enabled_tools(cx) - .into_iter() - .filter_map(|(name, tool)| { - // Skip tools that cannot be supported - let input_schema = tool.input_schema(model.tool_input_format()).ok()?; - Some(LanguageModelRequestTool { - name: name.into(), - description: tool.description(), - input_schema, - }) - }) - .collect() + fn handle_text_event( + &mut self, + new_text: String, + event_stream: &ThreadEventStream, + cx: &mut Context, + ) { + event_stream.send_text(&new_text); + + let last_message = self.pending_message(); + if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() { + text.push_str(&new_text); } else { - Vec::default() + last_message + .content + .push(AgentMessageContent::Text(new_text)); } + + cx.notify(); } - pub fn insert_user_message( + fn handle_thinking_event( &mut self, - text: impl Into, - loaded_context: ContextLoadResult, - git_checkpoint: Option, - creases: Vec, + new_text: String, + new_signature: Option, + event_stream: &ThreadEventStream, cx: &mut Context, - ) -> MessageId { - if !loaded_context.referenced_buffers.is_empty() { - self.action_log.update(cx, |log, cx| { - for buffer in loaded_context.referenced_buffers { - log.buffer_read(buffer, cx); - } + ) { + event_stream.send_thinking(&new_text); + + let last_message = self.pending_message(); + if let Some(AgentMessageContent::Thinking { text, signature }) = + last_message.content.last_mut() + { + text.push_str(&new_text); + *signature = new_signature.or(signature.take()); + } else { + last_message.content.push(AgentMessageContent::Thinking { + text: new_text, + signature: new_signature, }); } - let message_id = self.insert_message( - Role::User, - vec![MessageSegment::Text(text.into())], - loaded_context.loaded_context, - creases, - false, - cx, - ); - - if let Some(git_checkpoint) = git_checkpoint { - self.pending_checkpoint = Some(ThreadCheckpoint { - message_id, - git_checkpoint, - }); - } - - self.auto_capture_telemetry(cx); - - message_id + cx.notify(); } - pub fn insert_invisible_continue_message(&mut self, cx: &mut Context) -> MessageId { - let id = self.insert_message( - Role::User, - vec![MessageSegment::Text("Continue where you left off".into())], - LoadedContext::default(), - vec![], - true, - cx, - ); - self.pending_checkpoint = None; - - id + fn handle_redacted_thinking_event(&mut self, data: String, cx: &mut Context) { + let last_message = self.pending_message(); + last_message + .content + .push(AgentMessageContent::RedactedThinking(data)); + cx.notify(); } - pub fn insert_assistant_message( + fn handle_tool_use_event( &mut self, - segments: Vec, + tool_use: LanguageModelToolUse, + event_stream: &ThreadEventStream, cx: &mut Context, - ) -> MessageId { - self.insert_message( - Role::Assistant, - segments, - LoadedContext::default(), - Vec::new(), - false, - cx, - ) - } + ) -> Option> { + cx.notify(); - pub fn insert_message( - &mut self, - role: Role, - segments: Vec, - loaded_context: LoadedContext, - creases: Vec, - is_hidden: bool, - cx: &mut Context, - ) -> MessageId { - let id = self.next_message_id.post_inc(); - self.messages.push(Message { - id, - role, - segments, - loaded_context, - creases, - is_hidden, - ui_only: false, + let tool = self.tool(tool_use.name.as_ref()); + let mut title = SharedString::from(&tool_use.name); + let mut kind = acp::ToolKind::Other; + if let Some(tool) = tool.as_ref() { + title = tool.initial_title(tool_use.input.clone(), cx); + kind = tool.kind(); + } + + // Ensure the last message ends in the current tool use + let last_message = self.pending_message(); + let push_new_tool_use = last_message.content.last_mut().is_none_or(|content| { + if let AgentMessageContent::ToolUse(last_tool_use) = content { + if last_tool_use.id == tool_use.id { + *last_tool_use = tool_use.clone(); + false + } else { + true + } + } else { + true + } }); - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageAdded(id)); - id - } - pub fn edit_message( - &mut self, - id: MessageId, - new_role: Role, - new_segments: Vec, - creases: Vec, - loaded_context: Option, - checkpoint: Option, - cx: &mut Context, - ) -> bool { - let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else { - return false; - }; - message.role = new_role; - message.segments = new_segments; - message.creases = creases; - if let Some(context) = loaded_context { - message.loaded_context = context; - } - if let Some(git_checkpoint) = checkpoint { - self.checkpoints_by_message.insert( - id, - ThreadCheckpoint { - message_id: id, - git_checkpoint, + if push_new_tool_use { + event_stream.send_tool_call( + &tool_use.id, + &tool_use.name, + title, + kind, + tool_use.input.clone(), + ); + last_message + .content + .push(AgentMessageContent::ToolUse(tool_use.clone())); + } else { + event_stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields { + title: Some(title.into()), + kind: Some(kind), + raw_input: Some(tool_use.input.clone()), + ..Default::default() }, ); } - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageEdited(id)); - true - } - pub fn delete_message(&mut self, id: MessageId, cx: &mut Context) -> bool { - let Some(index) = self.messages.iter().position(|message| message.id == id) else { - return false; - }; - self.messages.remove(index); - self.touch_updated_at(); - cx.emit(ThreadEvent::MessageDeleted(id)); - true - } + if !tool_use.is_input_complete { + return None; + } - /// Returns the representation of this [`Thread`] in a textual form. - /// - /// This is the representation we use when attaching a thread as context to another thread. - pub fn text(&self) -> String { - let mut text = String::new(); + let Some(tool) = tool else { + let content = format!("No tool named {} exists", tool_use.name); + return Some(Task::ready(LanguageModelToolResult { + content: LanguageModelToolResultContent::Text(Arc::from(content)), + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + output: None, + })); + }; - for message in &self.messages { - text.push_str(match message.role { - language_model::Role::User => "User:", - language_model::Role::Assistant => "Agent:", - language_model::Role::System => "System:", + let fs = self.project.read(cx).fs().clone(); + let tool_event_stream = + ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); + tool_event_stream.update_fields(acp::ToolCallUpdateFields { + status: Some(acp::ToolCallStatus::InProgress), + ..Default::default() + }); + let supports_images = self.model().is_some_and(|model| model.supports_images()); + let tool_result = tool.run(tool_use.input, tool_event_stream, cx); + log::debug!("Running tool {}", tool_use.name); + Some(cx.foreground_executor().spawn(async move { + let tool_result = tool_result.await.and_then(|output| { + if let LanguageModelToolResultContent::Image(_) = &output.llm_output + && !supports_images + { + return Err(anyhow!( + "Attempted to read an image, but this model doesn't support it.", + )); + } + Ok(output) }); - text.push('\n'); - for segment in &message.segments { - match segment { - MessageSegment::Text(content) => text.push_str(content), - MessageSegment::Thinking { text: content, .. } => { - text.push_str(&format!("{}", content)) - } - MessageSegment::RedactedThinking(_) => {} - } + match tool_result { + Ok(output) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: false, + content: output.llm_output, + output: Some(output.raw_output), + }, + Err(error) => LanguageModelToolResult { + tool_use_id: tool_use.id, + tool_name: tool_use.name, + is_error: true, + content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), + output: Some(error.to_string().into()), + }, } - text.push('\n'); - } - - text - } - - /// Serializes this thread into a format for storage or telemetry. - pub fn serialize(&self, cx: &mut Context) -> Task> { - let initial_project_snapshot = self.initial_project_snapshot.clone(); - cx.spawn(async move |this, cx| { - let initial_project_snapshot = initial_project_snapshot.await; - this.read_with(cx, |this, cx| SerializedThread { - version: SerializedThread::VERSION.to_string(), - summary: this.summary().or_default(), - updated_at: this.updated_at(), - messages: this - .messages() - .filter(|message| !message.ui_only) - .map(|message| SerializedMessage { - id: message.id, - role: message.role, - segments: message - .segments - .iter() - .map(|segment| match segment { - MessageSegment::Text(text) => { - SerializedMessageSegment::Text { text: text.clone() } - } - MessageSegment::Thinking { text, signature } => { - SerializedMessageSegment::Thinking { - text: text.clone(), - signature: signature.clone(), - } - } - MessageSegment::RedactedThinking(data) => { - SerializedMessageSegment::RedactedThinking { - data: data.clone(), - } - } - }) - .collect(), - tool_uses: this - .tool_uses_for_message(message.id, cx) - .into_iter() - .map(|tool_use| SerializedToolUse { - id: tool_use.id, - name: tool_use.name, - input: tool_use.input, - }) - .collect(), - tool_results: this - .tool_results_for_message(message.id) - .into_iter() - .map(|tool_result| SerializedToolResult { - tool_use_id: tool_result.tool_use_id.clone(), - is_error: tool_result.is_error, - content: tool_result.content.clone(), - output: tool_result.output.clone(), - }) - .collect(), - context: message.loaded_context.text.clone(), - creases: message - .creases - .iter() - .map(|crease| SerializedCrease { - start: crease.range.start, - end: crease.range.end, - icon_path: crease.icon_path.clone(), - label: crease.label.clone(), - }) - .collect(), - is_hidden: message.is_hidden, - }) - .collect(), - initial_project_snapshot, - cumulative_token_usage: this.cumulative_token_usage, - request_token_usage: this.request_token_usage.clone(), - detailed_summary_state: this.detailed_summary_rx.borrow().clone(), - exceeded_window_error: this.exceeded_window_error.clone(), - model: this - .configured_model - .as_ref() - .map(|model| SerializedLanguageModel { - provider: model.provider.id().0.to_string(), - model: model.model.id().0.to_string(), - }), - completion_mode: Some(this.completion_mode), - tool_use_limit_reached: this.tool_use_limit_reached, - profile: Some(this.profile.id().clone()), - }) - }) - } - - pub fn remaining_turns(&self) -> u32 { - self.remaining_turns + })) } - pub fn set_remaining_turns(&mut self, remaining_turns: u32) { - self.remaining_turns = remaining_turns; - } - - pub fn send_to_model( + fn handle_tool_use_json_parse_error_event( &mut self, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) { - if self.remaining_turns == 0 { - return; + tool_use_id: LanguageModelToolUseId, + tool_name: Arc, + raw_input: Arc, + json_parse_error: String, + ) -> LanguageModelToolResult { + let tool_output = format!("Error parsing input JSON: {json_parse_error}"); + LanguageModelToolResult { + tool_use_id, + tool_name, + is_error: true, + content: LanguageModelToolResultContent::Text(tool_output.into()), + output: Some(serde_json::Value::String(raw_input.to_string())), } - - self.remaining_turns -= 1; - - self.flush_notifications(model.clone(), intent, cx); - - let _checkpoint = self.finalize_pending_checkpoint(cx); - self.stream_completion( - self.to_completion_request(model.clone(), intent, cx), - model, - intent, - window, - cx, - ); } - pub fn retry_last_completion( - &mut self, - window: Option, - cx: &mut Context, - ) { - // Clear any existing error state - self.retry_state = None; - - // Use the last error context if available, otherwise fall back to configured model - let (model, intent) = if let Some((model, intent)) = self.last_error_context.take() { - (model, intent) - } else if let Some(configured_model) = self.configured_model.as_ref() { - let model = configured_model.model.clone(); - let intent = if self.has_pending_tool_uses() { - CompletionIntent::ToolResults - } else { - CompletionIntent::UserPrompt - }; - (model, intent) - } else if let Some(configured_model) = self.get_or_init_configured_model(cx) { - let model = configured_model.model.clone(); - let intent = if self.has_pending_tool_uses() { - CompletionIntent::ToolResults - } else { - CompletionIntent::UserPrompt - }; - (model, intent) - } else { - return; - }; - - self.send_to_model(model, intent, window, cx); + fn update_model_request_usage(&self, amount: usize, limit: UsageLimit, cx: &mut Context) { + self.project + .read(cx) + .user_store() + .update(cx, |user_store, cx| { + user_store.update_model_request_usage( + ModelRequestUsage(RequestUsage { + amount: amount as i32, + limit, + }), + cx, + ) + }); } - pub fn enable_burn_mode_and_retry( - &mut self, - window: Option, - cx: &mut Context, - ) { - self.completion_mode = CompletionMode::Burn; - cx.emit(ThreadEvent::ProfileChanged); - self.retry_last_completion(window, cx); + pub fn title(&self) -> SharedString { + self.title.clone().unwrap_or("New Thread".into()) } - pub fn used_tools_since_last_user_message(&self) -> bool { - for message in self.messages.iter().rev() { - if self.tool_use.message_has_tool_results(message.id) { - return true; - } else if message.role == Role::User { - return false; - } - } - - false + pub fn is_generating_summary(&self) -> bool { + self.pending_summary_generation.is_some() } - pub fn to_completion_request( - &self, - model: Arc, - intent: CompletionIntent, - cx: &mut Context, - ) -> LanguageModelRequest { + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { + if let Some(summary) = self.summary.as_ref() { + return Task::ready(Some(summary.clone())).shared(); + } + if let Some(task) = self.pending_summary_generation.clone() { + return task; + } + let Some(model) = self.summarization_model.clone() else { + log::error!("No summarization model available"); + return Task::ready(None).shared(); + }; let mut request = LanguageModelRequest { - thread_id: Some(self.id.to_string()), - prompt_id: Some(self.last_prompt_id.to_string()), - intent: Some(intent), - mode: None, - messages: vec![], - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), + intent: Some(CompletionIntent::ThreadContextSummarization), temperature: AgentSettings::temperature_for_model(&model, cx), - thinking_allowed: true, - }; - - let available_tools = self.available_tools(cx, model.clone()); - let available_tool_names = available_tools - .iter() - .map(|tool| tool.name.clone()) - .collect(); - - let model_context = &ModelContext { - available_tools: available_tool_names, + ..Default::default() }; - if let Some(project_context) = self.project_context.borrow().as_ref() { - match self - .prompt_builder - .generate_assistant_system_prompt(project_context, model_context) - { - Err(err) => { - let message = format!("{err:?}").into(); - log::error!("{message}"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error generating system prompt".into(), - message, - })); - } - Ok(system_prompt) => { - request.messages.push(LanguageModelRequestMessage { - role: Role::System, - content: vec![MessageContent::Text(system_prompt)], - cache: true, - }); - } - } - } else { - let message = "Context for system prompt unexpectedly not ready.".into(); - log::error!("{message}"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error generating system prompt".into(), - message, - })); - } - - let mut message_ix_to_cache = None; for message in &self.messages { - // ui_only messages are for the UI only, not for the model - if message.ui_only { - continue; - } + request.messages.extend(message.to_request()); + } - let mut request_message = LanguageModelRequestMessage { - role: message.role, - content: Vec::new(), - cache: false, - }; + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()], + cache: false, + }); - message - .loaded_context - .add_to_request_message(&mut request_message); - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => { - let text = text.trim_end(); - if !text.is_empty() { - request_message - .content - .push(MessageContent::Text(text.into())); - } - } - MessageSegment::Thinking { text, signature } => { - if !text.is_empty() { - request_message.content.push(MessageContent::Thinking { - text: text.into(), - signature: signature.clone(), - }); + let task = cx + .spawn(async move |this, cx| { + let mut summary = String::new(); + let mut messages = model.stream_completion(request, cx).await.log_err()?; + while let Some(event) = messages.next().await { + let event = event.log_err()?; + let text = match event { + LanguageModelCompletionEvent::Text(text) => text, + LanguageModelCompletionEvent::StatusUpdate( + CompletionRequestStatus::UsageUpdated { amount, limit }, + ) => { + this.update(cx, |thread, cx| { + thread.update_model_request_usage(amount, limit, cx); + }) + .ok()?; + continue; } - } - MessageSegment::RedactedThinking(data) => { - request_message - .content - .push(MessageContent::RedactedThinking(data.clone())); - } - }; - } + _ => continue, + }; - let mut cache_message = true; - let mut tool_results_message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::new(), - cache: false, - }; - for (tool_use, tool_result) in self.tool_use.tool_results(message.id) { - if let Some(tool_result) = tool_result { - request_message - .content - .push(MessageContent::ToolUse(tool_use.clone())); - tool_results_message - .content - .push(MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_use.id.clone(), - tool_name: tool_result.tool_name.clone(), - is_error: tool_result.is_error, - content: if tool_result.content.is_empty() { - // Surprisingly, the API fails if we return an empty string here. - // It thinks we are sending a tool use without a tool result. - "".into() - } else { - tool_result.content.clone() - }, - output: None, - })); - } else { - cache_message = false; - log::debug!( - "skipped tool use {:?} because it is still pending", - tool_use - ); + let mut lines = text.lines(); + summary.extend(lines.next()); } - } - if cache_message { - message_ix_to_cache = Some(request.messages.len()); - } - request.messages.push(request_message); + log::debug!("Setting summary: {}", summary); + let summary = SharedString::from(summary); - if !tool_results_message.content.is_empty() { - if cache_message { - message_ix_to_cache = Some(request.messages.len()); - } - request.messages.push(tool_results_message); - } - } + this.update(cx, |this, cx| { + this.summary = Some(summary.clone()); + this.pending_summary_generation = None; + cx.notify() + }) + .ok()?; - // https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching - if let Some(message_ix_to_cache) = message_ix_to_cache { - request.messages[message_ix_to_cache].cache = true; - } + Some(summary) + }) + .shared(); + self.pending_summary_generation = Some(task.clone()); + task + } - request.tools = available_tools; - request.mode = if model.supports_burn_mode() { - Some(self.completion_mode.into()) - } else { - Some(CompletionMode::Normal.into()) + fn generate_title(&mut self, cx: &mut Context) { + let Some(model) = self.summarization_model.clone() else { + return; }; - request - } - - fn to_summarize_request( - &self, - model: &Arc, - intent: CompletionIntent, - added_user_message: String, - cx: &App, - ) -> LanguageModelRequest { + log::debug!( + "Generating title with model: {:?}", + self.summarization_model.as_ref().map(|model| model.name()) + ); let mut request = LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: Some(intent), - mode: None, - messages: vec![], - tools: Vec::new(), - tool_choice: None, - stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(model, cx), - thinking_allowed: false, + intent: Some(CompletionIntent::ThreadSummarization), + temperature: AgentSettings::temperature_for_model(&model, cx), + ..Default::default() }; for message in &self.messages { - let mut request_message = LanguageModelRequestMessage { - role: message.role, - content: Vec::new(), - cache: false, - }; - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => request_message - .content - .push(MessageContent::Text(text.clone())), - MessageSegment::Thinking { .. } => {} - MessageSegment::RedactedThinking(_) => {} - } - } - - if request_message.content.is_empty() { - continue; - } - - request.messages.push(request_message); + request.messages.extend(message.to_request()); } request.messages.push(LanguageModelRequestMessage { role: Role::User, - content: vec![MessageContent::Text(added_user_message)], + content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, }); + self.pending_title_generation = Some(cx.spawn(async move |this, cx| { + let mut title = String::new(); - request - } + let generate = async { + let mut messages = model.stream_completion(request, cx).await?; + while let Some(event) = messages.next().await { + let event = event?; + let text = match event { + LanguageModelCompletionEvent::Text(text) => text, + LanguageModelCompletionEvent::StatusUpdate( + CompletionRequestStatus::UsageUpdated { amount, limit }, + ) => { + this.update(cx, |thread, cx| { + thread.update_model_request_usage(amount, limit, cx); + })?; + continue; + } + _ => continue, + }; - /// Insert auto-generated notifications (if any) to the thread - fn flush_notifications( - &mut self, - model: Arc, - intent: CompletionIntent, - cx: &mut Context, - ) { - match intent { - CompletionIntent::UserPrompt | CompletionIntent::ToolResults => { - if let Some(pending_tool_use) = self.attach_tracked_files_state(model, cx) { - cx.emit(ThreadEvent::ToolFinished { - tool_use_id: pending_tool_use.id.clone(), - pending_tool_use: Some(pending_tool_use), - }); + let mut lines = text.lines(); + title.extend(lines.next()); + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; + } } + anyhow::Ok(()) + }; + + if generate.await.context("failed to generate title").is_ok() { + _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); } - CompletionIntent::ThreadSummarization - | CompletionIntent::ThreadContextSummarization - | CompletionIntent::CreateFile - | CompletionIntent::EditFile - | CompletionIntent::InlineAssist - | CompletionIntent::TerminalInlineAssist - | CompletionIntent::GenerateGitCommitMessage => {} - }; + _ = this.update(cx, |this, _| this.pending_title_generation = None); + })); } - fn attach_tracked_files_state( - &mut self, - model: Arc, - cx: &mut App, - ) -> Option { - // Represent notification as a simulated `project_notifications` tool call - let tool_name = Arc::from("project_notifications"); - let tool = self.tools.read(cx).tool(&tool_name, cx)?; - - if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { - return None; + pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { + self.pending_title_generation = None; + if Some(&title) != self.title.as_ref() { + self.title = Some(title); + cx.emit(TitleUpdated); + cx.notify(); } + } - if self - .action_log - .update(cx, |log, cx| log.unnotified_user_edits(cx).is_none()) - { - return None; - } + fn clear_summary(&mut self) { + self.summary = None; + self.pending_summary_generation = None; + } - let input = serde_json::json!({}); - let request = Arc::new(LanguageModelRequest::default()); // unused - let window = None; - let tool_result = tool.run( - input, - request, - self.project.clone(), - self.action_log.clone(), - model.clone(), - window, - cx, - ); + fn last_user_message(&self) -> Option<&UserMessage> { + self.messages + .iter() + .rev() + .find_map(|message| match message { + Message::User(user_message) => Some(user_message), + Message::Agent(_) => None, + Message::Resume => None, + }) + } - let tool_use_id = - LanguageModelToolUseId::from(format!("project_notifications_{}", self.messages.len())); + fn pending_message(&mut self) -> &mut AgentMessage { + self.pending_message.get_or_insert_default() + } - let tool_use = LanguageModelToolUse { - id: tool_use_id.clone(), - name: tool_name.clone(), - raw_input: "{}".to_string(), - input: serde_json::json!({}), - is_input_complete: true, + fn flush_pending_message(&mut self, cx: &mut Context) { + let Some(mut message) = self.pending_message.take() else { + return; }; - let tool_output = cx.background_executor().block(tool_result.output); - - // Attach a project_notification tool call to the latest existing - // Assistant message. We cannot create a new Assistant message - // because thinking models require a `thinking` block that we - // cannot mock. We cannot send a notification as a normal - // (non-tool-use) User message because this distracts Agent - // too much. - let tool_message_id = self - .messages - .iter() - .enumerate() - .rfind(|(_, message)| message.role == Role::Assistant) - .map(|(_, message)| message.id)?; - - let tool_use_metadata = ToolUseMetadata { - model: model.clone(), - thread_id: self.id.clone(), - prompt_id: self.last_prompt_id.clone(), - }; + if message.content.is_empty() { + return; + } - self.tool_use - .request_tool_use(tool_message_id, tool_use, tool_use_metadata.clone(), cx); + for content in &message.content { + let AgentMessageContent::ToolUse(tool_use) = content else { + continue; + }; - let pending_tool_use = self.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - tool_output, - self.configured_model.as_ref(), - self.completion_mode, - ); + if !message.tool_results.contains_key(&tool_use.id) { + message.tool_results.insert( + tool_use.id.clone(), + LanguageModelToolResult { + tool_use_id: tool_use.id.clone(), + tool_name: tool_use.name.clone(), + is_error: true, + content: LanguageModelToolResultContent::Text(TOOL_CANCELED_MESSAGE.into()), + output: None, + }, + ); + } + } - pending_tool_use + self.messages.push(Message::Agent(message)); + self.updated_at = Utc::now(); + self.clear_summary(); + cx.notify() } - pub fn stream_completion( - &mut self, - request: LanguageModelRequest, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) { - self.tool_use_limit_reached = false; - - let pending_completion_id = post_inc(&mut self.completion_count); - let mut request_callback_parameters = if self.request_callback.is_some() { - Some((request.clone(), Vec::new())) + pub(crate) fn build_completion_request( + &self, + completion_intent: CompletionIntent, + cx: &App, + ) -> Result { + let model = self.model().context("No language model configured")?; + let tools = if let Some(turn) = self.running_turn.as_ref() { + turn.tools + .iter() + .filter_map(|(tool_name, tool)| { + log::trace!("Including tool: {}", tool_name); + Some(LanguageModelRequestTool { + name: tool_name.to_string(), + description: tool.description().to_string(), + input_schema: tool.input_schema(model.tool_input_format()).log_err()?, + }) + }) + .collect::>() } else { - None + Vec::new() }; - let prompt_id = self.last_prompt_id.clone(); - let tool_use_metadata = ToolUseMetadata { - model: model.clone(), - thread_id: self.id.clone(), - prompt_id: prompt_id.clone(), - }; - - let completion_mode = request - .mode - .unwrap_or(cloud_llm_client::CompletionMode::Normal); - - self.last_received_chunk_at = Some(Instant::now()); - - let task = cx.spawn(async move |thread, cx| { - let stream_completion_future = model.stream_completion(request, &cx); - let initial_token_usage = - thread.read_with(cx, |thread, _cx| thread.cumulative_token_usage); - let stream_completion = async { - let mut events = stream_completion_future.await?; - let mut stop_reason = StopReason::EndTurn; - let mut current_token_usage = TokenUsage::default(); + log::debug!("Building completion request"); + log::debug!("Completion intent: {:?}", completion_intent); + log::debug!("Completion mode: {:?}", self.completion_mode); - thread - .update(cx, |_thread, cx| { - cx.emit(ThreadEvent::NewRequest); - }) - .ok(); - - let mut request_assistant_message_id = None; - - while let Some(event) = events.next().await { - if let Some((_, response_events)) = request_callback_parameters.as_mut() { - response_events - .push(event.as_ref().map_err(|error| error.to_string()).cloned()); - } + let available_tools: Vec<_> = self + .running_turn + .as_ref() + .map(|turn| turn.tools.keys().cloned().collect()) + .unwrap_or_default(); - thread.update(cx, |thread, cx| { - match event? { - LanguageModelCompletionEvent::StartMessage { .. } => { - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Text(String::new())], - cx, - )); - } - LanguageModelCompletionEvent::Stop(reason) => { - stop_reason = reason; - } - LanguageModelCompletionEvent::UsageUpdate(token_usage) => { - thread.update_token_usage_at_last_message(token_usage); - thread.cumulative_token_usage = thread.cumulative_token_usage - + token_usage - - current_token_usage; - current_token_usage = token_usage; - } - LanguageModelCompletionEvent::Text(chunk) => { - thread.received_chunk(); - - cx.emit(ThreadEvent::ReceivedTextChunk); - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_text(&chunk); - cx.emit(ThreadEvent::StreamedAssistantText( - last_message.id, - chunk, - )); - } else { - // If we won't have an Assistant message yet, assume this chunk marks the beginning - // of a new Assistant response. - // - // Importantly: We do *not* want to emit a `StreamedAssistantText` event here, as it - // will result in duplicating the text of the chunk in the rendered Markdown. - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Text(chunk.to_string())], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::Thinking { - text: chunk, - signature, - } => { - thread.received_chunk(); - - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_thinking(&chunk, signature); - cx.emit(ThreadEvent::StreamedAssistantThinking( - last_message.id, - chunk, - )); - } else { - // If we won't have an Assistant message yet, assume this chunk marks the beginning - // of a new Assistant response. - // - // Importantly: We do *not* want to emit a `StreamedAssistantText` event here, as it - // will result in duplicating the text of the chunk in the rendered Markdown. - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::Thinking { - text: chunk.to_string(), - signature, - }], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::RedactedThinking { data } => { - thread.received_chunk(); - - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant - && !thread.tool_use.has_tool_results(last_message.id) - { - last_message.push_redacted_thinking(data); - } else { - request_assistant_message_id = - Some(thread.insert_assistant_message( - vec![MessageSegment::RedactedThinking(data)], - cx, - )); - }; - } - } - LanguageModelCompletionEvent::ToolUse(tool_use) => { - let last_assistant_message_id = request_assistant_message_id - .unwrap_or_else(|| { - let new_assistant_message_id = - thread.insert_assistant_message(vec![], cx); - request_assistant_message_id = - Some(new_assistant_message_id); - new_assistant_message_id - }); - - let tool_use_id = tool_use.id.clone(); - let streamed_input = if tool_use.is_input_complete { - None - } else { - Some((&tool_use.input).clone()) - }; - - let ui_text = thread.tool_use.request_tool_use( - last_assistant_message_id, - tool_use, - tool_use_metadata.clone(), - cx, - ); + log::debug!("Request includes {} tools", available_tools.len()); + let messages = self.build_request_messages(available_tools, cx); + log::debug!("Request will include {} messages", messages.len()); - if let Some(input) = streamed_input { - cx.emit(ThreadEvent::StreamedToolUse { - tool_use_id, - ui_text, - input, - }); - } - } - LanguageModelCompletionEvent::ToolUseJsonParseError { - id, - tool_name, - raw_input: invalid_input_json, - json_parse_error, - } => { - thread.receive_invalid_tool_json( - id, - tool_name, - invalid_input_json, - json_parse_error, - window, - cx, - ); - } - LanguageModelCompletionEvent::StatusUpdate(status_update) => { - if let Some(completion) = thread - .pending_completions - .iter_mut() - .find(|completion| completion.id == pending_completion_id) - { - match status_update { - CompletionRequestStatus::Queued { position } => { - completion.queue_state = - QueueState::Queued { position }; - } - CompletionRequestStatus::Started => { - completion.queue_state = QueueState::Started; - } - CompletionRequestStatus::Failed { - code, - message, - request_id: _, - retry_after, - } => { - return Err( - LanguageModelCompletionError::from_cloud_failure( - model.upstream_provider_name(), - code, - message, - retry_after.map(Duration::from_secs_f64), - ), - ); - } - CompletionRequestStatus::UsageUpdated { amount, limit } => { - thread.update_model_request_usage( - amount as u32, - limit, - cx, - ); - } - CompletionRequestStatus::ToolUseLimitReached => { - thread.tool_use_limit_reached = true; - cx.emit(ThreadEvent::ToolUseLimitReached); - } - } - } - } - } + let request = LanguageModelRequest { + thread_id: Some(self.id.to_string()), + prompt_id: Some(self.prompt_id.to_string()), + intent: Some(completion_intent), + mode: Some(self.completion_mode.into()), + messages, + tools, + tool_choice: None, + stop: Vec::new(), + temperature: AgentSettings::temperature_for_model(model, cx), + thinking_allowed: true, + }; - thread.touch_updated_at(); - cx.emit(ThreadEvent::StreamedCompletion); - cx.notify(); + log::debug!("Completion request built successfully"); + Ok(request) + } - thread.auto_capture_telemetry(cx); - Ok(()) - })??; + fn enabled_tools( + &self, + profile: &AgentProfileSettings, + model: &Arc, + cx: &App, + ) -> BTreeMap> { + fn truncate(tool_name: &SharedString) -> SharedString { + if tool_name.len() > MAX_TOOL_NAME_LENGTH { + let mut truncated = tool_name.to_string(); + truncated.truncate(MAX_TOOL_NAME_LENGTH); + truncated.into() + } else { + tool_name.clone() + } + } - smol::future::yield_now().await; + let mut tools = self + .tools + .iter() + .filter_map(|(tool_name, tool)| { + if tool.supports_provider(&model.provider_id()) + && profile.is_tool_enabled(tool_name) + { + Some((truncate(tool_name), tool.clone())) + } else { + None } - - thread.update(cx, |thread, cx| { - thread.last_received_chunk_at = None; - thread - .pending_completions - .retain(|completion| completion.id != pending_completion_id); - - // If there is a response without tool use, summarize the message. Otherwise, - // allow two tool uses before summarizing. - if matches!(thread.summary, ThreadSummary::Pending) - && thread.messages.len() >= 2 - && (!thread.has_pending_tool_uses() || thread.messages.len() >= 6) - { - thread.summarize(cx); - } - })?; - - anyhow::Ok(stop_reason) - }; - - let result = stream_completion.await; - let mut retry_scheduled = false; - - thread - .update(cx, |thread, cx| { - thread.finalize_pending_checkpoint(cx); - match result.as_ref() { - Ok(stop_reason) => { - match stop_reason { - StopReason::ToolUse => { - let tool_uses = - thread.use_pending_tools(window, model.clone(), cx); - cx.emit(ThreadEvent::UsePendingTools { tool_uses }); - } - StopReason::EndTurn | StopReason::MaxTokens => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - } - StopReason::Refusal => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - - // Remove the turn that was refused. - // - // https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals#reset-context-after-refusal - { - let mut messages_to_remove = Vec::new(); - - for (ix, message) in - thread.messages.iter().enumerate().rev() - { - messages_to_remove.push(message.id); - - if message.role == Role::User { - if ix == 0 { - break; - } - - if let Some(prev_message) = - thread.messages.get(ix - 1) - { - if prev_message.role == Role::Assistant { - break; - } - } - } - } - - for message_id in messages_to_remove { - thread.delete_message(message_id, cx); - } - } - - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Language model refusal".into(), - message: - "Model refused to generate content for safety reasons." - .into(), - })); - } - } - - // We successfully completed, so cancel any remaining retries. - thread.retry_state = None; - } - Err(error) => { - thread.project.update(cx, |project, cx| { - project.set_agent_location(None, cx); - }); - - if error.is::() { - cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); - } else if let Some(error) = - error.downcast_ref::() - { - cx.emit(ThreadEvent::ShowError( - ThreadError::ModelRequestLimitReached { plan: error.plan }, - )); - } else if let Some(completion_error) = - error.downcast_ref::() - { - match &completion_error { - LanguageModelCompletionError::PromptTooLarge { - tokens, .. - } => { - let tokens = tokens.unwrap_or_else(|| { - // We didn't get an exact token count from the API, so fall back on our estimate. - thread - .total_token_usage() - .map(|usage| usage.total) - .unwrap_or(0) - // We know the context window was exceeded in practice, so if our estimate was - // lower than max tokens, the estimate was wrong; return that we exceeded by 1. - .max( - model - .max_token_count_for_mode(completion_mode) - .saturating_add(1), - ) - }); - thread.exceeded_window_error = Some(ExceededWindowError { - model_id: model.id(), - token_count: tokens, - }); - cx.notify(); - } - _ => { - if let Some(retry_strategy) = - Thread::get_retry_strategy(completion_error) - { - log::info!( - "Retrying with {:?} for language model completion error {:?}", - retry_strategy, - completion_error - ); - - retry_scheduled = thread - .handle_retryable_error_with_delay( - &completion_error, - Some(retry_strategy), - model.clone(), - intent, - window, - cx, - ); - } - } - } - } - - if !retry_scheduled { - thread.cancel_last_completion(window, cx); - } - } - } - - if !retry_scheduled { - cx.emit(ThreadEvent::Stopped(result.map_err(Arc::new))); - } - - if let Some((request_callback, (request, response_events))) = thread - .request_callback - .as_mut() - .zip(request_callback_parameters.as_ref()) - { - request_callback(request, response_events); + }) + .collect::>(); + + let mut context_server_tools = Vec::new(); + let mut seen_tools = tools.keys().cloned().collect::>(); + let mut duplicate_tool_names = HashSet::default(); + for (server_id, server_tools) in self.context_server_registry.read(cx).servers() { + for (tool_name, tool) in server_tools { + if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) { + let tool_name = truncate(tool_name); + if !seen_tools.insert(tool_name.clone()) { + duplicate_tool_names.insert(tool_name.clone()); } + context_server_tools.push((server_id.clone(), tool_name, tool.clone())); + } + } + } - thread.auto_capture_telemetry(cx); - - if let Ok(initial_usage) = initial_token_usage { - let usage = thread.cumulative_token_usage - initial_usage; - - telemetry::event!( - "Assistant Thread Completion", - thread_id = thread.id().to_string(), - prompt_id = prompt_id, - model = model.telemetry_id(), - model_provider = model.provider_id().to_string(), - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, - cache_creation_input_tokens = usage.cache_creation_input_tokens, - cache_read_input_tokens = usage.cache_read_input_tokens, - ); - } - }) - .ok(); - }); + // When there are duplicate tool names, disambiguate by prefixing them + // with the server ID. In the rare case there isn't enough space for the + // disambiguated tool name, keep only the last tool with this name. + for (server_id, tool_name, tool) in context_server_tools { + if duplicate_tool_names.contains(&tool_name) { + let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); + if available >= 2 { + let mut disambiguated = server_id.0.to_string(); + disambiguated.truncate(available - 1); + disambiguated.push('_'); + disambiguated.push_str(&tool_name); + tools.insert(disambiguated.into(), tool.clone()); + } else { + tools.insert(tool_name, tool.clone()); + } + } else { + tools.insert(tool_name, tool.clone()); + } + } - self.pending_completions.push(PendingCompletion { - id: pending_completion_id, - queue_state: QueueState::Sending, - _task: task, - }); + tools } - pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { - println!("No thread summary model"); - return; - }; - - if !model.provider.is_authenticated(cx) { - return; - } + fn tool(&self, name: &str) -> Option> { + self.running_turn.as_ref()?.tools.get(name).cloned() + } - let request = self.to_summarize_request( - &model.model, - CompletionIntent::ThreadSummarization, - SUMMARIZE_THREAD_PROMPT.into(), - cx, + fn build_request_messages( + &self, + available_tools: Vec, + cx: &App, + ) -> Vec { + log::trace!( + "Building request messages from {} thread messages", + self.messages.len() ); - self.summary = ThreadSummary::Generating; - - self.pending_summary = cx.spawn(async move |this, cx| { - let result = async { - let mut messages = model.model.stream_completion(request, &cx).await?; + let system_prompt = SystemPromptTemplate { + project: self.project_context.read(cx), + available_tools, + } + .render(&self.templates) + .context("failed to build system prompt") + .expect("Invalid template"); + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + }]; + for message in &self.messages { + messages.extend(message.to_request()); + } - let mut new_summary = String::new(); - while let Some(event) = messages.next().await { - let Ok(event) = event else { - continue; - }; - let text = match event { - LanguageModelCompletionEvent::Text(text) => text, - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::UsageUpdated { amount, limit }, - ) => { - this.update(cx, |thread, cx| { - thread.update_model_request_usage(amount as u32, limit, cx); - })?; - continue; - } - _ => continue, - }; + if let Some(last_message) = messages.last_mut() { + last_message.cache = true; + } - let mut lines = text.lines(); - new_summary.extend(lines.next()); + if let Some(message) = self.pending_message.as_ref() { + messages.extend(message.to_request()); + } - // Stop if the LLM generated multiple lines. - if lines.next().is_some() { - break; - } - } + messages + } - anyhow::Ok(new_summary) + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + for (ix, message) in self.messages.iter().enumerate() { + if ix > 0 { + markdown.push('\n'); } - .await; + markdown.push_str(&message.to_markdown()); + } - this.update(cx, |this, cx| { - match result { - Ok(new_summary) => { - if new_summary.is_empty() { - this.summary = ThreadSummary::Error; - } else { - this.summary = ThreadSummary::Ready(new_summary.into()); - } - } - Err(err) => { - this.summary = ThreadSummary::Error; - log::error!("Failed to generate thread summary: {}", err); - } - } - cx.emit(ThreadEvent::SummaryGenerated); - }) - .log_err()?; + if let Some(message) = self.pending_message.as_ref() { + markdown.push('\n'); + markdown.push_str(&message.to_markdown()); + } - Some(()) - }); + markdown + } + + fn advance_prompt_id(&mut self) { + self.prompt_id = PromptId::new(); } - fn get_retry_strategy(error: &LanguageModelCompletionError) -> Option { + fn retry_strategy_for(error: &LanguageModelCompletionError) -> Option { use LanguageModelCompletionError::*; + use http_client::StatusCode; // General strategy here: // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. @@ -2275,8 +2064,8 @@ impl Thread { }) } Other(err) - if err.is::() - || err.is::() => + if err.is::() + || err.is::() => { // Retrying won't help for Payment Required or Model Request Limit errors (where // the user must upgrade to usage-based billing to get more requests, or else wait @@ -2290,3272 +2079,561 @@ impl Thread { }), } } +} - fn handle_retryable_error_with_delay( - &mut self, - error: &LanguageModelCompletionError, - strategy: Option, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) -> bool { - // Store context for the Retry button - self.last_error_context = Some((model.clone(), intent)); - - // Only auto-retry if Burn Mode is enabled - if self.completion_mode != CompletionMode::Burn { - // Show error with retry options - cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError { - message: format!( - "{}\n\nTo automatically retry when similar errors happen, enable Burn Mode.", - error - ) - .into(), - can_enable_burn_mode: true, - })); - return false; - } - - let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else { - return false; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let retry_state = self.retry_state.get_or_insert(RetryState { - attempt: 0, - max_attempts, - intent, - }); +struct RunningTurn { + /// Holds the task that handles agent interaction until the end of the turn. + /// Survives across multiple requests as the model performs tool calls and + /// we run tools, report their results. + _task: Task<()>, + /// The current event stream for the running turn. Used to report a final + /// cancellation event if we cancel the turn. + event_stream: ThreadEventStream, + /// The tools that were enabled for this turn. + tools: BTreeMap>, +} - retry_state.attempt += 1; - let attempt = retry_state.attempt; - let max_attempts = retry_state.max_attempts; - let intent = retry_state.intent; +impl RunningTurn { + fn cancel(self) { + log::debug!("Cancelling in progress turn"); + self.event_stream.send_canceled(); + } +} - if attempt <= max_attempts { - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; +pub struct TokenUsageUpdated(pub Option); - // Add a transient message to inform the user - let delay_secs = delay.as_secs(); - let retry_message = if max_attempts == 1 { - format!("{error}. Retrying in {delay_secs} seconds...") - } else { - format!( - "{error}. Retrying (attempt {attempt} of {max_attempts}) \ - in {delay_secs} seconds..." - ) - }; - log::warn!( - "Retrying completion request (attempt {attempt} of {max_attempts}) \ - in {delay_secs} seconds: {error:?}", - ); +impl EventEmitter for Thread {} - // Add a UI-only message instead of a regular message - let id = self.next_message_id.post_inc(); - self.messages.push(Message { - id, - role: Role::System, - segments: vec![MessageSegment::Text(retry_message)], - loaded_context: LoadedContext::default(), - creases: Vec::new(), - is_hidden: false, - ui_only: true, - }); - cx.emit(ThreadEvent::MessageAdded(id)); +pub struct TitleUpdated; - // Schedule the retry - let thread_handle = cx.entity().downgrade(); +impl EventEmitter for Thread {} - cx.spawn(async move |_thread, cx| { - cx.background_executor().timer(delay).await; +pub trait AgentTool +where + Self: 'static + Sized, +{ + type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; + type Output: for<'de> Deserialize<'de> + Serialize + Into; - thread_handle - .update(cx, |thread, cx| { - // Retry the completion - thread.send_to_model(model, intent, window, cx); - }) - .log_err(); - }) - .detach(); + fn name() -> &'static str; - true - } else { - // Max retries exceeded - self.retry_state = None; + fn description() -> SharedString { + let schema = schemars::schema_for!(Self::Input); + SharedString::new( + schema + .get("description") + .and_then(|description| description.as_str()) + .unwrap_or_default(), + ) + } - // Stop generating since we're giving up on retrying. - self.pending_completions.clear(); + fn kind() -> acp::ToolKind; - // Show error alongside a Retry button, but no - // Enable Burn Mode button (since it's already enabled) - cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError { - message: format!("Failed after retrying: {}", error).into(), - can_enable_burn_mode: false, - })); + /// The initial tool title to display. Can be updated during the tool run. + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString; - false - } + /// Returns the JSON schema that describes the tool's input. + fn input_schema(format: LanguageModelToolSchemaFormat) -> Schema { + crate::tool_schema::root_schema_for::(format) } - pub fn start_generating_detailed_summary_if_needed( - &mut self, - thread_store: WeakEntity, - cx: &mut Context, - ) { - let Some(last_message_id) = self.messages.last().map(|message| message.id) else { - return; - }; - - match &*self.detailed_summary_rx.borrow() { - DetailedSummaryState::Generating { message_id, .. } - | DetailedSummaryState::Generated { message_id, .. } - if *message_id == last_message_id => - { - // Already up-to-date - return; - } - _ => {} - } + /// Some tools rely on a provider for the underlying billing or other reasons. + /// Allow the tool to check if they are compatible, or should be filtered out. + fn supports_provider(_provider: &LanguageModelProviderId) -> bool { + true + } - let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model() - else { - return; - }; + /// Runs the tool with the provided input. + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; - if !provider.is_authenticated(cx) { - return; - } + /// Emits events for a previous execution of the tool. + fn replay( + &self, + _input: Self::Input, + _output: Self::Output, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Result<()> { + Ok(()) + } - let added_user_message = include_str!("./prompts/summarize_thread_detailed_prompt.txt"); + fn erase(self) -> Arc { + Arc::new(Erased(Arc::new(self))) + } +} - let request = self.to_summarize_request( - &model, - CompletionIntent::ThreadContextSummarization, - added_user_message.into(), - cx, - ); +pub struct Erased(T); - *self.detailed_summary_tx.borrow_mut() = DetailedSummaryState::Generating { - message_id: last_message_id, - }; +pub struct AgentToolOutput { + pub llm_output: LanguageModelToolResultContent, + pub raw_output: serde_json::Value, +} - // Replace the detailed summarization task if there is one, cancelling it. It would probably - // be better to allow the old task to complete, but this would require logic for choosing - // which result to prefer (the old task could complete after the new one, resulting in a - // stale summary). - self.detailed_summary_task = cx.spawn(async move |thread, cx| { - let stream = model.stream_completion_text(request, &cx); - let Some(mut messages) = stream.await.log_err() else { - thread - .update(cx, |thread, _cx| { - *thread.detailed_summary_tx.borrow_mut() = - DetailedSummaryState::NotGenerated; - }) - .ok()?; - return None; - }; +pub trait AnyAgentTool { + fn name(&self) -> SharedString; + fn description(&self) -> SharedString; + fn kind(&self) -> acp::ToolKind; + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString; + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; + fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { + true + } + fn run( + self: Arc, + input: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task>; + fn replay( + &self, + input: serde_json::Value, + output: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()>; +} - let mut new_detailed_summary = String::new(); +impl AnyAgentTool for Erased> +where + T: AgentTool, +{ + fn name(&self) -> SharedString { + T::name().into() + } - while let Some(chunk) = messages.stream.next().await { - if let Some(chunk) = chunk.log_err() { - new_detailed_summary.push_str(&chunk); - } - } + fn description(&self) -> SharedString { + T::description() + } - thread - .update(cx, |thread, _cx| { - *thread.detailed_summary_tx.borrow_mut() = DetailedSummaryState::Generated { - text: new_detailed_summary.into(), - message_id: last_message_id, - }; - }) - .ok()?; + fn kind(&self) -> agent_client_protocol::ToolKind { + T::kind() + } - // Save thread so its summary can be reused later - if let Some(thread) = thread.upgrade() { - if let Ok(Ok(save_task)) = cx.update(|cx| { - thread_store - .update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx)) - }) { - save_task.await.log_err(); - } - } + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { + let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); + self.0.initial_title(parsed_input, _cx) + } - Some(()) - }); + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + let mut json = serde_json::to_value(T::input_schema(format))?; + crate::tool_schema::adapt_schema_to_format(&mut json, format)?; + Ok(json) } - pub async fn wait_for_detailed_summary_or_text( - this: &Entity, - cx: &mut AsyncApp, - ) -> Option { - let mut detailed_summary_rx = this - .read_with(cx, |this, _cx| this.detailed_summary_rx.clone()) - .ok()?; - loop { - match detailed_summary_rx.recv().await? { - DetailedSummaryState::Generating { .. } => {} - DetailedSummaryState::NotGenerated => { - return this.read_with(cx, |this, _cx| this.text().into()).ok(); - } - DetailedSummaryState::Generated { text, .. } => return Some(text), - } - } + fn supports_provider(&self, provider: &LanguageModelProviderId) -> bool { + T::supports_provider(provider) } - pub fn latest_detailed_summary_or_text(&self) -> SharedString { - self.detailed_summary_rx - .borrow() - .text() - .unwrap_or_else(|| self.text().into()) + fn run( + self: Arc, + input: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |cx| { + let input = serde_json::from_value(input)?; + let output = cx + .update(|cx| self.0.clone().run(input, event_stream, cx))? + .await?; + let raw_output = serde_json::to_value(&output)?; + Ok(AgentToolOutput { + llm_output: output.into(), + raw_output, + }) + }) } - pub fn is_generating_detailed_summary(&self) -> bool { - matches!( - &*self.detailed_summary_rx.borrow(), - DetailedSummaryState::Generating { .. } - ) + fn replay( + &self, + input: serde_json::Value, + output: serde_json::Value, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()> { + let input = serde_json::from_value(input)?; + let output = serde_json::from_value(output)?; + self.0.replay(input, output, event_stream, cx) } +} - pub fn use_pending_tools( - &mut self, - window: Option, - model: Arc, - cx: &mut Context, - ) -> Vec { - self.auto_capture_telemetry(cx); - let request = - Arc::new(self.to_completion_request(model.clone(), CompletionIntent::ToolResults, cx)); - let pending_tool_uses = self - .tool_use - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.is_idle()) - .cloned() - .collect::>(); - - for tool_use in pending_tool_uses.iter() { - self.use_pending_tool(tool_use.clone(), request.clone(), model.clone(), window, cx); - } +#[derive(Clone)] +struct ThreadEventStream(mpsc::UnboundedSender>); - pending_tool_uses +impl ThreadEventStream { + fn send_user_message(&self, message: &UserMessage) { + self.0 + .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) + .ok(); } - fn use_pending_tool( - &mut self, - tool_use: PendingToolUse, - request: Arc, - model: Arc, - window: Option, - cx: &mut Context, - ) { - let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) else { - return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); - }; + fn send_text(&self, text: &str) { + self.0 + .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string()))) + .ok(); + } - if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { - return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); - } - - if tool.needs_confirmation(&tool_use.input, &self.project, cx) - && !AgentSettings::get_global(cx).always_allow_tool_actions - { - self.tool_use.confirm_tool_use( - tool_use.id, - tool_use.ui_text, - tool_use.input, - request, - tool, - ); - cx.emit(ThreadEvent::ToolConfirmationNeeded); - } else { - self.run_tool( - tool_use.id, - tool_use.ui_text, - tool_use.input, - request, - tool, - model, - window, - cx, - ); - } - } - - pub fn handle_hallucinated_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - hallucinated_tool_name: Arc, - window: Option, - cx: &mut Context, - ) { - let available_tools = self.profile.enabled_tools(cx); - - let tool_list = available_tools - .iter() - .map(|(name, tool)| format!("- {}: {}", name, tool.description())) - .collect::>() - .join("\n"); - - let error_message = format!( - "The tool '{}' doesn't exist or is not enabled. Available tools:\n{}", - hallucinated_tool_name, tool_list - ); - - let pending_tool_use = self.tool_use.insert_tool_output( - tool_use_id.clone(), - hallucinated_tool_name, - Err(anyhow!("Missing tool call: {error_message}")), - self.configured_model.as_ref(), - self.completion_mode, - ); - - cx.emit(ThreadEvent::MissingToolUse { - tool_use_id: tool_use_id.clone(), - ui_text: error_message.into(), - }); - - self.tool_finished(tool_use_id, pending_tool_use, false, window, cx); - } - - pub fn receive_invalid_tool_json( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - invalid_json: Arc, - error: String, - window: Option, - cx: &mut Context, - ) { - log::error!("The model returned invalid input JSON: {invalid_json}"); - - let pending_tool_use = self.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - Err(anyhow!("Error parsing input JSON: {error}")), - self.configured_model.as_ref(), - self.completion_mode, - ); - let ui_text = if let Some(pending_tool_use) = &pending_tool_use { - pending_tool_use.ui_text.clone() - } else { - log::error!( - "There was no pending tool use for tool use {tool_use_id}, even though it finished (with invalid input JSON)." - ); - format!("Unknown tool {}", tool_use_id).into() - }; - - cx.emit(ThreadEvent::InvalidToolInput { - tool_use_id: tool_use_id.clone(), - ui_text, - invalid_input_json: invalid_json, - }); - - self.tool_finished(tool_use_id, pending_tool_use, false, window, cx); + fn send_thinking(&self, text: &str) { + self.0 + .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string()))) + .ok(); } - pub fn run_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: impl Into, + fn send_tool_call( + &self, + id: &LanguageModelToolUseId, + tool_name: &str, + title: SharedString, + kind: acp::ToolKind, input: serde_json::Value, - request: Arc, - tool: Arc, - model: Arc, - window: Option, - cx: &mut Context, ) { - let task = - self.spawn_tool_use(tool_use_id.clone(), request, input, tool, model, window, cx); - self.tool_use - .run_pending_tool(tool_use_id, ui_text.into(), task); + self.0 + .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call( + id, + tool_name, + title.to_string(), + kind, + input, + )))) + .ok(); } - fn spawn_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - request: Arc, + fn initial_tool_call( + id: &LanguageModelToolUseId, + tool_name: &str, + title: String, + kind: acp::ToolKind, input: serde_json::Value, - tool: Arc, - model: Arc, - window: Option, - cx: &mut Context, - ) -> Task<()> { - let tool_name: Arc = tool.name().into(); - - let tool_result = tool.run( - input, - request, - self.project.clone(), - self.action_log.clone(), - model, - window, - cx, - ); - - // Store the card separately if it exists - if let Some(card) = tool_result.card.clone() { - self.tool_use - .insert_tool_result_card(tool_use_id.clone(), card); - } - - cx.spawn({ - async move |thread: WeakEntity, cx| { - let output = tool_result.output.await; - - thread - .update(cx, |thread, cx| { - let pending_tool_use = thread.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - output, - thread.configured_model.as_ref(), - thread.completion_mode, - ); - thread.tool_finished(tool_use_id, pending_tool_use, false, window, cx); - }) - .ok(); - } - }) - } - - fn tool_finished( - &mut self, - tool_use_id: LanguageModelToolUseId, - pending_tool_use: Option, - canceled: bool, - window: Option, - cx: &mut Context, + ) -> acp::ToolCall { + acp::ToolCall { + meta: Some(serde_json::json!({ + "tool_name": tool_name + })), + id: acp::ToolCallId(id.to_string().into()), + title, + kind, + status: acp::ToolCallStatus::Pending, + content: vec![], + locations: vec![], + raw_input: Some(input), + raw_output: None, + } + } + + fn update_tool_call_fields( + &self, + tool_use_id: &LanguageModelToolUseId, + fields: acp::ToolCallUpdateFields, ) { - if self.all_tools_finished() { - if let Some(ConfiguredModel { model, .. }) = self.configured_model.as_ref() { - if !canceled { - self.send_to_model(model.clone(), CompletionIntent::ToolResults, window, cx); + self.0 + .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( + acp::ToolCallUpdate { + meta: None, + id: acp::ToolCallId(tool_use_id.to_string().into()), + fields, } - self.auto_capture_telemetry(cx); - } - } - - cx.emit(ThreadEvent::ToolFinished { - tool_use_id, - pending_tool_use, - }); + .into(), + ))) + .ok(); } - /// Cancels the last pending completion, if there are any pending. - /// - /// Returns whether a completion was canceled. - pub fn cancel_last_completion( - &mut self, - window: Option, - cx: &mut Context, - ) -> bool { - let mut canceled = self.pending_completions.pop().is_some() || self.retry_state.is_some(); - - self.retry_state = None; - - for pending_tool_use in self.tool_use.cancel_pending() { - canceled = true; - self.tool_finished( - pending_tool_use.id.clone(), - Some(pending_tool_use), - true, - window, - cx, - ); - } - - if canceled { - cx.emit(ThreadEvent::CompletionCanceled); - - // When canceled, we always want to insert the checkpoint. - // (We skip over finalize_pending_checkpoint, because it - // would conclude we didn't have anything to insert here.) - if let Some(checkpoint) = self.pending_checkpoint.take() { - self.insert_checkpoint(checkpoint, cx); - } - } else { - self.finalize_pending_checkpoint(cx); - } - - canceled + fn send_retry(&self, status: acp_thread::RetryStatus) { + self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } - /// Signals that any in-progress editing should be canceled. - /// - /// This method is used to notify listeners (like ActiveThread) that - /// they should cancel any editing operations. - pub fn cancel_editing(&mut self, cx: &mut Context) { - cx.emit(ThreadEvent::CancelEditing); + fn send_stop(&self, reason: acp::StopReason) { + self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); } - pub fn feedback(&self) -> Option { - self.feedback + fn send_canceled(&self) { + self.0 + .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) + .ok(); } - pub fn message_feedback(&self, message_id: MessageId) -> Option { - self.message_feedback.get(&message_id).copied() + fn send_error(&self, error: impl Into) { + self.0.unbounded_send(Err(error.into())).ok(); } +} - pub fn report_message_feedback( - &mut self, - message_id: MessageId, - feedback: ThreadFeedback, - cx: &mut Context, - ) -> Task> { - if self.message_feedback.get(&message_id) == Some(&feedback) { - return Task::ready(Ok(())); - } - - let final_project_snapshot = Self::project_snapshot(self.project.clone(), cx); - let serialized_thread = self.serialize(cx); - let thread_id = self.id().clone(); - let client = self.project.read(cx).client(); - - let enabled_tool_names: Vec = self - .profile - .enabled_tools(cx) - .iter() - .map(|(name, _)| name.clone().into()) - .collect(); - - self.message_feedback.insert(message_id, feedback); - - cx.notify(); +#[derive(Clone)] +pub struct ToolCallEventStream { + tool_use_id: LanguageModelToolUseId, + stream: ThreadEventStream, + fs: Option>, +} - let message_content = self - .message(message_id) - .map(|msg| msg.to_string()) - .unwrap_or_default(); +impl ToolCallEventStream { + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> (Self, ToolCallEventStreamReceiver) { + let (events_tx, events_rx) = mpsc::unbounded::>(); - cx.background_spawn(async move { - let final_project_snapshot = final_project_snapshot.await; - let serialized_thread = serialized_thread.await?; - let thread_data = - serde_json::to_value(serialized_thread).unwrap_or_else(|_| serde_json::Value::Null); - - let rating = match feedback { - ThreadFeedback::Positive => "positive", - ThreadFeedback::Negative => "negative", - }; - telemetry::event!( - "Assistant Thread Rated", - rating, - thread_id, - enabled_tool_names, - message_id = message_id.0, - message_content, - thread_data, - final_project_snapshot - ); - client.telemetry().flush_events().await; + let stream = ToolCallEventStream::new("test_id".into(), ThreadEventStream(events_tx), None); - Ok(()) - }) + (stream, ToolCallEventStreamReceiver(events_rx)) } - pub fn report_feedback( - &mut self, - feedback: ThreadFeedback, - cx: &mut Context, - ) -> Task> { - let last_assistant_message_id = self - .messages - .iter() - .rev() - .find(|msg| msg.role == Role::Assistant) - .map(|msg| msg.id); - - if let Some(message_id) = last_assistant_message_id { - self.report_message_feedback(message_id, feedback, cx) - } else { - let final_project_snapshot = Self::project_snapshot(self.project.clone(), cx); - let serialized_thread = self.serialize(cx); - let thread_id = self.id().clone(); - let client = self.project.read(cx).client(); - self.feedback = Some(feedback); - cx.notify(); - - cx.background_spawn(async move { - let final_project_snapshot = final_project_snapshot.await; - let serialized_thread = serialized_thread.await?; - let thread_data = serde_json::to_value(serialized_thread) - .unwrap_or_else(|_| serde_json::Value::Null); - - let rating = match feedback { - ThreadFeedback::Positive => "positive", - ThreadFeedback::Negative => "negative", - }; - telemetry::event!( - "Assistant Thread Rated", - rating, - thread_id, - thread_data, - final_project_snapshot - ); - client.telemetry().flush_events().await; - - Ok(()) - }) + fn new( + tool_use_id: LanguageModelToolUseId, + stream: ThreadEventStream, + fs: Option>, + ) -> Self { + Self { + tool_use_id, + stream, + fs, } } - /// Create a snapshot of the current project state including git information and unsaved buffers. - fn project_snapshot( - project: Entity, - cx: &mut Context, - ) -> Task> { - let git_store = project.read(cx).git_store().clone(); - let worktree_snapshots: Vec<_> = project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| Self::worktree_snapshot(worktree, git_store.clone(), cx)) - .collect(); - - cx.spawn(async move |_, cx| { - let worktree_snapshots = futures::future::join_all(worktree_snapshots).await; - - let mut unsaved_buffers = Vec::new(); - cx.update(|app_cx| { - let buffer_store = project.read(app_cx).buffer_store(); - for buffer_handle in buffer_store.read(app_cx).buffers() { - let buffer = buffer_handle.read(app_cx); - if buffer.is_dirty() { - if let Some(file) = buffer.file() { - let path = file.path().to_string_lossy().to_string(); - unsaved_buffers.push(path); - } - } + pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { + self.stream + .update_tool_call_fields(&self.tool_use_id, fields); + } + + pub fn update_diff(&self, diff: Entity) { + self.stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( + acp_thread::ToolCallUpdateDiff { + id: acp::ToolCallId(self.tool_use_id.to_string().into()), + diff, } - }) + .into(), + ))) .ok(); - - Arc::new(ProjectSnapshot { - worktree_snapshots, - unsaved_buffer_paths: unsaved_buffers, - timestamp: Utc::now(), - }) - }) } - fn worktree_snapshot( - worktree: Entity, - git_store: Entity, - cx: &App, - ) -> Task { - cx.spawn(async move |cx| { - // Get worktree path and snapshot - let worktree_info = cx.update(|app_cx| { - let worktree = worktree.read(app_cx); - let path = worktree.abs_path().to_string_lossy().to_string(); - let snapshot = worktree.snapshot(); - (path, snapshot) - }); - - let Ok((worktree_path, _snapshot)) = worktree_info else { - return WorktreeSnapshot { - worktree_path: String::new(), - git_state: None, - }; - }; - - let git_state = git_store - .update(cx, |git_store, cx| { - git_store - .repositories() - .values() - .find(|repo| { - repo.read(cx) - .abs_path_to_repo_path(&worktree.read(cx).abs_path()) - .is_some() - }) - .cloned() - }) - .ok() - .flatten() - .map(|repo| { - repo.update(cx, |repo, _| { - let current_branch = - repo.branch.as_ref().map(|branch| branch.name().to_owned()); - repo.send_job(None, |state, _| async move { - let RepositoryState::Local { backend, .. } = state else { - return GitState { - remote_url: None, - head_sha: None, - current_branch, - diff: None, - }; - }; - - let remote_url = backend.remote_url("origin"); - let head_sha = backend.head_sha().await; - let diff = backend.diff(DiffType::HeadToWorktree).await.ok(); - - GitState { - remote_url, - head_sha, - current_branch, - diff, - } - }) - }) - }); + pub fn authorize(&self, title: impl Into, cx: &mut App) -> Task> { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return Task::ready(Ok(())); + } - let git_state = match git_state { - Some(git_state) => match git_state.ok() { - Some(git_state) => git_state.await.ok(), - None => None, + let (response_tx, response_rx) = oneshot::channel(); + self.stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate { + meta: None, + id: acp::ToolCallId(self.tool_use_id.to_string().into()), + fields: acp::ToolCallUpdateFields { + title: Some(title.into()), + ..Default::default() + }, + }, + options: vec![ + acp::PermissionOption { + id: acp::PermissionOptionId("always_allow".into()), + name: "Always Allow".into(), + kind: acp::PermissionOptionKind::AllowAlways, + meta: None, + }, + acp::PermissionOption { + id: acp::PermissionOptionId("allow".into()), + name: "Allow".into(), + kind: acp::PermissionOptionKind::AllowOnce, + meta: None, + }, + acp::PermissionOption { + id: acp::PermissionOptionId("deny".into()), + name: "Deny".into(), + kind: acp::PermissionOptionKind::RejectOnce, + meta: None, + }, + ], + response: response_tx, }, - None => None, - }; + ))) + .ok(); + let fs = self.fs.clone(); + cx.spawn(async move |cx| match response_rx.await?.0.as_ref() { + "always_allow" => { + if let Some(fs) = fs.clone() { + cx.update(|cx| { + update_settings_file(fs, cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_always_allow_tool_actions(true); + }); + })?; + } - WorktreeSnapshot { - worktree_path, - git_state, + Ok(()) } + "allow" => Ok(()), + _ => Err(anyhow!("Permission to run tool denied by user")), }) } +} - pub fn to_markdown(&self, cx: &App) -> Result { - let mut markdown = Vec::new(); - - let summary = self.summary().or_default(); - writeln!(markdown, "# {summary}\n")?; - - for message in self.messages() { - writeln!( - markdown, - "## {role}\n", - role = match message.role { - Role::User => "User", - Role::Assistant => "Agent", - Role::System => "System", - } - )?; - - if !message.loaded_context.text.is_empty() { - writeln!(markdown, "{}", message.loaded_context.text)?; - } - - if !message.loaded_context.images.is_empty() { - writeln!( - markdown, - "\n{} images attached as context.\n", - message.loaded_context.images.len() - )?; - } - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => writeln!(markdown, "{}\n", text)?, - MessageSegment::Thinking { text, .. } => { - writeln!(markdown, "\n{}\n\n", text)? - } - MessageSegment::RedactedThinking(_) => {} - } - } - - for tool_use in self.tool_uses_for_message(message.id, cx) { - writeln!( - markdown, - "**Use Tool: {} ({})**", - tool_use.name, tool_use.id - )?; - writeln!(markdown, "```json")?; - writeln!( - markdown, - "{}", - serde_json::to_string_pretty(&tool_use.input)? - )?; - writeln!(markdown, "```")?; - } - - for tool_result in self.tool_results_for_message(message.id) { - write!(markdown, "\n**Tool Results: {}", tool_result.tool_use_id)?; - if tool_result.is_error { - write!(markdown, " (Error)")?; - } - - writeln!(markdown, "**\n")?; - match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - writeln!(markdown, "{text}")?; - } - LanguageModelToolResultContent::Image(image) => { - writeln!(markdown, "![Image](data:base64,{})", image.source)?; - } - } +#[cfg(any(test, feature = "test-support"))] +pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver>); - if let Some(output) = tool_result.output.as_ref() { - writeln!( - markdown, - "\n\nDebug Output:\n\n```json\n{}\n```\n", - serde_json::to_string_pretty(output)? - )?; - } - } +#[cfg(any(test, feature = "test-support"))] +impl ToolCallEventStreamReceiver { + pub async fn expect_authorization(&mut self) -> ToolCallAuthorization { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event { + auth + } else { + panic!("Expected ToolCallAuthorization but got: {:?}", event); } - - Ok(String::from_utf8_lossy(&markdown).to_string()) } - pub fn keep_edits_in_range( - &mut self, - buffer: Entity, - buffer_range: Range, - cx: &mut Context, - ) { - self.action_log.update(cx, |action_log, cx| { - action_log.keep_edits_in_range(buffer, buffer_range, cx) - }); + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + update, + )))) = event + { + update.fields + } else { + panic!("Expected update fields but got: {:?}", event); + } } - pub fn keep_all_edits(&mut self, cx: &mut Context) { - self.action_log - .update(cx, |action_log, cx| action_log.keep_all_edits(cx)); + pub async fn expect_diff(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( + update, + )))) = event + { + update.diff + } else { + panic!("Expected diff but got: {:?}", event); + } } - pub fn reject_edits_in_ranges( - &mut self, - buffer: Entity, - buffer_ranges: Vec>, - cx: &mut Context, - ) -> Task> { - self.action_log.update(cx, |action_log, cx| { - action_log.reject_edits_in_ranges(buffer, buffer_ranges, cx) - }) + pub async fn expect_terminal(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( + update, + )))) = event + { + update.terminal + } else { + panic!("Expected terminal but got: {:?}", event); + } } +} - pub fn action_log(&self) -> &Entity { - &self.action_log +#[cfg(any(test, feature = "test-support"))] +impl std::ops::Deref for ToolCallEventStreamReceiver { + type Target = mpsc::UnboundedReceiver>; + + fn deref(&self) -> &Self::Target { + &self.0 } +} - pub fn project(&self) -> &Entity { - &self.project +#[cfg(any(test, feature = "test-support"))] +impl std::ops::DerefMut for ToolCallEventStreamReceiver { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } +} - pub fn auto_capture_telemetry(&mut self, cx: &mut Context) { - if !cx.has_flag::() { - return; - } +impl From<&str> for UserMessageContent { + fn from(text: &str) -> Self { + Self::Text(text.into()) + } +} - let now = Instant::now(); - if let Some(last) = self.last_auto_capture_at { - if now.duration_since(last).as_secs() < 10 { - return; +impl UserMessageContent { + pub fn from_content_block(value: acp::ContentBlock, path_style: PathStyle) -> Self { + match value { + acp::ContentBlock::Text(text_content) => Self::Text(text_content.text), + acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)), + acp::ContentBlock::Audio(_) => { + // TODO + Self::Text("[audio]".to_string()) + } + acp::ContentBlock::ResourceLink(resource_link) => { + match MentionUri::parse(&resource_link.uri, path_style) { + Ok(uri) => Self::Mention { + uri, + content: String::new(), + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri)) + } + } } - } - - self.last_auto_capture_at = Some(now); - - let thread_id = self.id().clone(); - let github_login = self - .project - .read(cx) - .user_store() - .read(cx) - .current_user() - .map(|user| user.github_login.clone()); - let client = self.project.read(cx).client(); - let serialize_task = self.serialize(cx); - - cx.background_executor() - .spawn(async move { - if let Ok(serialized_thread) = serialize_task.await { - if let Ok(thread_data) = serde_json::to_value(serialized_thread) { - telemetry::event!( - "Agent Thread Auto-Captured", - thread_id = thread_id.to_string(), - thread_data = thread_data, - auto_capture_reason = "tracked_user", - github_login = github_login - ); - - client.telemetry().flush_events().await; + acp::ContentBlock::Resource(resource) => match resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(resource) => { + match MentionUri::parse(&resource.uri, path_style) { + Ok(uri) => Self::Mention { + uri, + content: resource.text, + }, + Err(err) => { + log::error!("Failed to parse mention link: {}", err); + Self::Text( + MarkdownCodeBlock { + tag: &resource.uri, + text: &resource.text, + } + .to_string(), + ) + } } } - }) - .detach(); - } - - pub fn cumulative_token_usage(&self) -> TokenUsage { - self.cumulative_token_usage - } - - pub fn token_usage_up_to_message(&self, message_id: MessageId) -> TotalTokenUsage { - let Some(model) = self.configured_model.as_ref() else { - return TotalTokenUsage::default(); - }; - - let max = model - .model - .max_token_count_for_mode(self.completion_mode().into()); - - let index = self - .messages - .iter() - .position(|msg| msg.id == message_id) - .unwrap_or(0); - - if index == 0 { - return TotalTokenUsage { total: 0, max }; - } - - let token_usage = &self - .request_token_usage - .get(index - 1) - .cloned() - .unwrap_or_default(); - - TotalTokenUsage { - total: token_usage.total_tokens(), - max, + acp::EmbeddedResourceResource::BlobResourceContents(_) => { + // TODO + Self::Text("[blob]".to_string()) + } + }, } } +} - pub fn total_token_usage(&self) -> Option { - let model = self.configured_model.as_ref()?; - - let max = model - .model - .max_token_count_for_mode(self.completion_mode().into()); - - if let Some(exceeded_error) = &self.exceeded_window_error { - if model.model.id() == exceeded_error.model_id { - return Some(TotalTokenUsage { - total: exceeded_error.token_count, - max, - }); +impl From for acp::ContentBlock { + fn from(content: UserMessageContent) -> Self { + match content { + UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent { + text, + annotations: None, + meta: None, + }), + UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent { + data: image.source.to_string(), + mime_type: "image/png".to_string(), + meta: None, + annotations: None, + uri: None, + }), + UserMessageContent::Mention { uri, content } => { + acp::ContentBlock::Resource(acp::EmbeddedResource { + meta: None, + resource: acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { + meta: None, + mime_type: None, + text: content, + uri: uri.to_uri().to_string(), + }, + ), + annotations: None, + }) } } - - let total = self - .token_usage_at_last_message() - .unwrap_or_default() - .total_tokens(); - - Some(TotalTokenUsage { total, max }) - } - - fn token_usage_at_last_message(&self) -> Option { - self.request_token_usage - .get(self.messages.len().saturating_sub(1)) - .or_else(|| self.request_token_usage.last()) - .cloned() } - - fn update_token_usage_at_last_message(&mut self, token_usage: TokenUsage) { - let placeholder = self.token_usage_at_last_message().unwrap_or_default(); - self.request_token_usage - .resize(self.messages.len(), placeholder); - - if let Some(last) = self.request_token_usage.last_mut() { - *last = token_usage; - } - } - - fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) { - self.project - .read(cx) - .user_store() - .update(cx, |user_store, cx| { - user_store.update_model_request_usage( - ModelRequestUsage(RequestUsage { - amount: amount as i32, - limit, - }), - cx, - ) - }); - } - - pub fn deny_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - window: Option, - cx: &mut Context, - ) { - let err = Err(anyhow::anyhow!( - "Permission to run tool action denied by user" - )); - - self.tool_use.insert_tool_output( - tool_use_id.clone(), - tool_name, - err, - self.configured_model.as_ref(), - self.completion_mode, - ); - self.tool_finished(tool_use_id.clone(), None, true, window, cx); - } -} - -#[derive(Debug, Clone, Error)] -pub enum ThreadError { - #[error("Payment required")] - PaymentRequired, - #[error("Model request limit reached")] - ModelRequestLimitReached { plan: Plan }, - #[error("Message {header}: {message}")] - Message { - header: SharedString, - message: SharedString, - }, - #[error("Retryable error: {message}")] - RetryableError { - message: SharedString, - can_enable_burn_mode: bool, - }, -} - -#[derive(Debug, Clone)] -pub enum ThreadEvent { - ShowError(ThreadError), - StreamedCompletion, - ReceivedTextChunk, - NewRequest, - StreamedAssistantText(MessageId, String), - StreamedAssistantThinking(MessageId, String), - StreamedToolUse { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - input: serde_json::Value, - }, - MissingToolUse { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - }, - InvalidToolInput { - tool_use_id: LanguageModelToolUseId, - ui_text: Arc, - invalid_input_json: Arc, - }, - Stopped(Result>), - MessageAdded(MessageId), - MessageEdited(MessageId), - MessageDeleted(MessageId), - SummaryGenerated, - SummaryChanged, - UsePendingTools { - tool_uses: Vec, - }, - ToolFinished { - #[allow(unused)] - tool_use_id: LanguageModelToolUseId, - /// The pending tool use that corresponds to this tool. - pending_tool_use: Option, - }, - CheckpointChanged, - ToolConfirmationNeeded, - ToolUseLimitReached, - CancelEditing, - CompletionCanceled, - ProfileChanged, -} - -impl EventEmitter for Thread {} - -struct PendingCompletion { - id: usize, - queue_state: QueueState, - _task: Task<()>, } -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - context::load_context, context_store::ContextStore, thread_store, thread_store::ThreadStore, - }; - - // Test-specific constants - const TEST_RATE_LIMIT_RETRY_SECS: u64 = 30; - use agent_settings::{AgentProfileId, AgentSettings, LanguageModelParameters}; - use assistant_tool::ToolRegistry; - use assistant_tools; - use futures::StreamExt; - use futures::future::BoxFuture; - use futures::stream::BoxStream; - use gpui::TestAppContext; - use http_client; - use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; - use language_model::{ - LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelToolChoice, - }; - use parking_lot::Mutex; - use project::{FakeFs, Project}; - use prompt_store::PromptBuilder; - use serde_json::json; - use settings::{Settings, SettingsStore}; - use std::sync::Arc; - use std::time::Duration; - use theme::ThemeSettings; - use util::path; - use workspace::Workspace; - - #[gpui::test] - async fn test_message_with_context(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - add_file_to_context(&project, &context_store, "test/code.rs", cx) - .await - .unwrap(); - - let context = - context_store.read_with(cx, |store, _| store.context().next().cloned().unwrap()); - let loaded_context = cx - .update(|cx| load_context(vec![context], &project, &None, cx)) - .await; - - // Insert user message with context - let message_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Please explain this code", - loaded_context, - None, - Vec::new(), - cx, - ) - }); - - // Check content and context in message object - let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone()); - - // Use different path format strings based on platform for the test - #[cfg(windows)] - let path_part = r"test\code.rs"; - #[cfg(not(windows))] - let path_part = "test/code.rs"; - - let expected_context = format!( - r#" - -The following items were attached by the user. They are up-to-date and don't need to be re-read. - - -```rs {path_part} -fn main() {{ - println!("Hello, world!"); -}} -``` - - -"# - ); - - assert_eq!(message.role, Role::User); - assert_eq!(message.segments.len(), 1); - assert_eq!( - message.segments[0], - MessageSegment::Text("Please explain this code".to_string()) - ); - assert_eq!(message.loaded_context.text, expected_context); - - // Check message in request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 2); - let expected_full_message = format!("{}Please explain this code", expected_context); - assert_eq!(request.messages[1].string_contents(), expected_full_message); - } - - #[gpui::test] - async fn test_only_include_new_contexts(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({ - "file1.rs": "fn function1() {}\n", - "file2.rs": "fn function2() {}\n", - "file3.rs": "fn function3() {}\n", - "file4.rs": "fn function4() {}\n", - }), - ) - .await; - - let (_, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // First message with context 1 - add_file_to_context(&project, &context_store, "test/file1.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message1_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 1", loaded_context, None, Vec::new(), cx) - }); - - // Second message with contexts 1 and 2 (context 1 should be skipped as it's already included) - add_file_to_context(&project, &context_store, "test/file2.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message2_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 2", loaded_context, None, Vec::new(), cx) - }); - - // Third message with all three contexts (contexts 1 and 2 should be skipped) - // - add_file_to_context(&project, &context_store, "test/file3.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), None) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await; - let message3_id = thread.update(cx, |thread, cx| { - thread.insert_user_message("Message 3", loaded_context, None, Vec::new(), cx) - }); - - // Check what contexts are included in each message - let (message1, message2, message3) = thread.read_with(cx, |thread, _| { - ( - thread.message(message1_id).unwrap().clone(), - thread.message(message2_id).unwrap().clone(), - thread.message(message3_id).unwrap().clone(), - ) - }); - - // First message should include context 1 - assert!(message1.loaded_context.text.contains("file1.rs")); - - // Second message should include only context 2 (not 1) - assert!(!message2.loaded_context.text.contains("file1.rs")); - assert!(message2.loaded_context.text.contains("file2.rs")); - - // Third message should include only context 3 (not 1 or 2) - assert!(!message3.loaded_context.text.contains("file1.rs")); - assert!(!message3.loaded_context.text.contains("file2.rs")); - assert!(message3.loaded_context.text.contains("file3.rs")); - - // Check entire request to make sure all contexts are properly included - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // The request should contain all 3 messages - assert_eq!(request.messages.len(), 4); - - // Check that the contexts are properly formatted in each message - assert!(request.messages[1].string_contents().contains("file1.rs")); - assert!(!request.messages[1].string_contents().contains("file2.rs")); - assert!(!request.messages[1].string_contents().contains("file3.rs")); - - assert!(!request.messages[2].string_contents().contains("file1.rs")); - assert!(request.messages[2].string_contents().contains("file2.rs")); - assert!(!request.messages[2].string_contents().contains("file3.rs")); - - assert!(!request.messages[3].string_contents().contains("file1.rs")); - assert!(!request.messages[3].string_contents().contains("file2.rs")); - assert!(request.messages[3].string_contents().contains("file3.rs")); - - add_file_to_context(&project, &context_store, "test/file4.rs", cx) - .await - .unwrap(); - let new_contexts = context_store.update(cx, |store, cx| { - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 3); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(loaded_context.text.contains("file3.rs")); - assert!(loaded_context.text.contains("file4.rs")); - - let new_contexts = context_store.update(cx, |store, cx| { - // Remove file4.rs - store.remove_context(&loaded_context.contexts[2].handle(), cx); - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 2); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(loaded_context.text.contains("file3.rs")); - assert!(!loaded_context.text.contains("file4.rs")); - - let new_contexts = context_store.update(cx, |store, cx| { - // Remove file3.rs - store.remove_context(&loaded_context.contexts[1].handle(), cx); - store.new_context_for_thread(thread.read(cx), Some(message2_id)) - }); - assert_eq!(new_contexts.len(), 1); - let loaded_context = cx - .update(|cx| load_context(new_contexts, &project, &None, cx)) - .await - .loaded_context; - - assert!(!loaded_context.text.contains("file1.rs")); - assert!(loaded_context.text.contains("file2.rs")); - assert!(!loaded_context.text.contains("file3.rs")); - assert!(!loaded_context.text.contains("file4.rs")); - } - - #[gpui::test] - async fn test_message_without_files(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Insert user message without any context (empty context vector) - let message_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What is the best way to learn Rust?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - // Check content and context in message object - let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone()); - - // Context should be empty when no files are included - assert_eq!(message.role, Role::User); - assert_eq!(message.segments.len(), 1); - assert_eq!( - message.segments[0], - MessageSegment::Text("What is the best way to learn Rust?".to_string()) - ); - assert_eq!(message.loaded_context.text, ""); - - // Check message in request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 2); - assert_eq!( - request.messages[1].string_contents(), - "What is the best way to learn Rust?" - ); - - // Add second message, also without context - let message2_id = thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Are there any good books?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - let message2 = - thread.read_with(cx, |thread, _| thread.message(message2_id).unwrap().clone()); - assert_eq!(message2.loaded_context.text, ""); - - // Check that both messages appear in the request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - assert_eq!(request.messages.len(), 3); - assert_eq!( - request.messages[1].string_contents(), - "What is the best way to learn Rust?" - ); - assert_eq!( - request.messages[2].string_contents(), - "Are there any good books?" - ); - } - - #[gpui::test] - #[ignore] // turn this test on when project_notifications tool is re-enabled - async fn test_stale_buffer_notification(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Add a buffer to the context. This will be a tracked buffer - let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx) - .await - .unwrap(); - - let context = context_store - .read_with(cx, |store, _| store.context().next().cloned()) - .unwrap(); - let loaded_context = cx - .update(|cx| load_context(vec![context], &project, &None, cx)) - .await; - - // Insert user message and assistant response - thread.update(cx, |thread, cx| { - thread.insert_user_message("Explain this code", loaded_context, None, Vec::new(), cx); - thread.insert_assistant_message( - vec![MessageSegment::Text("This code prints 42.".into())], - cx, - ); - }); - cx.run_until_parked(); - - // We shouldn't have a stale buffer notification yet - let notifications = thread.read_with(cx, |thread, _| { - find_tool_uses(thread, "project_notifications") - }); - assert!( - notifications.is_empty(), - "Should not have stale buffer notification before buffer is modified" - ); - - // Modify the buffer - buffer.update(cx, |buffer, cx| { - buffer.edit( - [(1..1, "\n println!(\"Added a new line\");\n")], - None, - cx, - ); - }); - - // Insert another user message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What does the code do now?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - cx.run_until_parked(); - - // Check for the stale buffer warning - thread.update(cx, |thread, cx| { - thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) - }); - cx.run_until_parked(); - - let notifications = thread.read_with(cx, |thread, _cx| { - find_tool_uses(thread, "project_notifications") - }); - - let [notification] = notifications.as_slice() else { - panic!("Should have a `project_notifications` tool use"); - }; - - let Some(notification_content) = notification.content.to_str() else { - panic!("`project_notifications` should return text"); - }; - - assert!(notification_content.contains("These files have changed since the last read:")); - assert!(notification_content.contains("code.rs")); - - // Insert another user message and flush notifications again - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Can you tell me more?", - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ) - }); - - thread.update(cx, |thread, cx| { - thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) - }); - cx.run_until_parked(); - - // There should be no new notifications (we already flushed one) - let notifications = thread.read_with(cx, |thread, _cx| { - find_tool_uses(thread, "project_notifications") - }); - - assert_eq!( - notifications.len(), - 1, - "Should still have only one notification after second flush - no duplicates" - ); - } - - fn find_tool_uses(thread: &Thread, tool_name: &str) -> Vec { - thread - .messages() - .flat_map(|message| { - thread - .tool_results_for_message(message.id) - .into_iter() - .filter(|result| result.tool_name == tool_name.into()) - .cloned() - .collect::>() - }) - .collect() - } - - #[gpui::test] - async fn test_storing_profile_setting_per_thread(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, thread_store, thread, _context_store, _model) = - setup_test_environment(cx, project.clone()).await; - - // Check that we are starting with the default profile - let profile = cx.read(|cx| thread.read(cx).profile.clone()); - let tool_set = cx.read(|cx| thread_store.read(cx).tools()); - assert_eq!( - profile, - AgentProfile::new(AgentProfileId::default(), tool_set) - ); - } - - #[gpui::test] - async fn test_serializing_thread_profile(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, thread_store, thread, _context_store, _model) = - setup_test_environment(cx, project.clone()).await; - - // Profile gets serialized with default values - let serialized = thread - .update(cx, |thread, cx| thread.serialize(cx)) - .await - .unwrap(); - - assert_eq!(serialized.profile, Some(AgentProfileId::default())); - - let deserialized = cx.update(|cx| { - thread.update(cx, |thread, cx| { - Thread::deserialize( - thread.id.clone(), - serialized, - thread.project.clone(), - thread.tools.clone(), - thread.prompt_builder.clone(), - thread.project_context.clone(), - None, - cx, - ) - }) - }); - let tool_set = cx.read(|cx| thread_store.read(cx).tools()); - - assert_eq!( - deserialized.profile, - AgentProfile::new(AgentProfileId::default(), tool_set) - ); - } - - #[gpui::test] - async fn test_temperature_setting(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (_workspace, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Both model and provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some(model.provider_id().0.to_string().into()), - model: Some(model.id().0.clone()), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Only model - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: None, - model: Some(model.id().0.clone()), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Only provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some(model.provider_id().0.to_string().into()), - model: None, - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, Some(0.66)); - - // Same model name, different provider - cx.update(|cx| { - AgentSettings::override_global( - AgentSettings { - model_parameters: vec![LanguageModelParameters { - provider: Some("anthropic".into()), - model: Some(model.id().0.clone()), - temperature: Some(0.66), - }], - ..AgentSettings::get_global(cx).clone() - }, - cx, - ); - }); - - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - assert_eq!(request.temperature, None); - } - - #[gpui::test] - async fn test_thread_summary(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - // Initial state should be pending - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Pending)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - // Manually setting the summary should not be allowed in this state - thread.update(cx, |thread, cx| { - thread.set_summary("This should not work", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Pending)); - }); - - // Send a message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx); - thread.send_to_model( - model.clone(), - CompletionIntent::ThreadSummarization, - None, - cx, - ); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(&fake_model, cx); - - // Should start generating summary when there are >= 2 messages - thread.read_with(cx, |thread, _| { - assert_eq!(*thread.summary(), ThreadSummary::Generating); - }); - - // Should not be able to set the summary while generating - thread.update(cx, |thread, cx| { - thread.set_summary("This should not work either", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Brief"); - fake_model.send_last_completion_stream_text_chunk(" Introduction"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Summary should be set - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "Brief Introduction"); - }); - - // Now we should be able to set a summary - thread.update(cx, |thread, cx| { - thread.set_summary("Brief Intro", cx); - }); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.summary().or_default(), "Brief Intro"); - }); - - // Test setting an empty summary (should default to DEFAULT) - thread.update(cx, |thread, cx| { - thread.set_summary("", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - } - - #[gpui::test] - async fn test_thread_summary_error_set_manually(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - test_summarize_error(&model, &thread, cx); - - // Now we should be able to set a summary - thread.update(cx, |thread, cx| { - thread.set_summary("Brief Intro", cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "Brief Intro"); - }); - } - - #[gpui::test] - async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - - let (_, _thread_store, thread, _context_store, model) = - setup_test_environment(cx, project.clone()).await; - - test_summarize_error(&model, &thread, cx); - - // Sending another message should not trigger another summarize request - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "How are you?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(&fake_model, cx); - - thread.read_with(cx, |thread, _| { - // State is still Error, not Generating - assert!(matches!(thread.summary(), ThreadSummary::Error)); - }); - - // But the summarize request can be invoked manually - thread.update(cx, |thread, cx| { - thread.summarize(cx); - }); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - }); - - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("A successful summary"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Ready(_))); - assert_eq!(thread.summary().or_default(), "A successful summary"); - }); - } - - // Helper to create a model that returns errors - enum TestError { - Overloaded, - InternalServerError, - } - - struct ErrorInjector { - inner: Arc, - error_type: TestError, - } - - impl ErrorInjector { - fn new(error_type: TestError) -> Self { - Self { - inner: Arc::new(FakeLanguageModel::default()), - error_type, - } - } - } - - impl LanguageModel for ErrorInjector { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - _request: LanguageModelRequest, - _cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - let error = match self.error_type { - TestError::Overloaded => LanguageModelCompletionError::ServerOverloaded { - provider: self.provider_name(), - retry_after: None, - }, - TestError::InternalServerError => { - LanguageModelCompletionError::ApiInternalServerError { - provider: self.provider_name(), - message: "I'm a teapot orbiting the sun".to_string(), - } - } - }; - async move { - let stream = futures::stream::once(async move { Err(error) }); - Ok(stream.boxed()) - } - .boxed() - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - #[gpui::test] - async fn test_retry_on_overloaded_error(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Should retry MAX_RETRY_ATTEMPTS times for overloaded errors" - ); - }); - - // Check that a retry message was added - thread.read_with(cx, |thread, _| { - let mut messages = thread.messages(); - assert!( - messages.any(|msg| { - msg.role == Role::System - && msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("overloaded") - && text - .contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)) - } else { - false - } - }) - }), - "Should have added a system retry message" - ); - }); - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - assert_eq!(retry_count, 1, "Should have one retry message"); - } - - #[gpui::test] - async fn test_retry_on_internal_server_error(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns internal server error - let model = Arc::new(ErrorInjector::new(TestError::InternalServerError)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Check retry state on thread - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, 3, - "Should have correct max attempts" - ); - }); - - // Check that a retry message was added with provider name - thread.read_with(cx, |thread, _| { - let mut messages = thread.messages(); - assert!( - messages.any(|msg| { - msg.role == Role::System - && msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("internal") - && text.contains("Fake") - && text.contains("Retrying") - && text.contains("attempt 1 of 3") - && text.contains("seconds") - } else { - false - } - }) - }), - "Should have added a system retry message with provider name" - ); - }); - - // Count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - assert_eq!(retry_count, 1, "Should have one retry message"); - } - - #[gpui::test] - async fn test_exponential_backoff_on_retries(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns internal server error - let model = Arc::new(ErrorInjector::new(TestError::InternalServerError)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track retry events and completion count - // Track completion events - let completion_count = Arc::new(Mutex::new(0)); - let completion_count_clone = completion_count.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::NewRequest = event { - *completion_count_clone.lock() += 1; - } - }) - }); - - // First attempt - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Should have scheduled first retry - count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!(retry_count, 1, "Should have scheduled first retry"); - - // Check retry state - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - assert_eq!( - retry_state.max_attempts, 3, - "Internal server errors should retry up to 3 times" - ); - }); - - // Advance clock for first retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Advance clock for second retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Advance clock for third retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Should have completed all retries - count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!( - retry_count, 3, - "Should have 3 retries for internal server errors" - ); - - // For internal server errors, we retry 3 times and then give up - // Check that retry_state is cleared after all retries - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after all retries" - ); - }); - - // Verify total attempts (1 initial + 3 retries) - assert_eq!( - *completion_count.lock(), - 4, - "Should have attempted once plus 3 retries" - ); - } - - #[gpui::test] - async fn test_max_retries_exceeded(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track events - let stopped_with_error = Arc::new(Mutex::new(false)); - let stopped_with_error_clone = stopped_with_error.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::Stopped(Err(_)) = event { - *stopped_with_error_clone.lock() = true; - } - }) - }); - - // Start initial completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Advance through all retries - for _ in 0..MAX_RETRY_ATTEMPTS { - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - } - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - - // After max retries, should emit Stopped(Err(...)) event - assert_eq!( - retry_count, MAX_RETRY_ATTEMPTS as usize, - "Should have attempted MAX_RETRY_ATTEMPTS retries for overloaded errors" - ); - assert!( - *stopped_with_error.lock(), - "Should emit Stopped(Err(...)) event after max retries exceeded" - ); - - // Retry state should be cleared - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after max retries" - ); - - // Verify we have the expected number of retry messages - let retry_messages = thread - .messages - .iter() - .filter(|msg| msg.ui_only && msg.role == Role::System) - .count(); - assert_eq!( - retry_messages, MAX_RETRY_ATTEMPTS as usize, - "Should have MAX_RETRY_ATTEMPTS retry messages for overloaded errors" - ); - }); - } - - #[gpui::test] - async fn test_retry_message_removed_on_retry(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // We'll use a wrapper to switch behavior after first failure - struct RetryTestModel { - inner: Arc, - failed_once: Arc>, - } - - impl LanguageModel for RetryTestModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - request: LanguageModelRequest, - cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - if !*self.failed_once.lock() { - *self.failed_once.lock() = true; - let provider = self.provider_name(); - // Return error on first attempt - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::ServerOverloaded { - provider, - retry_after: None, - }) - }); - async move { Ok(stream.boxed()) }.boxed() - } else { - // Succeed on retry - self.inner.stream_completion(request, cx) - } - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - let model = Arc::new(RetryTestModel { - inner: Arc::new(FakeLanguageModel::default()), - failed_once: Arc::new(Mutex::new(false)), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Track message deletions - // Track when retry completes successfully - let retry_completed = Arc::new(Mutex::new(false)); - let retry_completed_clone = retry_completed.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::StreamedCompletion = event { - *retry_completed_clone.lock() = true; - } - }) - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - cx.run_until_parked(); - - // Get the retry message ID - let retry_message_id = thread.read_with(cx, |thread, _| { - thread - .messages() - .find(|msg| msg.role == Role::System && msg.ui_only) - .map(|msg| msg.id) - .expect("Should have a retry message") - }); - - // Wait for retry - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // Stream some successful content - let fake_model = model.as_fake(); - // After the retry, there should be a new pending completion - let pending = fake_model.pending_completions(); - assert!( - !pending.is_empty(), - "Should have a pending completion after retry" - ); - fake_model.send_completion_stream_text_chunk(&pending[0], "Success!"); - fake_model.end_completion_stream(&pending[0]); - cx.run_until_parked(); - - // Check that the retry completed successfully - assert!( - *retry_completed.lock(), - "Retry should have completed successfully" - ); - - // Retry message should still exist but be marked as ui_only - thread.read_with(cx, |thread, _| { - let retry_msg = thread - .message(retry_message_id) - .expect("Retry message should still exist"); - assert!(retry_msg.ui_only, "Retry message should be ui_only"); - assert_eq!( - retry_msg.role, - Role::System, - "Retry message should have System role" - ); - }); - } - - #[gpui::test] - async fn test_successful_completion_clears_retry_state(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create a model that fails once then succeeds - struct FailOnceModel { - inner: Arc, - failed_once: Arc>, - } - - impl LanguageModel for FailOnceModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - request: LanguageModelRequest, - cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - if !*self.failed_once.lock() { - *self.failed_once.lock() = true; - let provider = self.provider_name(); - // Return error on first attempt - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::ServerOverloaded { - provider, - retry_after: None, - }) - }); - async move { Ok(stream.boxed()) }.boxed() - } else { - // Succeed on retry - self.inner.stream_completion(request, cx) - } - } - } - - let fail_once_model = Arc::new(FailOnceModel { - inner: Arc::new(FakeLanguageModel::default()), - failed_once: Arc::new(Mutex::new(false)), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "Test message", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - }); - - // Start completion with fail-once model - thread.update(cx, |thread, cx| { - thread.send_to_model( - fail_once_model.clone(), - CompletionIntent::UserPrompt, - None, - cx, - ); - }); - - cx.run_until_parked(); - - // Verify retry state exists after first failure - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_some(), - "Should have retry state after failure" - ); - }); - - // Wait for retry delay - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - // The retry should now use our FailOnceModel which should succeed - // We need to help the FakeLanguageModel complete the stream - let inner_fake = fail_once_model.inner.clone(); - - // Wait a bit for the retry to start - cx.run_until_parked(); - - // Check for pending completions and complete them - if let Some(pending) = inner_fake.pending_completions().first() { - inner_fake.send_completion_stream_text_chunk(pending, "Success!"); - inner_fake.end_completion_stream(pending); - } - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Retry state should be cleared after successful completion" - ); - - let has_assistant_message = thread - .messages - .iter() - .any(|msg| msg.role == Role::Assistant && !msg.ui_only); - assert!( - has_assistant_message, - "Should have an assistant message after successful retry" - ); - }); - } - - #[gpui::test] - async fn test_rate_limit_retry_single_attempt(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create a model that returns rate limit error with retry_after - struct RateLimitModel { - inner: Arc, - } - - impl LanguageModel for RateLimitModel { - fn id(&self) -> LanguageModelId { - self.inner.id() - } - - fn name(&self) -> LanguageModelName { - self.inner.name() - } - - fn provider_id(&self) -> LanguageModelProviderId { - self.inner.provider_id() - } - - fn provider_name(&self) -> LanguageModelProviderName { - self.inner.provider_name() - } - - fn supports_tools(&self) -> bool { - self.inner.supports_tools() - } - - fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { - self.inner.supports_tool_choice(choice) - } - - fn supports_images(&self) -> bool { - self.inner.supports_images() - } - - fn telemetry_id(&self) -> String { - self.inner.telemetry_id() - } - - fn max_token_count(&self) -> u64 { - self.inner.max_token_count() - } - - fn count_tokens( - &self, - request: LanguageModelRequest, - cx: &App, - ) -> BoxFuture<'static, Result> { - self.inner.count_tokens(request, cx) - } - - fn stream_completion( - &self, - _request: LanguageModelRequest, - _cx: &AsyncApp, - ) -> BoxFuture< - 'static, - Result< - BoxStream< - 'static, - Result, - >, - LanguageModelCompletionError, - >, - > { - let provider = self.provider_name(); - async move { - let stream = futures::stream::once(async move { - Err(LanguageModelCompletionError::RateLimitExceeded { - provider, - retry_after: Some(Duration::from_secs(TEST_RATE_LIMIT_RETRY_SECS)), - }) - }); - Ok(stream.boxed()) - } - .boxed() - } - - fn as_fake(&self) -> &FakeLanguageModel { - &self.inner - } - } - - let model = Arc::new(RateLimitModel { - inner: Arc::new(FakeLanguageModel::default()), - }); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("rate limit exceeded") - } else { - false - } - }) - }) - .count() - }); - assert_eq!(retry_count, 1, "Should have scheduled one retry"); - - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_some(), - "Rate limit errors should set retry_state" - ); - if let Some(retry_state) = &thread.retry_state { - assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Rate limit errors should use MAX_RETRY_ATTEMPTS" - ); - } - }); - - // Verify we have one retry message - thread.read_with(cx, |thread, _| { - let retry_messages = thread - .messages - .iter() - .filter(|msg| { - msg.ui_only - && msg.segments.iter().any(|seg| { - if let MessageSegment::Text(text) = seg { - text.contains("rate limit exceeded") - } else { - false - } - }) - }) - .count(); - assert_eq!( - retry_messages, 1, - "Should have one rate limit retry message" - ); - }); - - // Check that retry message doesn't include attempt count - thread.read_with(cx, |thread, _| { - let retry_message = thread - .messages - .iter() - .find(|msg| msg.role == Role::System && msg.ui_only) - .expect("Should have a retry message"); - - // Check that the message contains attempt count since we use retry_state - if let Some(MessageSegment::Text(text)) = retry_message.segments.first() { - assert!( - text.contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)), - "Rate limit retry message should contain attempt count with MAX_RETRY_ATTEMPTS" - ); - assert!( - text.contains("Retrying"), - "Rate limit retry message should contain retry text" - ); - } - }); - } - - #[gpui::test] - async fn test_ui_only_messages_not_sent_to_model(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, model) = setup_test_environment(cx, project.clone()).await; - - // Insert a regular user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Insert a UI-only message (like our retry notifications) - thread.update(cx, |thread, cx| { - let id = thread.next_message_id.post_inc(); - thread.messages.push(Message { - id, - role: Role::System, - segments: vec![MessageSegment::Text( - "This is a UI-only message that should not be sent to the model".to_string(), - )], - loaded_context: LoadedContext::default(), - creases: Vec::new(), - is_hidden: true, - ui_only: true, - }); - cx.emit(ThreadEvent::MessageAdded(id)); - }); - - // Insert another regular message - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "How are you?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - }); - - // Generate the completion request - let request = thread.update(cx, |thread, cx| { - thread.to_completion_request(model.clone(), CompletionIntent::UserPrompt, cx) - }); - - // Verify that the request only contains non-UI-only messages - // Should have system prompt + 2 user messages, but not the UI-only message - let user_messages: Vec<_> = request - .messages - .iter() - .filter(|msg| msg.role == Role::User) - .collect(); - assert_eq!( - user_messages.len(), - 2, - "Should have exactly 2 user messages" - ); - - // Verify the UI-only content is not present anywhere in the request - let request_text = request - .messages - .iter() - .flat_map(|msg| &msg.content) - .filter_map(|content| match content { - MessageContent::Text(text) => Some(text.as_str()), - _ => None, - }) - .collect::(); - - assert!( - !request_text.contains("UI-only message"), - "UI-only message content should not be in the request" - ); - - // Verify the thread still has all 3 messages (including UI-only) - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.messages().count(), - 3, - "Thread should have 3 messages" - ); - assert_eq!( - thread.messages().filter(|m| m.ui_only).count(), - 1, - "Thread should have 1 UI-only message" - ); - }); - - // Verify that UI-only messages are not serialized - let serialized = thread - .update(cx, |thread, cx| thread.serialize(cx)) - .await - .unwrap(); - assert_eq!( - serialized.messages.len(), - 2, - "Serialized thread should only have 2 messages (no UI-only)" - ); - } - - #[gpui::test] - async fn test_no_retry_without_burn_mode(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Ensure we're in Normal mode (not Burn mode) - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Normal); - }); - - // Track error events - let error_events = Arc::new(Mutex::new(Vec::new())); - let error_events_clone = error_events.clone(); - - let _subscription = thread.update(cx, |_, cx| { - cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::ShowError(error) = event { - error_events_clone.lock().push(error.clone()); - } - }) - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Verify no retry state was created - thread.read_with(cx, |thread, _| { - assert!( - thread.retry_state.is_none(), - "Should not have retry state in Normal mode" - ); - }); - - // Check that a retryable error was reported - let errors = error_events.lock(); - assert!(!errors.is_empty(), "Should have received an error event"); - - if let ThreadError::RetryableError { - message: _, - can_enable_burn_mode, - } = &errors[0] - { - assert!( - *can_enable_burn_mode, - "Error should indicate burn mode can be enabled" - ); - } else { - panic!("Expected RetryableError, got {:?}", errors[0]); - } - - // Verify the thread is no longer generating - thread.read_with(cx, |thread, _| { - assert!( - !thread.is_generating(), - "Should not be generating after error without retry" - ); - }); - } - - #[gpui::test] - async fn test_retry_canceled_on_stop(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - - // Enable Burn Mode to allow retries - thread.update(cx, |thread, _| { - thread.set_completion_mode(CompletionMode::Burn); - }); - - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); - - // Insert a user message - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx); - }); - - // Start completion - thread.update(cx, |thread, cx| { - thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx); - }); - - cx.run_until_parked(); - - // Verify retry was scheduled by checking for retry message - let has_retry_message = thread.read_with(cx, |thread, _| { - thread.messages.iter().any(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - }); - assert!(has_retry_message, "Should have scheduled a retry"); - - // Cancel the completion before the retry happens - thread.update(cx, |thread, cx| { - thread.cancel_last_completion(None, cx); - }); - - cx.run_until_parked(); - - // The retry should not have happened - no pending completions - let fake_model = model.as_fake(); - assert_eq!( - fake_model.pending_completions().len(), - 0, - "Should have no pending completions after cancellation" - ); - - // Verify the retry was canceled by checking retry state - thread.read_with(cx, |thread, _| { - if let Some(retry_state) = &thread.retry_state { - panic!( - "retry_state should be cleared after cancellation, but found: attempt={}, max_attempts={}, intent={:?}", - retry_state.attempt, retry_state.max_attempts, retry_state.intent - ); - } - }); - } - - fn test_summarize_error( - model: &Arc, - thread: &Entity, - cx: &mut TestAppContext, - ) { - thread.update(cx, |thread, cx| { - thread.insert_user_message("Hi!", ContextLoadResult::default(), None, vec![], cx); - thread.send_to_model( - model.clone(), - CompletionIntent::ThreadSummarization, - None, - cx, - ); - }); - - let fake_model = model.as_fake(); - simulate_successful_response(&fake_model, cx); - - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Generating)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - - // Simulate summary request ending - cx.run_until_parked(); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // State is set to Error and default message - thread.read_with(cx, |thread, _| { - assert!(matches!(thread.summary(), ThreadSummary::Error)); - assert_eq!(thread.summary().or_default(), ThreadSummary::DEFAULT); - }); - } - - fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestAppContext) { - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Assistant response"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - } - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); - prompt_store::init(cx); - thread_store::init(cx); - workspace::init_settings(cx); - language_model::init_settings(cx); - ThemeSettings::register(cx); - ToolRegistry::default_global(cx); - assistant_tool::init(cx); - - let http_client = Arc::new(http_client::HttpClientWithUrl::new( - http_client::FakeHttpClient::with_200_response(), - "http://localhost".to_string(), - None, - )); - assistant_tools::init(http_client, cx); - }); - } - - // Helper to create a test project with test files - async fn create_test_project( - cx: &mut TestAppContext, - files: serde_json::Value, - ) -> Entity { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/test"), files).await; - Project::test(fs, [path!("/test").as_ref()], cx).await - } - - async fn setup_test_environment( - cx: &mut TestAppContext, - project: Entity, - ) -> ( - Entity, - Entity, - Entity, - Entity, - Arc, - ) { - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - - let thread_store = cx - .update(|_, cx| { - ThreadStore::load( - project.clone(), - cx.new(|_| ToolWorkingSet::default()), - None, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) - }) - .await - .unwrap(); - - let thread = thread_store.update(cx, |store, cx| store.create_thread(cx)); - let context_store = cx.new(|_cx| ContextStore::new(project.downgrade(), None)); - - let provider = Arc::new(FakeLanguageModelProvider::default()); - let model = provider.test_model(); - let model: Arc = Arc::new(model); - - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model( - Some(ConfiguredModel { - provider: provider.clone(), - model: model.clone(), - }), - cx, - ); - registry.set_thread_summary_model( - Some(ConfiguredModel { - provider, - model: model.clone(), - }), - cx, - ); - }) - }); - - (workspace, thread_store, thread, context_store, model) - } - - async fn add_file_to_context( - project: &Entity, - context_store: &Entity, - path: &str, - cx: &mut TestAppContext, - ) -> Result> { - let buffer_path = project - .read_with(cx, |project, cx| project.find_project_path(path, cx)) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(buffer_path.clone(), cx) - }) - .await - .unwrap(); - - context_store.update(cx, |context_store, cx| { - context_store.add_file_from_buffer(&buffer_path, buffer.clone(), false, cx); - }); - - Ok(buffer) +fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { + LanguageModelImage { + source: image_content.data.into(), + // TODO: make this optional? + size: gpui::Size::new(0.into(), 0.into()), } } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs deleted file mode 100644 index 12c94a522d52de78e52dab4764a7f187054eca47..0000000000000000000000000000000000000000 --- a/crates/agent/src/thread_store.rs +++ /dev/null @@ -1,1264 +0,0 @@ -use crate::{ - context_server_tool::ContextServerTool, - thread::{ - DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId, - }, -}; -use agent_settings::{AgentProfileId, CompletionMode}; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolId, ToolWorkingSet}; -use chrono::{DateTime, Utc}; -use collections::HashMap; -use context_server::ContextServerId; -use futures::{ - FutureExt as _, StreamExt as _, - channel::{mpsc, oneshot}, - future::{self, BoxFuture, Shared}, -}; -use gpui::{ - App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, - Subscription, Task, Window, prelude::*, -}; -use indoc::indoc; -use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage}; -use project::context_server_store::{ContextServerStatus, ContextServerStore}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::{ - ProjectContext, PromptBuilder, PromptId, PromptStore, PromptsUpdatedEvent, RulesFileContext, - UserRulesContext, WorktreeContext, -}; -use serde::{Deserialize, Serialize}; -use sqlez::{ - bindable::{Bind, Column}, - connection::Connection, - statement::Statement, -}; -use std::{ - cell::{Ref, RefCell}, - path::{Path, PathBuf}, - rc::Rc, - sync::{Arc, Mutex}, -}; -use util::ResultExt as _; - -pub static ZED_STATELESS: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty())); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum DataType { - #[serde(rename = "json")] - Json, - #[serde(rename = "zstd")] - Zstd, -} - -impl Bind for DataType { - fn bind(&self, statement: &Statement, start_index: i32) -> Result { - let value = match self { - DataType::Json => "json", - DataType::Zstd => "zstd", - }; - value.bind(statement, start_index) - } -} - -impl Column for DataType { - fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let (value, next_index) = String::column(statement, start_index)?; - let data_type = match value.as_str() { - "json" => DataType::Json, - "zstd" => DataType::Zstd, - _ => anyhow::bail!("Unknown data type: {}", value), - }; - Ok((data_type, next_index)) - } -} - -const RULES_FILE_NAMES: [&'static str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - -pub fn init(cx: &mut App) { - ThreadsDatabase::init(cx); -} - -/// A system prompt shared by all threads created by this ThreadStore -#[derive(Clone, Default)] -pub struct SharedProjectContext(Rc>>); - -impl SharedProjectContext { - pub fn borrow(&self) -> Ref<'_, Option> { - self.0.borrow() - } -} - -pub type TextThreadStore = assistant_context::ContextStore; - -pub struct ThreadStore { - project: Entity, - tools: Entity, - prompt_builder: Arc, - prompt_store: Option>, - context_server_tool_ids: HashMap>, - threads: Vec, - project_context: SharedProjectContext, - reload_system_prompt_tx: mpsc::Sender<()>, - _reload_system_prompt_task: Task<()>, - _subscriptions: Vec, -} - -pub struct RulesLoadingError { - pub message: SharedString, -} - -impl EventEmitter for ThreadStore {} - -impl ThreadStore { - pub fn load( - project: Entity, - tools: Entity, - prompt_store: Option>, - prompt_builder: Arc, - cx: &mut App, - ) -> Task>> { - cx.spawn(async move |cx| { - let (thread_store, ready_rx) = cx.update(|cx| { - let mut option_ready_rx = None; - let thread_store = cx.new(|cx| { - let (thread_store, ready_rx) = - Self::new(project, tools, prompt_builder, prompt_store, cx); - option_ready_rx = Some(ready_rx); - thread_store - }); - (thread_store, option_ready_rx.take().unwrap()) - })?; - ready_rx.await?; - Ok(thread_store) - }) - } - - fn new( - project: Entity, - tools: Entity, - prompt_builder: Arc, - prompt_store: Option>, - cx: &mut Context, - ) -> (Self, oneshot::Receiver<()>) { - let mut subscriptions = vec![cx.subscribe(&project, Self::handle_project_event)]; - - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe( - prompt_store, - |this, _prompt_store, PromptsUpdatedEvent, _cx| { - this.enqueue_system_prompt_reload(); - }, - )) - } - - // This channel and task prevent concurrent and redundant loading of the system prompt. - let (reload_system_prompt_tx, mut reload_system_prompt_rx) = mpsc::channel(1); - let (ready_tx, ready_rx) = oneshot::channel(); - let mut ready_tx = Some(ready_tx); - let reload_system_prompt_task = cx.spawn({ - let prompt_store = prompt_store.clone(); - async move |thread_store, cx| { - loop { - let Some(reload_task) = thread_store - .update(cx, |thread_store, cx| { - thread_store.reload_system_prompt(prompt_store.clone(), cx) - }) - .ok() - else { - return; - }; - reload_task.await; - if let Some(ready_tx) = ready_tx.take() { - ready_tx.send(()).ok(); - } - reload_system_prompt_rx.next().await; - } - } - }); - - let this = Self { - project, - tools, - prompt_builder, - prompt_store, - context_server_tool_ids: HashMap::default(), - threads: Vec::new(), - project_context: SharedProjectContext::default(), - reload_system_prompt_tx, - _reload_system_prompt_task: reload_system_prompt_task, - _subscriptions: subscriptions, - }; - this.register_context_server_handlers(cx); - this.reload(cx).detach_and_log_err(cx); - (this, ready_rx) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn fake(project: Entity, cx: &mut App) -> Self { - Self { - project, - tools: cx.new(|_| ToolWorkingSet::default()), - prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()), - prompt_store: None, - context_server_tool_ids: HashMap::default(), - threads: Vec::new(), - project_context: SharedProjectContext::default(), - reload_system_prompt_tx: mpsc::channel(0).0, - _reload_system_prompt_task: Task::ready(()), - _subscriptions: vec![], - } - } - - fn handle_project_event( - &mut self, - _project: Entity, - event: &project::Event, - _cx: &mut Context, - ) { - match event { - project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.enqueue_system_prompt_reload(); - } - project::Event::WorktreeUpdatedEntries(_, items) => { - if items.iter().any(|(path, _, _)| { - RULES_FILE_NAMES - .iter() - .any(|name| path.as_ref() == Path::new(name)) - }) { - self.enqueue_system_prompt_reload(); - } - } - _ => {} - } - } - - fn enqueue_system_prompt_reload(&mut self) { - self.reload_system_prompt_tx.try_send(()).ok(); - } - - // Note that this should only be called from `reload_system_prompt_task`. - fn reload_system_prompt( - &self, - prompt_store: Option>, - cx: &mut Context, - ) -> Task<()> { - let worktrees = self - .project - .read(cx) - .visible_worktrees(cx) - .collect::>(); - let worktree_tasks = worktrees - .into_iter() - .map(|worktree| { - Self::load_worktree_info_for_system_prompt(worktree, self.project.clone(), cx) - }) - .collect::>(); - let default_user_rules_task = match prompt_store { - None => Task::ready(vec![]), - Some(prompt_store) => prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }), - }; - - cx.spawn(async move |this, cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; - - let worktrees = worktrees - .into_iter() - .map(|(worktree, rules_error)| { - if let Some(rules_error) = rules_error { - this.update(cx, |_, cx| cx.emit(rules_error)).ok(); - } - worktree - }) - .collect::>(); - - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - PromptId::User { uuid } => uuid, - PromptId::EditWorkflow => return None, - }, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(err) => { - this.update(cx, |_, cx| { - cx.emit(RulesLoadingError { - message: format!("{err:?}").into(), - }); - }) - .ok(); - None - } - }) - .collect::>(); - - this.update(cx, |this, _cx| { - *this.project_context.0.borrow_mut() = - Some(ProjectContext::new(worktrees, default_user_rules)); - }) - .ok(); - }) - } - - fn load_worktree_info_for_system_prompt( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Task<(WorktreeContext, Option)> { - let tree = worktree.read(cx); - let root_name = tree.root_name().into(); - let abs_path = tree.abs_path(); - - let mut context = WorktreeContext { - root_name, - abs_path, - rules_file: None, - }; - - let rules_task = Self::load_worktree_rules_file(worktree, project, cx); - let Some(rules_task) = rules_task else { - return Task::ready((context, None)); - }; - - cx.spawn(async move |_| { - let (rules_file, rules_file_error) = match rules_task.await { - Ok(rules_file) => (Some(rules_file), None), - Err(err) => ( - None, - Some(RulesLoadingError { - message: format!("{err}").into(), - }), - ), - }; - context.rules_file = rules_file; - (context, rules_file_error) - }) - } - - fn load_worktree_rules_file( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Option>> { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - let selected_rules_file = RULES_FILE_NAMES - .into_iter() - .filter_map(|name| { - worktree - .entry_for_path(name) - .filter(|entry| entry.is_file()) - .map(|entry| entry.path.clone()) - }) - .next(); - - // Note that Cline supports `.clinerules` being a directory, but that is not currently - // supported. This doesn't seem to occur often in GitHub repositories. - selected_rules_file.map(|path_in_worktree| { - let project_path = ProjectPath { - worktree_id, - path: path_in_worktree.clone(), - }; - let buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - let rope_task = cx.spawn(async move |cx| { - buffer_task.await?.read_with(cx, |buffer, cx| { - let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; - anyhow::Ok((project_entry_id, buffer.as_rope().clone())) - })? - }); - // Build a string from the rope on a background thread. - cx.background_spawn(async move { - let (project_entry_id, rope) = rope_task.await?; - anyhow::Ok(RulesFileContext { - path_in_worktree, - text: rope.to_string().trim().to_string(), - project_entry_id: project_entry_id.to_usize(), - }) - }) - }) - } - - pub fn prompt_store(&self) -> &Option> { - &self.prompt_store - } - - pub fn tools(&self) -> Entity { - self.tools.clone() - } - - /// Returns the number of threads. - pub fn thread_count(&self) -> usize { - self.threads.len() - } - - pub fn reverse_chronological_threads(&self) -> impl Iterator { - // ordering is from "ORDER BY" in `list_threads` - self.threads.iter() - } - - pub fn create_thread(&mut self, cx: &mut Context) -> Entity { - cx.new(|cx| { - Thread::new( - self.project.clone(), - self.tools.clone(), - self.prompt_builder.clone(), - self.project_context.clone(), - cx, - ) - }) - } - - pub fn create_thread_from_serialized( - &mut self, - serialized: SerializedThread, - cx: &mut Context, - ) -> Entity { - cx.new(|cx| { - Thread::deserialize( - ThreadId::new(), - serialized, - self.project.clone(), - self.tools.clone(), - self.prompt_builder.clone(), - self.project_context.clone(), - None, - cx, - ) - }) - } - - pub fn open_thread( - &self, - id: &ThreadId, - window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let id = id.clone(); - let database_future = ThreadsDatabase::global_future(cx); - let this = cx.weak_entity(); - window.spawn(cx, async move |cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - let thread = database - .try_find_thread(id.clone()) - .await? - .with_context(|| format!("no thread found with ID: {id:?}"))?; - - let thread = this.update_in(cx, |this, window, cx| { - cx.new(|cx| { - Thread::deserialize( - id.clone(), - thread, - this.project.clone(), - this.tools.clone(), - this.prompt_builder.clone(), - this.project_context.clone(), - Some(window), - cx, - ) - }) - })?; - - Ok(thread) - }) - } - - pub fn save_thread(&self, thread: &Entity, cx: &mut Context) -> Task> { - let (metadata, serialized_thread) = - thread.update(cx, |thread, cx| (thread.id().clone(), thread.serialize(cx))); - - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let serialized_thread = serialized_thread.await?; - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.save_thread(metadata, serialized_thread).await?; - - this.update(cx, |this, cx| this.reload(cx))?.await - }) - } - - pub fn delete_thread(&mut self, id: &ThreadId, cx: &mut Context) -> Task> { - let id = id.clone(); - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let database = database_future.await.map_err(|err| anyhow!(err))?; - database.delete_thread(id.clone()).await?; - - this.update(cx, |this, cx| { - this.threads.retain(|thread| thread.id != id); - cx.notify(); - }) - }) - } - - pub fn reload(&self, cx: &mut Context) -> Task> { - let database_future = ThreadsDatabase::global_future(cx); - cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - - this.update(cx, |this, cx| { - this.threads = threads; - cx.notify(); - }) - }) - } - - fn register_context_server_handlers(&self, cx: &mut Context) { - let context_server_store = self.project.read(cx).context_server_store(); - cx.subscribe(&context_server_store, Self::handle_context_server_event) - .detach(); - - // Check for any servers that were already running before the handler was registered - for server in context_server_store.read(cx).running_servers() { - self.load_context_server_tools(server.id(), context_server_store.clone(), cx); - } - } - - fn handle_context_server_event( - &mut self, - context_server_store: Entity, - event: &project::context_server_store::Event, - cx: &mut Context, - ) { - let tool_working_set = self.tools.clone(); - match event { - project::context_server_store::Event::ServerStatusChanged { server_id, status } => { - match status { - ContextServerStatus::Starting => {} - ContextServerStatus::Running => { - self.load_context_server_tools(server_id.clone(), context_server_store, cx); - } - ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { - if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) { - tool_working_set.update(cx, |tool_working_set, cx| { - tool_working_set.remove(&tool_ids, cx); - }); - } - } - } - } - } - } - - fn load_context_server_tools( - &self, - server_id: ContextServerId, - context_server_store: Entity, - cx: &mut Context, - ) { - let Some(server) = context_server_store.read(cx).get_running_server(&server_id) else { - return; - }; - let tool_working_set = self.tools.clone(); - cx.spawn(async move |this, cx| { - let Some(protocol) = server.client() else { - return; - }; - - if protocol.capable(context_server::protocol::ServerCapability::Tools) { - if let Some(response) = protocol - .request::(()) - .await - .log_err() - { - let tool_ids = tool_working_set - .update(cx, |tool_working_set, cx| { - tool_working_set.extend( - response.tools.into_iter().map(|tool| { - Arc::new(ContextServerTool::new( - context_server_store.clone(), - server.id(), - tool, - )) as Arc - }), - cx, - ) - }) - .log_err(); - - if let Some(tool_ids) = tool_ids { - this.update(cx, |this, _| { - this.context_server_tool_ids.insert(server_id, tool_ids); - }) - .log_err(); - } - } - } - }) - .detach(); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializedThreadMetadata { - pub id: ThreadId, - pub summary: SharedString, - pub updated_at: DateTime, -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct SerializedThread { - pub version: String, - pub summary: SharedString, - pub updated_at: DateTime, - pub messages: Vec, - #[serde(default)] - pub initial_project_snapshot: Option>, - #[serde(default)] - pub cumulative_token_usage: TokenUsage, - #[serde(default)] - pub request_token_usage: Vec, - #[serde(default)] - pub detailed_summary_state: DetailedSummaryState, - #[serde(default)] - pub exceeded_window_error: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub completion_mode: Option, - #[serde(default)] - pub tool_use_limit_reached: bool, - #[serde(default)] - pub profile: Option, -} - -#[derive(Serialize, Deserialize, Debug, PartialEq)] -pub struct SerializedLanguageModel { - pub provider: String, - pub model: String, -} - -impl SerializedThread { - pub const VERSION: &'static str = "0.2.0"; - - pub fn from_json(json: &[u8]) -> Result { - let saved_thread_json = serde_json::from_slice::(json)?; - match saved_thread_json.get("version") { - Some(serde_json::Value::String(version)) => match version.as_str() { - SerializedThreadV0_1_0::VERSION => { - let saved_thread = - serde_json::from_value::(saved_thread_json)?; - Ok(saved_thread.upgrade()) - } - SerializedThread::VERSION => Ok(serde_json::from_value::( - saved_thread_json, - )?), - _ => anyhow::bail!("unrecognized serialized thread version: {version:?}"), - }, - None => { - let saved_thread = - serde_json::from_value::(saved_thread_json)?; - Ok(saved_thread.upgrade()) - } - version => anyhow::bail!("unrecognized serialized thread version: {version:?}"), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct SerializedThreadV0_1_0( - // The structure did not change, so we are reusing the latest SerializedThread. - // When making the next version, make sure this points to SerializedThreadV0_2_0 - SerializedThread, -); - -impl SerializedThreadV0_1_0 { - pub const VERSION: &'static str = "0.1.0"; - - pub fn upgrade(self) -> SerializedThread { - debug_assert_eq!(SerializedThread::VERSION, "0.2.0"); - - let mut messages: Vec = Vec::with_capacity(self.0.messages.len()); - - for message in self.0.messages { - if message.role == Role::User && !message.tool_results.is_empty() { - if let Some(last_message) = messages.last_mut() { - debug_assert!(last_message.role == Role::Assistant); - - last_message.tool_results = message.tool_results; - continue; - } - } - - messages.push(message); - } - - SerializedThread { - messages, - version: SerializedThread::VERSION.to_string(), - ..self.0 - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedMessage { - pub id: MessageId, - pub role: Role, - #[serde(default)] - pub segments: Vec, - #[serde(default)] - pub tool_uses: Vec, - #[serde(default)] - pub tool_results: Vec, - #[serde(default)] - pub context: String, - #[serde(default)] - pub creases: Vec, - #[serde(default)] - pub is_hidden: bool, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type")] -pub enum SerializedMessageSegment { - #[serde(rename = "text")] - Text { - text: String, - }, - #[serde(rename = "thinking")] - Thinking { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, - }, - RedactedThinking { - data: String, - }, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedToolUse { - pub id: LanguageModelToolUseId, - pub name: SharedString, - pub input: serde_json::Value, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedToolResult { - pub tool_use_id: LanguageModelToolUseId, - pub is_error: bool, - pub content: LanguageModelToolResultContent, - pub output: Option, -} - -#[derive(Serialize, Deserialize)] -struct LegacySerializedThread { - pub summary: SharedString, - pub updated_at: DateTime, - pub messages: Vec, - #[serde(default)] - pub initial_project_snapshot: Option>, -} - -impl LegacySerializedThread { - pub fn upgrade(self) -> SerializedThread { - SerializedThread { - version: SerializedThread::VERSION.to_string(), - summary: self.summary, - updated_at: self.updated_at, - messages: self.messages.into_iter().map(|msg| msg.upgrade()).collect(), - initial_project_snapshot: self.initial_project_snapshot, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: Vec::new(), - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct LegacySerializedMessage { - pub id: MessageId, - pub role: Role, - pub text: String, - #[serde(default)] - pub tool_uses: Vec, - #[serde(default)] - pub tool_results: Vec, -} - -impl LegacySerializedMessage { - fn upgrade(self) -> SerializedMessage { - SerializedMessage { - id: self.id, - role: self.role, - segments: vec![SerializedMessageSegment::Text { text: self.text }], - tool_uses: self.tool_uses, - tool_results: self.tool_results, - context: String::new(), - creases: Vec::new(), - is_hidden: false, - } - } -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct SerializedCrease { - pub start: usize, - pub end: usize, - pub icon_path: SharedString, - pub label: SharedString, -} - -struct GlobalThreadsDatabase( - Shared, Arc>>>, -); - -impl Global for GlobalThreadsDatabase {} - -pub(crate) struct ThreadsDatabase { - executor: BackgroundExecutor, - connection: Arc>, -} - -impl ThreadsDatabase { - fn connection(&self) -> Arc> { - self.connection.clone() - } - - const COMPRESSION_LEVEL: i32 = 3; -} - -impl Bind for ThreadId { - fn bind(&self, statement: &Statement, start_index: i32) -> Result { - self.to_string().bind(statement, start_index) - } -} - -impl Column for ThreadId { - fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let (id_str, next_index) = String::column(statement, start_index)?; - Ok((ThreadId::from(id_str.as_str()), next_index)) - } -} - -impl ThreadsDatabase { - fn global_future( - cx: &mut App, - ) -> Shared, Arc>>> { - GlobalThreadsDatabase::global(cx).0.clone() - } - - fn init(cx: &mut App) { - let executor = cx.background_executor().clone(); - let database_future = executor - .spawn({ - let executor = executor.clone(); - let threads_dir = paths::data_dir().join("threads"); - async move { ThreadsDatabase::new(threads_dir, executor) } - }) - .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new))) - .boxed() - .shared(); - - cx.set_global(GlobalThreadsDatabase(database_future)); - } - - pub fn new(threads_dir: PathBuf, executor: BackgroundExecutor) -> Result { - std::fs::create_dir_all(&threads_dir)?; - - let sqlite_path = threads_dir.join("threads.db"); - let mdb_path = threads_dir.join("threads-db.1.mdb"); - - let needs_migration_from_heed = mdb_path.exists(); - - let connection = if *ZED_STATELESS { - Connection::open_memory(Some("THREAD_FALLBACK_DB")) - } else { - Connection::open_file(&sqlite_path.to_string_lossy()) - }; - - connection.exec(indoc! {" - CREATE TABLE IF NOT EXISTS threads ( - id TEXT PRIMARY KEY, - summary TEXT NOT NULL, - updated_at TEXT NOT NULL, - data_type TEXT NOT NULL, - data BLOB NOT NULL - ) - "})?() - .map_err(|e| anyhow!("Failed to create threads table: {}", e))?; - - let db = Self { - executor: executor.clone(), - connection: Arc::new(Mutex::new(connection)), - }; - - if needs_migration_from_heed { - let db_connection = db.connection(); - let executor_clone = executor.clone(); - executor - .spawn(async move { - log::info!("Starting threads.db migration"); - Self::migrate_from_heed(&mdb_path, db_connection, executor_clone)?; - std::fs::remove_dir_all(mdb_path)?; - log::info!("threads.db migrated to sqlite"); - Ok::<(), anyhow::Error>(()) - }) - .detach(); - } - - Ok(db) - } - - // Remove this migration after 2025-09-01 - fn migrate_from_heed( - mdb_path: &Path, - connection: Arc>, - _executor: BackgroundExecutor, - ) -> Result<()> { - use heed::types::SerdeBincode; - struct SerializedThreadHeed(SerializedThread); - - impl heed::BytesEncode<'_> for SerializedThreadHeed { - type EItem = SerializedThreadHeed; - - fn bytes_encode( - item: &Self::EItem, - ) -> Result, heed::BoxedError> { - serde_json::to_vec(&item.0) - .map(std::borrow::Cow::Owned) - .map_err(Into::into) - } - } - - impl<'a> heed::BytesDecode<'a> for SerializedThreadHeed { - type DItem = SerializedThreadHeed; - - fn bytes_decode(bytes: &'a [u8]) -> Result { - SerializedThread::from_json(bytes) - .map(SerializedThreadHeed) - .map_err(Into::into) - } - } - - const ONE_GB_IN_BYTES: usize = 1024 * 1024 * 1024; - - let env = unsafe { - heed::EnvOpenOptions::new() - .map_size(ONE_GB_IN_BYTES) - .max_dbs(1) - .open(mdb_path)? - }; - - let txn = env.write_txn()?; - let threads: heed::Database, SerializedThreadHeed> = env - .open_database(&txn, Some("threads"))? - .ok_or_else(|| anyhow!("threads database not found"))?; - - for result in threads.iter(&txn)? { - let (thread_id, thread_heed) = result?; - Self::save_thread_sync(&connection, thread_id, thread_heed.0)?; - } - - Ok(()) - } - - fn save_thread_sync( - connection: &Arc>, - id: ThreadId, - thread: SerializedThread, - ) -> Result<()> { - let json_data = serde_json::to_string(&thread)?; - let summary = thread.summary.to_string(); - let updated_at = thread.updated_at.to_rfc3339(); - - let connection = connection.lock().unwrap(); - - let compressed = zstd::encode_all(json_data.as_bytes(), Self::COMPRESSION_LEVEL)?; - let data_type = DataType::Zstd; - let data = compressed; - - let mut insert = connection.exec_bound::<(ThreadId, String, String, DataType, Vec)>(indoc! {" - INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?) - "})?; - - insert((id, summary, updated_at, data_type, data))?; - - Ok(()) - } - - pub fn list_threads(&self) -> Task>> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - let mut select = - connection.select_bound::<(), (ThreadId, String, String)>(indoc! {" - SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC - "})?; - - let rows = select(())?; - let mut threads = Vec::new(); - - for (id, summary, updated_at) in rows { - threads.push(SerializedThreadMetadata { - id, - summary: summary.into(), - updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), - }); - } - - Ok(threads) - }) - } - - pub fn try_find_thread(&self, id: ThreadId) -> Task>> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - let mut select = connection.select_bound::)>(indoc! {" - SELECT data_type, data FROM threads WHERE id = ? LIMIT 1 - "})?; - - let rows = select(id)?; - if let Some((data_type, data)) = rows.into_iter().next() { - let json_data = match data_type { - DataType::Zstd => { - let decompressed = zstd::decode_all(&data[..])?; - String::from_utf8(decompressed)? - } - DataType::Json => String::from_utf8(data)?, - }; - - let thread = SerializedThread::from_json(json_data.as_bytes())?; - Ok(Some(thread)) - } else { - Ok(None) - } - }) - } - - pub fn save_thread(&self, id: ThreadId, thread: SerializedThread) -> Task> { - let connection = self.connection.clone(); - - self.executor - .spawn(async move { Self::save_thread_sync(&connection, id, thread) }) - } - - pub fn delete_thread(&self, id: ThreadId) -> Task> { - let connection = self.connection.clone(); - - self.executor.spawn(async move { - let connection = connection.lock().unwrap(); - - let mut delete = connection.exec_bound::(indoc! {" - DELETE FROM threads WHERE id = ? - "})?; - - delete(id)?; - - Ok(()) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::thread::{DetailedSummaryState, MessageId}; - use chrono::Utc; - use language_model::{Role, TokenUsage}; - use pretty_assertions::assert_eq; - - #[test] - fn test_legacy_serialized_thread_upgrade() { - let updated_at = Utc::now(); - let legacy_thread = LegacySerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![LegacySerializedMessage { - id: MessageId(1), - role: Role::User, - text: "Hello, world!".to_string(), - tool_uses: vec![], - tool_results: vec![], - }], - initial_project_snapshot: None, - }; - - let upgraded = legacy_thread.upgrade(); - - assert_eq!( - upgraded, - SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Hello, world!".to_string() - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false - }], - version: SerializedThread::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None - } - ) - } - - #[test] - fn test_serialized_threadv0_1_0_upgrade() { - let updated_at = Utc::now(); - let thread_v0_1_0 = SerializedThreadV0_1_0(SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![ - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Use tool_1".to_string(), - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - SerializedMessage { - id: MessageId(2), - role: Role::Assistant, - segments: vec![SerializedMessageSegment::Text { - text: "I want to use a tool".to_string(), - }], - tool_uses: vec![SerializedToolUse { - id: "abc".into(), - name: "tool_1".into(), - input: serde_json::Value::Null, - }], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Here is the tool result".to_string(), - }], - tool_uses: vec![], - tool_results: vec![SerializedToolResult { - tool_use_id: "abc".into(), - is_error: false, - content: LanguageModelToolResultContent::Text("abcdef".into()), - output: Some(serde_json::Value::Null), - }], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - ], - version: SerializedThreadV0_1_0::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None, - }); - let upgraded = thread_v0_1_0.upgrade(); - - assert_eq!( - upgraded, - SerializedThread { - summary: "Test conversation".into(), - updated_at, - messages: vec![ - SerializedMessage { - id: MessageId(1), - role: Role::User, - segments: vec![SerializedMessageSegment::Text { - text: "Use tool_1".to_string() - }], - tool_uses: vec![], - tool_results: vec![], - context: "".to_string(), - creases: vec![], - is_hidden: false - }, - SerializedMessage { - id: MessageId(2), - role: Role::Assistant, - segments: vec![SerializedMessageSegment::Text { - text: "I want to use a tool".to_string(), - }], - tool_uses: vec![SerializedToolUse { - id: "abc".into(), - name: "tool_1".into(), - input: serde_json::Value::Null, - }], - tool_results: vec![SerializedToolResult { - tool_use_id: "abc".into(), - is_error: false, - content: LanguageModelToolResultContent::Text("abcdef".into()), - output: Some(serde_json::Value::Null), - }], - context: "".to_string(), - creases: vec![], - is_hidden: false, - }, - ], - version: SerializedThread::VERSION.to_string(), - initial_project_snapshot: None, - cumulative_token_usage: TokenUsage::default(), - request_token_usage: vec![], - detailed_summary_state: DetailedSummaryState::default(), - exceeded_window_error: None, - model: None, - completion_mode: None, - tool_use_limit_reached: false, - profile: None - } - ) - } -} diff --git a/crates/assistant_tool/src/tool_schema.rs b/crates/agent/src/tool_schema.rs similarity index 78% rename from crates/assistant_tool/src/tool_schema.rs rename to crates/agent/src/tool_schema.rs index 7b48f93ba6d23bcc1a6e2cf051737efaf69fa595..4b0de3e5c63fb0c5ccafbb89a22dad8a33072b35 100644 --- a/crates/assistant_tool/src/tool_schema.rs +++ b/crates/agent/src/tool_schema.rs @@ -1,7 +1,48 @@ use anyhow::Result; +use language_model::LanguageModelToolSchemaFormat; +use schemars::{ + JsonSchema, Schema, + generate::SchemaSettings, + transform::{Transform, transform_subschemas}, +}; use serde_json::Value; -use crate::LanguageModelToolSchemaFormat; +pub(crate) fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { + let mut generator = match format { + LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), + LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .with_transform(ToJsonSchemaSubsetTransform) + .into_generator(), + }; + generator.root_schema_for::() +} + +#[derive(Debug, Clone)] +struct ToJsonSchemaSubsetTransform; + +impl Transform for ToJsonSchemaSubsetTransform { + fn transform(&mut self, schema: &mut Schema) { + // Ensure that the type field is not an array, this happens when we use + // Option, the type will be [T, "null"]. + if let Some(type_field) = schema.get_mut("type") + && let Some(types) = type_field.as_array() + && let Some(first_type) = types.first() + { + *type_field = first_type.clone(); + } + + // oneOf is not supported, use anyOf instead + if let Some(one_of) = schema.remove("oneOf") { + schema.insert("anyOf".to_string(), one_of); + } + + transform_subschemas(self, schema); + } +} /// Tries to adapt a JSON schema representation to be compatible with the specified format. /// @@ -24,16 +65,16 @@ pub fn adapt_schema_to_format( fn preprocess_json_schema(json: &mut Value) -> Result<()> { // `additionalProperties` defaults to `false` unless explicitly specified. // This prevents models from hallucinating tool parameters. - if let Value::Object(obj) = json { - if matches!(obj.get("type"), Some(Value::String(s)) if s == "object") { - if !obj.contains_key("additionalProperties") { - obj.insert("additionalProperties".to_string(), Value::Bool(false)); - } + if let Value::Object(obj) = json + && matches!(obj.get("type"), Some(Value::String(s)) if s == "object") + { + if !obj.contains_key("additionalProperties") { + obj.insert("additionalProperties".to_string(), Value::Bool(false)); + } - // OpenAI API requires non-missing `properties` - if !obj.contains_key("properties") { - obj.insert("properties".to_string(), Value::Object(Default::default())); - } + // OpenAI API requires non-missing `properties` + if !obj.contains_key("properties") { + obj.insert("properties".to_string(), Value::Object(Default::default())); } } Ok(()) @@ -59,10 +100,10 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { ("optional", |value| value.is_boolean()), ]; for (key, predicate) in KEYS_TO_REMOVE { - if let Some(value) = obj.get(key) { - if predicate(value) { - obj.remove(key); - } + if let Some(value) = obj.get(key) + && predicate(value) + { + obj.remove(key); } } @@ -77,12 +118,12 @@ fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> { } // Handle oneOf -> anyOf conversion - if let Some(subschemas) = obj.get_mut("oneOf") { - if subschemas.is_array() { - let subschemas_clone = subschemas.clone(); - obj.remove("oneOf"); - obj.insert("anyOf".to_string(), subschemas_clone); - } + if let Some(subschemas) = obj.get_mut("oneOf") + && subschemas.is_array() + { + let subschemas_clone = subschemas.clone(); + obj.remove("oneOf"); + obj.insert("anyOf".to_string(), subschemas_clone); } // Recursively process all nested objects and arrays diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs deleted file mode 100644 index 74dfaf9a85852d151554df9439a53fee90ec5686..0000000000000000000000000000000000000000 --- a/crates/agent/src/tool_use.rs +++ /dev/null @@ -1,581 +0,0 @@ -use crate::{ - thread::{MessageId, PromptId, ThreadId}, - thread_store::SerializedMessage, -}; -use agent_settings::CompletionMode; -use anyhow::Result; -use assistant_tool::{ - AnyToolCard, Tool, ToolResultContent, ToolResultOutput, ToolUseStatus, ToolWorkingSet, -}; -use collections::HashMap; -use futures::{FutureExt as _, future::Shared}; -use gpui::{App, Entity, SharedString, Task, Window}; -use icons::IconName; -use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelExt, LanguageModelRequest, - LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, - LanguageModelToolUseId, Role, -}; -use project::Project; -use std::sync::Arc; -use util::truncate_lines_to_byte_limit; - -#[derive(Debug)] -pub struct ToolUse { - pub id: LanguageModelToolUseId, - pub name: SharedString, - pub ui_text: SharedString, - pub status: ToolUseStatus, - pub input: serde_json::Value, - pub icon: icons::IconName, - pub needs_confirmation: bool, -} - -pub struct ToolUseState { - tools: Entity, - tool_uses_by_assistant_message: HashMap>, - tool_results: HashMap, - pending_tool_uses_by_id: HashMap, - tool_result_cards: HashMap, - tool_use_metadata_by_id: HashMap, -} - -impl ToolUseState { - pub fn new(tools: Entity) -> Self { - Self { - tools, - tool_uses_by_assistant_message: HashMap::default(), - tool_results: HashMap::default(), - pending_tool_uses_by_id: HashMap::default(), - tool_result_cards: HashMap::default(), - tool_use_metadata_by_id: HashMap::default(), - } - } - - /// Constructs a [`ToolUseState`] from the given list of [`SerializedMessage`]s. - /// - /// Accepts a function to filter the tools that should be used to populate the state. - /// - /// If `window` is `None` (e.g., when in headless mode or when running evals), - /// tool cards won't be deserialized - pub fn from_serialized_messages( - tools: Entity, - messages: &[SerializedMessage], - project: Entity, - window: Option<&mut Window>, // None in headless mode - cx: &mut App, - ) -> Self { - let mut this = Self::new(tools); - let mut tool_names_by_id = HashMap::default(); - let mut window = window; - - for message in messages { - match message.role { - Role::Assistant => { - if !message.tool_uses.is_empty() { - let tool_uses = message - .tool_uses - .iter() - .map(|tool_use| LanguageModelToolUse { - id: tool_use.id.clone(), - name: tool_use.name.clone().into(), - raw_input: tool_use.input.to_string(), - input: tool_use.input.clone(), - is_input_complete: true, - }) - .collect::>(); - - tool_names_by_id.extend( - tool_uses - .iter() - .map(|tool_use| (tool_use.id.clone(), tool_use.name.clone())), - ); - - this.tool_uses_by_assistant_message - .insert(message.id, tool_uses); - - for tool_result in &message.tool_results { - let tool_use_id = tool_result.tool_use_id.clone(); - let Some(tool_use) = tool_names_by_id.get(&tool_use_id) else { - log::warn!("no tool name found for tool use: {tool_use_id:?}"); - continue; - }; - - this.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name: tool_use.clone(), - is_error: tool_result.is_error, - content: tool_result.content.clone(), - output: tool_result.output.clone(), - }, - ); - - if let Some(window) = &mut window { - if let Some(tool) = this.tools.read(cx).tool(tool_use, cx) { - if let Some(output) = tool_result.output.clone() { - if let Some(card) = tool.deserialize_card( - output, - project.clone(), - window, - cx, - ) { - this.tool_result_cards.insert(tool_use_id, card); - } - } - } - } - } - } - } - Role::System | Role::User => {} - } - } - - this - } - - pub fn cancel_pending(&mut self) -> Vec { - let mut canceled_tool_uses = Vec::new(); - self.pending_tool_uses_by_id - .retain(|tool_use_id, tool_use| { - if matches!(tool_use.status, PendingToolUseStatus::Error { .. }) { - return true; - } - - let content = "Tool canceled by user".into(); - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name: tool_use.name.clone(), - content, - output: None, - is_error: true, - }, - ); - canceled_tool_uses.push(tool_use.clone()); - false - }); - canceled_tool_uses - } - - pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> { - self.pending_tool_uses_by_id.values().collect() - } - - pub fn tool_uses_for_message( - &self, - id: MessageId, - project: &Entity, - cx: &App, - ) -> Vec { - let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else { - return Vec::new(); - }; - - let mut tool_uses = Vec::new(); - - for tool_use in tool_uses_for_message.iter() { - let tool_result = self.tool_results.get(&tool_use.id); - - let status = (|| { - if let Some(tool_result) = tool_result { - let content = tool_result - .content - .to_str() - .map(|str| str.to_owned().into()) - .unwrap_or_default(); - - return if tool_result.is_error { - ToolUseStatus::Error(content) - } else { - ToolUseStatus::Finished(content) - }; - } - - if let Some(pending_tool_use) = self.pending_tool_uses_by_id.get(&tool_use.id) { - match pending_tool_use.status { - PendingToolUseStatus::Idle => ToolUseStatus::Pending, - PendingToolUseStatus::NeedsConfirmation { .. } => { - ToolUseStatus::NeedsConfirmation - } - PendingToolUseStatus::Running { .. } => ToolUseStatus::Running, - PendingToolUseStatus::Error(ref err) => { - ToolUseStatus::Error(err.clone().into()) - } - PendingToolUseStatus::InputStillStreaming => { - ToolUseStatus::InputStillStreaming - } - } - } else { - ToolUseStatus::Pending - } - })(); - - let (icon, needs_confirmation) = - if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { - ( - tool.icon(), - tool.needs_confirmation(&tool_use.input, project, cx), - ) - } else { - (IconName::Cog, false) - }; - - tool_uses.push(ToolUse { - id: tool_use.id.clone(), - name: tool_use.name.clone().into(), - ui_text: self.tool_ui_label( - &tool_use.name, - &tool_use.input, - tool_use.is_input_complete, - cx, - ), - input: tool_use.input.clone(), - status, - icon, - needs_confirmation, - }) - } - - tool_uses - } - - pub fn tool_ui_label( - &self, - tool_name: &str, - input: &serde_json::Value, - is_input_complete: bool, - cx: &App, - ) -> SharedString { - if let Some(tool) = self.tools.read(cx).tool(tool_name, cx) { - if is_input_complete { - tool.ui_text(input).into() - } else { - tool.still_streaming_ui_text(input).into() - } - } else { - format!("Unknown tool {tool_name:?}").into() - } - } - - pub fn tool_results_for_message( - &self, - assistant_message_id: MessageId, - ) -> Vec<&LanguageModelToolResult> { - let Some(tool_uses) = self - .tool_uses_by_assistant_message - .get(&assistant_message_id) - else { - return Vec::new(); - }; - - tool_uses - .iter() - .filter_map(|tool_use| self.tool_results.get(&tool_use.id)) - .collect() - } - - pub fn message_has_tool_results(&self, assistant_message_id: MessageId) -> bool { - self.tool_uses_by_assistant_message - .get(&assistant_message_id) - .map_or(false, |results| !results.is_empty()) - } - - pub fn tool_result( - &self, - tool_use_id: &LanguageModelToolUseId, - ) -> Option<&LanguageModelToolResult> { - self.tool_results.get(tool_use_id) - } - - pub fn tool_result_card(&self, tool_use_id: &LanguageModelToolUseId) -> Option<&AnyToolCard> { - self.tool_result_cards.get(tool_use_id) - } - - pub fn insert_tool_result_card( - &mut self, - tool_use_id: LanguageModelToolUseId, - card: AnyToolCard, - ) { - self.tool_result_cards.insert(tool_use_id, card); - } - - pub fn request_tool_use( - &mut self, - assistant_message_id: MessageId, - tool_use: LanguageModelToolUse, - metadata: ToolUseMetadata, - cx: &App, - ) -> Arc { - let tool_uses = self - .tool_uses_by_assistant_message - .entry(assistant_message_id) - .or_default(); - - let mut existing_tool_use_found = false; - - for existing_tool_use in tool_uses.iter_mut() { - if existing_tool_use.id == tool_use.id { - *existing_tool_use = tool_use.clone(); - existing_tool_use_found = true; - } - } - - if !existing_tool_use_found { - tool_uses.push(tool_use.clone()); - } - - let status = if tool_use.is_input_complete { - self.tool_use_metadata_by_id - .insert(tool_use.id.clone(), metadata); - - PendingToolUseStatus::Idle - } else { - PendingToolUseStatus::InputStillStreaming - }; - - let ui_text: Arc = self - .tool_ui_label( - &tool_use.name, - &tool_use.input, - tool_use.is_input_complete, - cx, - ) - .into(); - - let may_perform_edits = self - .tools - .read(cx) - .tool(&tool_use.name, cx) - .is_some_and(|tool| tool.may_perform_edits()); - - self.pending_tool_uses_by_id.insert( - tool_use.id.clone(), - PendingToolUse { - assistant_message_id, - id: tool_use.id, - name: tool_use.name.clone(), - ui_text: ui_text.clone(), - input: tool_use.input, - may_perform_edits, - status, - }, - ); - - ui_text - } - - pub fn run_pending_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: SharedString, - task: Task<()>, - ) { - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - tool_use.ui_text = ui_text.into(); - tool_use.status = PendingToolUseStatus::Running { - _task: task.shared(), - }; - } - } - - pub fn confirm_tool_use( - &mut self, - tool_use_id: LanguageModelToolUseId, - ui_text: impl Into>, - input: serde_json::Value, - request: Arc, - tool: Arc, - ) { - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - let ui_text = ui_text.into(); - tool_use.ui_text = ui_text.clone(); - let confirmation = Confirmation { - tool_use_id, - input, - request, - tool, - ui_text, - }; - tool_use.status = PendingToolUseStatus::NeedsConfirmation(Arc::new(confirmation)); - } - } - - pub fn insert_tool_output( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - output: Result, - configured_model: Option<&ConfiguredModel>, - completion_mode: CompletionMode, - ) -> Option { - let metadata = self.tool_use_metadata_by_id.remove(&tool_use_id); - - telemetry::event!( - "Agent Tool Finished", - model = metadata - .as_ref() - .map(|metadata| metadata.model.telemetry_id()), - model_provider = metadata - .as_ref() - .map(|metadata| metadata.model.provider_id().to_string()), - thread_id = metadata.as_ref().map(|metadata| metadata.thread_id.clone()), - prompt_id = metadata.as_ref().map(|metadata| metadata.prompt_id.clone()), - tool_name, - success = output.is_ok() - ); - - match output { - Ok(output) => { - let tool_result = output.content; - const BYTES_PER_TOKEN_ESTIMATE: usize = 3; - - let old_use = self.pending_tool_uses_by_id.remove(&tool_use_id); - - // Protect from overly large output - let tool_output_limit = configured_model - .map(|model| { - model.model.max_token_count_for_mode(completion_mode.into()) as usize - * BYTES_PER_TOKEN_ESTIMATE - }) - .unwrap_or(usize::MAX); - - let content = match tool_result { - ToolResultContent::Text(text) => { - let text = if text.len() < tool_output_limit { - text - } else { - let truncated = truncate_lines_to_byte_limit(&text, tool_output_limit); - format!( - "Tool result too long. The first {} bytes:\n\n{}", - truncated.len(), - truncated - ) - }; - LanguageModelToolResultContent::Text(text.into()) - } - ToolResultContent::Image(language_model_image) => { - if language_model_image.estimate_tokens() < tool_output_limit { - LanguageModelToolResultContent::Image(language_model_image) - } else { - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content: "Tool responded with an image that would exceeded the remaining tokens".into(), - is_error: true, - output: None, - }, - ); - - return old_use; - } - } - }; - - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content, - is_error: false, - output: output.output, - }, - ); - - old_use - } - Err(err) => { - self.tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use_id.clone(), - tool_name, - content: LanguageModelToolResultContent::Text(err.to_string().into()), - is_error: true, - output: None, - }, - ); - - if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { - tool_use.status = PendingToolUseStatus::Error(err.to_string().into()); - } - - self.pending_tool_uses_by_id.get(&tool_use_id).cloned() - } - } - } - - pub fn has_tool_results(&self, assistant_message_id: MessageId) -> bool { - self.tool_uses_by_assistant_message - .contains_key(&assistant_message_id) - } - - pub fn tool_results( - &self, - assistant_message_id: MessageId, - ) -> impl Iterator)> { - self.tool_uses_by_assistant_message - .get(&assistant_message_id) - .into_iter() - .flatten() - .map(|tool_use| (tool_use, self.tool_results.get(&tool_use.id))) - } -} - -#[derive(Debug, Clone)] -pub struct PendingToolUse { - pub id: LanguageModelToolUseId, - /// The ID of the Assistant message in which the tool use was requested. - #[allow(unused)] - pub assistant_message_id: MessageId, - pub name: Arc, - pub ui_text: Arc, - pub input: serde_json::Value, - pub status: PendingToolUseStatus, - pub may_perform_edits: bool, -} - -#[derive(Debug, Clone)] -pub struct Confirmation { - pub tool_use_id: LanguageModelToolUseId, - pub input: serde_json::Value, - pub ui_text: Arc, - pub request: Arc, - pub tool: Arc, -} - -#[derive(Debug, Clone)] -pub enum PendingToolUseStatus { - InputStillStreaming, - Idle, - NeedsConfirmation(Arc), - Running { _task: Shared> }, - Error(#[allow(unused)] Arc), -} - -impl PendingToolUseStatus { - pub fn is_idle(&self) -> bool { - matches!(self, PendingToolUseStatus::Idle) - } - - pub fn is_error(&self) -> bool { - matches!(self, PendingToolUseStatus::Error(_)) - } - - pub fn needs_confirmation(&self) -> bool { - matches!(self, PendingToolUseStatus::NeedsConfirmation { .. }) - } -} - -#[derive(Clone)] -pub struct ToolUseMetadata { - pub model: Arc, - pub thread_id: ThreadId, - pub prompt_id: PromptId, -} diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs new file mode 100644 index 0000000000000000000000000000000000000000..1d3c0d557716ec3a52f910971547df4ee764cab0 --- /dev/null +++ b/crates/agent/src/tools.rs @@ -0,0 +1,94 @@ +mod context_server_registry; +mod copy_path_tool; +mod create_directory_tool; +mod delete_path_tool; +mod diagnostics_tool; +mod edit_file_tool; +mod fetch_tool; +mod find_path_tool; +mod grep_tool; +mod list_directory_tool; +mod move_path_tool; +mod now_tool; +mod open_tool; +mod read_file_tool; +mod terminal_tool; +mod thinking_tool; +mod web_search_tool; + +use crate::AgentTool; +use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; + +pub use context_server_registry::*; +pub use copy_path_tool::*; +pub use create_directory_tool::*; +pub use delete_path_tool::*; +pub use diagnostics_tool::*; +pub use edit_file_tool::*; +pub use fetch_tool::*; +pub use find_path_tool::*; +pub use grep_tool::*; +pub use list_directory_tool::*; +pub use move_path_tool::*; +pub use now_tool::*; +pub use open_tool::*; +pub use read_file_tool::*; +pub use terminal_tool::*; +pub use thinking_tool::*; +pub use web_search_tool::*; + +macro_rules! tools { + ($($tool:ty),* $(,)?) => { + /// A list of all built-in tool names + pub fn supported_built_in_tool_names(provider: Option) -> impl Iterator { + [ + $( + (if let Some(provider) = provider.as_ref() { + <$tool>::supports_provider(provider) + } else { + true + }) + .then(|| <$tool>::name().to_string()), + )* + ] + .into_iter() + .flatten() + } + + /// A list of all built-in tools + pub fn built_in_tools() -> impl Iterator { + fn language_model_tool() -> LanguageModelRequestTool { + LanguageModelRequestTool { + name: T::name().to_string(), + description: T::description().to_string(), + input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + } + } + [ + $( + language_model_tool::<$tool>(), + )* + ] + .into_iter() + } + }; +} + +tools! { + CopyPathTool, + CreateDirectoryTool, + DeletePathTool, + DiagnosticsTool, + EditFileTool, + FetchTool, + FindPathTool, + GrepTool, + ListDirectoryTool, + MovePathTool, + NowTool, + OpenTool, + ReadFileTool, + TerminalTool, + ThinkingTool, + WebSearchTool, +} diff --git a/crates/agent2/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs similarity index 88% rename from crates/agent2/src/tools/context_server_registry.rs rename to crates/agent/src/tools/context_server_registry.rs index db39e9278c250865d63922cd802e1ce9fb1d003f..382d2ba9be74b4518de853037c858fd054366d5d 100644 --- a/crates/agent2/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -32,6 +32,17 @@ impl ContextServerRegistry { this } + pub fn tools_for_server( + &self, + server_id: &ContextServerId, + ) -> impl Iterator> { + self.registered_servers + .get(server_id) + .map(|server| server.tools.values()) + .into_iter() + .flatten() + } + pub fn servers( &self, ) -> impl Iterator< @@ -103,7 +114,7 @@ impl ContextServerRegistry { self.reload_tools_for_server(server_id.clone(), cx); } ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { - self.registered_servers.remove(&server_id); + self.registered_servers.remove(server_id); cx.notify(); } } @@ -145,7 +156,7 @@ impl AnyAgentTool for ContextServerTool { ToolKind::Other } - fn initial_title(&self, _input: serde_json::Value) -> SharedString { + fn initial_title(&self, _input: serde_json::Value, _cx: &mut App) -> SharedString { format!("Run MCP tool `{}`", self.tool.name).into() } @@ -154,7 +165,7 @@ impl AnyAgentTool for ContextServerTool { format: language_model::LanguageModelToolSchemaFormat, ) -> Result { let mut schema = self.tool.input_schema.clone(); - assistant_tool::adapt_schema_to_format(&mut schema, format)?; + crate::tool_schema::adapt_schema_to_format(&mut schema, format)?; Ok(match schema { serde_json::Value::Null => { serde_json::json!({ "type": "object", "properties": [] }) @@ -169,22 +180,23 @@ impl AnyAgentTool for ContextServerTool { fn run( self: Arc, input: serde_json::Value, - _event_stream: ToolCallEventStream, + event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { let Some(server) = self.store.read(cx).get_running_server(&self.server_id) else { return Task::ready(Err(anyhow!("Context server not found"))); }; let tool_name = self.tool.name.clone(); - let server_clone = server.clone(); - let input_clone = input.clone(); + let authorize = event_stream.authorize(self.initial_title(input.clone(), cx), cx); cx.spawn(async move |_cx| { - let Some(protocol) = server_clone.client() else { + authorize.await?; + + let Some(protocol) = server.client() else { bail!("Context server not initialized"); }; - let arguments = if let serde_json::Value::Object(map) = input_clone { + let arguments = if let serde_json::Value::Object(map) = input { Some(map.into_iter().collect()) } else { None @@ -228,4 +240,14 @@ impl AnyAgentTool for ContextServerTool { }) }) } + + fn replay( + &self, + _input: serde_json::Value, + _output: serde_json::Value, + _event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Result<()> { + Ok(()) + } } diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs similarity index 77% rename from crates/agent2/src/tools/copy_path_tool.rs rename to crates/agent/src/tools/copy_path_tool.rs index f973b86990af76ea923d548f95a4f05b4cd32c18..236978c78f0c2fee7ecf611486349bab094b3cec 100644 --- a/crates/agent2/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -1,27 +1,22 @@ use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol::ToolKind; use anyhow::{Context as _, Result, anyhow}; -use gpui::{App, AppContext, Entity, SharedString, Task}; +use gpui::{App, AppContext, Entity, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; use util::markdown::MarkdownInlineCode; -/// Copies a file or directory in the project, and returns confirmation that the -/// copy succeeded. +/// Copies a file or directory in the project, and returns confirmation that the copy succeeded. +/// Directory contents will be copied recursively. /// -/// Directory contents will be copied recursively (like `cp -r`). -/// -/// This tool should be used when it's desirable to create a copy of a file or -/// directory without modifying the original. It's much more efficient than -/// doing this by separately reading and then writing the file or directory's -/// contents, so this tool should be preferred over that approach whenever -/// copying is the goal. +/// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. +/// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CopyPathToolInput { /// The source path of the file or directory to copy. - /// If a directory is specified, its contents will be copied recursively (like `cp -r`). + /// If a directory is specified, its contents will be copied recursively. /// /// /// If the project has the following files: @@ -33,12 +28,10 @@ pub struct CopyPathToolInput { /// You can copy the first file by providing a source_path of "directory1/a/something.txt" /// pub source_path: String, - /// The destination path where the file or directory should be copied to. /// /// - /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", - /// provide a destination_path of "directory2/b/copy.txt" + /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", provide a destination_path of "directory2/b/copy.txt" /// pub destination_path: String, } @@ -57,15 +50,19 @@ impl AgentTool for CopyPathTool { type Input = CopyPathToolInput; type Output = String; - fn name(&self) -> SharedString { - "copy_path".into() + fn name() -> &'static str { + "copy_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Move } - fn initial_title(&self, input: Result) -> ui::SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> ui::SharedString { if let Ok(input) = input { let src = MarkdownInlineCode(&input.source_path); let dest = MarkdownInlineCode(&input.destination_path); @@ -87,9 +84,7 @@ impl AgentTool for CopyPathTool { .and_then(|project_path| project.entry_for_path(&project_path, cx)) { Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => { - project.copy_entry(entity.id, None, project_path.path, cx) - } + Some(project_path) => project.copy_entry(entity.id, project_path, cx), None => Task::ready(Err(anyhow!( "Destination path {} was outside the project.", input.destination_path diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs similarity index 85% rename from crates/agent2/src/tools/create_directory_tool.rs rename to crates/agent/src/tools/create_directory_tool.rs index c173c5ae67512813b610552c2001dc16ceb38212..b6240e99cf4dd6698bf9f46edd8d4681247d8f64 100644 --- a/crates/agent2/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -9,12 +9,9 @@ use util::markdown::MarkdownInlineCode; use crate::{AgentTool, ToolCallEventStream}; -/// Creates a new directory at the specified path within the project. Returns -/// confirmation that the directory was created. +/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. /// -/// This tool creates a directory and all necessary parent directories (similar -/// to `mkdir -p`). It should be used whenever you need to create new -/// directories within the project. +/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. @@ -44,15 +41,19 @@ impl AgentTool for CreateDirectoryTool { type Input = CreateDirectoryToolInput; type Output = String; - fn name(&self) -> SharedString { - "create_directory".into() + fn name() -> &'static str { + "create_directory" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Read } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { format!("Create directory {}", MarkdownInlineCode(&input.path)).into() } else { diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs similarity index 92% rename from crates/agent2/src/tools/delete_path_tool.rs rename to crates/agent/src/tools/delete_path_tool.rs index e013b3a3e755cf6662718d620264cb1e38fa5417..01a77f5d811127b3df470ec73fbc91ff7c26fd52 100644 --- a/crates/agent2/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -9,8 +9,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; -/// Deletes the file or directory (and the directory's contents, recursively) at -/// the specified path in the project, and returns confirmation of the deletion. +/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct DeletePathToolInput { /// The path of the file or directory to delete. @@ -45,15 +44,19 @@ impl AgentTool for DeletePathTool { type Input = DeletePathToolInput; type Output = String; - fn name(&self) -> SharedString { - "delete_path".into() + fn name() -> &'static str { + "delete_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Delete } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { format!("Delete “`{}`”", input.path).into() } else { diff --git a/crates/agent2/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs similarity index 92% rename from crates/agent2/src/tools/diagnostics_tool.rs rename to crates/agent/src/tools/diagnostics_tool.rs index 6ba8b7b377a770fa3af35b725b4427e7102d70c1..f07ec4cfe6903ec454eb39a7afc7748327e026ec 100644 --- a/crates/agent2/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -6,7 +6,7 @@ use language::{DiagnosticSeverity, OffsetRangeExt}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{fmt::Write, path::Path, sync::Arc}; +use std::{fmt::Write, sync::Arc}; use ui::SharedString; use util::markdown::MarkdownInlineCode; @@ -63,15 +63,19 @@ impl AgentTool for DiagnosticsTool { type Input = DiagnosticsToolInput; type Output = String; - fn name(&self) -> SharedString { - "diagnostics".into() + fn name() -> &'static str { + "diagnostics" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Read } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Some(path) = input.ok().and_then(|input| match input.path { Some(path) if !path.is_empty() => Some(path), _ => None, @@ -143,9 +147,7 @@ impl AgentTool for DiagnosticsTool { has_diagnostics = true; output.push_str(&format!( "{}: {} error(s), {} warning(s)\n", - Path::new(worktree.read(cx).root_name()) - .join(project_path.path) - .display(), + worktree.read(cx).absolutize(&project_path.path).display(), summary.error_count, summary.warning_count )); diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs similarity index 73% rename from crates/agent2/src/tools/edit_file_tool.rs rename to crates/agent/src/tools/edit_file_tool.rs index 4b4f98daecb90593aa642d41e1becf325aa4c699..0adff2dee3571f09b40ee69896c05e50c56b51b9 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1,14 +1,16 @@ -use crate::{AgentTool, Thread, ToolCallEventStream}; +use crate::{ + AgentTool, Templates, Thread, ToolCallEventStream, + edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, +}; use acp_thread::Diff; use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; -use assistant_tools::edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}; use cloud_llm_client::CompletionIntent; use collections::HashSet; -use gpui::{App, AppContext, AsyncApp, Entity, Task}; +use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use indoc::formatdoc; -use language::ToPoint; use language::language_settings::{self, FormatOnSave}; +use language::{LanguageRegistry, ToPoint}; use language_model::LanguageModelToolResultContent; use paths; use project::lsp_store::{FormatTrigger, LspFormatTarget}; @@ -17,10 +19,12 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use smol::stream::StreamExt as _; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::Arc; use ui::SharedString; use util::ResultExt; +use util::rel_path::RelPath; const DEFAULT_UI_TEXT: &str = "Editing file"; @@ -32,27 +36,23 @@ const DEFAULT_UI_TEXT: &str = "Editing file"; /// /// 2. Verify the directory path is correct (only applicable when creating new files): /// - Use the `list_directory` tool to verify the parent directory exists and is the correct location -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be - /// shown in the UI and also passed to another model to perform the edit. + /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit. /// - /// Be terse, but also descriptive in what you want to achieve with this - /// edit. Avoid generic instructions. + /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions. /// /// NEVER mention the file path in this description. /// /// Fix API endpoint URLs /// Update copyright year in `page_footer` /// - /// Make sure to include this field before all the others in the input object - /// so that we can display it immediately. + /// Make sure to include this field before all the others in the input object so that we can display it immediately. pub display_description: String, /// The full path of the file to create or modify in the project. /// - /// WARNING: When specifying which file path need changing, you MUST - /// start each path with one of the project's root directories. + /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. /// /// The following examples assume we have two root directories in the project: /// - /a/b/backend @@ -61,26 +61,23 @@ pub struct EditFileToolInput { /// /// `backend/src/main.rs` /// - /// Notice how the file path starts with `backend`. Without that, the path - /// would be ambiguous and the call would fail! + /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail! /// /// /// /// `frontend/db.js` /// pub path: PathBuf, - /// The mode of operation on the file. Possible values: /// - 'edit': Make granular edits to an existing file. /// - 'create': Create a new file if it doesn't exist. /// - 'overwrite': Replace the entire contents of an existing file. /// - /// When a file already exists or you just created it, prefer editing - /// it as opposed to recreating it from scratch. + /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. pub mode: EditFileMode, } -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] struct EditFileToolPartialInput { #[serde(default)] path: String, @@ -90,6 +87,7 @@ struct EditFileToolPartialInput { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[schemars(inline)] pub enum EditFileMode { Edit, Create, @@ -98,11 +96,13 @@ pub enum EditFileMode { #[derive(Debug, Serialize, Deserialize)] pub struct EditFileToolOutput { + #[serde(alias = "original_path")] input_path: PathBuf, - project_path: PathBuf, new_text: String, old_text: Arc, + #[serde(default)] diff: String, + #[serde(alias = "raw_output")] edit_agent_output: EditAgentOutput, } @@ -122,12 +122,25 @@ impl From for LanguageModelToolResultContent { } pub struct EditFileTool { - thread: Entity, + thread: WeakEntity, + language_registry: Arc, + project: Entity, + templates: Arc, } impl EditFileTool { - pub fn new(thread: Entity) -> Self { - Self { thread } + pub fn new( + project: Entity, + thread: WeakEntity, + language_registry: Arc, + templates: Arc, + ) -> Self { + Self { + project, + thread, + language_registry, + templates, + } } fn authorize( @@ -142,12 +155,11 @@ impl EditFileTool { // If any path component matches the local settings folder, then this could affect // the editor in ways beyond the project source, so prompt. - let local_settings_folder = paths::local_settings_folder_relative_path(); + let local_settings_folder = paths::local_settings_folder_name(); let path = Path::new(&input.path); - if path - .components() - .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) - { + if path.components().any(|component| { + component.as_os_str() == <_ as AsRef>::as_ref(&local_settings_folder) + }) { return event_stream.authorize( format!("{} (local settings)", input.display_description), cx, @@ -156,19 +168,23 @@ impl EditFileTool { // It's also possible that the global config dir is configured to be inside the project, // so check for that edge case too. - if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { - if canonical_path.starts_with(paths::config_dir()) { - return event_stream.authorize( - format!("{} (global settings)", input.display_description), - cx, - ); - } + // TODO this is broken when remoting + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) + && canonical_path.starts_with(paths::config_dir()) + { + return event_stream.authorize( + format!("{} (global settings)", input.display_description), + cx, + ); } // Check if path is inside the global config directory // First check if it's already inside project - if not, try to canonicalize - let thread = self.thread.read(cx); - let project_path = thread.project().read(cx).find_project_path(&input.path, cx); + let Ok(project_path) = self.thread.read_with(cx, |thread, cx| { + thread.project().read(cx).find_project_path(&input.path, cx) + }) else { + return Task::ready(Err(anyhow!("thread was dropped"))); + }; // If the path is inside the project, and it's not one of the above edge cases, // then no confirmation is necessary. Otherwise, confirmation is necessary. @@ -184,30 +200,54 @@ impl AgentTool for EditFileTool { type Input = EditFileToolInput; type Output = EditFileToolOutput; - fn name(&self) -> SharedString { - "edit_file".into() + fn name() -> &'static str { + "edit_file" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Edit } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString { match input { - Ok(input) => input.display_description.into(), + Ok(input) => self + .project + .read(cx) + .find_project_path(&input.path, cx) + .and_then(|project_path| { + self.project + .read(cx) + .short_full_path_for_project_path(&project_path, cx) + }) + .unwrap_or(input.path.to_string_lossy().into_owned()) + .into(), Err(raw_input) => { if let Some(input) = serde_json::from_value::(raw_input).ok() { + let path = input.path.trim(); + if !path.is_empty() { + return self + .project + .read(cx) + .find_project_path(&input.path, cx) + .and_then(|project_path| { + self.project + .read(cx) + .short_full_path_for_project_path(&project_path, cx) + }) + .unwrap_or(input.path) + .into(); + } + let description = input.display_description.trim(); if !description.is_empty() { return description.to_string().into(); } - - let path = input.path.trim().to_string(); - if !path.is_empty() { - return path.into(); - } } DEFAULT_UI_TEXT.into() @@ -221,7 +261,12 @@ impl AgentTool for EditFileTool { event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let project = self.thread.read(cx).project().clone(); + let Ok(project) = self + .thread + .read_with(cx, |thread, _cx| thread.project().clone()) + else { + return Task::ready(Err(anyhow!("thread was dropped"))); + }; let project_path = match resolve_path(&input, project.clone(), cx) { Ok(path) => path, Err(err) => return Task::ready(Err(anyhow!(err))), @@ -232,29 +277,29 @@ impl AgentTool for EditFileTool { locations: Some(vec![acp::ToolCallLocation { path: abs_path, line: None, + meta: None, }]), ..Default::default() }); } - let request = self.thread.update(cx, |thread, cx| { - thread.build_completion_request(CompletionIntent::ToolResults, cx) - }); - let thread = self.thread.read(cx); - let model = thread.model().clone(); - let action_log = thread.action_log().clone(); - let authorize = self.authorize(&input, &event_stream, cx); cx.spawn(async move |cx: &mut AsyncApp| { authorize.await?; + let (request, model, action_log) = self.thread.update(cx, |thread, cx| { + let request = thread.build_completion_request(CompletionIntent::ToolResults, cx); + (request, thread.model().cloned(), thread.action_log().clone()) + })?; + let request = request?; + let model = model.context("No language model configured")?; + let edit_format = EditFormat::from_model(model.clone())?; let edit_agent = EditAgent::new( model, project.clone(), action_log.clone(), - // TODO: move edit agent to this crate so we can use our templates - assistant_tools::templates::Templates::new(), + self.templates.clone(), edit_format, ); @@ -266,6 +311,13 @@ impl AgentTool for EditFileTool { let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); + let _finalize_diff = util::defer({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let old_text = cx @@ -304,7 +356,7 @@ impl AgentTool for EditFileTool { }).ok(); if let Some(abs_path) = abs_path.clone() { event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![ToolCallLocation { path: abs_path, line }]), + locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]), ..Default::default() }); } @@ -382,8 +434,6 @@ impl AgentTool for EditFileTool { }) .await; - diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); - let input_path = input.path.display(); if unified_diff.is_empty() { anyhow::ensure!( @@ -413,14 +463,32 @@ impl AgentTool for EditFileTool { Ok(EditFileToolOutput { input_path: input.path, - project_path: project_path.path.to_path_buf(), - new_text: new_text.clone(), + new_text, old_text, diff: unified_diff, edit_agent_output, }) }) } + + fn replay( + &self, + _input: Self::Input, + output: Self::Output, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()> { + event_stream.update_diff(cx.new(|cx| { + Diff::finalized( + output.input_path.to_string_lossy().into_owned(), + Some(output.old_text.to_string()), + output.new_text, + self.language_registry.clone(), + cx, + ) + })); + Ok(()) + } } /// Validate that the file path is valid, meaning: @@ -465,7 +533,7 @@ fn resolve_path( let parent_entry = parent_project_path .as_ref() - .and_then(|path| project.entry_for_path(&path, cx)) + .and_then(|path| project.entry_for_path(path, cx)) .context("Can't create file: parent directory doesn't exist")?; anyhow::ensure!( @@ -476,10 +544,12 @@ fn resolve_path( let file_name = input .path .file_name() + .and_then(|file_name| file_name.to_str()) + .and_then(|file_name| RelPath::unix(file_name).ok()) .context("Can't create file: invalid filename")?; let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: Arc::from(parent.path.join(file_name)), + path: parent.path.join(file_name), ..parent }); @@ -492,15 +562,14 @@ fn resolve_path( mod tests { use super::*; use crate::{ContextServerRegistry, Templates}; - use action_log::ActionLog; use client::TelemetrySettings; use fs::Fs; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; + use prompt_store::ProjectContext; use serde_json::json; use settings::SettingsStore; - use std::rc::Rc; - use util::path; + use util::{path, rel_path::rel_path}; #[gpui::test] async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { @@ -509,18 +578,17 @@ mod tests { let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( - project, - Rc::default(), + project.clone(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log, Templates::new(), - model, + Some(model), cx, ) }); @@ -531,7 +599,13 @@ mod tests { path: "root/nonexistent_file.txt".into(), mode: EditFileMode::Edit, }; - Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + Arc::new(EditFileTool::new( + project, + thread.downgrade(), + language_registry, + Templates::new(), + )) + .run(input, ToolCallEventStream::test().0, cx) }) .await; assert_eq!( @@ -545,13 +619,13 @@ mod tests { let mode = &EditFileMode::Create; let result = test_resolve_path(mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); + assert_resolved_path_eq(result.await, rel_path("new.txt")); let result = test_resolve_path(mode, "new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); + assert_resolved_path_eq(result.await, rel_path("new.txt")); let result = test_resolve_path(mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, "dir/new.txt"); + assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); assert_eq!( @@ -573,10 +647,10 @@ mod tests { let path_with_root = "root/dir/subdir/existing.txt"; let path_without_root = "dir/subdir/existing.txt"; let result = test_resolve_path(mode, path_with_root, cx); - assert_resolved_path_eq(result.await, path_without_root); + assert_resolved_path_eq(result.await, rel_path(path_without_root)); let result = test_resolve_path(mode, path_without_root, cx); - assert_resolved_path_eq(result.await, path_without_root); + assert_resolved_path_eq(result.await, rel_path(path_without_root)); let result = test_resolve_path(mode, "root/nonexistent.txt", cx); assert_eq!( @@ -618,18 +692,13 @@ mod tests { mode: mode.clone(), }; - let result = cx.update(|cx| resolve_path(&input, project, cx)); - result + cx.update(|cx| resolve_path(&input, project, cx)) } - fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { - let actual = path - .expect("Should return valid path") - .path - .to_str() - .unwrap() - .replace("\\", "/"); // Naive Windows paths normalization - assert_eq!(actual, expected); + #[track_caller] + fn assert_resolved_path_eq(path: anyhow::Result, expected: &RelPath) { + let actual = path.expect("Should return valid path").path; + assert_eq!(actual.as_ref(), expected); } #[gpui::test] @@ -706,18 +775,16 @@ mod tests { } }); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( - project, - Rc::default(), + project.clone(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -725,14 +792,11 @@ mod tests { // First, test with format_on_save enabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::On); - settings.defaults.formatter = - Some(language::language_settings::SelectedFormatter::Auto); - }, - ); + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); + settings.project.all_languages.defaults.formatter = + Some(language::language_settings::FormatterList::default()); + }); }); }); @@ -744,9 +808,12 @@ mod tests { path: "root/src/main.rs".into(), mode: EditFileMode::Overwrite, }; - Arc::new(EditFileTool { - thread: thread.clone(), - }) + Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry.clone(), + Templates::new(), + )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -771,7 +838,9 @@ mod tests { "Code should be formatted when format_on_save is enabled" ); - let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); + let stale_buffer_count = thread + .read_with(cx, |thread, _cx| thread.action_log.clone()) + .read_with(cx, |log, cx| log.stale_buffers(cx).count()); assert_eq!( stale_buffer_count, 0, @@ -783,12 +852,10 @@ mod tests { // Next, test with format_on_save disabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::Off); - }, - ); + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = + Some(FormatOnSave::Off); + }); }); }); @@ -800,7 +867,13 @@ mod tests { path: "root/src/main.rs".into(), mode: EditFileMode::Overwrite, }; - Arc::new(EditFileTool { thread }).run(input, ToolCallEventStream::test().0, cx) + Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )) + .run(input, ToolCallEventStream::test().0, cx) }); // Stream the unformatted content @@ -844,16 +917,15 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( - project, - Rc::default(), + project.clone(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -861,12 +933,13 @@ mod tests { // First, test with remove_trailing_whitespace_on_save enabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(true); - }, - ); + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(true); + }); }); }); @@ -881,9 +954,12 @@ mod tests { path: "root/src/main.rs".into(), mode: EditFileMode::Overwrite, }; - Arc::new(EditFileTool { - thread: thread.clone(), - }) + Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry.clone(), + Templates::new(), + )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -915,12 +991,13 @@ mod tests { // Next, test with remove_trailing_whitespace_on_save disabled cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(false); - }, - ); + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(false); + }); }); }); @@ -932,9 +1009,12 @@ mod tests { path: "root/src/main.rs".into(), mode: EditFileMode::Overwrite, }; - Arc::new(EditFileTool { - thread: thread.clone(), - }) + Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )) .run(input, ToolCallEventStream::test().0, cx) }); @@ -970,20 +1050,24 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( - project, - Rc::default(), + project.clone(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )); fs.insert_tree("/root", json!({})).await; // Test 1: Path with .zed component should require confirmation @@ -1105,22 +1189,26 @@ mod tests { let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( - project, - Rc::default(), + project.clone(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )); // Test global config paths - these should require confirmation if they exist and are outside the project let test_cases = vec![ @@ -1214,23 +1302,26 @@ mod tests { cx, ) .await; - - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project.clone(), - Rc::default(), + cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )); // Test files in different worktrees let test_cases = vec![ @@ -1296,22 +1387,26 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project.clone(), - Rc::default(), + cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )); // Test edge cases let test_cases = vec![ @@ -1322,8 +1417,8 @@ mod tests { // Parent directory references - find_project_path resolves these ( "project/../other", - false, - "Path with .. is resolved by find_project_path", + true, + "Path with .. that goes outside of root directory", ), ( "project/./src/file.rs", @@ -1351,16 +1446,18 @@ mod tests { ) }); + cx.run_until_parked(); + if should_confirm { stream_rx.expect_authorization().await; } else { - auth.await.unwrap(); assert!( stream_rx.try_next().is_err(), "Failed for case: {} - path: {} - expected no confirmation but got one", description, path ); + auth.await.unwrap(); } } } @@ -1380,22 +1477,26 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project.clone(), - Rc::default(), + cx.new(|_cx| ProjectContext::default()), context_server_registry.clone(), - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + Templates::new(), + )); // Test different EditFileMode values let modes = vec![ @@ -1461,63 +1562,191 @@ mod tests { init_test(cx); let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let model = Arc::new(FakeLanguageModel::default()); let thread = cx.new(|cx| { Thread::new( project.clone(), - Rc::default(), + cx.new(|_cx| ProjectContext::default()), context_server_registry, - action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); - let tool = Arc::new(EditFileTool { thread }); + let tool = Arc::new(EditFileTool::new( + project, + thread.downgrade(), + language_registry, + Templates::new(), + )); - assert_eq!( - tool.initial_title(Err(json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }))), - "src/main.rs" - ); - assert_eq!( - tool.initial_title(Err(json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }))), - "Fix error handling" - ); - assert_eq!( - tool.initial_title(Err(json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }))), - "Fix error handling" - ); - assert_eq!( - tool.initial_title(Err(json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }))), - DEFAULT_UI_TEXT - ); - assert_eq!( - tool.initial_title(Err(serde_json::Value::Null)), - DEFAULT_UI_TEXT - ); + cx.update(|cx| { + // ... + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "src/main.rs", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + })), + cx + ), + "src/main.rs" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + })), + cx + ), + "Fix error handling" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "src/main.rs", + "display_description": "Fix error handling", + "old_string": "old code", + "new_string": "new code" + })), + cx + ), + "src/main.rs" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "", + "display_description": "", + "old_string": "old code", + "new_string": "new code" + })), + cx + ), + DEFAULT_UI_TEXT + ); + assert_eq!( + tool.initial_title(Err(serde_json::Value::Null), cx), + DEFAULT_UI_TEXT + ); + }); + } + + #[gpui::test] + async fn test_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let languages = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Ensure the diff is finalized after the edit completes. + { + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages.clone(), + Templates::new(), + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + model.end_last_completion_stream(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if an error occurs while editing. + { + model.forbid_requests(); + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages.clone(), + Templates::new(), + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + edit.await.unwrap_err(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + model.allow_requests(); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + languages.clone(), + Templates::new(), + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } } fn init_test(cx: &mut TestAppContext) { diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs similarity index 90% rename from crates/agent2/src/tools/fetch_tool.rs rename to crates/agent/src/tools/fetch_tool.rs index ae26c5fe195da3d73a8ae1da47d072a3bfc3706f..60654ac863acdc559aeaad90f1c73727f33d1b59 100644 --- a/crates/agent2/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -118,15 +118,19 @@ impl AgentTool for FetchTool { type Input = FetchToolInput; type Output = String; - fn name(&self) -> SharedString { - "fetch".into() + fn name() -> &'static str { + "fetch" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Fetch } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { match input { Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)).into(), Err(_) => "Fetch URL".into(), @@ -136,12 +140,17 @@ impl AgentTool for FetchTool { fn run( self: Arc, input: Self::Input, - _event_stream: ToolCallEventStream, + event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { + let authorize = event_stream.authorize(input.url.clone(), cx); + let text = cx.background_spawn({ let http_client = self.http_client.clone(); - async move { Self::build_message(http_client, &input.url).await } + async move { + authorize.await?; + Self::build_message(http_client, &input.url).await + } }); cx.foreground_executor().spawn(async move { diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs similarity index 84% rename from crates/agent2/src/tools/find_path_tool.rs rename to crates/agent/src/tools/find_path_tool.rs index 552de144a73365d10d4b9a565d852c1a13672be8..59f203cec98a17fda9e46f6fc222f3157d125060 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -31,7 +31,6 @@ pub struct FindPathToolInput { /// You can get back the first two paths by providing a glob of "*thing*.txt" /// pub glob: String, - /// Optional starting position for paginated results (0-based). /// When not provided, starts from the beginning. #[serde(default)] @@ -86,15 +85,19 @@ impl AgentTool for FindPathTool { type Input = FindPathToolInput; type Output = FindPathToolOutput; - fn name(&self) -> SharedString { - "find_path".into() + fn name() -> &'static str { + "find_path" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Search } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { let mut title = "Find paths".to_string(); if let Ok(input) = input { title.push_str(&format!(" matching “`{}`”", input.glob)); @@ -116,7 +119,7 @@ impl AgentTool for FindPathTool { ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(if paginated_matches.len() == 0 { + title: Some(if paginated_matches.is_empty() { "No matches".into() } else if paginated_matches.len() == 1 { "1 match".into() @@ -135,6 +138,7 @@ impl AgentTool for FindPathTool { mime_type: None, size: None, title: None, + meta: None, }), }) .collect(), @@ -152,10 +156,14 @@ impl AgentTool for FindPathTool { } fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task>> { - let path_matcher = match PathMatcher::new([ - // Sometimes models try to search for "". In this case, return all paths in the project. - if glob.is_empty() { "*" } else { glob }, - ]) { + let path_style = project.read(cx).path_style(cx); + let path_matcher = match PathMatcher::new( + [ + // Sometimes models try to search for "". In this case, return all paths in the project. + if glob.is_empty() { "*" } else { glob }, + ], + path_style, + ) { Ok(matcher) => matcher, Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), }; @@ -166,16 +174,16 @@ fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task SharedString { - "grep".into() + fn name() -> &'static str { + "grep" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Search } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { match input { Ok(input) => { let page = input.page(); @@ -107,12 +110,15 @@ impl AgentTool for GrepTool { const CONTEXT_LINES: u32 = 2; const MAX_ANCESTOR_LINES: u32 = 10; + let path_style = self.project.read(cx).path_style(cx); + let include_matcher = match PathMatcher::new( input .include_pattern .as_ref() .into_iter() .collect::>(), + path_style, ) { Ok(matcher) => matcher, Err(error) => { @@ -129,7 +135,7 @@ impl AgentTool for GrepTool { .iter() .chain(global_settings.private_files.sources().iter()); - match PathMatcher::new(exclude_patterns) { + match PathMatcher::new(exclude_patterns, path_style) { Ok(matcher) => matcher, Err(error) => { return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))); @@ -179,15 +185,14 @@ impl AgentTool for GrepTool { // Check if this file should be excluded based on its worktree settings if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| { project.find_project_path(&path, cx) - }) { - if cx.update(|cx| { + }) + && cx.update(|cx| { let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); worktree_settings.is_path_excluded(&project_path.path) || worktree_settings.is_path_private(&project_path.path) }).unwrap_or(false) { continue; } - } while *parse_status.borrow() != ParseStatus::Idle { parse_status.changed().await?; @@ -259,10 +264,8 @@ impl AgentTool for GrepTool { let end_row = range.end.row; output.push_str("\n### "); - if let Some(parent_symbols) = &parent_symbols { - for symbol in parent_symbols { - write!(output, "{} › ", symbol.text)?; - } + for symbol in parent_symbols { + write!(output, "{} › ", symbol.text)?; } if range.start.row == end_row { @@ -275,12 +278,11 @@ impl AgentTool for GrepTool { output.extend(snapshot.text_for_range(range)); output.push_str("\n```\n"); - if let Some(ancestor_range) = ancestor_range { - if end_row < ancestor_range.end.row { + if let Some(ancestor_range) = ancestor_range + && end_row < ancestor_range.end.row { let remaining_lines = ancestor_range.end.row - end_row; writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?; } - } matches_found += 1; } @@ -309,7 +311,7 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; use language::{Language, LanguageConfig, LanguageMatcher}; - use project::{FakeFs, Project, WorktreeSettings}; + use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use unindent::Unindent; @@ -320,7 +322,7 @@ mod tests { init_test(cx); cx.executor().allow_parking(); - let fs = FakeFs::new(cx.executor().clone()); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), serde_json::json!({ @@ -405,7 +407,7 @@ mod tests { init_test(cx); cx.executor().allow_parking(); - let fs = FakeFs::new(cx.executor().clone()); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), serde_json::json!({ @@ -480,7 +482,7 @@ mod tests { init_test(cx); cx.executor().allow_parking(); - let fs = FakeFs::new(cx.executor().clone()); + let fs = FakeFs::new(cx.executor()); // Create test file with syntax structures fs.insert_tree( @@ -765,7 +767,7 @@ mod tests { if cfg!(windows) { result.replace("root\\", "root/") } else { - result.to_string() + result } } Err(e) => panic!("Failed to run grep tool: {}", e), @@ -828,19 +830,21 @@ mod tests { cx.update(|cx| { use gpui::UpdateGlobal; - use project::WorktreeSettings; use settings::SettingsStore; SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec![ "**/.secretdir".to_string(), "**/.mymetadata".to_string(), ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); + settings.project.worktree.private_files = Some( + vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ] + .into(), + ); }); }); }); @@ -1063,10 +1067,11 @@ mod tests { // Set global settings cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); + settings.project.worktree.private_files = + Some(vec!["**/.env".to_string()].into()); }); }); }); diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs similarity index 91% rename from crates/agent2/src/tools/list_directory_tool.rs rename to crates/agent/src/tools/list_directory_tool.rs index 61f21d8f95117f0b0a8efccf7481874037af365c..cd8b46ddebc2d9ffb953f8aabef10c30a33dde37 100644 --- a/crates/agent2/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -2,22 +2,20 @@ use crate::{AgentTool, ToolCallEventStream}; use agent_client_protocol::ToolKind; use anyhow::{Result, anyhow}; use gpui::{App, Entity, SharedString, Task}; -use project::{Project, WorktreeSettings}; +use project::{Project, ProjectPath, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::fmt::Write; -use std::{path::Path, sync::Arc}; +use std::sync::Arc; use util::markdown::MarkdownInlineCode; -/// Lists files and directories in a given path. Prefer the `grep` or -/// `find_path` tools when searching the codebase. +/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ListDirectoryToolInput { /// The fully-qualified path of the directory to list in the project. /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project. /// /// /// If the project has the following root directories: @@ -53,15 +51,19 @@ impl AgentTool for ListDirectoryTool { type Input = ListDirectoryToolInput; type Output = String; - fn name(&self) -> SharedString { - "list_directory".into() + fn name() -> &'static str { + "list_directory" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Read } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { let path = MarkdownInlineCode(&input.path); format!("List the {path} directory's contents").into() @@ -84,13 +86,13 @@ impl AgentTool for ListDirectoryTool { .read(cx) .worktrees(cx) .filter_map(|worktree| { - worktree.read(cx).root_entry().and_then(|entry| { - if entry.is_dir() { - entry.path.to_str() - } else { - None - } - }) + let worktree = worktree.read(cx); + let root_entry = worktree.root_entry()?; + if root_entry.is_dir() { + Some(root_entry.path.display(worktree.path_style())) + } else { + None + } }) .collect::>() .join("\n"); @@ -141,7 +143,7 @@ impl AgentTool for ListDirectoryTool { } let worktree_snapshot = worktree.read(cx).snapshot(); - let worktree_root_name = worktree.read(cx).root_name().to_string(); + let worktree_root_name = worktree.read(cx).root_name(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { return Task::ready(Err(anyhow!("Path not found: {}", input.path))); @@ -163,25 +165,17 @@ impl AgentTool for ListDirectoryTool { continue; } - if self - .project - .read(cx) - .find_project_path(&entry.path, cx) - .map(|project_path| { - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - - worktree_settings.is_path_excluded(&project_path.path) - || worktree_settings.is_path_private(&project_path.path) - }) - .unwrap_or(false) + let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into(); + if worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) { continue; } - let full_path = Path::new(&worktree_root_name) + let full_path = worktree_root_name .join(&entry.path) - .display() - .to_string(); + .display(worktree_snapshot.path_style()) + .into_owned(); if entry.is_dir() { folders.push(full_path); } else { @@ -212,7 +206,7 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; use indoc::indoc; - use project::{FakeFs, Project, WorktreeSettings}; + use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use util::path; @@ -419,17 +413,20 @@ mod tests { // Configure settings explicitly cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec![ "**/.secretdir".to_string(), "**/.mymetadata".to_string(), "**/.hidden_subdir".to_string(), ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); + settings.project.worktree.private_files = Some( + vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ] + .into(), + ); }); }); }); @@ -563,10 +560,11 @@ mod tests { // Set global settings cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); + settings.project.worktree.private_files = + Some(vec!["**/.env".to_string()].into()); }); }); }); diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs similarity index 89% rename from crates/agent2/src/tools/move_path_tool.rs rename to crates/agent/src/tools/move_path_tool.rs index f8d5d0d176e5cd53d1f563385797596048c9a87e..ae58145126f6356beaa1457d719812bb56d6e7db 100644 --- a/crates/agent2/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -8,14 +8,11 @@ use serde::{Deserialize, Serialize}; use std::{path::Path, sync::Arc}; use util::markdown::MarkdownInlineCode; -/// Moves or rename a file or directory in the project, and returns confirmation -/// that the move succeeded. +/// Moves or rename a file or directory in the project, and returns confirmation that the move succeeded. /// -/// If the source and destination directories are the same, but the filename is -/// different, this performs a rename. Otherwise, it performs a move. +/// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move. /// -/// This tool should be used when it's desirable to move or rename a file or -/// directory without changing its contents at all. +/// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct MovePathToolInput { /// The source path of the file or directory to move/rename. @@ -55,15 +52,19 @@ impl AgentTool for MovePathTool { type Input = MovePathToolInput; type Output = String; - fn name(&self) -> SharedString { - "move_path".into() + fn name() -> &'static str { + "move_path" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Move } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { let src = MarkdownInlineCode(&input.source_path); let dest = MarkdownInlineCode(&input.destination_path); @@ -97,7 +98,7 @@ impl AgentTool for MovePathTool { .and_then(|project_path| project.entry_for_path(&project_path, cx)) { Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => project.rename_entry(entity.id, project_path.path, cx), + Some(project_path) => project.rename_entry(entity.id, project_path, cx), None => Task::ready(Err(anyhow!( "Destination path {} was outside the project.", input.destination_path diff --git a/crates/agent2/src/tools/now_tool.rs b/crates/agent/src/tools/now_tool.rs similarity index 85% rename from crates/agent2/src/tools/now_tool.rs rename to crates/agent/src/tools/now_tool.rs index a72ede26fea1ee42eddb08e2b22b2b2b89c77075..3387c0a617017991f8b2590868864287f399ec28 100644 --- a/crates/agent2/src/tools/now_tool.rs +++ b/crates/agent/src/tools/now_tool.rs @@ -11,6 +11,7 @@ use crate::{AgentTool, ToolCallEventStream}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] +#[schemars(inline)] pub enum Timezone { /// Use UTC for the datetime. Utc, @@ -32,15 +33,19 @@ impl AgentTool for NowTool { type Input = NowToolInput; type Output = String; - fn name(&self) -> SharedString { - "now".into() + fn name() -> &'static str { + "now" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Other } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "Get current time".into() } diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent/src/tools/open_tool.rs similarity index 90% rename from crates/agent2/src/tools/open_tool.rs rename to crates/agent/src/tools/open_tool.rs index 36420560c1832d40496a95c69505ab8eb9cbb2c6..b98ae9af3bd98cd44bc9348e72519ceea53c6292 100644 --- a/crates/agent2/src/tools/open_tool.rs +++ b/crates/agent/src/tools/open_tool.rs @@ -8,19 +8,15 @@ use serde::{Deserialize, Serialize}; use std::{path::PathBuf, sync::Arc}; use util::markdown::MarkdownEscaped; -/// This tool opens a file or URL with the default application associated with -/// it on the user's operating system: +/// This tool opens a file or URL with the default application associated with it on the user's operating system: /// /// - On macOS, it's equivalent to the `open` command /// - On Windows, it's equivalent to `start` /// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate /// -/// For example, it can open a web browser with a URL, open a PDF file with the -/// default PDF viewer, etc. +/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc. /// -/// You MUST ONLY use this tool when the user has explicitly requested opening -/// something. You MUST NEVER assume that the user would like for you to use -/// this tool. +/// You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that the user would like for you to use this tool. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct OpenToolInput { /// The path or URL to open with the default application. @@ -41,15 +37,19 @@ impl AgentTool for OpenTool { type Input = OpenToolInput; type Output = String; - fn name(&self) -> SharedString { - "open".into() + fn name() -> &'static str { + "open" } - fn kind(&self) -> ToolKind { + fn kind() -> ToolKind { ToolKind::Execute } - fn initial_title(&self, input: Result) -> SharedString { + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { if let Ok(input) = input { format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into() } else { @@ -65,7 +65,7 @@ impl AgentTool for OpenTool { ) -> Task> { // If path_or_url turns out to be a path in the project, make it absolute. let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx); - let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx); + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx); cx.background_spawn(async move { authorize.await?; @@ -104,7 +104,7 @@ mod tests { async fn test_to_absolute_path(cx: &mut TestAppContext) { init_test(cx); let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let temp_path = temp_dir.path().to_string_lossy().to_string(); + let temp_path = temp_dir.path().to_string_lossy().into_owned(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs similarity index 88% rename from crates/agent2/src/tools/read_file_tool.rs rename to crates/agent/src/tools/read_file_tool.rs index f21643cbbbffca7a489918c3466ccc369a17156c..f3ce8e35f2856a3dd53770eef48ec1091fe9b116 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,7 +1,6 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::outline; use gpui::{App, Entity, SharedString, Task}; use indoc::formatdoc; use language::Point; @@ -11,8 +10,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; +use util::markdown::MarkdownCodeBlock; -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, outline}; /// Reads the content of the given file in the project. /// @@ -21,8 +21,7 @@ use crate::{AgentTool, ToolCallEventStream}; pub struct ReadFileToolInput { /// The relative path of the file to read. /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. + /// This path should never be absolute, and the first component of the path should always be a root directory in a project. /// /// /// If the project has the following root directories: @@ -34,11 +33,9 @@ pub struct ReadFileToolInput { /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. /// pub path: String, - /// Optional line number to start reading on (1-based index) #[serde(default)] pub start_line: Option, - /// Optional line number to end reading on (1-based index, inclusive) #[serde(default)] pub end_line: Option, @@ -62,31 +59,34 @@ impl AgentTool for ReadFileTool { type Input = ReadFileToolInput; type Output = LanguageModelToolResultContent; - fn name(&self) -> SharedString { - "read_file".into() + fn name() -> &'static str { + "read_file" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Read } - fn initial_title(&self, input: Result) -> SharedString { - if let Ok(input) = input { - let path = &input.path; + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString { + if let Ok(input) = input + && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) + && let Some(path) = self + .project + .read(cx) + .short_full_path_for_project_path(&project_path, cx) + { match (input.start_line, input.end_line) { (Some(start), Some(end)) => { - format!( - "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", - path, start, end, path, start, end - ) + format!("Read file `{path}` (lines {}-{})", start, end,) } (Some(start), None) => { - format!( - "[Read file `{}` (from line {})](@selection:{}:({}-{}))", - path, start, path, start, start - ) + format!("Read file `{path}` (from line {})", start) } - _ => format!("[Read file `{}`](@file:{})", path, path), + _ => format!("Read file `{path}`"), } .into() } else { @@ -103,6 +103,12 @@ impl AgentTool for ReadFileTool { let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else { return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))); }; + let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else { + return Task::ready(Err(anyhow!( + "Failed to convert {} to absolute path", + &input.path + ))); + }; // Error out if this path is either excluded or private in global settings let global_settings = WorktreeSettings::get_global(cx); @@ -138,6 +144,15 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); + event_stream.update_fields(ToolCallUpdateFields { + locations: Some(vec![acp::ToolCallLocation { + path: abs_path.clone(), + line: input.start_line.map(|line| line.saturating_sub(1)), + meta: None, + }]), + ..Default::default() + }); + if image_store::is_image_file(&self.project, &project_path, cx) { return cx.spawn(async move |cx| { let image_entity: Entity = cx @@ -175,7 +190,7 @@ impl AgentTool for ReadFileTool { buffer .file() .as_ref() - .map_or(true, |file| !file.disk_state().exists()) + .is_none_or(|file| !file.disk_state().exists()) })? { anyhow::bail!("{file_path} not found"); } @@ -185,7 +200,6 @@ impl AgentTool for ReadFileTool { // Check if specific line ranges are provided let result = if input.start_line.is_some() || input.end_line.is_some() { let result = buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. let start = input.start_line.unwrap_or(1).max(1); let start_row = start - 1; @@ -194,13 +208,13 @@ impl AgentTool for ReadFileTool { anchor = Some(buffer.anchor_before(Point::new(start_row, column))); } - let lines = text.split('\n').skip(start_row as usize); - if let Some(end) = input.end_line { - let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line - itertools::intersperse(lines.take(count as usize), "\n").collect::() - } else { - itertools::intersperse(lines, "\n").collect::() + let mut end_row = input.end_line.unwrap_or(u32::MAX); + if end_row <= start_row { + end_row = start_row + 1; // read at least one lines } + let start = buffer.anchor_before(Point::new(start_row, 0)); + let end = buffer.anchor_before(Point::new(end_row, 0)); + buffer.text_for_range(start..end).collect::() })?; action_log.update(cx, |log, cx| { @@ -210,57 +224,56 @@ impl AgentTool for ReadFileTool { Ok(result.into()) } else { // No line ranges specified, so check file size to see if it's too big. - let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; - - if file_size <= outline::AUTO_OUTLINE_SIZE { - // File is small enough, so return its contents. - let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&abs_path.to_string_lossy()), + cx, + ) + .await?; - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - })?; + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + })?; - Ok(result.into()) - } else { - // File is too big, so return the outline - // and a suggestion to read again with line numbers. - let outline = - outline::file_outline(project.clone(), file_path, action_log, None, cx) - .await?; + if buffer_content.is_outline { Ok(formatdoc! {" This file was too big to read all at once. - Here is an outline of its symbols: - - {outline} + {} Using the line numbers in this outline, you can call this tool again while specifying the start_line and end_line fields to see the implementations of symbols in the outline. Alternatively, you can fall back to the `grep` tool (if available) - to search the file for specific content." + to search the file for specific content.", buffer_content.text } .into()) + } else { + Ok(buffer_content.text.into()) } }; project.update(cx, |project, cx| { - if let Some(abs_path) = project.absolute_path(&project_path, cx) { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: anchor.unwrap_or(text::Anchor::MIN), - }), - cx, - ); + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: anchor.unwrap_or(text::Anchor::MIN), + }), + cx, + ); + if let Ok(LanguageModelToolResultContent::Text(text)) = &result { + let markdown = MarkdownCodeBlock { + tag: &input.path, + text, + } + .to_string(); event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path, - line: input.start_line.map(|line| line.saturating_sub(1)), + content: Some(vec![acp::ToolCallContent::Content { + content: markdown.into(), }]), ..Default::default() - }); + }) } })?; @@ -433,7 +446,7 @@ mod test { tool.run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into()); + assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into()); } #[gpui::test] @@ -463,7 +476,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1\nLine 2".into()); + assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into()); // end_line of 0 should result in at least 1 line let result = cx @@ -476,7 +489,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 1".into()); + assert_eq!(result.unwrap(), "Line 1\n".into()); // when start_line > end_line, should still return at least 1 line let result = cx @@ -489,7 +502,7 @@ mod test { tool.clone().run(input, ToolCallEventStream::test().0, cx) }) .await; - assert_eq!(result.unwrap(), "Line 3".into()); + assert_eq!(result.unwrap(), "Line 3\n".into()); } fn init_test(cx: &mut TestAppContext) { @@ -575,19 +588,21 @@ mod test { cx.update(|cx| { use gpui::UpdateGlobal; - use project::WorktreeSettings; use settings::SettingsStore; SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec![ "**/.secretdir".to_string(), "**/.mymetadata".to_string(), ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); + settings.project.worktree.private_files = Some( + vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ] + .into(), + ); }); }); }); @@ -791,10 +806,11 @@ mod test { // Set global settings cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); + settings.project.worktree.private_files = + Some(vec!["**/.env".to_string()].into()); }); }); }); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..6d30c19152001deaef5deeacbdf266e28ac03d08 --- /dev/null +++ b/crates/agent/src/tools/terminal_tool.rs @@ -0,0 +1,213 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use gpui::{App, Entity, SharedString, Task}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; +use util::markdown::MarkdownInlineCode; + +use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream}; + +const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; + +/// Executes a shell one-liner and returns the combined output. +/// +/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. +/// +/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant. +/// +/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. +/// +/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. +/// +/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct TerminalToolInput { + /// The one-liner command to execute. + command: String, + /// Working directory for the command. This must be one of the root directories of the project. + cd: String, +} + +pub struct TerminalTool { + project: Entity, + environment: Rc, +} + +impl TerminalTool { + pub fn new(project: Entity, environment: Rc) -> Self { + Self { + project, + environment, + } + } +} + +impl AgentTool for TerminalTool { + type Input = TerminalToolInput; + type Output = String; + + fn name() -> &'static str { + "terminal" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Execute + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + let mut lines = input.command.lines(); + let first_line = lines.next().unwrap_or_default(); + let remaining_line_count = lines.count(); + match remaining_line_count { + 0 => MarkdownInlineCode(first_line).to_string().into(), + 1 => MarkdownInlineCode(&format!( + "{} - {} more line", + first_line, remaining_line_count + )) + .to_string() + .into(), + n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n)) + .to_string() + .into(), + } + } else { + "".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let working_dir = match working_dir(&input, &self.project, cx) { + Ok(dir) => dir, + Err(err) => return Task::ready(Err(err)), + }; + + let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx); + cx.spawn(async move |cx| { + authorize.await?; + + let terminal = self + .environment + .create_terminal( + input.command.clone(), + working_dir, + Some(COMMAND_OUTPUT_LIMIT), + cx, + ) + .await?; + + let terminal_id = terminal.id(cx)?; + event_stream.update_fields(acp::ToolCallUpdateFields { + content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]), + ..Default::default() + }); + + let exit_status = terminal.wait_for_exit(cx)?.await; + let output = terminal.current_output(cx)?; + + Ok(process_content(output, &input.command, exit_status)) + }) + } +} + +fn process_content( + output: acp::TerminalOutputResponse, + command: &str, + exit_status: acp::TerminalExitStatus, +) -> String { + let content = output.output.trim(); + let is_empty = content.is_empty(); + + let content = format!("```\n{content}\n```"); + let content = if output.truncated { + format!( + "Command output too long. The first {} bytes:\n\n{content}", + content.len(), + ) + } else { + content + }; + + let content = match exit_status.exit_code { + Some(0) => { + if is_empty { + "Command executed successfully.".to_string() + } else { + content + } + } + Some(exit_code) => { + if is_empty { + format!("Command \"{command}\" failed with exit code {}.", exit_code) + } else { + format!( + "Command \"{command}\" failed with exit code {}.\n\n{content}", + exit_code + ) + } + } + None => { + format!( + "Command failed or was interrupted.\nPartial output captured:\n\n{}", + content, + ) + } + }; + content +} + +fn working_dir( + input: &TerminalToolInput, + project: &Entity, + cx: &mut App, +) -> Result> { + let project = project.read(cx); + let cd = &input.cd; + + if cd == "." || cd.is_empty() { + // Accept "." or "" as meaning "the one worktree" if we only have one worktree. + let mut worktrees = project.worktrees(cx); + + match worktrees.next() { + Some(worktree) => { + anyhow::ensure!( + worktrees.next().is_none(), + "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.", + ); + Ok(Some(worktree.read(cx).abs_path().to_path_buf())) + } + None => Ok(None), + } + } else { + let input_path = Path::new(cd); + + if input_path.is_absolute() { + // Absolute paths are allowed, but only if they're in one of the project's worktrees. + if project + .worktrees(cx) + .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path())) + { + return Ok(Some(input_path.into())); + } + } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) { + return Ok(Some(worktree.read(cx).abs_path().to_path_buf())); + } + + anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees."); + } +} diff --git a/crates/agent2/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs similarity index 82% rename from crates/agent2/src/tools/thinking_tool.rs rename to crates/agent/src/tools/thinking_tool.rs index 43647bb468d808b978a1b5176539a3167c5065f6..0a68f7545f81ce3202c110b1435d33b57adf409c 100644 --- a/crates/agent2/src/tools/thinking_tool.rs +++ b/crates/agent/src/tools/thinking_tool.rs @@ -11,8 +11,7 @@ use crate::{AgentTool, ToolCallEventStream}; /// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ThinkingToolInput { - /// Content to think about. This should be a description of what to think about or - /// a problem to solve. + /// Content to think about. This should be a description of what to think about or a problem to solve. content: String, } @@ -22,15 +21,19 @@ impl AgentTool for ThinkingTool { type Input = ThinkingToolInput; type Output = String; - fn name(&self) -> SharedString { - "thinking".into() + fn name() -> &'static str { + "thinking" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Think } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "Thinking".into() } diff --git a/crates/agent2/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs similarity index 58% rename from crates/agent2/src/tools/web_search_tool.rs rename to crates/agent/src/tools/web_search_tool.rs index c1c09707426431bf8a3ad4c59a012a567366d392..03e9db6601579e082e4d83de50f1999209d9f197 100644 --- a/crates/agent2/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -14,7 +14,7 @@ use ui::prelude::*; use web_search::WebSearchRegistry; /// Search the web for information using your query. -/// Use this when you need real-time information, facts, or data that might not be in your training. \ +/// Use this when you need real-time information, facts, or data that might not be in your training. /// Results will include snippets and links from relevant web pages. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct WebSearchToolInput { @@ -40,20 +40,24 @@ impl AgentTool for WebSearchTool { type Input = WebSearchToolInput; type Output = WebSearchToolOutput; - fn name(&self) -> SharedString { - "web_search".into() + fn name() -> &'static str { + "web_search" } - fn kind(&self) -> acp::ToolKind { + fn kind() -> acp::ToolKind { acp::ToolKind::Fetch } - fn initial_title(&self, _input: Result) -> SharedString { + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { "Searching the Web".into() } /// We currently only support Zed Cloud as a provider. - fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool { + fn supports_provider(provider: &LanguageModelProviderId) -> bool { provider == &ZED_CLOUD_PROVIDER_ID } @@ -80,33 +84,49 @@ impl AgentTool for WebSearchTool { } }; - let result_text = if response.results.len() == 1 { - "1 result".to_string() - } else { - format!("{} results", response.results.len()) - }; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(format!("Searched the web: {result_text}")), - content: Some( - response - .results - .iter() - .map(|result| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: result.title.clone(), - uri: result.url.clone(), - title: Some(result.title.clone()), - description: Some(result.text.clone()), - mime_type: None, - annotations: None, - size: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + emit_update(&response, &event_stream); Ok(WebSearchToolOutput(response)) }) } + + fn replay( + &self, + _input: Self::Input, + output: Self::Output, + event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> Result<()> { + emit_update(&output.0, &event_stream); + Ok(()) + } +} + +fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) { + let result_text = if response.results.len() == 1 { + "1 result".to_string() + } else { + format!("{} results", response.results.len()) + }; + event_stream.update_fields(acp::ToolCallUpdateFields { + title: Some(format!("Searched the web: {result_text}")), + content: Some( + response + .results + .iter() + .map(|result| acp::ToolCallContent::Content { + content: acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: result.title.clone(), + uri: result.url.clone(), + title: Some(result.title.clone()), + description: Some(result.text.clone()), + mime_type: None, + annotations: None, + size: None, + meta: None, + }), + }) + .collect(), + ), + ..Default::default() + }); } diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml deleted file mode 100644 index ac1840e5e53f7812a792fa207de3ba24a64e355b..0000000000000000000000000000000000000000 --- a/crates/agent2/Cargo.toml +++ /dev/null @@ -1,84 +0,0 @@ -[package] -name = "agent2" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lib] -path = "src/agent2.rs" - -[lints] -workspace = true - -[dependencies] -acp_thread.workspace = true -action_log.workspace = true -agent-client-protocol.workspace = true -agent_servers.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_tool.workspace = true -assistant_tools.workspace = true -chrono.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -context_server.workspace = true -fs.workspace = true -futures.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -language_models.workspace = true -log.workspace = true -open.workspace = true -paths.workspace = true -portable-pty.workspace = true -project.workspace = true -prompt_store.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smol.workspace = true -task.workspace = true -terminal.workspace = true -text.workspace = true -ui.workspace = true -util.workspace = true -uuid.workspace = true -watch.workspace = true -web_search.workspace = true -which.workspace = true -workspace-hack.workspace = true - -[dev-dependencies] -ctor.workspace = true -client = { workspace = true, "features" = ["test-support"] } -clock = { workspace = true, "features" = ["test-support"] } -context_server = { workspace = true, "features" = ["test-support"] } -editor = { workspace = true, "features" = ["test-support"] } -env_logger.workspace = true -fs = { workspace = true, "features" = ["test-support"] } -gpui = { workspace = true, "features" = ["test-support"] } -gpui_tokio.workspace = true -language = { workspace = true, "features" = ["test-support"] } -language_model = { workspace = true, "features" = ["test-support"] } -lsp = { workspace = true, "features" = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, "features" = ["test-support"] } -reqwest_client.workspace = true -settings = { workspace = true, "features" = ["test-support"] } -tempfile.workspace = true -terminal = { workspace = true, "features" = ["test-support"] } -theme = { workspace = true, "features" = ["test-support"] } -tree-sitter-rust.workspace = true -unindent = { workspace = true } -worktree = { workspace = true, "features" = ["test-support"] } -zlog.workspace = true diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs deleted file mode 100644 index d63e3f81345e690b2ab7ea0e5644b62da740fa20..0000000000000000000000000000000000000000 --- a/crates/agent2/src/agent.rs +++ /dev/null @@ -1,1043 +0,0 @@ -use crate::{ - AgentResponseEvent, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DeletePathTool, - DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, - MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, Thread, - ToolCallAuthorization, UserMessageContent, WebSearchTool, templates::Templates, -}; -use acp_thread::AgentModelSelector; -use agent_client_protocol as acp; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result, anyhow}; -use collections::{HashSet, IndexMap}; -use fs::Fs; -use futures::channel::mpsc; -use futures::{StreamExt, future}; -use gpui::{ - App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, -}; -use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; -use prompt_store::{ - ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, -}; -use settings::update_settings_file; -use std::any::Any; -use std::cell::RefCell; -use std::collections::HashMap; -use std::path::Path; -use std::rc::Rc; -use std::sync::Arc; -use util::ResultExt; - -const RULES_FILE_NAMES: [&'static str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - -pub struct RulesLoadingError { - pub message: SharedString, -} - -/// Holds both the internal Thread and the AcpThread for a session -struct Session { - /// The internal thread that processes messages - thread: Entity, - /// The ACP thread that handles protocol communication - acp_thread: WeakEntity, - _subscription: Subscription, -} - -pub struct LanguageModels { - /// Access language model by ID - models: HashMap>, - /// Cached list for returning language model information - model_list: acp_thread::AgentModelList, - refresh_models_rx: watch::Receiver<()>, - refresh_models_tx: watch::Sender<()>, -} - -impl LanguageModels { - fn new(cx: &App) -> Self { - let (refresh_models_tx, refresh_models_rx) = watch::channel(()); - let mut this = Self { - models: HashMap::default(), - model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()), - refresh_models_rx, - refresh_models_tx, - }; - this.refresh_list(cx); - this - } - - fn refresh_list(&mut self, cx: &App) { - let providers = LanguageModelRegistry::global(cx) - .read(cx) - .providers() - .into_iter() - .filter(|provider| provider.is_authenticated(cx)) - .collect::>(); - - let mut language_model_list = IndexMap::default(); - let mut recommended_models = HashSet::default(); - - let mut recommended = Vec::new(); - for provider in &providers { - for model in provider.recommended_models(cx) { - recommended_models.insert(model.id()); - recommended.push(Self::map_language_model_to_info(&model, &provider)); - } - } - if !recommended.is_empty() { - language_model_list.insert( - acp_thread::AgentModelGroupName("Recommended".into()), - recommended, - ); - } - - let mut models = HashMap::default(); - for provider in providers { - let mut provider_models = Vec::new(); - for model in provider.provided_models(cx) { - let model_info = Self::map_language_model_to_info(&model, &provider); - let model_id = model_info.id.clone(); - if !recommended_models.contains(&model.id()) { - provider_models.push(model_info); - } - models.insert(model_id, model); - } - if !provider_models.is_empty() { - language_model_list.insert( - acp_thread::AgentModelGroupName(provider.name().0.clone()), - provider_models, - ); - } - } - - self.models = models; - self.model_list = acp_thread::AgentModelList::Grouped(language_model_list); - self.refresh_models_tx.send(()).ok(); - } - - fn watch(&self) -> watch::Receiver<()> { - self.refresh_models_rx.clone() - } - - pub fn model_from_id( - &self, - model_id: &acp_thread::AgentModelId, - ) -> Option> { - self.models.get(model_id).cloned() - } - - fn map_language_model_to_info( - model: &Arc, - provider: &Arc, - ) -> acp_thread::AgentModelInfo { - acp_thread::AgentModelInfo { - id: Self::model_id(model), - name: model.name().0, - icon: Some(provider.icon()), - } - } - - fn model_id(model: &Arc) -> acp_thread::AgentModelId { - acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) - } -} - -pub struct NativeAgent { - /// Session ID -> Session mapping - sessions: HashMap, - /// Shared project context for all threads - project_context: Rc>, - project_context_needs_refresh: watch::Sender<()>, - _maintain_project_context: Task>, - context_server_registry: Entity, - /// Shared templates for all threads - templates: Arc, - /// Cached model information - models: LanguageModels, - project: Entity, - prompt_store: Option>, - fs: Arc, - _subscriptions: Vec, -} - -impl NativeAgent { - pub async fn new( - project: Entity, - templates: Arc, - prompt_store: Option>, - fs: Arc, - cx: &mut AsyncApp, - ) -> Result> { - log::info!("Creating new NativeAgent"); - - let project_context = cx - .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? - .await; - - cx.new(|cx| { - let mut subscriptions = vec![ - cx.subscribe(&project, Self::handle_project_event), - cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - ), - ]; - if let Some(prompt_store) = prompt_store.as_ref() { - subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) - } - - let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = - watch::channel(()); - Self { - sessions: HashMap::new(), - project_context: Rc::new(RefCell::new(project_context)), - project_context_needs_refresh: project_context_needs_refresh_tx, - _maintain_project_context: cx.spawn(async move |this, cx| { - Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await - }), - context_server_registry: cx.new(|cx| { - ContextServerRegistry::new(project.read(cx).context_server_store(), cx) - }), - templates, - models: LanguageModels::new(cx), - project, - prompt_store, - fs, - _subscriptions: subscriptions, - } - }) - } - - pub fn models(&self) -> &LanguageModels { - &self.models - } - - async fn maintain_project_context( - this: WeakEntity, - mut needs_refresh: watch::Receiver<()>, - cx: &mut AsyncApp, - ) -> Result<()> { - while needs_refresh.changed().await.is_ok() { - let project_context = this - .update(cx, |this, cx| { - Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) - })? - .await; - this.update(cx, |this, _| this.project_context.replace(project_context))?; - } - - Ok(()) - } - - fn build_project_context( - project: &Entity, - prompt_store: Option<&Entity>, - cx: &mut App, - ) -> Task { - let worktrees = project.read(cx).visible_worktrees(cx).collect::>(); - let worktree_tasks = worktrees - .into_iter() - .map(|worktree| { - Self::load_worktree_info_for_system_prompt(worktree, project.clone(), cx) - }) - .collect::>(); - let default_user_rules_task = if let Some(prompt_store) = prompt_store.as_ref() { - prompt_store.read_with(cx, |prompt_store, cx| { - let prompts = prompt_store.default_prompt_metadata(); - let load_tasks = prompts.into_iter().map(|prompt_metadata| { - let contents = prompt_store.load(prompt_metadata.id, cx); - async move { (contents.await, prompt_metadata) } - }); - cx.background_spawn(future::join_all(load_tasks)) - }) - } else { - Task::ready(vec![]) - }; - - cx.spawn(async move |_cx| { - let (worktrees, default_user_rules) = - future::join(future::join_all(worktree_tasks), default_user_rules_task).await; - - let worktrees = worktrees - .into_iter() - .map(|(worktree, _rules_error)| { - // TODO: show error message - // if let Some(rules_error) = rules_error { - // this.update(cx, |_, cx| cx.emit(rules_error)).ok(); - // } - worktree - }) - .collect::>(); - - let default_user_rules = default_user_rules - .into_iter() - .flat_map(|(contents, prompt_metadata)| match contents { - Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - PromptId::User { uuid } => uuid, - PromptId::EditWorkflow => return None, - }, - title: prompt_metadata.title.map(|title| title.to_string()), - contents, - }), - Err(_err) => { - // TODO: show error message - // this.update(cx, |_, cx| { - // cx.emit(RulesLoadingError { - // message: format!("{err:?}").into(), - // }); - // }) - // .ok(); - None - } - }) - .collect::>(); - - ProjectContext::new(worktrees, default_user_rules) - }) - } - - fn load_worktree_info_for_system_prompt( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Task<(WorktreeContext, Option)> { - let tree = worktree.read(cx); - let root_name = tree.root_name().into(); - let abs_path = tree.abs_path(); - - let mut context = WorktreeContext { - root_name, - abs_path, - rules_file: None, - }; - - let rules_task = Self::load_worktree_rules_file(worktree, project, cx); - let Some(rules_task) = rules_task else { - return Task::ready((context, None)); - }; - - cx.spawn(async move |_| { - let (rules_file, rules_file_error) = match rules_task.await { - Ok(rules_file) => (Some(rules_file), None), - Err(err) => ( - None, - Some(RulesLoadingError { - message: format!("{err}").into(), - }), - ), - }; - context.rules_file = rules_file; - (context, rules_file_error) - }) - } - - fn load_worktree_rules_file( - worktree: Entity, - project: Entity, - cx: &mut App, - ) -> Option>> { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - let selected_rules_file = RULES_FILE_NAMES - .into_iter() - .filter_map(|name| { - worktree - .entry_for_path(name) - .filter(|entry| entry.is_file()) - .map(|entry| entry.path.clone()) - }) - .next(); - - // Note that Cline supports `.clinerules` being a directory, but that is not currently - // supported. This doesn't seem to occur often in GitHub repositories. - selected_rules_file.map(|path_in_worktree| { - let project_path = ProjectPath { - worktree_id, - path: path_in_worktree.clone(), - }; - let buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - let rope_task = cx.spawn(async move |cx| { - buffer_task.await?.read_with(cx, |buffer, cx| { - let project_entry_id = buffer.entry_id(cx).context("buffer has no file")?; - anyhow::Ok((project_entry_id, buffer.as_rope().clone())) - })? - }); - // Build a string from the rope on a background thread. - cx.background_spawn(async move { - let (project_entry_id, rope) = rope_task.await?; - anyhow::Ok(RulesFileContext { - path_in_worktree, - text: rope.to_string().trim().to_string(), - project_entry_id: project_entry_id.to_usize(), - }) - }) - }) - } - - fn handle_project_event( - &mut self, - _project: Entity, - event: &project::Event, - _cx: &mut Context, - ) { - match event { - project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.project_context_needs_refresh.send(()).ok(); - } - project::Event::WorktreeUpdatedEntries(_, items) => { - if items.iter().any(|(path, _, _)| { - RULES_FILE_NAMES - .iter() - .any(|name| path.as_ref() == Path::new(name)) - }) { - self.project_context_needs_refresh.send(()).ok(); - } - } - _ => {} - } - } - - fn handle_prompts_updated_event( - &mut self, - _prompt_store: Entity, - _event: &prompt_store::PromptsUpdatedEvent, - _cx: &mut Context, - ) { - self.project_context_needs_refresh.send(()).ok(); - } - - fn handle_models_updated_event( - &mut self, - _registry: Entity, - _event: &language_model::Event, - cx: &mut Context, - ) { - self.models.refresh_list(cx); - for session in self.sessions.values_mut() { - session.thread.update(cx, |thread, _| { - let model_id = LanguageModels::model_id(&thread.model()); - if let Some(model) = self.models.model_from_id(&model_id) { - thread.set_model(model.clone()); - } - }); - } - } -} - -/// Wrapper struct that implements the AgentConnection trait -#[derive(Clone)] -pub struct NativeAgentConnection(pub Entity); - -impl NativeAgentConnection { - pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option> { - self.0 - .read(cx) - .sessions - .get(session_id) - .map(|session| session.thread.clone()) - } - - fn run_turn( - &self, - session_id: acp::SessionId, - cx: &mut App, - f: impl 'static - + FnOnce( - Entity, - &mut App, - ) -> Result>>, - ) -> Task> { - let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { - agent - .sessions - .get_mut(&session_id) - .map(|s| (s.thread.clone(), s.acp_thread.clone())) - }) else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - log::debug!("Found session for: {}", session_id); - - let mut response_stream = match f(thread, cx) { - Ok(stream) => stream, - Err(err) => return Task::ready(Err(err)), - }; - cx.spawn(async move |cx| { - // Handle response stream and forward to session.acp_thread - while let Some(result) = response_stream.next().await { - match result { - Ok(event) => { - log::trace!("Received completion event: {:?}", event); - - match event { - AgentResponseEvent::Text(text) => { - acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - }), - false, - cx, - ) - })?; - } - AgentResponseEvent::Thinking(text) => { - acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - }), - true, - cx, - ) - })?; - } - AgentResponseEvent::ToolCallAuthorization(ToolCallAuthorization { - tool_call, - options, - response, - }) => { - let recv = acp_thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call, options, cx) - })?; - cx.background_spawn(async move { - if let Some(recv) = recv.log_err() - && let Some(option) = recv - .await - .context("authorization sender was dropped") - .log_err() - { - response - .send(option) - .map(|_| anyhow!("authorization receiver was dropped")) - .log_err(); - } - }) - .detach(); - } - AgentResponseEvent::ToolCall(tool_call) => { - acp_thread.update(cx, |thread, cx| { - thread.upsert_tool_call(tool_call, cx) - })??; - } - AgentResponseEvent::ToolCallUpdate(update) => { - acp_thread.update(cx, |thread, cx| { - thread.update_tool_call(update, cx) - })??; - } - AgentResponseEvent::Stop(stop_reason) => { - log::debug!("Assistant message complete: {:?}", stop_reason); - return Ok(acp::PromptResponse { stop_reason }); - } - } - } - Err(e) => { - log::error!("Error in model response stream: {:?}", e); - return Err(e); - } - } - } - - log::info!("Response stream completed"); - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - }) - }) - } -} - -impl AgentModelSelector for NativeAgentConnection { - fn list_models(&self, cx: &mut App) -> Task> { - log::debug!("NativeAgentConnection::list_models called"); - let list = self.0.read(cx).models.model_list.clone(); - Task::ready(if list.is_empty() { - Err(anyhow::anyhow!("No models available")) - } else { - Ok(list) - }) - } - - fn select_model( - &self, - session_id: acp::SessionId, - model_id: acp_thread::AgentModelId, - cx: &mut App, - ) -> Task> { - log::info!("Setting model for session {}: {}", session_id, model_id); - let Some(thread) = self - .0 - .read(cx) - .sessions - .get(&session_id) - .map(|session| session.thread.clone()) - else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - - let Some(model) = self.0.read(cx).models.model_from_id(&model_id) else { - return Task::ready(Err(anyhow!("Invalid model ID {}", model_id))); - }; - - thread.update(cx, |thread, _cx| { - thread.set_model(model.clone()); - }); - - update_settings_file::( - self.0.read(cx).fs.clone(), - cx, - move |settings, _cx| { - settings.set_model(model); - }, - ); - - Task::ready(Ok(())) - } - - fn selected_model( - &self, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - let session_id = session_id.clone(); - - let Some(thread) = self - .0 - .read(cx) - .sessions - .get(&session_id) - .map(|session| session.thread.clone()) - else { - return Task::ready(Err(anyhow!("Session not found"))); - }; - let model = thread.read(cx).model().clone(); - let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) - else { - return Task::ready(Err(anyhow!("Provider not found"))); - }; - Task::ready(Ok(LanguageModels::map_language_model_to_info( - &model, &provider, - ))) - } - - fn watch(&self, cx: &mut App) -> watch::Receiver<()> { - self.0.read(cx).models.watch() - } -} - -impl acp_thread::AgentConnection for NativeAgentConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let agent = self.0.clone(); - log::info!("Creating new thread for project at: {:?}", cwd); - - cx.spawn(async move |cx| { - log::debug!("Starting thread creation in async context"); - - // Generate session ID - let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into()); - log::info!("Created session with ID: {}", session_id); - - // Create AcpThread - let acp_thread = cx.update(|cx| { - cx.new(|cx| { - acp_thread::AcpThread::new( - "agent2", - self.clone(), - project.clone(), - session_id.clone(), - cx, - ) - }) - })?; - let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?; - - // Create Thread - let thread = agent.update( - cx, - |agent, cx: &mut gpui::Context| -> Result<_> { - // Fetch default model from registry settings - let registry = LanguageModelRegistry::read_global(cx); - - // Log available models for debugging - let available_count = registry.available_models(cx).count(); - log::debug!("Total available models: {}", available_count); - - let default_model = registry - .default_model() - .and_then(|default_model| { - agent - .models - .model_from_id(&LanguageModels::model_id(&default_model.model)) - }) - .ok_or_else(|| { - log::warn!("No default model configured in settings"); - anyhow!( - "No default model. Please configure a default model in settings." - ) - })?; - - let thread = cx.new(|cx| { - let mut thread = Thread::new( - project.clone(), - agent.project_context.clone(), - agent.context_server_registry.clone(), - action_log.clone(), - agent.templates.clone(), - default_model, - cx, - ); - thread.add_tool(CopyPathTool::new(project.clone())); - thread.add_tool(CreateDirectoryTool::new(project.clone())); - thread.add_tool(DeletePathTool::new(project.clone(), action_log.clone())); - thread.add_tool(DiagnosticsTool::new(project.clone())); - thread.add_tool(EditFileTool::new(cx.entity())); - thread.add_tool(FetchTool::new(project.read(cx).client().http_client())); - thread.add_tool(FindPathTool::new(project.clone())); - thread.add_tool(GrepTool::new(project.clone())); - thread.add_tool(ListDirectoryTool::new(project.clone())); - thread.add_tool(MovePathTool::new(project.clone())); - thread.add_tool(NowTool); - thread.add_tool(OpenTool::new(project.clone())); - thread.add_tool(ReadFileTool::new(project.clone(), action_log)); - thread.add_tool(TerminalTool::new(project.clone(), cx)); - thread.add_tool(ThinkingTool); - thread.add_tool(WebSearchTool); // TODO: Enable this only if it's a zed model. - thread - }); - - Ok(thread) - }, - )??; - - // Store the session - agent.update(cx, |agent, cx| { - agent.sessions.insert( - session_id, - Session { - thread, - acp_thread: acp_thread.downgrade(), - _subscription: cx.observe_release(&acp_thread, |this, acp_thread, _cx| { - this.sessions.remove(acp_thread.session_id()); - }), - }, - ); - })?; - - Ok(acp_thread) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] // No auth for in-process - } - - fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task> { - Task::ready(Ok(())) - } - - fn model_selector(&self) -> Option> { - Some(Rc::new(self.clone()) as Rc) - } - - fn prompt( - &self, - id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let id = id.expect("UserMessageId is required"); - let session_id = params.session_id.clone(); - log::info!("Received prompt request for session: {}", session_id); - log::debug!("Prompt blocks count: {}", params.prompt.len()); - - self.run_turn(session_id, cx, |thread, cx| { - let content: Vec = params - .prompt - .into_iter() - .map(Into::into) - .collect::>(); - log::info!("Converted prompt to message: {} chars", content.len()); - log::debug!("Message id: {:?}", id); - log::debug!("Message content: {:?}", content); - - Ok(thread.update(cx, |thread, cx| { - log::info!( - "Sending message to thread with model: {:?}", - thread.model().name() - ); - thread.send(id, content, cx) - })) - }) - } - - fn resume( - &self, - session_id: &acp::SessionId, - _cx: &mut App, - ) -> Option> { - Some(Rc::new(NativeAgentSessionResume { - connection: self.clone(), - session_id: session_id.clone(), - }) as _) - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - log::info!("Cancelling on session: {}", session_id); - self.0.update(cx, |agent, cx| { - if let Some(agent) = agent.sessions.get(session_id) { - agent.thread.update(cx, |thread, _cx| thread.cancel()); - } - }); - } - - fn session_editor( - &self, - session_id: &agent_client_protocol::SessionId, - cx: &mut App, - ) -> Option> { - self.0.update(cx, |agent, _cx| { - agent - .sessions - .get(session_id) - .map(|session| Rc::new(NativeAgentSessionEditor(session.thread.clone())) as _) - }) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct NativeAgentSessionEditor(Entity); - -impl acp_thread::AgentSessionEditor for NativeAgentSessionEditor { - fn truncate(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task> { - Task::ready(self.0.update(cx, |thread, _cx| thread.truncate(message_id))) - } -} - -struct NativeAgentSessionResume { - connection: NativeAgentConnection, - session_id: acp::SessionId, -} - -impl acp_thread::AgentSessionResume for NativeAgentSessionResume { - fn run(&self, cx: &mut App) -> Task> { - self.connection - .run_turn(self.session_id.clone(), cx, |thread, cx| { - thread.update(cx, |thread, cx| thread.resume(cx)) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelId, AgentModelInfo}; - use fs::FakeFs; - use gpui::TestAppContext; - use serde_json::json; - use settings::SettingsStore; - - #[gpui::test] - async fn test_maintaining_project_context(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/", - json!({ - "a": {} - }), - ) - .await; - let project = Project::test(fs.clone(), [], cx).await; - let agent = NativeAgent::new( - project.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - agent.read_with(cx, |agent, _| { - assert_eq!(agent.project_context.borrow().worktrees, vec![]) - }); - - let worktree = project - .update(cx, |project, cx| project.create_worktree("/a", true, cx)) - .await - .unwrap(); - cx.run_until_parked(); - agent.read_with(cx, |agent, _| { - assert_eq!( - agent.project_context.borrow().worktrees, - vec![WorktreeContext { - root_name: "a".into(), - abs_path: Path::new("/a").into(), - rules_file: None - }] - ) - }); - - // Creating `/a/.rules` updates the project context. - fs.insert_file("/a/.rules", Vec::new()).await; - cx.run_until_parked(); - agent.read_with(cx, |agent, cx| { - let rules_entry = worktree.read(cx).entry_for_path(".rules").unwrap(); - assert_eq!( - agent.project_context.borrow().worktrees, - vec![WorktreeContext { - root_name: "a".into(), - abs_path: Path::new("/a").into(), - rules_file: Some(RulesFileContext { - path_in_worktree: Path::new(".rules").into(), - text: "".into(), - project_entry_id: rules_entry.id.to_usize() - }) - }] - ) - }); - } - - #[gpui::test] - async fn test_listing_models(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/", json!({ "a": {} })).await; - let project = Project::test(fs.clone(), [], cx).await; - let connection = NativeAgentConnection( - NativeAgent::new( - project.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(), - ); - - let models = cx.update(|cx| connection.list_models(cx)).await.unwrap(); - - let acp_thread::AgentModelList::Grouped(models) = models else { - panic!("Unexpected model group"); - }; - assert_eq!( - models, - IndexMap::from_iter([( - AgentModelGroupName("Fake".into()), - vec![AgentModelInfo { - id: AgentModelId("fake/fake".into()), - name: "Fake".into(), - icon: Some(ui::IconName::ZedAssistant), - }] - )]) - ); - } - - #[gpui::test] - async fn test_model_selection_persists_to_settings(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.create_dir(paths::settings_file().parent().unwrap()) - .await - .unwrap(); - fs.insert_file( - paths::settings_file(), - json!({ - "agent": { - "default_model": { - "provider": "foo", - "model": "bar" - } - } - }) - .to_string() - .into_bytes(), - ) - .await; - let project = Project::test(fs.clone(), [], cx).await; - - // Create the agent and connection - let agent = NativeAgent::new( - project.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - let connection = NativeAgentConnection(agent.clone()); - - // Create a thread/session - let acp_thread = cx - .update(|cx| { - Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx) - }) - .await - .unwrap(); - - let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); - - // Select a model - let model_id = AgentModelId("fake/fake".into()); - cx.update(|cx| connection.select_model(session_id.clone(), model_id.clone(), cx)) - .await - .unwrap(); - - // Verify the thread has the selected model - agent.read_with(cx, |agent, _| { - let session = agent.sessions.get(&session_id).unwrap(); - session.thread.read_with(cx, |thread, _| { - assert_eq!(thread.model().id().0, "fake"); - }); - }); - - cx.run_until_parked(); - - // Verify settings file was updated - let settings_content = fs.load(paths::settings_file()).await.unwrap(); - let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); - - // Check that the agent settings contain the selected model - assert_eq!( - settings_json["agent"]["default_model"]["model"], - json!("fake") - ); - assert_eq!( - settings_json["agent"]["default_model"]["provider"], - json!("fake") - ); - } - - fn init_test(cx: &mut TestAppContext) { - env_logger::try_init().ok(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - Project::init_settings(cx); - agent_settings::init(cx); - language::init(cx); - LanguageModelRegistry::test(cx); - }); - } -} diff --git a/crates/agent2/src/agent2.rs b/crates/agent2/src/agent2.rs deleted file mode 100644 index f13cd1bd673b5e122333264ac3cbcbe83edd7627..0000000000000000000000000000000000000000 --- a/crates/agent2/src/agent2.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod agent; -mod native_agent_server; -mod templates; -mod thread; -mod tools; - -#[cfg(test)] -mod tests; - -pub use agent::*; -pub use native_agent_server::NativeAgentServer; -pub use templates::*; -pub use thread::*; -pub use tools::*; diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs deleted file mode 100644 index cadd88a8462ca0c297ef0b7b8cd516f87104c4eb..0000000000000000000000000000000000000000 --- a/crates/agent2/src/native_agent_server.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::{path::Path, rc::Rc, sync::Arc}; - -use agent_servers::AgentServer; -use anyhow::Result; -use fs::Fs; -use gpui::{App, Entity, Task}; -use project::Project; -use prompt_store::PromptStore; - -use crate::{NativeAgent, NativeAgentConnection, templates::Templates}; - -#[derive(Clone)] -pub struct NativeAgentServer { - fs: Arc, -} - -impl NativeAgentServer { - pub fn new(fs: Arc) -> Self { - Self { fs } - } -} - -impl AgentServer for NativeAgentServer { - fn name(&self) -> &'static str { - "Native Agent" - } - - fn empty_state_headline(&self) -> &'static str { - "Native Agent" - } - - fn empty_state_message(&self) -> &'static str { - "How can I help you today?" - } - - fn logo(&self) -> ui::IconName { - // Using the ZedAssistant icon as it's the native built-in agent - ui::IconName::ZedAssistant - } - - fn connect( - &self, - _root_dir: &Path, - project: &Entity, - cx: &mut App, - ) -> Task>> { - log::info!( - "NativeAgentServer::connect called for path: {:?}", - _root_dir - ); - let project = project.clone(); - let fs = self.fs.clone(); - let prompt_store = PromptStore::global(cx); - cx.spawn(async move |cx| { - log::debug!("Creating templates for native agent"); - let templates = Templates::new(); - let prompt_store = prompt_store.await?; - - log::debug!("Creating native agent entity"); - let agent = NativeAgent::new(project, templates, Some(prompt_store), fs, cx).await?; - - // Create the connection wrapper - let connection = NativeAgentConnection(agent); - log::info!("NativeAgentServer connection established successfully"); - - Ok(Rc::new(connection) as Rc) - }) - } -} diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs deleted file mode 100644 index cc8bd483bbe26ac12c092d6743b43086fd5edfd4..0000000000000000000000000000000000000000 --- a/crates/agent2/src/tests/mod.rs +++ /dev/null @@ -1,1486 +0,0 @@ -use super::*; -use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList, UserMessageId}; -use action_log::ActionLog; -use agent_client_protocol::{self as acp}; -use agent_settings::AgentProfileId; -use anyhow::Result; -use client::{Client, UserStore}; -use fs::{FakeFs, Fs}; -use futures::channel::mpsc::UnboundedReceiver; -use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, -}; -use indoc::indoc; -use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, - LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, MessageContent, - Role, StopReason, fake_provider::FakeLanguageModel, -}; -use pretty_assertions::assert_eq; -use project::Project; -use prompt_store::ProjectContext; -use reqwest_client::ReqwestClient; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use settings::SettingsStore; -use smol::stream::StreamExt; -use std::{cell::RefCell, path::Path, rc::Rc, sync::Arc, time::Duration}; -use util::path; - -mod test_tools; -use test_tools::*; - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_echo(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - - let events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx) - }) - .collect() - .await; - thread.update(cx, |thread, _cx| { - assert_eq!( - thread.last_message().unwrap().to_markdown(), - indoc! {" - ## Assistant - - Hello - "} - ) - }); - assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); -} - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_thinking(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4Thinking).await; - - let events = thread - .update(cx, |thread, cx| { - thread.send( - UserMessageId::new(), - [indoc! {" - Testing: - - Generate a thinking step where you just think the word 'Think', - and have your final answer be 'Hello' - "}], - cx, - ) - }) - .collect() - .await; - thread.update(cx, |thread, _cx| { - assert_eq!( - thread.last_message().unwrap().to_markdown(), - indoc! {" - ## Assistant - - Think - Hello - "} - ) - }); - assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); -} - -#[gpui::test] -async fn test_system_prompt(cx: &mut TestAppContext) { - let ThreadTest { - model, - thread, - project_context, - .. - } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - project_context.borrow_mut().shell = "test-shell".into(); - thread.update(cx, |thread, _| thread.add_tool(EchoTool)); - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) - }); - cx.run_until_parked(); - let mut pending_completions = fake_model.pending_completions(); - assert_eq!( - pending_completions.len(), - 1, - "unexpected pending completions: {:?}", - pending_completions - ); - - let pending_completion = pending_completions.pop().unwrap(); - assert_eq!(pending_completion.messages[0].role, Role::System); - - let system_message = &pending_completion.messages[0]; - let system_prompt = system_message.content[0].to_str().unwrap(); - assert!( - system_prompt.contains("test-shell"), - "unexpected system message: {:?}", - system_message - ); - assert!( - system_prompt.contains("## Fixing Diagnostics"), - "unexpected system message: {:?}", - system_message - ); -} - -#[gpui::test] -async fn test_prompt_caching(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - // Send initial user message and verify it's cached - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 1"], cx) - }); - cx.run_until_parked(); - - let completion = fake_model.pending_completions().pop().unwrap(); - assert_eq!( - completion.messages[1..], - vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Message 1".into()], - cache: true - }] - ); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( - "Response to Message 1".into(), - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Send another user message and verify only the latest is cached - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 2"], cx) - }); - cx.run_until_parked(); - - let completion = fake_model.pending_completions().pop().unwrap(); - assert_eq!( - completion.messages[1..], - vec![ - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Message 1".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec!["Response to Message 1".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Message 2".into()], - cache: true - } - ] - ); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( - "Response to Message 2".into(), - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - // Simulate a tool call and verify that the latest tool result is cached - thread.update(cx, |thread, _| thread.add_tool(EchoTool)); - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Use the echo tool"], cx) - }); - cx.run_until_parked(); - - let tool_use = LanguageModelToolUse { - id: "tool_1".into(), - name: EchoTool.name().into(), - raw_input: json!({"text": "test"}).to_string(), - input: json!({"text": "test"}), - is_input_complete: true, - }; - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let completion = fake_model.pending_completions().pop().unwrap(); - let tool_result = LanguageModelToolResult { - tool_use_id: "tool_1".into(), - tool_name: EchoTool.name().into(), - is_error: false, - content: "test".into(), - output: Some("test".into()), - }; - assert_eq!( - completion.messages[1..], - vec![ - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Message 1".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec!["Response to Message 1".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Message 2".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec!["Response to Message 2".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Use the echo tool".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec![MessageContent::ToolUse(tool_use)], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::ToolResult(tool_result)], - cache: true - } - ] - ); -} - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_basic_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - - // Test a tool call that's likely to complete *before* streaming stops. - let events = thread - .update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send( - UserMessageId::new(), - ["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."], - cx, - ) - }) - .collect() - .await; - assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); - - // Test a tool calls that's likely to complete *after* streaming stops. - let events = thread - .update(cx, |thread, cx| { - thread.remove_tool(&AgentTool::name(&EchoTool)); - thread.add_tool(DelayTool); - thread.send( - UserMessageId::new(), - [ - "Now call the delay tool with 200ms.", - "When the timer goes off, then you echo the output of the tool.", - ], - cx, - ) - }) - .collect() - .await; - assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); - thread.update(cx, |thread, _cx| { - assert!( - thread - .last_message() - .unwrap() - .as_agent_message() - .unwrap() - .content - .iter() - .any(|content| { - if let AgentMessageContent::Text(text) = content { - text.contains("Ding") - } else { - false - } - }), - "{}", - thread.to_markdown() - ); - }); -} - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_streaming_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - - // Test a tool call that's likely to complete *before* streaming stops. - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(WordListTool); - thread.send(UserMessageId::new(), ["Test the word_list tool."], cx) - }); - - let mut saw_partial_tool_use = false; - while let Some(event) = events.next().await { - if let Ok(AgentResponseEvent::ToolCall(tool_call)) = event { - thread.update(cx, |thread, _cx| { - // Look for a tool use in the thread's last message - let message = thread.last_message().unwrap(); - let agent_message = message.as_agent_message().unwrap(); - let last_content = agent_message.content.last().unwrap(); - if let AgentMessageContent::ToolUse(last_tool_use) = last_content { - assert_eq!(last_tool_use.name.as_ref(), "word_list"); - if tool_call.status == acp::ToolCallStatus::Pending { - if !last_tool_use.is_input_complete - && last_tool_use.input.get("g").is_none() - { - saw_partial_tool_use = true; - } - } else { - last_tool_use - .input - .get("a") - .expect("'a' has streamed because input is now complete"); - last_tool_use - .input - .get("g") - .expect("'g' has streamed because input is now complete"); - } - } else { - panic!("last content should be a tool use"); - } - }); - } - } - - assert!( - saw_partial_tool_use, - "should see at least one partially streamed tool use in the history" - ); -} - -#[gpui::test] -async fn test_tool_authorization(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) - }); - cx.run_until_parked(); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "tool_id_1".into(), - name: ToolRequiringPermission.name().into(), - raw_input: "{}".into(), - input: json!({}), - is_input_complete: true, - }, - )); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "tool_id_2".into(), - name: ToolRequiringPermission.name().into(), - raw_input: "{}".into(), - input: json!({}), - is_input_complete: true, - }, - )); - fake_model.end_last_completion_stream(); - let tool_call_auth_1 = next_tool_call_authorization(&mut events).await; - let tool_call_auth_2 = next_tool_call_authorization(&mut events).await; - - // Approve the first - tool_call_auth_1 - .response - .send(tool_call_auth_1.options[1].id.clone()) - .unwrap(); - cx.run_until_parked(); - - // Reject the second - tool_call_auth_2 - .response - .send(tool_call_auth_1.options[2].id.clone()) - .unwrap(); - cx.run_until_parked(); - - let completion = fake_model.pending_completions().pop().unwrap(); - let message = completion.messages.last().unwrap(); - assert_eq!( - message.content, - vec![ - language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: false, - content: "Allowed".into(), - output: Some("Allowed".into()) - }), - language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: true, - content: "Permission to run tool denied by user".into(), - output: None - }) - ] - ); - - // Simulate yet another tool call. - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "tool_id_3".into(), - name: ToolRequiringPermission.name().into(), - raw_input: "{}".into(), - input: json!({}), - is_input_complete: true, - }, - )); - fake_model.end_last_completion_stream(); - - // Respond by always allowing tools. - let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; - tool_call_auth_3 - .response - .send(tool_call_auth_3.options[0].id.clone()) - .unwrap(); - cx.run_until_parked(); - let completion = fake_model.pending_completions().pop().unwrap(); - let message = completion.messages.last().unwrap(); - assert_eq!( - message.content, - vec![language_model::MessageContent::ToolResult( - LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: false, - content: "Allowed".into(), - output: Some("Allowed".into()) - } - )] - ); - - // Simulate a final tool call, ensuring we don't trigger authorization. - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "tool_id_4".into(), - name: ToolRequiringPermission.name().into(), - raw_input: "{}".into(), - input: json!({}), - is_input_complete: true, - }, - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - let completion = fake_model.pending_completions().pop().unwrap(); - let message = completion.messages.last().unwrap(); - assert_eq!( - message.content, - vec![language_model::MessageContent::ToolResult( - LanguageModelToolResult { - tool_use_id: "tool_id_4".into(), - tool_name: ToolRequiringPermission.name().into(), - is_error: false, - content: "Allowed".into(), - output: Some("Allowed".into()) - } - )] - ); -} - -#[gpui::test] -async fn test_tool_hallucination(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let mut events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) - }); - cx.run_until_parked(); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "tool_id_1".into(), - name: "nonexistent_tool".into(), - raw_input: "{}".into(), - input: json!({}), - is_input_complete: true, - }, - )); - fake_model.end_last_completion_stream(); - - let tool_call = expect_tool_call(&mut events).await; - assert_eq!(tool_call.title, "nonexistent_tool"); - assert_eq!(tool_call.status, acp::ToolCallStatus::Pending); - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed)); -} - -#[gpui::test] -async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let events = thread.update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["abc"], cx) - }); - cx.run_until_parked(); - let tool_use = LanguageModelToolUse { - id: "tool_id_1".into(), - name: EchoTool.name().into(), - raw_input: "{}".into(), - input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), - is_input_complete: true, - }; - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); - fake_model.end_last_completion_stream(); - - cx.run_until_parked(); - let completion = fake_model.pending_completions().pop().unwrap(); - let tool_result = LanguageModelToolResult { - tool_use_id: "tool_id_1".into(), - tool_name: EchoTool.name().into(), - is_error: false, - content: "def".into(), - output: Some("def".into()), - }; - assert_eq!( - completion.messages[1..], - vec![ - LanguageModelRequestMessage { - role: Role::User, - content: vec!["abc".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec![MessageContent::ToolUse(tool_use.clone())], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::ToolResult(tool_result.clone())], - cache: true - }, - ] - ); - - // Simulate reaching tool use limit. - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( - cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, - )); - fake_model.end_last_completion_stream(); - let last_event = events.collect::>().await.pop().unwrap(); - assert!( - last_event - .unwrap_err() - .is::() - ); - - let events = thread.update(cx, |thread, cx| thread.resume(cx)).unwrap(); - cx.run_until_parked(); - let completion = fake_model.pending_completions().pop().unwrap(); - assert_eq!( - completion.messages[1..], - vec![ - LanguageModelRequestMessage { - role: Role::User, - content: vec!["abc".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec![MessageContent::ToolUse(tool_use)], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::ToolResult(tool_result)], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec!["Continue where you left off".into()], - cache: true - } - ] - ); - - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text("Done".into())); - fake_model.end_last_completion_stream(); - events.collect::>().await; - thread.read_with(cx, |thread, _cx| { - assert_eq!( - thread.last_message().unwrap().to_markdown(), - indoc! {" - ## Assistant - - Done - "} - ) - }); - - // Ensure we error if calling resume when tool use limit was *not* reached. - let error = thread - .update(cx, |thread, cx| thread.resume(cx)) - .unwrap_err(); - assert_eq!( - error.to_string(), - "can only resume after tool use limit is reached" - ) -} - -#[gpui::test] -async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let events = thread.update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["abc"], cx) - }); - cx.run_until_parked(); - - let tool_use = LanguageModelToolUse { - id: "tool_id_1".into(), - name: EchoTool.name().into(), - raw_input: "{}".into(), - input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(), - is_input_complete: true, - }; - let tool_result = LanguageModelToolResult { - tool_use_id: "tool_id_1".into(), - tool_name: EchoTool.name().into(), - is_error: false, - content: "def".into(), - output: Some("def".into()), - }; - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone())); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate( - cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached, - )); - fake_model.end_last_completion_stream(); - let last_event = events.collect::>().await.pop().unwrap(); - assert!( - last_event - .unwrap_err() - .is::() - ); - - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), vec!["ghi"], cx) - }); - cx.run_until_parked(); - let completion = fake_model.pending_completions().pop().unwrap(); - assert_eq!( - completion.messages[1..], - vec![ - LanguageModelRequestMessage { - role: Role::User, - content: vec!["abc".into()], - cache: false - }, - LanguageModelRequestMessage { - role: Role::Assistant, - content: vec![MessageContent::ToolUse(tool_use)], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::ToolResult(tool_result)], - cache: false - }, - LanguageModelRequestMessage { - role: Role::User, - content: vec!["ghi".into()], - cache: true - } - ] - ); -} - -async fn expect_tool_call( - events: &mut UnboundedReceiver>, -) -> acp::ToolCall { - let event = events - .next() - .await - .expect("no tool call authorization event received") - .unwrap(); - match event { - AgentResponseEvent::ToolCall(tool_call) => return tool_call, - event => { - panic!("Unexpected event {event:?}"); - } - } -} - -async fn expect_tool_call_update_fields( - events: &mut UnboundedReceiver>, -) -> acp::ToolCallUpdate { - let event = events - .next() - .await - .expect("no tool call authorization event received") - .unwrap(); - match event { - AgentResponseEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => { - return update; - } - event => { - panic!("Unexpected event {event:?}"); - } - } -} - -async fn next_tool_call_authorization( - events: &mut UnboundedReceiver>, -) -> ToolCallAuthorization { - loop { - let event = events - .next() - .await - .expect("no tool call authorization event received") - .unwrap(); - if let AgentResponseEvent::ToolCallAuthorization(tool_call_authorization) = event { - let permission_kinds = tool_call_authorization - .options - .iter() - .map(|o| o.kind) - .collect::>(); - assert_eq!( - permission_kinds, - vec![ - acp::PermissionOptionKind::AllowAlways, - acp::PermissionOptionKind::AllowOnce, - acp::PermissionOptionKind::RejectOnce, - ] - ); - return tool_call_authorization; - } - } -} - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - - // Test concurrent tool calls with different delay times - let events = thread - .update(cx, |thread, cx| { - thread.add_tool(DelayTool); - thread.send( - UserMessageId::new(), - [ - "Call the delay tool twice in the same message.", - "Once with 100ms. Once with 300ms.", - "When both timers are complete, describe the outputs.", - ], - cx, - ) - }) - .collect() - .await; - - let stop_reasons = stop_events(events); - assert_eq!(stop_reasons, vec![acp::StopReason::EndTurn]); - - thread.update(cx, |thread, _cx| { - let last_message = thread.last_message().unwrap(); - let agent_message = last_message.as_agent_message().unwrap(); - let text = agent_message - .content - .iter() - .filter_map(|content| { - if let AgentMessageContent::Text(text) = content { - Some(text.as_str()) - } else { - None - } - }) - .collect::(); - - assert!(text.contains("Ding")); - }); -} - -#[gpui::test] -async fn test_profiles(cx: &mut TestAppContext) { - let ThreadTest { - model, thread, fs, .. - } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - thread.update(cx, |thread, _cx| { - thread.add_tool(DelayTool); - thread.add_tool(EchoTool); - thread.add_tool(InfiniteTool); - }); - - // Override profiles and wait for settings to be loaded. - fs.insert_file( - paths::settings_file(), - json!({ - "agent": { - "profiles": { - "test-1": { - "name": "Test Profile 1", - "tools": { - EchoTool.name(): true, - DelayTool.name(): true, - } - }, - "test-2": { - "name": "Test Profile 2", - "tools": { - InfiniteTool.name(): true, - } - } - } - } - }) - .to_string() - .into_bytes(), - ) - .await; - cx.run_until_parked(); - - // Test that test-1 profile (default) has echo and delay tools - thread.update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-1".into())); - thread.send(UserMessageId::new(), ["test"], cx); - }); - cx.run_until_parked(); - - let mut pending_completions = fake_model.pending_completions(); - assert_eq!(pending_completions.len(), 1); - let completion = pending_completions.pop().unwrap(); - let tool_names: Vec = completion - .tools - .iter() - .map(|tool| tool.name.clone()) - .collect(); - assert_eq!(tool_names, vec![DelayTool.name(), EchoTool.name()]); - fake_model.end_last_completion_stream(); - - // Switch to test-2 profile, and verify that it has only the infinite tool. - thread.update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-2".into())); - thread.send(UserMessageId::new(), ["test2"], cx) - }); - cx.run_until_parked(); - let mut pending_completions = fake_model.pending_completions(); - assert_eq!(pending_completions.len(), 1); - let completion = pending_completions.pop().unwrap(); - let tool_names: Vec = completion - .tools - .iter() - .map(|tool| tool.name.clone()) - .collect(); - assert_eq!(tool_names, vec![InfiniteTool.name()]); -} - -#[gpui::test] -#[ignore = "can't run on CI yet"] -async fn test_cancellation(cx: &mut TestAppContext) { - let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(InfiniteTool); - thread.add_tool(EchoTool); - thread.send( - UserMessageId::new(), - ["Call the echo tool, then call the infinite tool, then explain their output"], - cx, - ) - }); - - // Wait until both tools are called. - let mut expected_tools = vec!["Echo", "Infinite Tool"]; - let mut echo_id = None; - let mut echo_completed = false; - while let Some(event) = events.next().await { - match event.unwrap() { - AgentResponseEvent::ToolCall(tool_call) => { - assert_eq!(tool_call.title, expected_tools.remove(0)); - if tool_call.title == "Echo" { - echo_id = Some(tool_call.id); - } - } - AgentResponseEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( - acp::ToolCallUpdate { - id, - fields: - acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - .. - }, - }, - )) if Some(&id) == echo_id.as_ref() => { - echo_completed = true; - } - _ => {} - } - - if expected_tools.is_empty() && echo_completed { - break; - } - } - - // Cancel the current send and ensure that the event stream is closed, even - // if one of the tools is still running. - thread.update(cx, |thread, _cx| thread.cancel()); - events.collect::>().await; - - // Ensure we can still send a new message after cancellation. - let events = thread - .update(cx, |thread, cx| { - thread.send( - UserMessageId::new(), - ["Testing: reply with 'Hello' then stop."], - cx, - ) - }) - .collect::>() - .await; - thread.update(cx, |thread, _cx| { - let message = thread.last_message().unwrap(); - let agent_message = message.as_agent_message().unwrap(); - assert_eq!( - agent_message.content, - vec![AgentMessageContent::Text("Hello".to_string())] - ); - }); - assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); -} - -#[gpui::test] -async fn test_refusal(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) - }); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello - "} - ); - }); - - fake_model.send_last_completion_stream_text_chunk("Hey!"); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello - - ## Assistant - - Hey! - "} - ); - }); - - // If the model refuses to continue, the thread should remove all the messages after the last user message. - fake_model - .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::Refusal)); - let events = events.collect::>().await; - assert_eq!(stop_events(events), vec![acp::StopReason::Refusal]); - thread.read_with(cx, |thread, _| { - assert_eq!(thread.to_markdown(), ""); - }); -} - -#[gpui::test] -async fn test_truncate(cx: &mut TestAppContext) { - let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let message_id = UserMessageId::new(); - thread.update(cx, |thread, cx| { - thread.send(message_id.clone(), ["Hello"], cx) - }); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello - "} - ); - }); - - fake_model.send_last_completion_stream_text_chunk("Hey!"); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello - - ## Assistant - - Hey! - "} - ); - }); - - thread - .update(cx, |thread, _cx| thread.truncate(message_id)) - .unwrap(); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!(thread.to_markdown(), ""); - }); - - // Ensure we can still send a new message after truncation. - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hi"], cx) - }); - thread.update(cx, |thread, _cx| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hi - "} - ); - }); - cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Ahoy!"); - cx.run_until_parked(); - thread.read_with(cx, |thread, _| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hi - - ## Assistant - - Ahoy! - "} - ); - }); -} - -#[gpui::test] -async fn test_agent_connection(cx: &mut TestAppContext) { - cx.update(settings::init); - let templates = Templates::new(); - - // Initialize language model system with test provider - cx.update(|cx| { - gpui_tokio::init(cx); - client::init_settings(cx); - - let http_client = FakeHttpClient::with_404_response(); - let clock = Arc::new(clock::FakeSystemClock::new()); - let client = Client::new(clock, http_client, cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); - Project::init_settings(cx); - LanguageModelRegistry::test(cx); - agent_settings::init(cx); - }); - cx.executor().forbid_parking(); - - // Create a project for new_thread - let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); - fake_fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; - let cwd = Path::new("/test"); - - // Create agent and connection - let agent = NativeAgent::new( - project.clone(), - templates.clone(), - None, - fake_fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); - let connection = NativeAgentConnection(agent.clone()); - - // Test model_selector returns Some - let selector_opt = connection.model_selector(); - assert!( - selector_opt.is_some(), - "agent2 should always support ModelSelector" - ); - let selector = selector_opt.unwrap(); - - // Test list_models - let listed_models = cx - .update(|cx| selector.list_models(cx)) - .await - .expect("list_models should succeed"); - let AgentModelList::Grouped(listed_models) = listed_models else { - panic!("Unexpected model list type"); - }; - assert!(!listed_models.is_empty(), "should have at least one model"); - assert_eq!( - listed_models[&AgentModelGroupName("Fake".into())][0].id.0, - "fake/fake" - ); - - // Create a thread using new_thread - let connection_rc = Rc::new(connection.clone()); - let acp_thread = cx - .update(|cx| connection_rc.new_thread(project, cwd, cx)) - .await - .expect("new_thread should succeed"); - - // Get the session_id from the AcpThread - let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); - - // Test selected_model returns the default - let model = cx - .update(|cx| selector.selected_model(&session_id, cx)) - .await - .expect("selected_model should succeed"); - let model = cx - .update(|cx| agent.read(cx).models().model_from_id(&model.id)) - .unwrap(); - let model = model.as_fake(); - assert_eq!(model.id().0, "fake", "should return default model"); - - let request = acp_thread.update(cx, |thread, cx| thread.send(vec!["abc".into()], cx)); - cx.run_until_parked(); - model.send_last_completion_stream_text_chunk("def"); - cx.run_until_parked(); - acp_thread.read_with(cx, |thread, cx| { - assert_eq!( - thread.to_markdown(cx), - indoc! {" - ## User - - abc - - ## Assistant - - def - - "} - ) - }); - - // Test cancel - cx.update(|cx| connection.cancel(&session_id, cx)); - request.await.expect("prompt should fail gracefully"); - - // Ensure that dropping the ACP thread causes the native thread to be - // dropped as well. - cx.update(|_| drop(acp_thread)); - let result = cx - .update(|cx| { - connection.prompt( - Some(acp_thread::UserMessageId::new()), - acp::PromptRequest { - session_id: session_id.clone(), - prompt: vec!["ghi".into()], - }, - cx, - ) - }) - .await; - assert_eq!( - result.as_ref().unwrap_err().to_string(), - "Session not found", - "unexpected result: {:?}", - result - ); -} - -#[gpui::test] -async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool)); - let fake_model = model.as_fake(); - - let mut events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Think"], cx) - }); - cx.run_until_parked(); - - // Simulate streaming partial input. - let input = json!({}); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "1".into(), - name: ThinkingTool.name().into(), - raw_input: input.to_string(), - input, - is_input_complete: false, - }, - )); - - // Input streaming completed - let input = json!({ "content": "Thinking hard!" }); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "1".into(), - name: "thinking".into(), - raw_input: input.to_string(), - input, - is_input_complete: true, - }, - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let tool_call = expect_tool_call(&mut events).await; - assert_eq!( - tool_call, - acp::ToolCall { - id: acp::ToolCallId("1".into()), - title: "Thinking".into(), - kind: acp::ToolKind::Think, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(json!({})), - raw_output: None, - } - ); - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - title: Some("Thinking".into()), - kind: Some(acp::ToolKind::Think), - raw_input: Some(json!({ "content": "Thinking hard!" })), - ..Default::default() - }, - } - ); - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - } - ); - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - content: Some(vec!["Thinking hard!".into()]), - ..Default::default() - }, - } - ); - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - raw_output: Some("Finished thinking.".into()), - ..Default::default() - }, - } - ); -} - -/// Filters out the stop events for asserting against in tests -fn stop_events(result_events: Vec>) -> Vec { - result_events - .into_iter() - .filter_map(|event| match event.unwrap() { - AgentResponseEvent::Stop(stop_reason) => Some(stop_reason), - _ => None, - }) - .collect() -} - -struct ThreadTest { - model: Arc, - thread: Entity, - project_context: Rc>, - fs: Arc, -} - -enum TestModel { - Sonnet4, - Sonnet4Thinking, - Fake, -} - -impl TestModel { - fn id(&self) -> LanguageModelId { - match self { - TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()), - TestModel::Sonnet4Thinking => LanguageModelId("claude-sonnet-4-thinking-latest".into()), - TestModel::Fake => unreachable!(), - } - } -} - -async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.create_dir(paths::settings_file().parent().unwrap()) - .await - .unwrap(); - fs.insert_file( - paths::settings_file(), - json!({ - "agent": { - "default_profile": "test-profile", - "profiles": { - "test-profile": { - "name": "Test Profile", - "tools": { - EchoTool.name(): true, - DelayTool.name(): true, - WordListTool.name(): true, - ToolRequiringPermission.name(): true, - InfiniteTool.name(): true, - } - } - } - } - }) - .to_string() - .into_bytes(), - ) - .await; - - cx.update(|cx| { - settings::init(cx); - Project::init_settings(cx); - agent_settings::init(cx); - gpui_tokio::init(cx); - let http_client = ReqwestClient::user_agent("agent tests").unwrap(); - cx.set_http_client(Arc::new(http_client)); - - client::init_settings(cx); - let client = Client::production(cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); - - watch_settings(fs.clone(), cx); - }); - - let templates = Templates::new(); - - fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - - let model = cx - .update(|cx| { - if let TestModel::Fake = model { - Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>) - } else { - let model_id = model.id(); - let models = LanguageModelRegistry::read_global(cx); - let model = models - .available_models(cx) - .find(|model| model.id() == model_id) - .unwrap(); - - let provider = models.provider(&model.provider_id()).unwrap(); - let authenticated = provider.authenticate(cx); - - cx.spawn(async move |_cx| { - authenticated.await.unwrap(); - model - }) - } - }) - .await; - - let project_context = Rc::new(RefCell::new(ProjectContext::default())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - Thread::new( - project, - project_context.clone(), - context_server_registry, - action_log, - templates, - model.clone(), - cx, - ) - }); - ThreadTest { - model, - thread, - project_context, - fs, - } -} - -#[cfg(test)] -#[ctor::ctor] -fn init_logger() { - if std::env::var("RUST_LOG").is_ok() { - env_logger::init(); - } -} - -fn watch_settings(fs: Arc, cx: &mut App) { - let fs = fs.clone(); - cx.spawn({ - async move |cx| { - let mut new_settings_content_rx = settings::watch_config_file( - cx.background_executor(), - fs, - paths::settings_file().clone(), - ); - - while let Some(new_settings_content) = new_settings_content_rx.next().await { - cx.update(|cx| { - SettingsStore::update_global(cx, |settings, cx| { - settings.set_user_settings(&new_settings_content, cx) - }) - }) - .ok(); - } - } - }) - .detach(); -} diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs deleted file mode 100644 index 0741bb9e081ca4c17536f96623ad0d2830243051..0000000000000000000000000000000000000000 --- a/crates/agent2/src/thread.rs +++ /dev/null @@ -1,1566 +0,0 @@ -use crate::{ContextServerRegistry, SystemPromptTemplate, Template, Templates}; -use acp_thread::{MentionUri, UserMessageId}; -use action_log::ActionLog; -use agent_client_protocol as acp; -use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::adapt_schema_to_format; -use cloud_llm_client::{CompletionIntent, CompletionRequestStatus}; -use collections::IndexMap; -use fs::Fs; -use futures::{ - channel::{mpsc, oneshot}, - stream::FuturesUnordered, -}; -use gpui::{App, Context, Entity, SharedString, Task}; -use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelImage, LanguageModelProviderId, - LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, - LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, LanguageModelToolUseId, Role, StopReason, -}; -use project::Project; -use prompt_store::ProjectContext; -use schemars::{JsonSchema, Schema}; -use serde::{Deserialize, Serialize}; -use settings::{Settings, update_settings_file}; -use smol::stream::StreamExt; -use std::{cell::RefCell, collections::BTreeMap, path::Path, rc::Rc, sync::Arc}; -use std::{fmt::Write, ops::Range}; -use util::{ResultExt, markdown::MarkdownCodeBlock}; -use uuid::Uuid; - -#[derive( - Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema, -)] -pub struct ThreadId(Arc); - -impl ThreadId { - pub fn new() -> Self { - Self(Uuid::new_v4().to_string().into()) - } -} - -impl std::fmt::Display for ThreadId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&str> for ThreadId { - fn from(value: &str) -> Self { - Self(value.into()) - } -} - -/// The ID of the user prompt that initiated a request. -/// -/// This equates to the user physically submitting a message to the model (e.g., by pressing the Enter key). -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] -pub struct PromptId(Arc); - -impl PromptId { - pub fn new() -> Self { - Self(Uuid::new_v4().to_string().into()) - } -} - -impl std::fmt::Display for PromptId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Message { - User(UserMessage), - Agent(AgentMessage), - Resume, -} - -impl Message { - pub fn as_agent_message(&self) -> Option<&AgentMessage> { - match self { - Message::Agent(agent_message) => Some(agent_message), - _ => None, - } - } - - pub fn to_markdown(&self) -> String { - match self { - Message::User(message) => message.to_markdown(), - Message::Agent(message) => message.to_markdown(), - Message::Resume => "[resumed after tool use limit was reached]".into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UserMessage { - pub id: UserMessageId, - pub content: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UserMessageContent { - Text(String), - Mention { uri: MentionUri, content: String }, - Image(LanguageModelImage), -} - -impl UserMessage { - pub fn to_markdown(&self) -> String { - let mut markdown = String::from("## User\n\n"); - - for content in &self.content { - match content { - UserMessageContent::Text(text) => { - markdown.push_str(text); - markdown.push('\n'); - } - UserMessageContent::Image(_) => { - markdown.push_str("\n"); - } - UserMessageContent::Mention { uri, content } => { - if !content.is_empty() { - let _ = write!(&mut markdown, "{}\n\n{}\n", uri.as_link(), content); - } else { - let _ = write!(&mut markdown, "{}\n", uri.as_link()); - } - } - } - } - - markdown - } - - fn to_request(&self) -> LanguageModelRequestMessage { - let mut message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::with_capacity(self.content.len()), - cache: false, - }; - - const OPEN_CONTEXT: &str = "\n\ - The following items were attached by the user. \ - They are up-to-date and don't need to be re-read.\n\n"; - - const OPEN_FILES_TAG: &str = ""; - const OPEN_SYMBOLS_TAG: &str = ""; - const OPEN_THREADS_TAG: &str = ""; - const OPEN_FETCH_TAG: &str = ""; - const OPEN_RULES_TAG: &str = - "\nThe user has specified the following rules that should be applied:\n"; - - let mut file_context = OPEN_FILES_TAG.to_string(); - let mut symbol_context = OPEN_SYMBOLS_TAG.to_string(); - let mut thread_context = OPEN_THREADS_TAG.to_string(); - let mut fetch_context = OPEN_FETCH_TAG.to_string(); - let mut rules_context = OPEN_RULES_TAG.to_string(); - - for chunk in &self.content { - let chunk = match chunk { - UserMessageContent::Text(text) => { - language_model::MessageContent::Text(text.clone()) - } - UserMessageContent::Image(value) => { - language_model::MessageContent::Image(value.clone()) - } - UserMessageContent::Mention { uri, content } => { - match uri { - MentionUri::File { abs_path, .. } => { - write!( - &mut symbol_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag(&abs_path, None), - text: &content.to_string(), - } - ) - .ok(); - } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. - } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: &codeblock_tag(&path, Some(line_range)), - text: &content - } - ) - .ok(); - } - MentionUri::Thread { .. } => { - write!(&mut thread_context, "\n{}\n", content).ok(); - } - MentionUri::TextThread { .. } => { - write!(&mut thread_context, "\n{}\n", content).ok(); - } - MentionUri::Rule { .. } => { - write!( - &mut rules_context, - "\n{}", - MarkdownCodeBlock { - tag: "", - text: &content - } - ) - .ok(); - } - MentionUri::Fetch { url } => { - write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); - } - } - - language_model::MessageContent::Text(uri.as_link().to_string()) - } - }; - - message.content.push(chunk); - } - - let len_before_context = message.content.len(); - - if file_context.len() > OPEN_FILES_TAG.len() { - file_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(file_context)); - } - - if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { - symbol_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(symbol_context)); - } - - if thread_context.len() > OPEN_THREADS_TAG.len() { - thread_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(thread_context)); - } - - if fetch_context.len() > OPEN_FETCH_TAG.len() { - fetch_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(fetch_context)); - } - - if rules_context.len() > OPEN_RULES_TAG.len() { - rules_context.push_str("\n"); - message - .content - .push(language_model::MessageContent::Text(rules_context)); - } - - if message.content.len() > len_before_context { - message.content.insert( - len_before_context, - language_model::MessageContent::Text(OPEN_CONTEXT.into()), - ); - message - .content - .push(language_model::MessageContent::Text("".into())); - } - - message - } -} - -fn codeblock_tag(full_path: &Path, line_range: Option<&Range>) -> String { - let mut result = String::new(); - - if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { - let _ = write!(result, "{} ", extension); - } - - let _ = write!(result, "{}", full_path.display()); - - if let Some(range) = line_range { - if range.start == range.end { - let _ = write!(result, ":{}", range.start + 1); - } else { - let _ = write!(result, ":{}-{}", range.start + 1, range.end + 1); - } - } - - result -} - -impl AgentMessage { - pub fn to_markdown(&self) -> String { - let mut markdown = String::from("## Assistant\n\n"); - - for content in &self.content { - match content { - AgentMessageContent::Text(text) => { - markdown.push_str(text); - markdown.push('\n'); - } - AgentMessageContent::Thinking { text, .. } => { - markdown.push_str(""); - markdown.push_str(text); - markdown.push_str("\n"); - } - AgentMessageContent::RedactedThinking(_) => { - markdown.push_str("\n") - } - AgentMessageContent::Image(_) => { - markdown.push_str("\n"); - } - AgentMessageContent::ToolUse(tool_use) => { - markdown.push_str(&format!( - "**Tool Use**: {} (ID: {})\n", - tool_use.name, tool_use.id - )); - markdown.push_str(&format!( - "{}\n", - MarkdownCodeBlock { - tag: "json", - text: &format!("{:#}", tool_use.input) - } - )); - } - } - } - - for tool_result in self.tool_results.values() { - markdown.push_str(&format!( - "**Tool Result**: {} (ID: {})\n\n", - tool_result.tool_name, tool_result.tool_use_id - )); - if tool_result.is_error { - markdown.push_str("**ERROR:**\n"); - } - - match &tool_result.content { - LanguageModelToolResultContent::Text(text) => { - writeln!(markdown, "{text}\n").ok(); - } - LanguageModelToolResultContent::Image(_) => { - writeln!(markdown, "\n").ok(); - } - } - - if let Some(output) = tool_result.output.as_ref() { - writeln!( - markdown, - "**Debug Output**:\n\n```json\n{}\n```\n", - serde_json::to_string_pretty(output).unwrap() - ) - .unwrap(); - } - } - - markdown - } - - pub fn to_request(&self) -> Vec { - let mut assistant_message = LanguageModelRequestMessage { - role: Role::Assistant, - content: Vec::with_capacity(self.content.len()), - cache: false, - }; - for chunk in &self.content { - let chunk = match chunk { - AgentMessageContent::Text(text) => { - language_model::MessageContent::Text(text.clone()) - } - AgentMessageContent::Thinking { text, signature } => { - language_model::MessageContent::Thinking { - text: text.clone(), - signature: signature.clone(), - } - } - AgentMessageContent::RedactedThinking(value) => { - language_model::MessageContent::RedactedThinking(value.clone()) - } - AgentMessageContent::ToolUse(value) => { - language_model::MessageContent::ToolUse(value.clone()) - } - AgentMessageContent::Image(value) => { - language_model::MessageContent::Image(value.clone()) - } - }; - assistant_message.content.push(chunk); - } - - let mut user_message = LanguageModelRequestMessage { - role: Role::User, - content: Vec::new(), - cache: false, - }; - - for tool_result in self.tool_results.values() { - user_message - .content - .push(language_model::MessageContent::ToolResult( - tool_result.clone(), - )); - } - - let mut messages = Vec::new(); - if !assistant_message.content.is_empty() { - messages.push(assistant_message); - } - if !user_message.content.is_empty() { - messages.push(user_message); - } - messages - } -} - -#[derive(Default, Debug, Clone, PartialEq, Eq)] -pub struct AgentMessage { - pub content: Vec, - pub tool_results: IndexMap, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AgentMessageContent { - Text(String), - Thinking { - text: String, - signature: Option, - }, - RedactedThinking(String), - Image(LanguageModelImage), - ToolUse(LanguageModelToolUse), -} - -#[derive(Debug)] -pub enum AgentResponseEvent { - Text(String), - Thinking(String), - ToolCall(acp::ToolCall), - ToolCallUpdate(acp_thread::ToolCallUpdate), - ToolCallAuthorization(ToolCallAuthorization), - Stop(acp::StopReason), -} - -#[derive(Debug)] -pub struct ToolCallAuthorization { - pub tool_call: acp::ToolCallUpdate, - pub options: Vec, - pub response: oneshot::Sender, -} - -pub struct Thread { - id: ThreadId, - prompt_id: PromptId, - messages: Vec, - completion_mode: CompletionMode, - /// Holds the task that handles agent interaction until the end of the turn. - /// Survives across multiple requests as the model performs tool calls and - /// we run tools, report their results. - running_turn: Option>, - pending_message: Option, - tools: BTreeMap>, - tool_use_limit_reached: bool, - context_server_registry: Entity, - profile_id: AgentProfileId, - project_context: Rc>, - templates: Arc, - model: Arc, - project: Entity, - action_log: Entity, -} - -impl Thread { - pub fn new( - project: Entity, - project_context: Rc>, - context_server_registry: Entity, - action_log: Entity, - templates: Arc, - model: Arc, - cx: &mut Context, - ) -> Self { - let profile_id = AgentSettings::get_global(cx).default_profile.clone(); - Self { - id: ThreadId::new(), - prompt_id: PromptId::new(), - messages: Vec::new(), - completion_mode: CompletionMode::Normal, - running_turn: None, - pending_message: None, - tools: BTreeMap::default(), - tool_use_limit_reached: false, - context_server_registry, - profile_id, - project_context, - templates, - model, - project, - action_log, - } - } - - pub fn project(&self) -> &Entity { - &self.project - } - - pub fn action_log(&self) -> &Entity { - &self.action_log - } - - pub fn model(&self) -> &Arc { - &self.model - } - - pub fn set_model(&mut self, model: Arc) { - self.model = model; - } - - pub fn completion_mode(&self) -> CompletionMode { - self.completion_mode - } - - pub fn set_completion_mode(&mut self, mode: CompletionMode) { - self.completion_mode = mode; - } - - #[cfg(any(test, feature = "test-support"))] - pub fn last_message(&self) -> Option { - if let Some(message) = self.pending_message.clone() { - Some(Message::Agent(message)) - } else { - self.messages.last().cloned() - } - } - - pub fn add_tool(&mut self, tool: impl AgentTool) { - self.tools.insert(tool.name(), tool.erase()); - } - - pub fn remove_tool(&mut self, name: &str) -> bool { - self.tools.remove(name).is_some() - } - - pub fn profile(&self) -> &AgentProfileId { - &self.profile_id - } - - pub fn set_profile(&mut self, profile_id: AgentProfileId) { - self.profile_id = profile_id; - } - - pub fn cancel(&mut self) { - // TODO: do we need to emit a stop::cancel for ACP? - self.running_turn.take(); - self.flush_pending_message(); - } - - pub fn truncate(&mut self, message_id: UserMessageId) -> Result<()> { - self.cancel(); - let Some(position) = self.messages.iter().position( - |msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id), - ) else { - return Err(anyhow!("Message not found")); - }; - self.messages.truncate(position); - Ok(()) - } - - pub fn resume( - &mut self, - cx: &mut Context, - ) -> Result>> { - anyhow::ensure!( - self.tool_use_limit_reached, - "can only resume after tool use limit is reached" - ); - - self.messages.push(Message::Resume); - cx.notify(); - - log::info!("Total messages in thread: {}", self.messages.len()); - Ok(self.run_turn(cx)) - } - - /// Sending a message results in the model streaming a response, which could include tool calls. - /// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent. - /// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn. - pub fn send( - &mut self, - id: UserMessageId, - content: impl IntoIterator, - cx: &mut Context, - ) -> mpsc::UnboundedReceiver> - where - T: Into, - { - log::info!("Thread::send called with model: {:?}", self.model.name()); - self.advance_prompt_id(); - - let content = content.into_iter().map(Into::into).collect::>(); - log::debug!("Thread::send content: {:?}", content); - - self.messages - .push(Message::User(UserMessage { id, content })); - cx.notify(); - - log::info!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) - } - - fn run_turn( - &mut self, - cx: &mut Context, - ) -> mpsc::UnboundedReceiver> { - let model = self.model.clone(); - let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = AgentResponseEventStream(events_tx); - let message_ix = self.messages.len().saturating_sub(1); - self.tool_use_limit_reached = false; - self.running_turn = Some(cx.spawn(async move |this, cx| { - log::info!("Starting agent turn execution"); - let turn_result: Result<()> = async { - let mut completion_intent = CompletionIntent::UserPrompt; - loop { - log::debug!( - "Building completion request with intent: {:?}", - completion_intent - ); - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })?; - - log::info!("Calling model.stream_completion"); - let mut events = model.stream_completion(request, cx).await?; - log::debug!("Stream completion started successfully"); - - let mut tool_use_limit_reached = false; - let mut tool_uses = FuturesUnordered::new(); - while let Some(event) = events.next().await { - match event? { - LanguageModelCompletionEvent::StatusUpdate( - CompletionRequestStatus::ToolUseLimitReached, - ) => { - tool_use_limit_reached = true; - } - LanguageModelCompletionEvent::Stop(reason) => { - event_stream.send_stop(reason); - if reason == StopReason::Refusal { - this.update(cx, |this, _cx| { - this.flush_pending_message(); - this.messages.truncate(message_ix); - })?; - return Ok(()); - } - } - event => { - log::trace!("Received completion event: {:?}", event); - this.update(cx, |this, cx| { - tool_uses.extend(this.handle_streamed_completion_event( - event, - &event_stream, - cx, - )); - }) - .ok(); - } - } - } - - let used_tools = tool_uses.is_empty(); - while let Some(tool_result) = tool_uses.next().await { - log::info!("Tool finished {:?}", tool_result); - - event_stream.update_tool_call_fields( - &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, - ); - this.update(cx, |this, _cx| { - this.pending_message() - .tool_results - .insert(tool_result.tool_use_id.clone(), tool_result); - }) - .ok(); - } - - if tool_use_limit_reached { - log::info!("Tool use limit reached, completing turn"); - this.update(cx, |this, _cx| this.tool_use_limit_reached = true)?; - return Err(language_model::ToolUseLimitReachedError.into()); - } else if used_tools { - log::info!("No tool uses found, completing turn"); - return Ok(()); - } else { - this.update(cx, |this, _| this.flush_pending_message())?; - completion_intent = CompletionIntent::ToolResults; - } - } - } - .await; - - this.update(cx, |this, _| this.flush_pending_message()).ok(); - if let Err(error) = turn_result { - log::error!("Turn execution failed: {:?}", error); - event_stream.send_error(error); - } else { - log::info!("Turn execution completed successfully"); - } - })); - events_rx - } - - pub fn build_system_message(&self) -> LanguageModelRequestMessage { - log::debug!("Building system message"); - let prompt = SystemPromptTemplate { - project: &self.project_context.borrow(), - available_tools: self.tools.keys().cloned().collect(), - } - .render(&self.templates) - .context("failed to build system prompt") - .expect("Invalid template"); - log::debug!("System message built"); - LanguageModelRequestMessage { - role: Role::System, - content: vec![prompt.into()], - cache: true, - } - } - - /// A helper method that's called on every streamed completion event. - /// Returns an optional tool result task, which the main agentic loop in - /// send will send back to the model when it resolves. - fn handle_streamed_completion_event( - &mut self, - event: LanguageModelCompletionEvent, - event_stream: &AgentResponseEventStream, - cx: &mut Context, - ) -> Option> { - log::trace!("Handling streamed completion event: {:?}", event); - use LanguageModelCompletionEvent::*; - - match event { - StartMessage { .. } => { - self.flush_pending_message(); - self.pending_message = Some(AgentMessage::default()); - } - Text(new_text) => self.handle_text_event(new_text, event_stream, cx), - Thinking { text, signature } => { - self.handle_thinking_event(text, signature, event_stream, cx) - } - RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), - ToolUse(tool_use) => { - return self.handle_tool_use_event(tool_use, event_stream, cx); - } - ToolUseJsonParseError { - id, - tool_name, - raw_input, - json_parse_error, - } => { - return Some(Task::ready(self.handle_tool_use_json_parse_error_event( - id, - tool_name, - raw_input, - json_parse_error, - ))); - } - UsageUpdate(_) | StatusUpdate(_) => {} - Stop(_) => unreachable!(), - } - - None - } - - fn handle_text_event( - &mut self, - new_text: String, - event_stream: &AgentResponseEventStream, - cx: &mut Context, - ) { - event_stream.send_text(&new_text); - - let last_message = self.pending_message(); - if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() { - text.push_str(&new_text); - } else { - last_message - .content - .push(AgentMessageContent::Text(new_text)); - } - - cx.notify(); - } - - fn handle_thinking_event( - &mut self, - new_text: String, - new_signature: Option, - event_stream: &AgentResponseEventStream, - cx: &mut Context, - ) { - event_stream.send_thinking(&new_text); - - let last_message = self.pending_message(); - if let Some(AgentMessageContent::Thinking { text, signature }) = - last_message.content.last_mut() - { - text.push_str(&new_text); - *signature = new_signature.or(signature.take()); - } else { - last_message.content.push(AgentMessageContent::Thinking { - text: new_text, - signature: new_signature, - }); - } - - cx.notify(); - } - - fn handle_redacted_thinking_event(&mut self, data: String, cx: &mut Context) { - let last_message = self.pending_message(); - last_message - .content - .push(AgentMessageContent::RedactedThinking(data)); - cx.notify(); - } - - fn handle_tool_use_event( - &mut self, - tool_use: LanguageModelToolUse, - event_stream: &AgentResponseEventStream, - cx: &mut Context, - ) -> Option> { - cx.notify(); - - let tool = self.tools.get(tool_use.name.as_ref()).cloned(); - let mut title = SharedString::from(&tool_use.name); - let mut kind = acp::ToolKind::Other; - if let Some(tool) = tool.as_ref() { - title = tool.initial_title(tool_use.input.clone()); - kind = tool.kind(); - } - - // Ensure the last message ends in the current tool use - let last_message = self.pending_message(); - let push_new_tool_use = last_message.content.last_mut().map_or(true, |content| { - if let AgentMessageContent::ToolUse(last_tool_use) = content { - if last_tool_use.id == tool_use.id { - *last_tool_use = tool_use.clone(); - false - } else { - true - } - } else { - true - } - }); - - if push_new_tool_use { - event_stream.send_tool_call(&tool_use.id, title, kind, tool_use.input.clone()); - last_message - .content - .push(AgentMessageContent::ToolUse(tool_use.clone())); - } else { - event_stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields { - title: Some(title.into()), - kind: Some(kind), - raw_input: Some(tool_use.input.clone()), - ..Default::default() - }, - ); - } - - if !tool_use.is_input_complete { - return None; - } - - let Some(tool) = tool else { - let content = format!("No tool named {} exists", tool_use.name); - return Some(Task::ready(LanguageModelToolResult { - content: LanguageModelToolResultContent::Text(Arc::from(content)), - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - output: None, - })); - }; - - let fs = self.project.read(cx).fs().clone(); - let tool_event_stream = - ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); - tool_event_stream.update_fields(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); - let supports_images = self.model.supports_images(); - let tool_result = tool.run(tool_use.input, tool_event_stream, cx); - log::info!("Running tool {}", tool_use.name); - Some(cx.foreground_executor().spawn(async move { - let tool_result = tool_result.await.and_then(|output| { - if let LanguageModelToolResultContent::Image(_) = &output.llm_output { - if !supports_images { - return Err(anyhow!( - "Attempted to read an image, but this model doesn't support it.", - )); - } - } - Ok(output) - }); - - match tool_result { - Ok(output) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: false, - content: output.llm_output, - output: Some(output.raw_output), - }, - Err(error) => LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, - is_error: true, - content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: None, - }, - } - })) - } - - fn handle_tool_use_json_parse_error_event( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - raw_input: Arc, - json_parse_error: String, - ) -> LanguageModelToolResult { - let tool_output = format!("Error parsing input JSON: {json_parse_error}"); - LanguageModelToolResult { - tool_use_id, - tool_name, - is_error: true, - content: LanguageModelToolResultContent::Text(tool_output.into()), - output: Some(serde_json::Value::String(raw_input.to_string())), - } - } - - fn pending_message(&mut self) -> &mut AgentMessage { - self.pending_message.get_or_insert_default() - } - - fn flush_pending_message(&mut self) { - let Some(mut message) = self.pending_message.take() else { - return; - }; - - for content in &message.content { - let AgentMessageContent::ToolUse(tool_use) = content else { - continue; - }; - - if !message.tool_results.contains_key(&tool_use.id) { - message.tool_results.insert( - tool_use.id.clone(), - LanguageModelToolResult { - tool_use_id: tool_use.id.clone(), - tool_name: tool_use.name.clone(), - is_error: true, - content: LanguageModelToolResultContent::Text( - "Tool canceled by user".into(), - ), - output: None, - }, - ); - } - } - - self.messages.push(Message::Agent(message)); - } - - pub(crate) fn build_completion_request( - &self, - completion_intent: CompletionIntent, - cx: &mut App, - ) -> LanguageModelRequest { - log::debug!("Building completion request"); - log::debug!("Completion intent: {:?}", completion_intent); - log::debug!("Completion mode: {:?}", self.completion_mode); - - let messages = self.build_request_messages(); - log::info!("Request will include {} messages", messages.len()); - - let tools = if let Some(tools) = self.tools(cx).log_err() { - tools - .filter_map(|tool| { - let tool_name = tool.name().to_string(); - log::trace!("Including tool: {}", tool_name); - Some(LanguageModelRequestTool { - name: tool_name, - description: tool.description().to_string(), - input_schema: tool - .input_schema(self.model.tool_input_format()) - .log_err()?, - }) - }) - .collect() - } else { - Vec::new() - }; - - log::info!("Request includes {} tools", tools.len()); - - let request = LanguageModelRequest { - thread_id: Some(self.id.to_string()), - prompt_id: Some(self.prompt_id.to_string()), - intent: Some(completion_intent), - mode: Some(self.completion_mode.into()), - messages, - tools, - tool_choice: None, - stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(self.model(), cx), - thinking_allowed: true, - }; - - log::debug!("Completion request built successfully"); - request - } - - fn tools<'a>(&'a self, cx: &'a App) -> Result>> { - let profile = AgentSettings::get_global(cx) - .profiles - .get(&self.profile_id) - .context("profile not found")?; - let provider_id = self.model.provider_id(); - - Ok(self - .tools - .iter() - .filter(move |(_, tool)| tool.supported_provider(&provider_id)) - .filter_map(|(tool_name, tool)| { - if profile.is_tool_enabled(tool_name) { - Some(tool) - } else { - None - } - }) - .chain(self.context_server_registry.read(cx).servers().flat_map( - |(server_id, tools)| { - tools.iter().filter_map(|(tool_name, tool)| { - if profile.is_context_server_tool_enabled(&server_id.0, tool_name) { - Some(tool) - } else { - None - } - }) - }, - ))) - } - - fn build_request_messages(&self) -> Vec { - log::trace!( - "Building request messages from {} thread messages", - self.messages.len() - ); - let mut messages = vec![self.build_system_message()]; - for message in &self.messages { - match message { - Message::User(message) => messages.push(message.to_request()), - Message::Agent(message) => messages.extend(message.to_request()), - Message::Resume => messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec!["Continue where you left off".into()], - cache: false, - }), - } - } - - if let Some(message) = self.pending_message.as_ref() { - messages.extend(message.to_request()); - } - - if let Some(last_user_message) = messages - .iter_mut() - .rev() - .find(|message| message.role == Role::User) - { - last_user_message.cache = true; - } - - messages - } - - pub fn to_markdown(&self) -> String { - let mut markdown = String::new(); - for (ix, message) in self.messages.iter().enumerate() { - if ix > 0 { - markdown.push('\n'); - } - markdown.push_str(&message.to_markdown()); - } - - if let Some(message) = self.pending_message.as_ref() { - markdown.push('\n'); - markdown.push_str(&message.to_markdown()); - } - - markdown - } - - fn advance_prompt_id(&mut self) { - self.prompt_id = PromptId::new(); - } -} - -pub trait AgentTool -where - Self: 'static + Sized, -{ - type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema; - type Output: for<'de> Deserialize<'de> + Serialize + Into; - - fn name(&self) -> SharedString; - - fn description(&self) -> SharedString { - let schema = schemars::schema_for!(Self::Input); - SharedString::new( - schema - .get("description") - .and_then(|description| description.as_str()) - .unwrap_or_default(), - ) - } - - fn kind(&self) -> acp::ToolKind; - - /// The initial tool title to display. Can be updated during the tool run. - fn initial_title(&self, input: Result) -> SharedString; - - /// Returns the JSON schema that describes the tool's input. - fn input_schema(&self) -> Schema { - schemars::schema_for!(Self::Input) - } - - /// Some tools rely on a provider for the underlying billing or other reasons. - /// Allow the tool to check if they are compatible, or should be filtered out. - fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool { - true - } - - /// Runs the tool with the provided input. - fn run( - self: Arc, - input: Self::Input, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task>; - - fn erase(self) -> Arc { - Arc::new(Erased(Arc::new(self))) - } -} - -pub struct Erased(T); - -pub struct AgentToolOutput { - pub llm_output: LanguageModelToolResultContent, - pub raw_output: serde_json::Value, -} - -pub trait AnyAgentTool { - fn name(&self) -> SharedString; - fn description(&self) -> SharedString; - fn kind(&self) -> acp::ToolKind; - fn initial_title(&self, input: serde_json::Value) -> SharedString; - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; - fn supported_provider(&self, _provider: &LanguageModelProviderId) -> bool { - true - } - fn run( - self: Arc, - input: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task>; -} - -impl AnyAgentTool for Erased> -where - T: AgentTool, -{ - fn name(&self) -> SharedString { - self.0.name() - } - - fn description(&self) -> SharedString { - self.0.description() - } - - fn kind(&self) -> agent_client_protocol::ToolKind { - self.0.kind() - } - - fn initial_title(&self, input: serde_json::Value) -> SharedString { - let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); - self.0.initial_title(parsed_input) - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - let mut json = serde_json::to_value(self.0.input_schema())?; - adapt_schema_to_format(&mut json, format)?; - Ok(json) - } - - fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool { - self.0.supported_provider(provider) - } - - fn run( - self: Arc, - input: serde_json::Value, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - cx.spawn(async move |cx| { - let input = serde_json::from_value(input)?; - let output = cx - .update(|cx| self.0.clone().run(input, event_stream, cx))? - .await?; - let raw_output = serde_json::to_value(&output)?; - Ok(AgentToolOutput { - llm_output: output.into(), - raw_output, - }) - }) - } -} - -#[derive(Clone)] -struct AgentResponseEventStream(mpsc::UnboundedSender>); - -impl AgentResponseEventStream { - fn send_text(&self, text: &str) { - self.0 - .unbounded_send(Ok(AgentResponseEvent::Text(text.to_string()))) - .ok(); - } - - fn send_thinking(&self, text: &str) { - self.0 - .unbounded_send(Ok(AgentResponseEvent::Thinking(text.to_string()))) - .ok(); - } - - fn send_tool_call( - &self, - id: &LanguageModelToolUseId, - title: SharedString, - kind: acp::ToolKind, - input: serde_json::Value, - ) { - self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCall(Self::initial_tool_call( - id, - title.to_string(), - kind, - input, - )))) - .ok(); - } - - fn initial_tool_call( - id: &LanguageModelToolUseId, - title: String, - kind: acp::ToolKind, - input: serde_json::Value, - ) -> acp::ToolCall { - acp::ToolCall { - id: acp::ToolCallId(id.to_string().into()), - title, - kind, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(input), - raw_output: None, - } - } - - fn update_tool_call_fields( - &self, - tool_use_id: &LanguageModelToolUseId, - fields: acp::ToolCallUpdateFields, - ) { - self.0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( - acp::ToolCallUpdate { - id: acp::ToolCallId(tool_use_id.to_string().into()), - fields, - } - .into(), - ))) - .ok(); - } - - fn send_stop(&self, reason: StopReason) { - match reason { - StopReason::EndTurn => { - self.0 - .unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::EndTurn))) - .ok(); - } - StopReason::MaxTokens => { - self.0 - .unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::MaxTokens))) - .ok(); - } - StopReason::Refusal => { - self.0 - .unbounded_send(Ok(AgentResponseEvent::Stop(acp::StopReason::Refusal))) - .ok(); - } - StopReason::ToolUse => {} - } - } - - fn send_error(&self, error: impl Into) { - self.0.unbounded_send(Err(error.into())).ok(); - } -} - -#[derive(Clone)] -pub struct ToolCallEventStream { - tool_use_id: LanguageModelToolUseId, - stream: AgentResponseEventStream, - fs: Option>, -} - -impl ToolCallEventStream { - #[cfg(test)] - pub fn test() -> (Self, ToolCallEventStreamReceiver) { - let (events_tx, events_rx) = mpsc::unbounded::>(); - - let stream = - ToolCallEventStream::new("test_id".into(), AgentResponseEventStream(events_tx), None); - - (stream, ToolCallEventStreamReceiver(events_rx)) - } - - fn new( - tool_use_id: LanguageModelToolUseId, - stream: AgentResponseEventStream, - fs: Option>, - ) -> Self { - Self { - tool_use_id, - stream, - fs, - } - } - - pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) { - self.stream - .update_tool_call_fields(&self.tool_use_id, fields); - } - - pub fn update_diff(&self, diff: Entity) { - self.stream - .0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( - acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - diff, - } - .into(), - ))) - .ok(); - } - - pub fn update_terminal(&self, terminal: Entity) { - self.stream - .0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate( - acp_thread::ToolCallUpdateTerminal { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - terminal, - } - .into(), - ))) - .ok(); - } - - pub fn authorize(&self, title: impl Into, cx: &mut App) -> Task> { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return Task::ready(Ok(())); - } - - let (response_tx, response_rx) = oneshot::channel(); - self.stream - .0 - .unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - title: Some(title.into()), - ..Default::default() - }, - }, - options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - }, - ], - response: response_tx, - }, - ))) - .ok(); - let fs = self.fs.clone(); - cx.spawn(async move |cx| match response_rx.await?.0.as_ref() { - "always_allow" => { - if let Some(fs) = fs.clone() { - cx.update(|cx| { - update_settings_file::(fs, cx, |settings, _| { - settings.set_always_allow_tool_actions(true); - }); - })?; - } - - Ok(()) - } - "allow" => Ok(()), - _ => Err(anyhow!("Permission to run tool denied by user")), - }) - } -} - -#[cfg(test)] -pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver>); - -#[cfg(test)] -impl ToolCallEventStreamReceiver { - pub async fn expect_authorization(&mut self) -> ToolCallAuthorization { - let event = self.0.next().await; - if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event { - auth - } else { - panic!("Expected ToolCallAuthorization but got: {:?}", event); - } - } - - pub async fn expect_terminal(&mut self) -> Entity { - let event = self.0.next().await; - if let Some(Ok(AgentResponseEvent::ToolCallUpdate( - acp_thread::ToolCallUpdate::UpdateTerminal(update), - ))) = event - { - update.terminal - } else { - panic!("Expected terminal but got: {:?}", event); - } - } -} - -#[cfg(test)] -impl std::ops::Deref for ToolCallEventStreamReceiver { - type Target = mpsc::UnboundedReceiver>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[cfg(test)] -impl std::ops::DerefMut for ToolCallEventStreamReceiver { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From<&str> for UserMessageContent { - fn from(text: &str) -> Self { - Self::Text(text.into()) - } -} - -impl From for UserMessageContent { - fn from(value: acp::ContentBlock) -> Self { - match value { - acp::ContentBlock::Text(text_content) => Self::Text(text_content.text), - acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)), - acp::ContentBlock::Audio(_) => { - // TODO - Self::Text("[audio]".to_string()) - } - acp::ContentBlock::ResourceLink(resource_link) => { - match MentionUri::parse(&resource_link.uri) { - Ok(uri) => Self::Mention { - uri, - content: String::new(), - }, - Err(err) => { - log::error!("Failed to parse mention link: {}", err); - Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri)) - } - } - } - acp::ContentBlock::Resource(resource) => match resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(resource) => { - match MentionUri::parse(&resource.uri) { - Ok(uri) => Self::Mention { - uri, - content: resource.text, - }, - Err(err) => { - log::error!("Failed to parse mention link: {}", err); - Self::Text( - MarkdownCodeBlock { - tag: &resource.uri, - text: &resource.text, - } - .to_string(), - ) - } - } - } - acp::EmbeddedResourceResource::BlobResourceContents(_) => { - // TODO - Self::Text("[blob]".to_string()) - } - }, - } - } -} - -fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { - LanguageModelImage { - source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), - } -} diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs deleted file mode 100644 index d1f2b3b1c7ad3ed7ade2324c61c1e72d7e7e4006..0000000000000000000000000000000000000000 --- a/crates/agent2/src/tools.rs +++ /dev/null @@ -1,35 +0,0 @@ -mod context_server_registry; -mod copy_path_tool; -mod create_directory_tool; -mod delete_path_tool; -mod diagnostics_tool; -mod edit_file_tool; -mod fetch_tool; -mod find_path_tool; -mod grep_tool; -mod list_directory_tool; -mod move_path_tool; -mod now_tool; -mod open_tool; -mod read_file_tool; -mod terminal_tool; -mod thinking_tool; -mod web_search_tool; - -pub use context_server_registry::*; -pub use copy_path_tool::*; -pub use create_directory_tool::*; -pub use delete_path_tool::*; -pub use diagnostics_tool::*; -pub use edit_file_tool::*; -pub use fetch_tool::*; -pub use find_path_tool::*; -pub use grep_tool::*; -pub use list_directory_tool::*; -pub use move_path_tool::*; -pub use now_tool::*; -pub use open_tool::*; -pub use read_file_tool::*; -pub use terminal_tool::*; -pub use thinking_tool::*; -pub use web_search_tool::*; diff --git a/crates/agent2/src/tools/terminal_tool.rs b/crates/agent2/src/tools/terminal_tool.rs deleted file mode 100644 index ecb855ac34d655caefab5ed0bd4f33d60be547f8..0000000000000000000000000000000000000000 --- a/crates/agent2/src/tools/terminal_tool.rs +++ /dev/null @@ -1,473 +0,0 @@ -use agent_client_protocol as acp; -use anyhow::Result; -use futures::{FutureExt as _, future::Shared}; -use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::{Project, terminals::TerminalKind}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; -use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode}; - -use crate::{AgentTool, ToolCallEventStream}; - -const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024; - -/// Executes a shell one-liner and returns the combined output. -/// -/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. -/// -/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant. -/// -/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. -/// -/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. -/// -/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct TerminalToolInput { - /// The one-liner command to execute. - command: String, - /// Working directory for the command. This must be one of the root directories of the project. - cd: String, -} - -pub struct TerminalTool { - project: Entity, - determine_shell: Shared>, -} - -impl TerminalTool { - pub fn new(project: Entity, cx: &mut App) -> Self { - let determine_shell = cx.background_spawn(async move { - if cfg!(windows) { - return get_system_shell(); - } - - if which::which("bash").is_ok() { - log::info!("agent selected bash for terminal tool"); - "bash".into() - } else { - let shell = get_system_shell(); - log::info!("agent selected {shell} for terminal tool"); - shell - } - }); - Self { - project, - determine_shell: determine_shell.shared(), - } - } -} - -impl AgentTool for TerminalTool { - type Input = TerminalToolInput; - type Output = String; - - fn name(&self) -> SharedString { - "terminal".into() - } - - fn kind(&self) -> acp::ToolKind { - acp::ToolKind::Execute - } - - fn initial_title(&self, input: Result) -> SharedString { - if let Ok(input) = input { - let mut lines = input.command.lines(); - let first_line = lines.next().unwrap_or_default(); - let remaining_line_count = lines.count(); - match remaining_line_count { - 0 => MarkdownInlineCode(&first_line).to_string().into(), - 1 => MarkdownInlineCode(&format!( - "{} - {} more line", - first_line, remaining_line_count - )) - .to_string() - .into(), - n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n)) - .to_string() - .into(), - } - } else { - "Run terminal command".into() - } - } - - fn run( - self: Arc, - input: Self::Input, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let language_registry = self.project.read(cx).languages().clone(); - let working_dir = match working_dir(&input, &self.project, cx) { - Ok(dir) => dir, - Err(err) => return Task::ready(Err(err)), - }; - let program = self.determine_shell.clone(); - let command = if cfg!(windows) { - format!("$null | & {{{}}}", input.command.replace("\"", "'")) - } else if let Some(cwd) = working_dir - .as_ref() - .and_then(|cwd| cwd.as_os_str().to_str()) - { - // Make sure once we're *inside* the shell, we cd into `cwd` - format!("(cd {cwd}; {}) self.project.update(cx, |project, cx| { - project.directory_environment(dir.as_path().into(), cx) - }), - None => Task::ready(None).shared(), - }; - - let env = cx.spawn(async move |_| { - let mut env = env.await.unwrap_or_default(); - if cfg!(unix) { - env.insert("PAGER".into(), "cat".into()); - } - env - }); - - let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx); - - cx.spawn({ - async move |cx| { - authorize.await?; - - let program = program.await; - let env = env.await; - let terminal = self - .project - .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { - command: Some(program), - args, - cwd: working_dir.clone(), - env, - ..Default::default() - }), - cx, - ) - })? - .await?; - let acp_terminal = cx.new(|cx| { - acp_thread::Terminal::new( - input.command.clone(), - working_dir.clone(), - terminal.clone(), - language_registry, - cx, - ) - })?; - event_stream.update_terminal(acp_terminal.clone()); - - let exit_status = terminal - .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .await; - let (content, content_line_count) = terminal.read_with(cx, |terminal, _| { - (terminal.get_content(), terminal.total_lines()) - })?; - - let (processed_content, finished_with_empty_output) = process_content( - &content, - &input.command, - exit_status.map(portable_pty::ExitStatus::from), - ); - - acp_terminal - .update(cx, |terminal, cx| { - terminal.finish( - exit_status, - content.len(), - processed_content.len(), - content_line_count, - finished_with_empty_output, - cx, - ); - }) - .log_err(); - - Ok(processed_content) - } - }) - } -} - -fn process_content( - content: &str, - command: &str, - exit_status: Option, -) -> (String, bool) { - let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT; - - let content = if should_truncate { - let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len()); - while !content.is_char_boundary(end_ix) { - end_ix -= 1; - } - // Don't truncate mid-line, clear the remainder of the last line - end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); - &content[..end_ix] - } else { - content - }; - let content = content.trim(); - let is_empty = content.is_empty(); - let content = format!("```\n{content}\n```"); - let content = if should_truncate { - format!( - "Command output too long. The first {} bytes:\n\n{content}", - content.len(), - ) - } else { - content - }; - - let content = match exit_status { - Some(exit_status) if exit_status.success() => { - if is_empty { - "Command executed successfully.".to_string() - } else { - content.to_string() - } - } - Some(exit_status) => { - if is_empty { - format!( - "Command \"{command}\" failed with exit code {}.", - exit_status.exit_code() - ) - } else { - format!( - "Command \"{command}\" failed with exit code {}.\n\n{content}", - exit_status.exit_code() - ) - } - } - None => { - format!( - "Command failed or was interrupted.\nPartial output captured:\n\n{}", - content, - ) - } - }; - (content, is_empty) -} - -fn working_dir( - input: &TerminalToolInput, - project: &Entity, - cx: &mut App, -) -> Result> { - let project = project.read(cx); - let cd = &input.cd; - - if cd == "." || cd == "" { - // Accept "." or "" as meaning "the one worktree" if we only have one worktree. - let mut worktrees = project.worktrees(cx); - - match worktrees.next() { - Some(worktree) => { - anyhow::ensure!( - worktrees.next().is_none(), - "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.", - ); - Ok(Some(worktree.read(cx).abs_path().to_path_buf())) - } - None => Ok(None), - } - } else { - let input_path = Path::new(cd); - - if input_path.is_absolute() { - // Absolute paths are allowed, but only if they're in one of the project's worktrees. - if project - .worktrees(cx) - .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path())) - { - return Ok(Some(input_path.into())); - } - } else { - if let Some(worktree) = project.worktree_for_root_name(cd, cx) { - return Ok(Some(worktree.read(cx).abs_path().to_path_buf())); - } - } - - anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees."); - } -} - -#[cfg(test)] -mod tests { - use agent_settings::AgentSettings; - use editor::EditorSettings; - use fs::RealFs; - use gpui::{BackgroundExecutor, TestAppContext}; - use pretty_assertions::assert_eq; - use serde_json::json; - use settings::{Settings, SettingsStore}; - use terminal::terminal_settings::TerminalSettings; - use theme::ThemeSettings; - use util::test::TempTree; - - use crate::AgentResponseEvent; - - use super::*; - - fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) { - zlog::init_test(); - - executor.allow_parking(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - ThemeSettings::register(cx); - TerminalSettings::register(cx); - EditorSettings::register(cx); - AgentSettings::register(cx); - }); - } - - #[gpui::test] - async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - - let input = TerminalToolInput { - command: "cat".to_owned(), - cd: tree - .path() - .join("project") - .as_path() - .to_string_lossy() - .to_string(), - }; - let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test(); - let result = cx - .update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx)); - - let auth = event_stream_rx.expect_authorization().await; - auth.response.send(auth.options[0].id.clone()).unwrap(); - event_stream_rx.expect_terminal().await; - assert_eq!(result.await.unwrap(), "Command executed successfully."); - } - - #[gpui::test] - async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - "other-project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - - let check = |input, expected, cx: &mut TestAppContext| { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let result = cx.update(|cx| { - Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx) - }); - cx.run_until_parked(); - let event = stream_rx.try_next(); - if let Ok(Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth)))) = event { - auth.response.send(auth.options[0].id.clone()).unwrap(); - } - - cx.spawn(async move |_| { - let output = result.await; - assert_eq!(output.ok(), expected); - }) - }; - - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("project").display() - )), - cx, - ) - .await; - - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - None, // other-project is a dir, but *not* a worktree (yet) - cx, - ) - .await; - - // Absolute path above the worktree root - check( - TerminalToolInput { - command: "pwd".into(), - cd: tree.path().to_string_lossy().into(), - }, - None, - cx, - ) - .await; - - project - .update(cx, |project, cx| { - project.create_worktree(tree.path().join("other-project"), true, cx) - }) - .await - .unwrap(); - - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("other-project").display() - )), - cx, - ) - .await; - - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - None, - cx, - ) - .await; - } -} diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 81c97c8aa6cc4fa64d017b97ade5ddd535487b81..d427290736f140e51cc9d9fc7285aa71fd9b2548 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"] +test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"] e2e = [] [lints] @@ -17,42 +17,53 @@ path = "src/agent_servers.rs" doctest = false [dependencies] +acp_tools.workspace = true acp_thread.workspace = true +action_log.workspace = true agent-client-protocol.workspace = true -agentic-coding-protocol.workspace = true +agent_settings.workspace = true anyhow.workspace = true +async-trait.workspace = true +client.workspace = true collections.workspace = true -context_server.workspace = true +env_logger = { workspace = true, optional = true } +fs.workspace = true futures.workspace = true gpui.workspace = true +gpui_tokio = { workspace = true, optional = true } +http_client.workspace = true indoc.workspace = true -itertools.workspace = true +language.workspace = true +language_model.workspace = true +language_models.workspace = true log.workspace = true -paths.workspace = true project.workspace = true -rand.workspace = true -schemars.workspace = true +release_channel.workspace = true +reqwest_client = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true -strum.workspace = true +task.workspace = true tempfile.workspace = true thiserror.workspace = true ui.workspace = true -util.workspace = true +terminal.workspace = true uuid.workspace = true +util.workspace = true watch.workspace = true -which.workspace = true -workspace-hack.workspace = true [target.'cfg(unix)'.dependencies] libc.workspace = true nix.workspace = true [dev-dependencies] +client = { workspace = true, features = ["test-support"] } env_logger.workspace = true +fs.workspace = true language.workspace = true indoc.workspace = true acp_thread = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +gpui_tokio.workspace = true +reqwest_client = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 00e3e3df5093c6f1acef32665ab0d3d8846fc39f..3a1d5c9fa84b108108542da385286f5bdb88005d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -1,34 +1,939 @@ -use std::{path::Path, rc::Rc}; - -use crate::AgentServerCommand; use acp_thread::AgentConnection; -use anyhow::Result; -use gpui::AsyncApp; +use acp_tools::AcpConnectionRegistry; +use action_log::ActionLog; +use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; +use anyhow::anyhow; +use collections::HashMap; +use futures::AsyncBufReadExt as _; +use futures::io::BufReader; +use project::Project; +use project::agent_server_store::AgentServerCommand; +use serde::Deserialize; +use util::ResultExt as _; + +use std::path::PathBuf; +use std::{any::Any, cell::RefCell}; +use std::{path::Path, rc::Rc}; use thiserror::Error; -mod v0; -mod v1; +use anyhow::{Context as _, Result}; +use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntity}; + +use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; +use terminal::TerminalBuilder; +use terminal::terminal_settings::{AlternateScroll, CursorShape}; #[derive(Debug, Error)] #[error("Unsupported version")] pub struct UnsupportedVersion; +pub struct AcpConnection { + server_name: SharedString, + connection: Rc, + sessions: Rc>>, + auth_methods: Vec, + agent_capabilities: acp::AgentCapabilities, + default_mode: Option, + root_dir: PathBuf, + // NB: Don't move this into the wait_task, since we need to ensure the process is + // killed on drop (setting kill_on_drop on the command seems to not always work). + child: smol::process::Child, + _io_task: Task>, + _wait_task: Task>, + _stderr_task: Task>, +} + +pub struct AcpSession { + thread: WeakEntity, + suppress_abort_err: bool, + models: Option>>, + session_modes: Option>>, +} + pub async fn connect( - server_name: &'static str, + server_name: SharedString, command: AgentServerCommand, root_dir: &Path, + default_mode: Option, + is_remote: bool, cx: &mut AsyncApp, ) -> Result> { - let conn = v1::AcpConnection::stdio(server_name, command.clone(), &root_dir, cx).await; + let conn = AcpConnection::stdio( + server_name, + command.clone(), + root_dir, + default_mode, + is_remote, + cx, + ) + .await?; + Ok(Rc::new(conn) as _) +} + +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; + +impl AcpConnection { + pub async fn stdio( + server_name: SharedString, + command: AgentServerCommand, + root_dir: &Path, + default_mode: Option, + is_remote: bool, + cx: &mut AsyncApp, + ) -> Result { + let mut child = util::command::new_smol_command(&command.path); + child + .args(command.args.iter().map(|arg| arg.as_str())) + .envs(command.env.iter().flatten()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if !is_remote { + child.current_dir(root_dir); + } + let mut child = child.spawn()?; + + let stdout = child.stdout.take().context("Failed to take stdout")?; + let stdin = child.stdin.take().context("Failed to take stdin")?; + let stderr = child.stderr.take().context("Failed to take stderr")?; + log::debug!( + "Spawning external agent server: {:?}, {:?}", + command.path, + command.args + ); + log::trace!("Spawned (pid: {})", child.id()); + + let sessions = Rc::new(RefCell::new(HashMap::default())); + + let (release_channel, version) = cx.update(|cx| { + ( + release_channel::ReleaseChannel::try_global(cx) + .map(|release_channel| release_channel.display_name()), + release_channel::AppVersion::global(cx).to_string(), + ) + })?; + + let client = ClientDelegate { + sessions: sessions.clone(), + cx: cx.clone(), + }; + let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { + let foreground_executor = cx.foreground_executor().clone(); + move |fut| { + foreground_executor.spawn(fut).detach(); + } + }); + + let io_task = cx.background_spawn(io_task); + + let stderr_task = cx.background_spawn(async move { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + log::warn!("agent stderr: {}", &line); + line.clear(); + } + Ok(()) + }); + + let wait_task = cx.spawn({ + let sessions = sessions.clone(); + let status_fut = child.status(); + async move |cx| { + let status = status_fut.await?; + + for session in sessions.borrow().values() { + session + .thread + .update(cx, |thread, cx| { + thread.emit_load_error(LoadError::Exited { status }, cx) + }) + .ok(); + } + + anyhow::Ok(()) + } + }); + + let connection = Rc::new(connection); + + cx.update(|cx| { + AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { + registry.set_active_connection(server_name.clone(), &connection, cx) + }); + })?; + + let response = connection + .initialize(acp::InitializeRequest { + protocol_version: acp::VERSION, + client_capabilities: acp::ClientCapabilities { + fs: acp::FileSystemCapability { + read_text_file: true, + write_text_file: true, + meta: None, + }, + terminal: true, + meta: Some(serde_json::json!({ + // Experimental: Allow for rendering terminal output from the agents + "terminal_output": true, + })), + }, + client_info: Some(acp::Implementation { + name: "zed".to_owned(), + title: release_channel.map(|c| c.to_owned()), + version, + }), + meta: None, + }) + .await?; + + if response.protocol_version < MINIMUM_SUPPORTED_VERSION { + return Err(UnsupportedVersion.into()); + } + + Ok(Self { + auth_methods: response.auth_methods, + root_dir: root_dir.to_owned(), + connection, + server_name, + sessions, + agent_capabilities: response.agent_capabilities, + default_mode, + _io_task: io_task, + _wait_task: wait_task, + _stderr_task: stderr_task, + child, + }) + } + + pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { + &self.agent_capabilities.prompt_capabilities + } + + pub fn root_dir(&self) -> &Path { + &self.root_dir + } +} + +impl Drop for AcpConnection { + fn drop(&mut self) { + // See the comment on the child field. + self.child.kill().log_err(); + } +} + +impl AgentConnection for AcpConnection { + fn new_thread( + self: Rc, + project: Entity, + cwd: &Path, + cx: &mut App, + ) -> Task>> { + let name = self.server_name.clone(); + let conn = self.connection.clone(); + let sessions = self.sessions.clone(); + let default_mode = self.default_mode.clone(); + let cwd = cwd.to_path_buf(); + let context_server_store = project.read(cx).context_server_store().read(cx); + let mcp_servers = if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + let command = configuration.command(); + Some(acp::McpServer::Stdio { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + meta: None, + }) + .collect() + } else { + vec![] + }, + }) + }) + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; + + cx.spawn(async move |cx| { + let response = conn + .new_session(acp::NewSessionRequest { mcp_servers, cwd, meta: None }) + .await + .map_err(|err| { + if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + let mut error = AuthRequired::new(); + + if err.message != acp::ErrorCode::AUTH_REQUIRED.message { + error = error.with_description(err.message); + } + + anyhow!(error) + } else { + anyhow!(err) + } + })?; + + let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes))); + let models = response.models.map(|models| Rc::new(RefCell::new(models))); + + if let Some(default_mode) = default_mode { + if let Some(modes) = modes.as_ref() { + let mut modes_ref = modes.borrow_mut(); + let has_mode = modes_ref.available_modes.iter().any(|mode| mode.id == default_mode); + + if has_mode { + let initial_mode_id = modes_ref.current_mode_id.clone(); + + cx.spawn({ + let default_mode = default_mode.clone(); + let session_id = response.session_id.clone(); + let modes = modes.clone(); + async move |_| { + let result = conn.set_session_mode(acp::SetSessionModeRequest { + session_id, + mode_id: default_mode, + meta: None, + }) + .await.log_err(); + + if result.is_none() { + modes.borrow_mut().current_mode_id = initial_mode_id; + } + } + }).detach(); + + modes_ref.current_mode_id = default_mode; + } else { + let available_modes = modes_ref + .available_modes + .iter() + .map(|mode| format!("- `{}`: {}", mode.id, mode.name)) + .collect::>() + .join("\n"); + + log::warn!( + "`{default_mode}` is not valid {name} mode. Available options:\n{available_modes}", + ); + } + } else { + log::warn!( + "`{name}` does not support modes, but `default_mode` was set in settings.", + ); + } + } + + let session_id = response.session_id; + let action_log = cx.new(|_| ActionLog::new(project.clone()))?; + let thread = cx.new(|cx| { + AcpThread::new( + self.server_name.clone(), + self.clone(), + project, + action_log, + session_id.clone(), + // ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically. + watch::Receiver::constant(self.agent_capabilities.prompt_capabilities.clone()), + cx, + ) + })?; + + + let session = AcpSession { + thread: thread.downgrade(), + suppress_abort_err: false, + session_modes: modes, + models, + }; + sessions.borrow_mut().insert(session_id, session); + + Ok(thread) + }) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &self.auth_methods + } + + fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { + let conn = self.connection.clone(); + cx.foreground_executor().spawn(async move { + conn.authenticate(acp::AuthenticateRequest { + method_id: method_id.clone(), + meta: None, + }) + .await?; + + Ok(()) + }) + } + + fn prompt( + &self, + _id: Option, + params: acp::PromptRequest, + cx: &mut App, + ) -> Task> { + let conn = self.connection.clone(); + let sessions = self.sessions.clone(); + let session_id = params.session_id.clone(); + cx.foreground_executor().spawn(async move { + let result = conn.prompt(params).await; + + let mut suppress_abort_err = false; + + if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { + suppress_abort_err = session.suppress_abort_err; + session.suppress_abort_err = false; + } + + match result { + Ok(response) => Ok(response), + Err(err) => { + if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + return Err(anyhow!(acp::Error::auth_required())); + } + + if err.code != ErrorCode::INTERNAL_ERROR.code { + anyhow::bail!(err) + } + + let Some(data) = &err.data else { + anyhow::bail!(err) + }; + + // Temporary workaround until the following PR is generally available: + // https://github.com/google-gemini/gemini-cli/pull/6656 + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ErrorDetails { + details: Box, + } + + match serde_json::from_value(data.clone()) { + Ok(ErrorDetails { details }) => { + if suppress_abort_err + && (details.contains("This operation was aborted") + || details.contains("The user aborted a request")) + { + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Cancelled, + meta: None, + }) + } else { + Err(anyhow!(details)) + } + } + Err(_) => Err(anyhow!(err)), + } + } + } + }) + } + + fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { + if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { + session.suppress_abort_err = true; + } + let conn = self.connection.clone(); + let params = acp::CancelNotification { + session_id: session_id.clone(), + meta: None, + }; + cx.foreground_executor() + .spawn(async move { conn.cancel(params).await }) + .detach(); + } + + fn session_modes( + &self, + session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + let sessions = self.sessions.clone(); + let sessions_ref = sessions.borrow(); + let Some(session) = sessions_ref.get(session_id) else { + return None; + }; + + if let Some(modes) = session.session_modes.as_ref() { + Some(Rc::new(AcpSessionModes { + connection: self.connection.clone(), + session_id: session_id.clone(), + state: modes.clone(), + }) as _) + } else { + None + } + } + + fn model_selector( + &self, + session_id: &acp::SessionId, + ) -> Option> { + let sessions = self.sessions.clone(); + let sessions_ref = sessions.borrow(); + let Some(session) = sessions_ref.get(session_id) else { + return None; + }; + + if let Some(models) = session.models.as_ref() { + Some(Rc::new(AcpModelSelector::new( + session_id.clone(), + self.connection.clone(), + models.clone(), + )) as _) + } else { + None + } + } + + fn into_any(self: Rc) -> Rc { + self + } +} + +struct AcpSessionModes { + session_id: acp::SessionId, + connection: Rc, + state: Rc>, +} + +impl acp_thread::AgentSessionModes for AcpSessionModes { + fn current_mode(&self) -> acp::SessionModeId { + self.state.borrow().current_mode_id.clone() + } + + fn all_modes(&self) -> Vec { + self.state.borrow().available_modes.clone() + } + + fn set_mode(&self, mode_id: acp::SessionModeId, cx: &mut App) -> Task> { + let connection = self.connection.clone(); + let session_id = self.session_id.clone(); + let old_mode_id; + { + let mut state = self.state.borrow_mut(); + old_mode_id = state.current_mode_id.clone(); + state.current_mode_id = mode_id.clone(); + }; + let state = self.state.clone(); + cx.foreground_executor().spawn(async move { + let result = connection + .set_session_mode(acp::SetSessionModeRequest { + session_id, + mode_id, + meta: None, + }) + .await; + + if result.is_err() { + state.borrow_mut().current_mode_id = old_mode_id; + } + + result?; + + Ok(()) + }) + } +} + +struct AcpModelSelector { + session_id: acp::SessionId, + connection: Rc, + state: Rc>, +} + +impl AcpModelSelector { + fn new( + session_id: acp::SessionId, + connection: Rc, + state: Rc>, + ) -> Self { + Self { + session_id, + connection, + state, + } + } +} + +impl acp_thread::AgentModelSelector for AcpModelSelector { + fn list_models(&self, _cx: &mut App) -> Task> { + Task::ready(Ok(acp_thread::AgentModelList::Flat( + self.state + .borrow() + .available_models + .clone() + .into_iter() + .map(acp_thread::AgentModelInfo::from) + .collect(), + ))) + } + + fn select_model(&self, model_id: acp::ModelId, cx: &mut App) -> Task> { + let connection = self.connection.clone(); + let session_id = self.session_id.clone(); + let old_model_id; + { + let mut state = self.state.borrow_mut(); + old_model_id = state.current_model_id.clone(); + state.current_model_id = model_id.clone(); + }; + let state = self.state.clone(); + cx.foreground_executor().spawn(async move { + let result = connection + .set_session_model(acp::SetSessionModelRequest { + session_id, + model_id, + meta: None, + }) + .await; + + if result.is_err() { + state.borrow_mut().current_model_id = old_model_id; + } + + result?; + + Ok(()) + }) + } + + fn selected_model(&self, _cx: &mut App) -> Task> { + let state = self.state.borrow(); + Task::ready( + state + .available_models + .iter() + .find(|m| m.model_id == state.current_model_id) + .cloned() + .map(acp_thread::AgentModelInfo::from) + .ok_or_else(|| anyhow::anyhow!("Model not found")), + ) + } +} + +struct ClientDelegate { + sessions: Rc>>, + cx: AsyncApp, +} + +#[async_trait::async_trait(?Send)] +impl acp::Client for ClientDelegate { + async fn request_permission( + &self, + arguments: acp::RequestPermissionRequest, + ) -> Result { + let respect_always_allow_setting; + let thread; + { + let sessions_ref = self.sessions.borrow(); + let session = sessions_ref + .get(&arguments.session_id) + .context("Failed to get session")?; + respect_always_allow_setting = session.session_modes.is_none(); + thread = session.thread.clone(); + } + + let cx = &mut self.cx.clone(); + + let task = thread.update(cx, |thread, cx| { + thread.request_tool_call_authorization( + arguments.tool_call, + arguments.options, + respect_always_allow_setting, + cx, + ) + })??; + + let outcome = task.await; + + Ok(acp::RequestPermissionResponse { + outcome, + meta: None, + }) + } + + async fn write_text_file( + &self, + arguments: acp::WriteTextFileRequest, + ) -> Result { + let cx = &mut self.cx.clone(); + let task = self + .session_thread(&arguments.session_id)? + .update(cx, |thread, cx| { + thread.write_text_file(arguments.path, arguments.content, cx) + })?; - match conn { - Ok(conn) => Ok(Rc::new(conn) as _), - Err(err) if err.is::() => { - // Consider re-using initialize response and subprocess when adding another version here - let conn: Rc = - Rc::new(v0::AcpConnection::stdio(server_name, command, &root_dir, cx).await?); - Ok(conn) + task.await?; + + Ok(Default::default()) + } + + async fn read_text_file( + &self, + arguments: acp::ReadTextFileRequest, + ) -> Result { + let task = self.session_thread(&arguments.session_id)?.update( + &mut self.cx.clone(), + |thread, cx| { + thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) + }, + )?; + + let content = task.await?; + + Ok(acp::ReadTextFileResponse { + content, + meta: None, + }) + } + + async fn session_notification( + &self, + notification: acp::SessionNotification, + ) -> Result<(), acp::Error> { + let sessions = self.sessions.borrow(); + let session = sessions + .get(¬ification.session_id) + .context("Failed to get session")?; + + if let acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate { + current_mode_id, + .. + }) = ¬ification.update + { + if let Some(session_modes) = &session.session_modes { + session_modes.borrow_mut().current_mode_id = current_mode_id.clone(); + } else { + log::error!( + "Got a `CurrentModeUpdate` notification, but they agent didn't specify `modes` during setting setup." + ); + } + } + + // Clone so we can inspect meta both before and after handing off to the thread + let update_clone = notification.update.clone(); + + // Pre-handle: if a ToolCall carries terminal_info, create/register a display-only terminal. + if let acp::SessionUpdate::ToolCall(tc) = &update_clone { + if let Some(meta) = &tc.meta { + if let Some(terminal_info) = meta.get("terminal_info") { + if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) + { + let terminal_id = acp::TerminalId(id_str.into()); + let cwd = terminal_info + .get("cwd") + .and_then(|v| v.as_str().map(PathBuf::from)); + + // Create a minimal display-only lower-level terminal and register it. + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { + let builder = TerminalBuilder::new_display_only( + CursorShape::default(), + AlternateScroll::On, + None, + 0, + )?; + let lower = cx.new(|cx| builder.subscribe(cx)); + thread.on_terminal_provider_event( + TerminalProviderEvent::Created { + terminal_id: terminal_id.clone(), + label: tc.title.clone(), + cwd, + output_byte_limit: None, + terminal: lower, + }, + cx, + ); + anyhow::Ok(()) + }); + } + } + } + } + + // Forward the update to the acp_thread as usual. + session.thread.update(&mut self.cx.clone(), |thread, cx| { + thread.handle_session_update(notification.update.clone(), cx) + })??; + + // Post-handle: stream terminal output/exit if present on ToolCallUpdate meta. + if let acp::SessionUpdate::ToolCallUpdate(tcu) = &update_clone { + if let Some(meta) = &tcu.meta { + if let Some(term_out) = meta.get("terminal_output") { + if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { + let terminal_id = acp::TerminalId(id_str.into()); + if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { + let data = s.as_bytes().to_vec(); + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Output { + terminal_id: terminal_id.clone(), + data, + }, + cx, + ); + }); + } + } + } + + // terminal_exit + if let Some(term_exit) = meta.get("terminal_exit") { + if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { + let terminal_id = acp::TerminalId(id_str.into()); + let status = acp::TerminalExitStatus { + exit_code: term_exit + .get("exit_code") + .and_then(|v| v.as_u64()) + .map(|i| i as u32), + signal: term_exit + .get("signal") + .and_then(|v| v.as_str().map(|s| s.to_string())), + meta: None, + }; + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { + thread.on_terminal_provider_event( + TerminalProviderEvent::Exit { + terminal_id: terminal_id.clone(), + status, + }, + cx, + ); + }); + } + } + } } - Err(err) => Err(err), + + Ok(()) + } + + async fn create_terminal( + &self, + args: acp::CreateTerminalRequest, + ) -> Result { + let thread = self.session_thread(&args.session_id)?; + let project = thread.read_with(&self.cx, |thread, _cx| thread.project().clone())?; + + let terminal_entity = acp_thread::create_terminal_entity( + args.command.clone(), + &args.args, + args.env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), + args.cwd.clone(), + &project, + &mut self.cx.clone(), + ) + .await?; + + // Register with renderer + let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { + thread.register_terminal_created( + acp::TerminalId(uuid::Uuid::new_v4().to_string().into()), + format!("{} {}", args.command, args.args.join(" ")), + args.cwd.clone(), + args.output_byte_limit, + terminal_entity, + cx, + ) + })?; + let terminal_id = + terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone())?; + Ok(acp::CreateTerminalResponse { + terminal_id, + meta: None, + }) + } + + async fn kill_terminal_command( + &self, + args: acp::KillTerminalCommandRequest, + ) -> Result { + self.session_thread(&args.session_id)? + .update(&mut self.cx.clone(), |thread, cx| { + thread.kill_terminal(args.terminal_id, cx) + })??; + + Ok(Default::default()) + } + + async fn ext_method(&self, _args: acp::ExtRequest) -> Result { + Err(acp::Error::method_not_found()) + } + + async fn ext_notification(&self, _args: acp::ExtNotification) -> Result<(), acp::Error> { + Err(acp::Error::method_not_found()) + } + + async fn release_terminal( + &self, + args: acp::ReleaseTerminalRequest, + ) -> Result { + self.session_thread(&args.session_id)? + .update(&mut self.cx.clone(), |thread, cx| { + thread.release_terminal(args.terminal_id, cx) + })??; + + Ok(Default::default()) + } + + async fn terminal_output( + &self, + args: acp::TerminalOutputRequest, + ) -> Result { + self.session_thread(&args.session_id)? + .read_with(&mut self.cx.clone(), |thread, cx| { + let out = thread + .terminal(args.terminal_id)? + .read(cx) + .current_output(cx); + + Ok(out) + })? + } + + async fn wait_for_terminal_exit( + &self, + args: acp::WaitForTerminalExitRequest, + ) -> Result { + let exit_status = self + .session_thread(&args.session_id)? + .update(&mut self.cx.clone(), |thread, cx| { + anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) + })?? + .await; + + Ok(acp::WaitForTerminalExitResponse { + exit_status, + meta: None, + }) + } +} + +impl ClientDelegate { + fn session_thread(&self, session_id: &acp::SessionId) -> Result> { + let sessions = self.sessions.borrow(); + sessions + .get(session_id) + .context("Failed to get session") + .map(|session| session.thread.clone()) } } diff --git a/crates/agent_servers/src/acp/v0.rs b/crates/agent_servers/src/acp/v0.rs deleted file mode 100644 index 74647f73133f23681f18da1d2bddb02675c55a22..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/acp/v0.rs +++ /dev/null @@ -1,514 +0,0 @@ -// Translates old acp agents into the new schema -use agent_client_protocol as acp; -use agentic_coding_protocol::{self as acp_old, AgentRequest as _}; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity}; -use project::Project; -use std::{any::Any, cell::RefCell, path::Path, rc::Rc}; -use ui::App; -use util::ResultExt as _; - -use crate::AgentServerCommand; -use acp_thread::{AcpThread, AgentConnection, AuthRequired}; - -#[derive(Clone)] -struct OldAcpClientDelegate { - thread: Rc>>, - cx: AsyncApp, - next_tool_call_id: Rc>, - // sent_buffer_versions: HashMap, HashMap>, -} - -impl OldAcpClientDelegate { - fn new(thread: Rc>>, cx: AsyncApp) -> Self { - Self { - thread, - cx, - next_tool_call_id: Rc::new(RefCell::new(0)), - } - } -} - -impl acp_old::Client for OldAcpClientDelegate { - async fn stream_assistant_message_chunk( - &self, - params: acp_old::StreamAssistantMessageChunkParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| match params.chunk { - acp_old::AssistantMessageChunk::Text { text } => { - thread.push_assistant_content_block(text.into(), false, cx) - } - acp_old::AssistantMessageChunk::Thought { thought } => { - thread.push_assistant_content_block(thought.into(), true, cx) - } - }) - .log_err(); - })?; - - Ok(()) - } - - async fn request_tool_call_confirmation( - &self, - request: acp_old::RequestToolCallConfirmationParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - let tool_call = into_new_tool_call( - acp::ToolCallId(old_acp_id.to_string().into()), - request.tool_call, - ); - - let mut options = match request.confirmation { - acp_old::ToolCallConfirmation::Edit { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow Edits".to_string(), - )], - acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", root_command), - )], - acp_old::ToolCallConfirmation::Mcp { - server_name, - tool_name, - .. - } => vec![ - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", server_name), - ), - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", tool_name), - ), - ], - acp_old::ToolCallConfirmation::Fetch { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - acp_old::ToolCallConfirmation::Other { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - }; - - options.extend([ - ( - acp_old::ToolCallConfirmationOutcome::Allow, - acp::PermissionOptionKind::AllowOnce, - "Allow".to_string(), - ), - ( - acp_old::ToolCallConfirmationOutcome::Reject, - acp::PermissionOptionKind::RejectOnce, - "Reject".to_string(), - ), - ]); - - let mut outcomes = Vec::with_capacity(options.len()); - let mut acp_options = Vec::with_capacity(options.len()); - - for (index, (outcome, kind, label)) in options.into_iter().enumerate() { - outcomes.push(outcome); - acp_options.push(acp::PermissionOption { - id: acp::PermissionOptionId(index.to_string().into()), - name: label, - kind, - }) - } - - let response = cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call.into(), acp_options, cx) - }) - })?? - .context("Failed to update thread")? - .await; - - let outcome = match response { - Ok(option_id) => outcomes[option_id.0.parse::().unwrap_or(0)], - Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel, - }; - - Ok(acp_old::RequestToolCallConfirmationResponse { - id: acp_old::ToolCallId(old_acp_id), - outcome: outcome, - }) - } - - async fn push_tool_call( - &self, - request: acp_old::PushToolCallParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.upsert_tool_call( - into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request), - cx, - ) - }) - })?? - .context("Failed to update thread")?; - - Ok(acp_old::PushToolCallResponse { - id: acp_old::ToolCallId(old_acp_id), - }) - } - - async fn update_tool_call( - &self, - request: acp_old::UpdateToolCallParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_tool_call( - acp::ToolCallUpdate { - id: acp::ToolCallId(request.tool_call_id.0.to_string().into()), - fields: acp::ToolCallUpdateFields { - status: Some(into_new_tool_call_status(request.status)), - content: Some( - request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect::>(), - ), - ..Default::default() - }, - }, - cx, - ) - }) - })? - .context("Failed to update thread")??; - - Ok(()) - } - - async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_plan( - acp::Plan { - entries: request - .entries - .into_iter() - .map(into_new_plan_entry) - .collect(), - }, - cx, - ) - }) - })? - .context("Failed to update thread")?; - - Ok(()) - } - - async fn read_text_file( - &self, - acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams, - ) -> Result { - let content = self - .cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.read_text_file(path, line, limit, false, cx) - }) - })? - .context("Failed to update thread")? - .await?; - Ok(acp_old::ReadTextFileResponse { content }) - } - - async fn write_text_file( - &self, - acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams, - ) -> Result<(), acp_old::Error> { - self.cx - .update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| thread.write_text_file(path, content, cx)) - })? - .context("Failed to update thread")? - .await?; - - Ok(()) - } -} - -fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall { - acp::ToolCall { - id: id, - title: request.label, - kind: acp_kind_from_old_icon(request.icon), - status: acp::ToolCallStatus::InProgress, - content: request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect(), - locations: request - .locations - .into_iter() - .map(into_new_tool_call_location) - .collect(), - raw_input: None, - raw_output: None, - } -} - -fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind { - match icon { - acp_old::Icon::FileSearch => acp::ToolKind::Search, - acp_old::Icon::Folder => acp::ToolKind::Search, - acp_old::Icon::Globe => acp::ToolKind::Search, - acp_old::Icon::Hammer => acp::ToolKind::Other, - acp_old::Icon::LightBulb => acp::ToolKind::Think, - acp_old::Icon::Pencil => acp::ToolKind::Edit, - acp_old::Icon::Regex => acp::ToolKind::Search, - acp_old::Icon::Terminal => acp::ToolKind::Execute, - } -} - -fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus { - match status { - acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress, - acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed, - acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed, - } -} - -fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent { - match content { - acp_old::ToolCallContent::Markdown { markdown } => markdown.into(), - acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff { - diff: into_new_diff(diff), - }, - } -} - -fn into_new_diff(diff: acp_old::Diff) -> acp::Diff { - acp::Diff { - path: diff.path, - old_text: diff.old_text, - new_text: diff.new_text, - } -} - -fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation { - acp::ToolCallLocation { - path: location.path, - line: location.line, - } -} - -fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry { - acp::PlanEntry { - content: entry.content, - priority: into_new_plan_priority(entry.priority), - status: into_new_plan_status(entry.status), - } -} - -fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority { - match priority { - acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low, - acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium, - acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High, - } -} - -fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus { - match status { - acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending, - acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress, - acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed, - } -} - -pub struct AcpConnection { - pub name: &'static str, - pub connection: acp_old::AgentConnection, - pub _child_status: Task>, - pub current_thread: Rc>>, -} - -impl AcpConnection { - pub fn stdio( - name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Task> { - let root_dir = root_dir.to_path_buf(); - - cx.spawn(async move |cx| { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - let stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - log::trace!("Spawned (pid: {})", child.id()); - - let foreground_executor = cx.foreground_executor().clone(); - - let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid())); - - let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent( - OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()), - stdin, - stdout, - move |fut| foreground_executor.spawn(fut).detach(), - ); - - let io_task = cx.background_spawn(async move { - io_fut.await.log_err(); - }); - - let child_status = cx.background_spawn(async move { - let result = match child.status().await { - Err(e) => Err(anyhow!(e)), - Ok(result) if result.success() => Ok(()), - Ok(result) => Err(anyhow!(result)), - }; - drop(io_task); - result - }); - - Ok(Self { - name, - connection, - _child_status: child_status, - current_thread: thread_rc, - }) - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut App, - ) -> Task>> { - let task = self.connection.request_any( - acp_old::InitializeParams { - protocol_version: acp_old::ProtocolVersion::latest(), - } - .into_any(), - ); - let current_thread = self.current_thread.clone(); - cx.spawn(async move |cx| { - let result = task.await?; - let result = acp_old::InitializeParams::response_from_any(result)?; - - if !result.is_authenticated { - anyhow::bail!(AuthRequired) - } - - cx.update(|cx| { - let thread = cx.new(|cx| { - let session_id = acp::SessionId("acp-old-no-id".into()); - AcpThread::new(self.name, self.clone(), project, session_id, cx) - }); - current_thread.replace(thread.downgrade()); - thread - }) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let task = self - .connection - .request_any(acp_old::AuthenticateParams.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - Ok(()) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let chunks = params - .prompt - .into_iter() - .filter_map(|block| match block { - acp::ContentBlock::Text(text) => { - Some(acp_old::UserMessageChunk::Text { text: text.text }) - } - acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path { - path: link.uri.into(), - }), - _ => None, - }) - .collect(); - - let task = self - .connection - .request_any(acp_old::SendUserMessageParams { chunks }.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - }) - }) - } - - fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) { - let task = self - .connection - .request_any(acp_old::CancelSendMessageParams.into_any()); - cx.foreground_executor() - .spawn(async move { - task.await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } - - fn into_any(self: Rc) -> Rc { - self - } -} diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs deleted file mode 100644 index b77b5ef36d26ebec9bae48cfe5c1a36c003e230b..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/acp/v1.rs +++ /dev/null @@ -1,302 +0,0 @@ -use agent_client_protocol::{self as acp, Agent as _}; -use anyhow::anyhow; -use collections::HashMap; -use futures::AsyncBufReadExt as _; -use futures::channel::oneshot; -use futures::io::BufReader; -use project::Project; -use std::path::Path; -use std::rc::Rc; -use std::{any::Any, cell::RefCell}; - -use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; - -use crate::{AgentServerCommand, acp::UnsupportedVersion}; -use acp_thread::{AcpThread, AgentConnection, AuthRequired}; - -pub struct AcpConnection { - server_name: &'static str, - connection: Rc, - sessions: Rc>>, - auth_methods: Vec, - _io_task: Task>, -} - -pub struct AcpSession { - thread: WeakEntity, -} - -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; - -impl AcpConnection { - pub async fn stdio( - server_name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Result { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter().map(|arg| arg.as_str())) - .envs(command.env.iter().flatten()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - log::trace!("Spawned (pid: {})", child.id()); - - let sessions = Rc::new(RefCell::new(HashMap::default())); - - let client = ClientDelegate { - sessions: sessions.clone(), - cx: cx.clone(), - }; - let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { - let foreground_executor = cx.foreground_executor().clone(); - move |fut| { - foreground_executor.spawn(fut).detach(); - } - }); - - let io_task = cx.background_spawn(io_task); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.spawn({ - let sessions = sessions.clone(); - async move |cx| { - let status = child.status().await?; - - for session in sessions.borrow().values() { - session - .thread - .update(cx, |thread, cx| thread.emit_server_exited(status, cx)) - .ok(); - } - - anyhow::Ok(()) - } - }) - .detach(); - - let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - }, - }, - }) - .await?; - - if response.protocol_version < MINIMUM_SUPPORTED_VERSION { - return Err(UnsupportedVersion.into()); - } - - Ok(Self { - auth_methods: response.auth_methods, - connection: connection.into(), - server_name, - sessions, - _io_task: io_task, - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let conn = self.connection.clone(); - let sessions = self.sessions.clone(); - let cwd = cwd.to_path_buf(); - cx.spawn(async move |cx| { - let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) - .await - .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { - anyhow!(AuthRequired) - } else { - anyhow!(err) - } - })?; - - let session_id = response.session_id; - - let thread = cx.new(|cx| { - AcpThread::new( - self.server_name, - self.clone(), - project, - session_id.clone(), - cx, - ) - })?; - - let session = AcpSession { - thread: thread.downgrade(), - }; - sessions.borrow_mut().insert(session_id, session); - - Ok(thread) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &self.auth_methods - } - - fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let conn = self.connection.clone(); - cx.foreground_executor().spawn(async move { - let result = conn - .authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - }) - .await?; - - Ok(result) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let conn = self.connection.clone(); - cx.foreground_executor().spawn(async move { - let response = conn.prompt(params).await?; - Ok(response) - }) - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - }; - cx.foreground_executor() - .spawn(async move { conn.cancel(params).await }) - .detach(); - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct ClientDelegate { - sessions: Rc>>, - cx: AsyncApp, -} - -impl acp::Client for ClientDelegate { - async fn request_permission( - &self, - arguments: acp::RequestPermissionRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let rx = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx) - })?; - - let result = rx?.await; - - let outcome = match result { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, - Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Canceled, - }; - - Ok(acp::RequestPermissionResponse { outcome }) - } - - async fn write_text_file( - &self, - arguments: acp::WriteTextFileRequest, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.write_text_file(arguments.path, arguments.content, cx) - })?; - - task.await?; - - Ok(()) - } - - async fn read_text_file( - &self, - arguments: acp::ReadTextFileRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) - })?; - - let content = task.await?; - - Ok(acp::ReadTextFileResponse { content }) - } - - async fn session_notification( - &self, - notification: acp::SessionNotification, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let sessions = self.sessions.borrow(); - let session = sessions - .get(¬ification.session_id) - .context("Failed to get session")?; - - session.thread.update(cx, |thread, cx| { - thread.handle_session_update(notification.update, cx) - })??; - - Ok(()) - } -} diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index b3b8a3317049927986a6a578bc50c4e5506b7650..b44c2123fb5052e2487464d813936cd1edf9821a 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -1,176 +1,110 @@ mod acp; mod claude; +mod codex; +mod custom; mod gemini; -mod settings; -#[cfg(test)] -mod e2e_tests; +#[cfg(any(test, feature = "test-support"))] +pub mod e2e_tests; pub use claude::*; +use client::ProxySettings; +pub use codex::*; +use collections::HashMap; +pub use custom::*; +use fs::Fs; pub use gemini::*; -pub use settings::*; +use http_client::read_no_proxy_from_env; +use project::agent_server_store::AgentServerStore; use acp_thread::AgentConnection; use anyhow::Result; -use collections::HashMap; -use gpui::{App, AsyncApp, Entity, SharedString, Task}; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{ - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; -use util::ResultExt as _; +use settings::SettingsStore; +use std::{any::Any, path::Path, rc::Rc, sync::Arc}; + +pub use acp::AcpConnection; + +pub struct AgentServerDelegate { + store: Entity, + project: Entity, + status_tx: Option>, + new_version_available: Option>>, +} + +impl AgentServerDelegate { + pub fn new( + store: Entity, + project: Entity, + status_tx: Option>, + new_version_tx: Option>>, + ) -> Self { + Self { + store, + project, + status_tx, + new_version_available: new_version_tx, + } + } -pub fn init(cx: &mut App) { - settings::init(cx); + pub fn project(&self) -> &Entity { + &self.project + } } pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; - fn name(&self) -> &'static str; - fn empty_state_headline(&self) -> &'static str; - fn empty_state_message(&self) -> &'static str; + fn name(&self) -> SharedString; + fn telemetry_id(&self) -> &'static str; + fn default_mode(&self, _cx: &mut App) -> Option { + None + } + fn set_default_mode( + &self, + _mode_id: Option, + _fs: Arc, + _cx: &mut App, + ) { + } fn connect( &self, - root_dir: &Path, - project: &Entity, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>>; -} - -impl std::fmt::Debug for AgentServerCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let filtered_env = self.env.as_ref().map(|env| { - env.iter() - .map(|(k, v)| { - ( - k, - if util::redact::should_redact(k) { - "[REDACTED]" - } else { - v - }, - ) - }) - .collect::>() - }); + ) -> Task, Option)>>; - f.debug_struct("AgentServerCommand") - .field("path", &self.path) - .field("args", &self.args) - .field("env", &filtered_env) - .finish() - } + fn into_any(self: Rc) -> Rc; } -pub enum AgentServerVersion { - Supported, - Unsupported { - error_message: SharedString, - upgrade_message: SharedString, - upgrade_command: String, - }, +impl dyn AgentServer { + pub fn downcast(self: Rc) -> Option> { + self.into_any().downcast().ok() + } } -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] -pub struct AgentServerCommand { - #[serde(rename = "command")] - pub path: PathBuf, - #[serde(default)] - pub args: Vec, - pub env: Option>, -} +/// Load the default proxy environment variables to pass through to the agent +pub fn load_proxy_env(cx: &mut App) -> HashMap { + let proxy_url = cx + .read_global(|settings: &SettingsStore, _| settings.get::(None).proxy_url()); + let mut env = HashMap::default(); -impl AgentServerCommand { - pub(crate) async fn resolve( - path_bin_name: &'static str, - extra_args: &[&'static str], - fallback_path: Option<&Path>, - settings: Option, - project: &Entity, - cx: &mut AsyncApp, - ) -> Option { - if let Some(agent_settings) = settings { - return Some(Self { - path: agent_settings.command.path, - args: agent_settings - .command - .args - .into_iter() - .chain(extra_args.iter().map(|arg| arg.to_string())) - .collect(), - env: agent_settings.command.env, - }); + if let Some(proxy_url) = &proxy_url { + let env_var = if proxy_url.scheme() == "https" { + "HTTPS_PROXY" } else { - match find_bin_in_path(path_bin_name, project, cx).await { - Some(path) => Some(Self { - path, - args: extra_args.iter().map(|arg| arg.to_string()).collect(), - env: None, - }), - None => fallback_path.and_then(|path| { - if path.exists() { - Some(Self { - path: path.to_path_buf(), - args: extra_args.iter().map(|arg| arg.to_string()).collect(), - env: None, - }) - } else { - None - } - }), - } - } + "HTTP_PROXY" + }; + env.insert(env_var.to_owned(), proxy_url.to_string()); } -} - -async fn find_bin_in_path( - bin_name: &'static str, - project: &Entity, - cx: &mut AsyncApp, -) -> Option { - let (env_task, root_dir) = project - .update(cx, |project, cx| { - let worktree = project.visible_worktrees(cx).next(); - match worktree { - Some(worktree) => { - let env_task = project.environment().update(cx, |env, cx| { - env.get_worktree_environment(worktree.clone(), cx) - }); - - let path = worktree.read(cx).abs_path(); - (env_task, path) - } - None => { - let path: Arc = paths::home_dir().as_path().into(); - let env_task = project.environment().update(cx, |env, cx| { - env.get_directory_environment(path.clone(), cx) - }); - (env_task, path) - } - } - }) - .log_err()?; - cx.background_executor() - .spawn(async move { - let which_result = if cfg!(windows) { - which::which(bin_name) - } else { - let env = env_task.await.unwrap_or_default(); - let shell_path = env.get("PATH").cloned(); - which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref()) - }; - - if let Err(which::Error::CannotFindBinaryPath) = which_result { - return None; - } + if let Some(no_proxy) = read_no_proxy_from_env() { + env.insert("NO_PROXY".to_owned(), no_proxy); + } else if proxy_url.is_some() { + // We sometimes need local MCP servers that we don't want to proxy + env.insert("NO_PROXY".to_owned(), "localhost,127.0.0.1".to_owned()); + } - which_result.log_err() - }) - .await + env } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index d15cc1dd89f8547da03704209af586e87ce8455f..b84a386679cee825be22d895634a6971b537fa89 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,1084 +1,102 @@ -mod mcp_server; -pub mod tools; - -use collections::HashMap; -use context_server::listener::McpServerTool; -use project::Project; -use settings::SettingsStore; -use smol::process::Child; -use std::any::Any; -use std::cell::RefCell; -use std::fmt::Display; +use agent_client_protocol as acp; +use fs::Fs; +use settings::{SettingsStore, update_settings_file}; use std::path::Path; use std::rc::Rc; -use uuid::Uuid; +use std::sync::Arc; +use std::{any::Any, path::PathBuf}; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use futures::{AsyncBufReadExt, AsyncWriteExt}; -use futures::{ - AsyncRead, AsyncWrite, FutureExt, StreamExt, - channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, - io::BufReader, - select_biased, -}; -use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; -use serde::{Deserialize, Serialize}; -use util::{ResultExt, debug_panic}; +use anyhow::{Context as _, Result}; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, CLAUDE_CODE_NAME}; -use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig}; -use crate::claude::tools::ClaudeTool; -use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings}; -use acp_thread::{AcpThread, AgentConnection}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; +use acp_thread::AgentConnection; #[derive(Clone)] pub struct ClaudeCode; -impl AgentServer for ClaudeCode { - fn name(&self) -> &'static str { - "Claude Code" - } +pub struct AgentServerLoginCommand { + pub path: PathBuf, + pub arguments: Vec, +} - fn empty_state_headline(&self) -> &'static str { - self.name() +impl AgentServer for ClaudeCode { + fn telemetry_id(&self) -> &'static str { + "claude-code" } - fn empty_state_message(&self) -> &'static str { - "How can I help you today?" + fn name(&self) -> SharedString { + "Claude Code".into() } fn logo(&self) -> ui::IconName { ui::IconName::AiClaude } - fn connect( - &self, - _root_dir: &Path, - _project: &Entity, - _cx: &mut App, - ) -> Task>> { - let connection = ClaudeAgentConnection { - sessions: Default::default(), - }; + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); - Task::ready(Ok(Rc::new(connection) as _)) + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) } -} -struct ClaudeAgentConnection { - sessions: Rc>>, -} + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + update_settings_file(fs, cx, |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .claude + .get_or_insert_default() + .default_mode = mode_id.map(|m| m.to_string()) + }); + } -impl AgentConnection for ClaudeAgentConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, + fn connect( + &self, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { - let cwd = cwd.to_owned(); - cx.spawn(async move |cx| { - let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); - let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), cx).await?; - - let mut mcp_servers = HashMap::default(); - mcp_servers.insert( - mcp_server::SERVER_NAME.to_string(), - permission_mcp_server.server_config()?, - ); - let mcp_config = McpConfig { mcp_servers }; + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); + let extra_env = load_proxy_env(cx); + let default_mode = self.default_mode(cx); - let mcp_config_file = tempfile::NamedTempFile::new()?; - let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts(); - - let mut mcp_config_file = smol::fs::File::from(mcp_config_file); - mcp_config_file - .write_all(serde_json::to_string(&mcp_config)?.as_bytes()) + cx.spawn(async move |cx| { + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&CLAUDE_CODE_NAME.into()) + .context("Claude Code is not registered")?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + extra_env, + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? .await?; - mcp_config_file.flush().await?; - - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - })?; - - let Some(command) = AgentServerCommand::resolve( - "claude", - &[], - Some(&util::paths::home_dir().join(".claude/local/claude")), - settings, - &project, + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, cx, ) - .await - else { - anyhow::bail!("Failed to find claude binary"); - }; - - let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); - let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); - - let session_id = acp::SessionId(Uuid::new_v4().to_string().into()); - - log::trace!("Starting session with id: {}", session_id); - - let mut child = spawn_claude( - &command, - ClaudeSessionMode::Start, - session_id.clone(), - &mcp_config_path, - &cwd, - )?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - - let pid = child.id(); - log::trace!("Spawned (pid: {})", pid); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.background_spawn(async move { - let mut outgoing_rx = Some(outgoing_rx); - - ClaudeAgentSession::handle_io( - outgoing_rx.take().unwrap(), - incoming_message_tx.clone(), - stdin, - stdout, - ) - .await?; - - log::trace!("Stopped (pid: {})", pid); - - drop(mcp_config_path); - anyhow::Ok(()) - }) - .detach(); - - let turn_state = Rc::new(RefCell::new(TurnState::None)); - - let handler_task = cx.spawn({ - let turn_state = turn_state.clone(); - let mut thread_rx = thread_rx.clone(); - async move |cx| { - while let Some(message) = incoming_message_rx.next().await { - ClaudeAgentSession::handle_message( - thread_rx.clone(), - message, - turn_state.clone(), - cx, - ) - .await - } - - if let Some(status) = child.status().await.log_err() { - if let Some(thread) = thread_rx.recv().await.ok() { - thread - .update(cx, |thread, cx| { - thread.emit_server_exited(status, cx); - }) - .ok(); - } - } - } - }); - - let thread = cx.new(|cx| { - AcpThread::new("Claude Code", self.clone(), project, session_id.clone(), cx) - })?; - - thread_tx.send(thread.downgrade())?; - - let session = ClaudeAgentSession { - outgoing_tx, - turn_state, - _handler_task: handler_task, - _mcp_server: Some(permission_mcp_server), - }; - - self.sessions.borrow_mut().insert(session_id, session); - - Ok(thread) + .await?; + Ok((connection, login)) }) } - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task> { - Task::ready(Err(anyhow!("Authentication not supported"))) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(¶ms.session_id) else { - return Task::ready(Err(anyhow!( - "Attempted to send message to nonexistent session {}", - params.session_id - ))); - }; - - let (end_tx, end_rx) = oneshot::channel(); - session.turn_state.replace(TurnState::InProgress { end_tx }); - - let mut content = String::new(); - for chunk in params.prompt { - match chunk { - acp::ContentBlock::Text(text_content) => { - content.push_str(&text_content.text); - } - acp::ContentBlock::ResourceLink(resource_link) => { - content.push_str(&format!("@{}", resource_link.uri)); - } - acp::ContentBlock::Audio(_) - | acp::ContentBlock::Image(_) - | acp::ContentBlock::Resource(_) => { - // TODO - } - } - } - - if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User { - message: Message { - role: Role::User, - content: Content::UntaggedText(content), - id: None, - model: None, - stop_reason: None, - stop_sequence: None, - usage: None, - }, - session_id: Some(params.session_id.to_string()), - }) { - return Task::ready(Err(anyhow!(err))); - } - - cx.foreground_executor().spawn(async move { end_rx.await? }) - } - - fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(&session_id) else { - log::warn!("Attempted to cancel nonexistent session {}", session_id); - return; - }; - - let request_id = new_request_id(); - - let turn_state = session.turn_state.take(); - let TurnState::InProgress { end_tx } = turn_state else { - // Already canceled or idle, put it back - session.turn_state.replace(turn_state); - return; - }; - - session.turn_state.replace(TurnState::CancelRequested { - end_tx, - request_id: request_id.clone(), - }); - - session - .outgoing_tx - .unbounded_send(SdkMessage::ControlRequest { - request_id, - request: ControlRequest::Interrupt, - }) - .log_err(); - } - fn into_any(self: Rc) -> Rc { self } } - -#[derive(Clone, Copy)] -enum ClaudeSessionMode { - Start, - #[expect(dead_code)] - Resume, -} - -fn spawn_claude( - command: &AgentServerCommand, - mode: ClaudeSessionMode, - session_id: acp::SessionId, - mcp_config_path: &Path, - root_dir: &Path, -) -> Result { - let child = util::command::new_smol_command(&command.path) - .args([ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--print", - "--verbose", - "--mcp-config", - mcp_config_path.to_string_lossy().as_ref(), - "--permission-prompt-tool", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - mcp_server::PermissionTool::NAME, - ), - "--allowedTools", - &format!( - "mcp__{}__{},mcp__{}__{}", - mcp_server::SERVER_NAME, - mcp_server::EditTool::NAME, - mcp_server::SERVER_NAME, - mcp_server::ReadTool::NAME - ), - "--disallowedTools", - "Read,Edit", - ]) - .args(match mode { - ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()], - ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()], - }) - .args(command.args.iter().map(|arg| arg.as_str())) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - Ok(child) -} - -struct ClaudeAgentSession { - outgoing_tx: UnboundedSender, - turn_state: Rc>, - _mcp_server: Option, - _handler_task: Task<()>, -} - -#[derive(Debug, Default)] -enum TurnState { - #[default] - None, - InProgress { - end_tx: oneshot::Sender>, - }, - CancelRequested { - end_tx: oneshot::Sender>, - request_id: String, - }, - CancelConfirmed { - end_tx: oneshot::Sender>, - }, -} - -impl TurnState { - fn is_canceled(&self) -> bool { - matches!(self, TurnState::CancelConfirmed { .. }) - } - - fn end_tx(self) -> Option>> { - match self { - TurnState::None => None, - TurnState::InProgress { end_tx, .. } => Some(end_tx), - TurnState::CancelRequested { end_tx, .. } => Some(end_tx), - TurnState::CancelConfirmed { end_tx } => Some(end_tx), - } - } - - fn confirm_cancellation(self, id: &str) -> Self { - match self { - TurnState::CancelRequested { request_id, end_tx } if request_id == id => { - TurnState::CancelConfirmed { end_tx } - } - _ => self, - } - } -} - -impl ClaudeAgentSession { - async fn handle_message( - mut thread_rx: watch::Receiver>, - message: SdkMessage, - turn_state: Rc>, - cx: &mut AsyncApp, - ) { - match message { - // we should only be sending these out, they don't need to be in the thread - SdkMessage::ControlRequest { .. } => {} - SdkMessage::User { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - if !turn_state.borrow().is_canceled() { - thread - .update(cx, |thread, cx| { - thread.push_user_content_block(None, text.into(), cx) - }) - .log_err(); - } - } - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - let content = content.to_string(); - thread - .update(cx, |thread, cx| { - thread.update_tool_call( - acp::ToolCallUpdate { - id: acp::ToolCallId(tool_use_id.into()), - fields: acp::ToolCallUpdateFields { - status: if turn_state.borrow().is_canceled() { - // Do not set to completed if turn was canceled - None - } else { - Some(acp::ToolCallStatus::Completed) - }, - content: (!content.is_empty()) - .then(|| vec![content.into()]), - ..Default::default() - }, - }, - cx, - ) - }) - .log_err(); - } - ContentChunk::Thinking { .. } - | ContentChunk::RedactedThinking - | ContentChunk::ToolUse { .. } => { - debug_panic!( - "Should not get {:?} with role: assistant. should we handle this?", - chunk - ); - } - - ContentChunk::Image - | ContentChunk::Document - | ContentChunk::WebSearchToolResult => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Assistant { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(text.into(), false, cx) - }) - .log_err(); - } - ContentChunk::Thinking { thinking } => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(thinking.into(), true, cx) - }) - .log_err(); - } - ContentChunk::RedactedThinking => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - "[REDACTED]".into(), - true, - cx, - ) - }) - .log_err(); - } - ContentChunk::ToolUse { id, name, input } => { - let claude_tool = ClaudeTool::infer(&name, input); - - thread - .update(cx, |thread, cx| { - if let ClaudeTool::TodoWrite(Some(params)) = claude_tool { - thread.update_plan( - acp::Plan { - entries: params - .todos - .into_iter() - .map(Into::into) - .collect(), - }, - cx, - ) - } else { - thread.upsert_tool_call( - claude_tool.as_acp(acp::ToolCallId(id.into())), - cx, - )?; - } - anyhow::Ok(()) - }) - .log_err(); - } - ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => { - debug_panic!( - "Should not get tool results with role: assistant. should we handle this?" - ); - } - ContentChunk::Image | ContentChunk::Document => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Result { - is_error, - subtype, - result, - .. - } => { - let turn_state = turn_state.take(); - let was_canceled = turn_state.is_canceled(); - let Some(end_turn_tx) = turn_state.end_tx() else { - debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn"); - return; - }; - - if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) { - end_turn_tx - .send(Err(anyhow!( - "Error: {}", - result.unwrap_or_else(|| subtype.to_string()) - ))) - .ok(); - } else { - let stop_reason = match subtype { - ResultErrorType::Success => acp::StopReason::EndTurn, - ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, - ResultErrorType::ErrorDuringExecution => acp::StopReason::Canceled, - }; - end_turn_tx - .send(Ok(acp::PromptResponse { stop_reason })) - .ok(); - } - } - SdkMessage::ControlResponse { response } => { - if matches!(response.subtype, ResultErrorType::Success) { - let new_state = turn_state.take().confirm_cancellation(&response.request_id); - turn_state.replace(new_state); - } else { - log::error!("Control response error: {:?}", response); - } - } - SdkMessage::System { .. } => {} - } - } - - async fn handle_io( - mut outgoing_rx: UnboundedReceiver, - incoming_tx: UnboundedSender, - mut outgoing_bytes: impl Unpin + AsyncWrite, - incoming_bytes: impl Unpin + AsyncRead, - ) -> Result> { - let mut output_reader = BufReader::new(incoming_bytes); - let mut outgoing_line = Vec::new(); - let mut incoming_line = String::new(); - loop { - select_biased! { - message = outgoing_rx.next() => { - if let Some(message) = message { - outgoing_line.clear(); - serde_json::to_writer(&mut outgoing_line, &message)?; - log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line)); - outgoing_line.push(b'\n'); - outgoing_bytes.write_all(&outgoing_line).await.ok(); - } else { - break; - } - } - bytes_read = output_reader.read_line(&mut incoming_line).fuse() => { - if bytes_read? == 0 { - break - } - log::trace!("recv: {}", &incoming_line); - match serde_json::from_str::(&incoming_line) { - Ok(message) => { - incoming_tx.unbounded_send(message).log_err(); - } - Err(error) => { - log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}"); - } - } - incoming_line.clear(); - } - } - } - - Ok(outgoing_rx) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Message { - role: Role, - content: Content, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_sequence: Option, - #[serde(skip_serializing_if = "Option::is_none")] - usage: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -enum Content { - UntaggedText(String), - Chunks(Vec), -} - -impl Content { - pub fn chunks(self) -> impl Iterator { - match self { - Self::Chunks(chunks) => chunks.into_iter(), - Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(), - } - } -} - -impl Display for Content { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Content::UntaggedText(txt) => write!(f, "{}", txt), - Content::Chunks(chunks) => { - for chunk in chunks { - write!(f, "{}", chunk)?; - } - Ok(()) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ContentChunk { - Text { - text: String, - }, - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, - ToolResult { - content: Content, - tool_use_id: String, - }, - Thinking { - thinking: String, - }, - RedactedThinking, - // TODO - Image, - Document, - WebSearchToolResult, - #[serde(untagged)] - UntaggedText(String), -} - -impl Display for ContentChunk { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ContentChunk::Text { text } => write!(f, "{}", text), - ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking), - ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"), - ContentChunk::UntaggedText(text) => write!(f, "{}", text), - ContentChunk::ToolResult { content, .. } => write!(f, "{}", content), - ContentChunk::Image - | ContentChunk::Document - | ContentChunk::ToolUse { .. } - | ContentChunk::WebSearchToolResult => { - write!(f, "\n{:?}\n", &self) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Usage { - input_tokens: u32, - cache_creation_input_tokens: u32, - cache_read_input_tokens: u32, - output_tokens: u32, - service_tier: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum Role { - System, - Assistant, - User, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MessageParam { - role: Role, - content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum SdkMessage { - // An assistant message - Assistant { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // A user message - User { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // Emitted as the last message in a conversation - Result { - subtype: ResultErrorType, - duration_ms: f64, - duration_api_ms: f64, - is_error: bool, - num_turns: i32, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - session_id: String, - total_cost_usd: f64, - }, - // Emitted as the first message at the start of a conversation - System { - cwd: String, - session_id: String, - tools: Vec, - model: String, - mcp_servers: Vec, - #[serde(rename = "apiKeySource")] - api_key_source: String, - #[serde(rename = "permissionMode")] - permission_mode: PermissionMode, - }, - /// Messages used to control the conversation, outside of chat messages to the model - ControlRequest { - request_id: String, - request: ControlRequest, - }, - /// Response to a control request - ControlResponse { response: ControlResponse }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "subtype", rename_all = "snake_case")] -enum ControlRequest { - /// Cancel the current conversation - Interrupt, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ControlResponse { - request_id: String, - subtype: ResultErrorType, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -enum ResultErrorType { - Success, - ErrorMaxTurns, - ErrorDuringExecution, -} - -impl Display for ResultErrorType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResultErrorType::Success => write!(f, "success"), - ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"), - ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"), - } - } -} - -fn new_request_id() -> String { - use rand::Rng; - // In the Claude Code TS SDK they just generate a random 12 character string, - // `Math.random().toString(36).substring(2, 15)` - rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(12) - .map(char::from) - .collect() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct McpServer { - name: String, - status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -enum PermissionMode { - Default, - AcceptEdits, - BypassPermissions, - Plan, -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::e2e_tests; - use gpui::TestAppContext; - use serde_json::json; - - crate::common_e2e_tests!(ClaudeCode, allow_option_id = "allow"); - - pub fn local_command() -> AgentServerCommand { - AgentServerCommand { - path: "claude".into(), - args: vec![], - env: None, - } - } - - #[gpui::test] - #[cfg_attr(not(feature = "e2e"), ignore)] - async fn test_todo_plan(cx: &mut TestAppContext) { - let fs = e2e_tests::init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = - e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await; - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.", - cx, - ) - }) - .await - .unwrap(); - - let mut entries_len = 0; - - thread.read_with(cx, |thread, _| { - entries_len = thread.plan().entries.len(); - assert!(thread.plan().entries.len() > 0, "Empty plan"); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Mark the first entry status as in progress without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::InProgress - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Now mark the first entry as completed without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::Completed - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - } - - #[test] - fn test_deserialize_content_untagged_text() { - let json = json!("Hello, world!"); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"), - _ => panic!("Expected UntaggedText variant"), - } - } - - #[test] - fn test_deserialize_content_chunks() { - let json = json!([ - { - "type": "text", - "text": "Hello" - }, - { - "type": "tool_use", - "id": "tool_123", - "name": "calculator", - "input": {"operation": "add", "a": 1, "b": 2} - } - ]); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Hello"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::ToolUse { id, name, input } => { - assert_eq!(id, "tool_123"); - assert_eq!(name, "calculator"); - assert_eq!(input["operation"], "add"); - assert_eq!(input["a"], 1); - assert_eq!(input["b"], 2); - } - _ => panic!("Expected ToolUse chunk"), - } - } - _ => panic!("Expected Chunks variant"), - } - } - - #[test] - fn test_deserialize_tool_result_untagged_text() { - let json = json!({ - "type": "tool_result", - "content": "Result content", - "tool_use_id": "tool_456" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::UntaggedText(text) => assert_eq!(text, "Result content"), - _ => panic!("Expected UntaggedText content"), - } - assert_eq!(tool_use_id, "tool_456"); - } - _ => panic!("Expected ToolResult variant"), - } - } - - #[test] - fn test_deserialize_tool_result_chunks() { - let json = json!({ - "type": "tool_result", - "content": [ - { - "type": "text", - "text": "Processing complete" - }, - { - "type": "text", - "text": "Result: 42" - } - ], - "tool_use_id": "tool_789" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Processing complete"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::Text { text } => assert_eq!(text, "Result: 42"), - _ => panic!("Expected Text chunk"), - } - } - _ => panic!("Expected Chunks content"), - } - assert_eq!(tool_use_id, "tool_789"); - } - _ => panic!("Expected ToolResult variant"), - } - } -} diff --git a/crates/agent_servers/src/claude/mcp_server.rs b/crates/agent_servers/src/claude/mcp_server.rs deleted file mode 100644 index 22cb2f8f8d7c15608527eb7492220bf9c7fe920c..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/mcp_server.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::path::PathBuf; - -use crate::claude::tools::{ClaudeTool, EditToolParams, ReadToolParams}; -use acp_thread::AcpThread; -use agent_client_protocol as acp; -use anyhow::{Context, Result}; -use collections::HashMap; -use context_server::listener::{McpServerTool, ToolResponse}; -use context_server::types::{ - Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities, - ToolAnnotations, ToolResponseContent, ToolsCapabilities, requests, -}; -use gpui::{App, AsyncApp, Task, WeakEntity}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -pub struct ClaudeZedMcpServer { - server: context_server::listener::McpServer, -} - -pub const SERVER_NAME: &str = "zed"; - -impl ClaudeZedMcpServer { - pub async fn new( - thread_rx: watch::Receiver>, - cx: &AsyncApp, - ) -> Result { - let mut mcp_server = context_server::listener::McpServer::new(cx).await?; - mcp_server.handle_request::(Self::handle_initialize); - - mcp_server.add_tool(PermissionTool { - thread_rx: thread_rx.clone(), - }); - mcp_server.add_tool(ReadTool { - thread_rx: thread_rx.clone(), - }); - mcp_server.add_tool(EditTool { - thread_rx: thread_rx.clone(), - }); - - Ok(Self { server: mcp_server }) - } - - pub fn server_config(&self) -> Result { - #[cfg(not(test))] - let zed_path = std::env::current_exe() - .context("finding current executable path for use in mcp_server")?; - - #[cfg(test)] - let zed_path = crate::e2e_tests::get_zed_path(); - - Ok(McpServerConfig { - command: zed_path, - args: vec![ - "--nc".into(), - self.server.socket_path().display().to_string(), - ], - env: None, - }) - } - - fn handle_initialize(_: InitializeParams, cx: &App) -> Task> { - cx.foreground_executor().spawn(async move { - Ok(InitializeResponse { - protocol_version: ProtocolVersion("2025-06-18".into()), - capabilities: ServerCapabilities { - experimental: None, - logging: None, - completions: None, - prompts: None, - resources: None, - tools: Some(ToolsCapabilities { - list_changed: Some(false), - }), - }, - server_info: Implementation { - name: SERVER_NAME.into(), - version: "0.1.0".into(), - }, - meta: None, - }) - }) - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct McpConfig { - pub mcp_servers: HashMap, -} - -#[derive(Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct McpServerConfig { - pub command: PathBuf, - pub args: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub env: Option>, -} - -// Tools - -#[derive(Clone)] -pub struct PermissionTool { - thread_rx: watch::Receiver>, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct PermissionToolParams { - tool_name: String, - input: serde_json::Value, - tool_use_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PermissionToolResponse { - behavior: PermissionToolBehavior, - updated_input: serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "snake_case")] -enum PermissionToolBehavior { - Allow, - Deny, -} - -impl McpServerTool for PermissionTool { - type Input = PermissionToolParams; - type Output = (); - - const NAME: &'static str = "Confirmation"; - - fn description(&self) -> &'static str { - "Request permission for tool calls" - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone()); - let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into()); - let allow_option_id = acp::PermissionOptionId("allow".into()); - let reject_option_id = acp::PermissionOptionId("reject".into()); - - let chosen_option = thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization( - claude_tool.as_acp(tool_call_id).into(), - vec![ - acp::PermissionOption { - id: allow_option_id.clone(), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - }, - acp::PermissionOption { - id: reject_option_id.clone(), - name: "Reject".into(), - kind: acp::PermissionOptionKind::RejectOnce, - }, - ], - cx, - ) - })?? - .await?; - - let response = if chosen_option == allow_option_id { - PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - } - } else { - debug_assert_eq!(chosen_option, reject_option_id); - PermissionToolResponse { - behavior: PermissionToolBehavior::Deny, - updated_input: input.input, - } - }; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: serde_json::to_string(&response)?, - }], - structured_content: (), - }) - } -} - -#[derive(Clone)] -pub struct ReadTool { - thread_rx: watch::Receiver>, -} - -impl McpServerTool for ReadTool { - type Input = ReadToolParams; - type Output = (); - - const NAME: &'static str = "Read"; - - fn description(&self) -> &'static str { - "Read the contents of a file. In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents." - } - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Read file".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: None, - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { text: content }], - structured_content: (), - }) - } -} - -#[derive(Clone)] -pub struct EditTool { - thread_rx: watch::Receiver>, -} - -impl McpServerTool for EditTool { - type Input = EditToolParams; - type Output = (); - - const NAME: &'static str = "Edit"; - - fn description(&self) -> &'static str { - "Edits a file. In sessions with mcp__zed__Edit always use it instead of Edit as it will show the diff to the user better." - } - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Edit file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: Some(false), - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path.clone(), None, None, true, cx) - })? - .await?; - - let new_content = content.replace(&input.old_text, &input.new_text); - if new_content == content { - return Err(anyhow::anyhow!("The old_text was not found in the content")); - } - - thread - .update(cx, |thread, cx| { - thread.write_text_file(input.abs_path, new_content, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs deleted file mode 100644 index 7ca150c0bd0b30b958a4791db9d01684d16460d6..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/tools.rs +++ /dev/null @@ -1,661 +0,0 @@ -use std::path::PathBuf; - -use agent_client_protocol as acp; -use itertools::Itertools; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use util::ResultExt; - -pub enum ClaudeTool { - Task(Option), - NotebookRead(Option), - NotebookEdit(Option), - Edit(Option), - MultiEdit(Option), - ReadFile(Option), - Write(Option), - Ls(Option), - Glob(Option), - Grep(Option), - Terminal(Option), - WebFetch(Option), - WebSearch(Option), - TodoWrite(Option), - ExitPlanMode(Option), - Other { - name: String, - input: serde_json::Value, - }, -} - -impl ClaudeTool { - pub fn infer(tool_name: &str, input: serde_json::Value) -> Self { - match tool_name { - // Known tools - "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()), - "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()), - "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()), - "Write" => Self::Write(serde_json::from_value(input).log_err()), - "LS" => Self::Ls(serde_json::from_value(input).log_err()), - "Glob" => Self::Glob(serde_json::from_value(input).log_err()), - "Grep" => Self::Grep(serde_json::from_value(input).log_err()), - "Bash" => Self::Terminal(serde_json::from_value(input).log_err()), - "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()), - "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()), - "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()), - "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()), - "Task" => Self::Task(serde_json::from_value(input).log_err()), - "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()), - "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()), - // Inferred from name - _ => { - let tool_name = tool_name.to_lowercase(); - - if tool_name.contains("edit") || tool_name.contains("write") { - Self::Edit(None) - } else if tool_name.contains("terminal") { - Self::Terminal(None) - } else { - Self::Other { - name: tool_name.to_string(), - input, - } - } - } - } - } - - pub fn label(&self) -> String { - match &self { - Self::Task(Some(params)) => params.description.clone(), - Self::Task(None) => "Task".into(), - Self::NotebookRead(Some(params)) => { - format!("Read Notebook {}", params.notebook_path.display()) - } - Self::NotebookRead(None) => "Read Notebook".into(), - Self::NotebookEdit(Some(params)) => { - format!("Edit Notebook {}", params.notebook_path.display()) - } - Self::NotebookEdit(None) => "Edit Notebook".into(), - Self::Terminal(Some(params)) => format!("`{}`", params.command), - Self::Terminal(None) => "Terminal".into(), - Self::ReadFile(_) => "Read File".into(), - Self::Ls(Some(params)) => { - format!("List Directory {}", params.path.display()) - } - Self::Ls(None) => "List Directory".into(), - Self::Edit(Some(params)) => { - format!("Edit {}", params.abs_path.display()) - } - Self::Edit(None) => "Edit".into(), - Self::MultiEdit(Some(params)) => { - format!("Multi Edit {}", params.file_path.display()) - } - Self::MultiEdit(None) => "Multi Edit".into(), - Self::Write(Some(params)) => { - format!("Write {}", params.file_path.display()) - } - Self::Write(None) => "Write".into(), - Self::Glob(Some(params)) => { - format!("Glob `{params}`") - } - Self::Glob(None) => "Glob".into(), - Self::Grep(Some(params)) => format!("`{params}`"), - Self::Grep(None) => "Grep".into(), - Self::WebFetch(Some(params)) => format!("Fetch {}", params.url), - Self::WebFetch(None) => "Fetch".into(), - Self::WebSearch(Some(params)) => format!("Web Search: {}", params), - Self::WebSearch(None) => "Web Search".into(), - Self::TodoWrite(Some(params)) => format!( - "Update TODOs: {}", - params.todos.iter().map(|todo| &todo.content).join(", ") - ), - Self::TodoWrite(None) => "Update TODOs".into(), - Self::ExitPlanMode(_) => "Exit Plan Mode".into(), - Self::Other { name, .. } => name.clone(), - } - } - pub fn content(&self) -> Vec { - match &self { - Self::Other { input, .. } => vec![ - format!( - "```json\n{}```", - serde_json::to_string_pretty(&input).unwrap_or("{}".to_string()) - ) - .into(), - ], - Self::Task(Some(params)) => vec![params.prompt.clone().into()], - Self::NotebookRead(Some(params)) => { - vec![params.notebook_path.display().to_string().into()] - } - Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()], - Self::Terminal(Some(params)) => vec![ - format!( - "`{}`\n\n{}", - params.command, - params.description.as_deref().unwrap_or_default() - ) - .into(), - ], - Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()], - Self::Ls(Some(params)) => vec![params.path.display().to_string().into()], - Self::Glob(Some(params)) => vec![params.to_string().into()], - Self::Grep(Some(params)) => vec![format!("`{params}`").into()], - Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()], - Self::WebSearch(Some(params)) => vec![params.to_string().into()], - Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()], - Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.abs_path.clone(), - old_text: Some(params.old_text.clone()), - new_text: params.new_text.clone(), - }, - }], - Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.file_path.clone(), - old_text: None, - new_text: params.content.clone(), - }, - }], - Self::MultiEdit(Some(params)) => { - // todo: show multiple edits in a multibuffer? - params - .edits - .first() - .map(|edit| { - vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.file_path.clone(), - old_text: Some(edit.old_string.clone()), - new_text: edit.new_string.clone(), - }, - }] - }) - .unwrap_or_default() - } - Self::TodoWrite(Some(_)) => { - // These are mapped to plan updates later - vec![] - } - Self::Task(None) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Terminal(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(None) - | Self::Grep(None) - | Self::WebFetch(None) - | Self::WebSearch(None) - | Self::TodoWrite(None) - | Self::ExitPlanMode(None) - | Self::Edit(None) - | Self::Write(None) - | Self::MultiEdit(None) => vec![], - } - } - - pub fn kind(&self) -> acp::ToolKind { - match self { - Self::Task(_) => acp::ToolKind::Think, - Self::NotebookRead(_) => acp::ToolKind::Read, - Self::NotebookEdit(_) => acp::ToolKind::Edit, - Self::Edit(_) => acp::ToolKind::Edit, - Self::MultiEdit(_) => acp::ToolKind::Edit, - Self::Write(_) => acp::ToolKind::Edit, - Self::ReadFile(_) => acp::ToolKind::Read, - Self::Ls(_) => acp::ToolKind::Search, - Self::Glob(_) => acp::ToolKind::Search, - Self::Grep(_) => acp::ToolKind::Search, - Self::Terminal(_) => acp::ToolKind::Execute, - Self::WebSearch(_) => acp::ToolKind::Search, - Self::WebFetch(_) => acp::ToolKind::Fetch, - Self::TodoWrite(_) => acp::ToolKind::Think, - Self::ExitPlanMode(_) => acp::ToolKind::Think, - Self::Other { .. } => acp::ToolKind::Other, - } - } - - pub fn locations(&self) -> Vec { - match &self { - Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: None, - }], - Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::Write(Some(WriteToolParams { file_path, .. })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::ReadFile(Some(ReadToolParams { - abs_path, offset, .. - })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: *offset, - }], - Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::Glob(Some(GlobToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Grep(Some(GrepToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: PathBuf::from(path), - line: None, - }], - Self::Task(_) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Edit(None) - | Self::MultiEdit(None) - | Self::Write(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(_) - | Self::Grep(_) - | Self::Terminal(_) - | Self::WebFetch(_) - | Self::WebSearch(_) - | Self::TodoWrite(_) - | Self::ExitPlanMode(_) - | Self::Other { .. } => vec![], - } - } - - pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall { - acp::ToolCall { - id, - kind: self.kind(), - status: acp::ToolCallStatus::InProgress, - title: self.label(), - content: self.content(), - locations: self.locations(), - raw_input: None, - raw_output: None, - } - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct EditToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// The old text to replace (must be unique in the file) - pub old_text: String, - /// The new text. - pub new_text: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ReadToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// Which line to start reading from. Omit to start from the beginning. - #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, - /// How many lines to read. Omit for the whole file. - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WriteToolParams { - /// Absolute path for new file - pub file_path: PathBuf, - /// File content - pub content: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct BashToolParams { - /// Shell command to execute - pub command: String, - /// 5-10 word description of what command does - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Timeout in ms (max 600000ms/10min, default 120000ms) - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GlobToolParams { - /// Glob pattern like **/*.js or src/**/*.ts - pub pattern: String, - /// Directory to search in (omit for current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -impl std::fmt::Display for GlobToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(path) = &self.path { - write!(f, "{}", path.display())?; - } - write!(f, "{}", self.pattern) - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct LsToolParams { - /// Absolute path to directory - pub path: PathBuf, - /// Array of glob patterns to ignore - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GrepToolParams { - /// Regex pattern to search for - pub pattern: String, - /// File/directory to search (defaults to current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - /// "content" (shows lines), "files_with_matches" (default), "count" - #[serde(skip_serializing_if = "Option::is_none")] - pub output_mode: Option, - /// Filter files with glob pattern like "*.js" - #[serde(skip_serializing_if = "Option::is_none")] - pub glob: Option, - /// File type filter like "js", "py", "rust" - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub file_type: Option, - /// Case insensitive search - #[serde(rename = "-i", default, skip_serializing_if = "is_false")] - pub case_insensitive: bool, - /// Show line numbers (content mode only) - #[serde(rename = "-n", default, skip_serializing_if = "is_false")] - pub line_numbers: bool, - /// Lines after match (content mode only) - #[serde(rename = "-A", skip_serializing_if = "Option::is_none")] - pub after_context: Option, - /// Lines before match (content mode only) - #[serde(rename = "-B", skip_serializing_if = "Option::is_none")] - pub before_context: Option, - /// Lines before and after match (content mode only) - #[serde(rename = "-C", skip_serializing_if = "Option::is_none")] - pub context: Option, - /// Enable multiline/cross-line matching - #[serde(default, skip_serializing_if = "is_false")] - pub multiline: bool, - /// Limit output to first N results - #[serde(skip_serializing_if = "Option::is_none")] - pub head_limit: Option, -} - -impl std::fmt::Display for GrepToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "grep")?; - - // Boolean flags - if self.case_insensitive { - write!(f, " -i")?; - } - if self.line_numbers { - write!(f, " -n")?; - } - - // Context options - if let Some(after) = self.after_context { - write!(f, " -A {}", after)?; - } - if let Some(before) = self.before_context { - write!(f, " -B {}", before)?; - } - if let Some(context) = self.context { - write!(f, " -C {}", context)?; - } - - // Output mode - if let Some(mode) = &self.output_mode { - match mode { - GrepOutputMode::FilesWithMatches => write!(f, " -l")?, - GrepOutputMode::Count => write!(f, " -c")?, - GrepOutputMode::Content => {} // Default mode - } - } - - // Head limit - if let Some(limit) = self.head_limit { - write!(f, " | head -{}", limit)?; - } - - // Glob pattern - if let Some(glob) = &self.glob { - write!(f, " --include=\"{}\"", glob)?; - } - - // File type - if let Some(file_type) = &self.file_type { - write!(f, " --type={}", file_type)?; - } - - // Multiline - if self.multiline { - write!(f, " -P")?; // Perl-compatible regex for multiline - } - - // Pattern (escaped if contains special characters) - write!(f, " \"{}\"", self.pattern)?; - - // Path - if let Some(path) = &self.path { - write!(f, " {}", path)?; - } - - Ok(()) - } -} - -#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoPriority { - High, - #[default] - Medium, - Low, -} - -impl Into for TodoPriority { - fn into(self) -> acp::PlanEntryPriority { - match self { - TodoPriority::High => acp::PlanEntryPriority::High, - TodoPriority::Medium => acp::PlanEntryPriority::Medium, - TodoPriority::Low => acp::PlanEntryPriority::Low, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoStatus { - Pending, - InProgress, - Completed, -} - -impl Into for TodoStatus { - fn into(self) -> acp::PlanEntryStatus { - match self { - TodoStatus::Pending => acp::PlanEntryStatus::Pending, - TodoStatus::InProgress => acp::PlanEntryStatus::InProgress, - TodoStatus::Completed => acp::PlanEntryStatus::Completed, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct Todo { - /// Task description - pub content: String, - /// Current status of the todo - pub status: TodoStatus, - /// Priority level of the todo - #[serde(default)] - pub priority: TodoPriority, -} - -impl Into for Todo { - fn into(self) -> acp::PlanEntry { - acp::PlanEntry { - content: self.content, - priority: self.priority.into(), - status: self.status.into(), - } - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TodoWriteToolParams { - pub todos: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ExitPlanModeToolParams { - /// Implementation plan in markdown format - pub plan: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TaskToolParams { - /// Short 3-5 word description of task - pub description: String, - /// Detailed task for agent to perform - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookReadToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// Specific cell ID to read - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum CellType { - Code, - Markdown, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum EditMode { - Replace, - Insert, - Delete, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookEditToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// New cell content - pub new_source: String, - /// Cell ID to edit - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, - /// Type of cell (code or markdown) - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_type: Option, - /// Edit operation mode - #[serde(skip_serializing_if = "Option::is_none")] - pub edit_mode: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct MultiEditItem { - /// The text to search for and replace - pub old_string: String, - /// The replacement text - pub new_string: String, - /// Whether to replace all occurrences or just the first - #[serde(default, skip_serializing_if = "is_false")] - pub replace_all: bool, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct MultiEditToolParams { - /// Absolute path to file - pub file_path: PathBuf, - /// List of edits to apply - pub edits: Vec, -} - -fn is_false(v: &bool) -> bool { - !*v -} - -#[derive(Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum GrepOutputMode { - Content, - FilesWithMatches, - Count, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebFetchToolParams { - /// Valid URL to fetch - #[serde(rename = "url")] - pub url: String, - /// What to extract from content - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebSearchToolParams { - /// Search query (min 2 chars) - pub query: String, - /// Only include these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_domains: Vec, - /// Exclude these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub blocked_domains: Vec, -} - -impl std::fmt::Display for WebSearchToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "\"{}\"", self.query)?; - - if !self.allowed_domains.is_empty() { - write!(f, " (allowed: {})", self.allowed_domains.join(", "))?; - } - - if !self.blocked_domains.is_empty() { - write!(f, " (blocked: {})", self.blocked_domains.join(", "))?; - } - - Ok(()) - } -} diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs new file mode 100644 index 0000000000000000000000000000000000000000..1b287063e5dc4363b6a1818434fc43285749b737 --- /dev/null +++ b/crates/agent_servers/src/codex.rs @@ -0,0 +1,104 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::{any::Any, path::Path}; + +use acp_thread::AgentConnection; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result}; +use fs::Fs; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME}; +use settings::{SettingsStore, update_settings_file}; + +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; + +#[derive(Clone)] +pub struct Codex; + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + crate::common_e2e_tests!(async |_, _, _| Codex, allow_option_id = "proceed_once"); +} + +impl AgentServer for Codex { + fn telemetry_id(&self) -> &'static str { + "codex" + } + + fn name(&self) -> SharedString { + "Codex".into() + } + + fn logo(&self) -> ui::IconName { + ui::IconName::AiOpenAi + } + + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + } + + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + update_settings_file(fs, cx, |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .default_mode = mode_id.map(|m| m.to_string()) + }); + } + + fn connect( + &self, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, + cx: &mut App, + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); + let extra_env = load_proxy_env(cx); + let default_mode = self.default_mode(cx); + + cx.spawn(async move |cx| { + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&CODEX_NAME.into()) + .context("Codex is not registered")?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + extra_env, + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; + Ok((connection, login)) + }) + } + + fn into_any(self: Rc) -> Rc { + self + } +} diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs new file mode 100644 index 0000000000000000000000000000000000000000..406a18965111a44bc4e78469b20aaf199cbda037 --- /dev/null +++ b/crates/agent_servers/src/custom.rs @@ -0,0 +1,109 @@ +use crate::{AgentServerDelegate, load_proxy_env}; +use acp_thread::AgentConnection; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result}; +use fs::Fs; +use gpui::{App, AppContext as _, SharedString, Task}; +use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName}; +use settings::{SettingsStore, update_settings_file}; +use std::{path::Path, rc::Rc, sync::Arc}; +use ui::IconName; + +/// A generic agent server implementation for custom user-defined agents +pub struct CustomAgentServer { + name: SharedString, +} + +impl CustomAgentServer { + pub fn new(name: SharedString) -> Self { + Self { name } + } +} + +impl crate::AgentServer for CustomAgentServer { + fn telemetry_id(&self) -> &'static str { + "custom" + } + + fn name(&self) -> SharedString { + self.name.clone() + } + + fn logo(&self) -> IconName { + IconName::Terminal + } + + fn default_mode(&self, cx: &mut App) -> Option { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + } + + fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { + let name = self.name(); + update_settings_file(fs, cx, move |settings, _| { + settings + .agent_servers + .get_or_insert_default() + .custom + .get_mut(&name) + .unwrap() + .default_mode = mode_id.map(|m| m.to_string()) + }); + } + + fn connect( + &self, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, + cx: &mut App, + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let default_mode = self.default_mode(cx); + let store = delegate.store.downgrade(); + let extra_env = load_proxy_env(cx); + + cx.spawn(async move |cx| { + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&ExternalAgentServerName(name.clone())) + .with_context(|| { + format!("Custom agent server `{}` is not registered", name) + })?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + extra_env, + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; + Ok((connection, login)) + }) + } + + fn into_any(self: Rc) -> Rc { + self + } +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 2b32edcd4f54b2ca216e11d37cb2dd7c1ee2243a..60480caa541ba1c39dba62ed709c157fd67fede0 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,24 +1,33 @@ +use crate::{AgentServer, AgentServerDelegate}; +use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; +use agent_client_protocol as acp; +use futures::{FutureExt, StreamExt, channel::mpsc, select}; +use gpui::{AppContext, Entity, TestAppContext}; +use indoc::indoc; +#[cfg(test)] +use project::agent_server_store::BuiltinAgentServerSettings; +use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings}; use std::{ path::{Path, PathBuf}, sync::Arc, time::Duration, }; - -use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings}; -use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; -use agent_client_protocol as acp; - -use futures::{FutureExt, StreamExt, channel::mpsc, select}; -use gpui::{Entity, TestAppContext}; -use indoc::indoc; -use project::{FakeFs, Project}; -use settings::{Settings, SettingsStore}; use util::path; -pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +pub async fn test_basic(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx)) @@ -42,8 +51,12 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont }); } -pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let _fs = init_test(cx).await; +pub async fn test_path_mentions(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as _; let tempdir = tempfile::tempdir().unwrap(); std::fs::write( @@ -56,7 +69,13 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes ) .expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + tempdir.path(), + cx, + ) + .await; thread .update(cx, |thread, cx| { thread.send( @@ -64,6 +83,7 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes acp::ContentBlock::Text(acp::TextContent { text: "Read the file ".into(), annotations: None, + meta: None, }), acp::ContentBlock::ResourceLink(acp::ResourceLink { uri: "foo.rs".into(), @@ -73,10 +93,12 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes mime_type: None, size: None, title: None, + meta: None, }), acp::ContentBlock::Text(acp::TextContent { text: " and tell me what the content of the println! is".into(), annotations: None, + meta: None, }), ], cx, @@ -110,15 +132,25 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes drop(tempdir); } -pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let _fs = init_test(cx).await; +pub async fn test_tool_call(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as _; let tempdir = tempfile::tempdir().unwrap(); let foo_path = tempdir.path().join("foo"); std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file"); let project = Project::example([tempdir.path()], &mut cx.to_async()).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| { @@ -152,14 +184,23 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp drop(tempdir); } -pub async fn test_tool_call_with_permission( - server: impl AgentServer + 'static, +pub async fn test_tool_call_with_permission( + server: F, allow_option_id: acp::PermissionOptionId, cx: &mut TestAppContext, -) { - let fs = init_test(cx).await; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +) where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -247,11 +288,21 @@ pub async fn test_tool_call_with_permission( }); } -pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +pub async fn test_cancel(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + + let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; let _ = thread.update(cx, |thread, cx| { thread.send_raw( r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, @@ -316,10 +367,20 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon }); } -pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; +pub async fn test_thread_drop(server: F, cx: &mut TestAppContext) +where + T: AgentServer + 'static, + F: AsyncFn(&Arc, &Entity, &mut TestAppContext) -> T, +{ + let fs = init_test(cx).await as Arc; + let project = Project::test(fs.clone(), [], cx).await; + let thread = new_test_thread( + server(&fs, &project, cx).await, + project.clone(), + "/private/tmp", + cx, + ) + .await; thread .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx)) @@ -386,27 +447,50 @@ macro_rules! common_e2e_tests { } }; } +pub use common_e2e_tests; // Helpers pub async fn init_test(cx: &mut TestAppContext) -> Arc { + use settings::Settings; + env_logger::try_init().ok(); cx.update(|cx| { - let settings_store = SettingsStore::test(cx); + let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); Project::init_settings(cx); language::init(cx); - crate::settings::init(cx); - - crate::AllAgentServersSettings::override_global( + gpui_tokio::init(cx); + let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap(); + cx.set_http_client(Arc::new(http_client)); + client::init_settings(cx); + let client = client::Client::production(cx); + let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client, cx); + agent_settings::init(cx); + AllAgentServersSettings::register(cx); + + #[cfg(test)] + AllAgentServersSettings::override_global( AllAgentServersSettings { - claude: Some(AgentServerSettings { - command: crate::claude::tests::local_command(), + claude: Some(BuiltinAgentServerSettings { + path: Some("claude-code-acp".into()), + args: None, + env: None, + ignore_system_version: None, + default_mode: None, }), - gemini: Some(AgentServerSettings { - command: crate::gemini::tests::local_command(), + gemini: Some(crate::gemini::tests::local_command().into()), + codex: Some(BuiltinAgentServerSettings { + path: Some("codex-acp".into()), + args: None, + env: None, + ignore_system_version: None, + default_mode: None, }), + custom: collections::HashMap::default(), }, cx, ); @@ -423,17 +507,17 @@ pub async fn new_test_thread( current_dir: impl AsRef, cx: &mut TestAppContext, ) -> Entity { - let connection = cx - .update(|cx| server.connect(current_dir.as_ref(), &project, cx)) - .await - .unwrap(); + let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); + let delegate = AgentServerDelegate::new(store, project.clone(), None, None); - let thread = cx - .update(|cx| connection.new_thread(project.clone(), current_dir.as_ref(), cx)) + let (connection, _) = cx + .update(|cx| server.connect(Some(current_dir.as_ref()), delegate, cx)) .await .unwrap(); - thread + cx.update(|cx| connection.new_thread(project.clone(), current_dir.as_ref(), cx)) + .await + .unwrap() } pub async fn run_until_first_tool_call( @@ -471,7 +555,7 @@ pub fn get_zed_path() -> PathBuf { while zed_path .file_name() - .map_or(true, |name| name.to_string_lossy() != "debug") + .is_none_or(|name| name.to_string_lossy() != "debug") { if !zed_path.pop() { panic!("Could not find target directory"); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index ad883f6da8bd344044e1db0051ca6f24120d5057..8004f5caec4a7bd2e3e6b1d9a885f4943fa21147 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -1,32 +1,23 @@ -use std::path::Path; use std::rc::Rc; +use std::{any::Any, path::Path}; -use crate::{AgentServer, AgentServerCommand}; -use acp_thread::{AgentConnection, LoadError}; -use anyhow::Result; -use gpui::{Entity, Task}; -use project::Project; -use settings::SettingsStore; -use ui::App; - -use crate::AllAgentServersSettings; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; +use acp_thread::AgentConnection; +use anyhow::{Context as _, Result}; +use gpui::{App, SharedString, Task}; +use language_models::provider::google::GoogleLanguageModelProvider; +use project::agent_server_store::GEMINI_NAME; #[derive(Clone)] pub struct Gemini; -const ACP_ARG: &str = "--experimental-acp"; - impl AgentServer for Gemini { - fn name(&self) -> &'static str { - "Gemini" - } - - fn empty_state_headline(&self) -> &'static str { - "Welcome to Gemini" + fn telemetry_id(&self) -> &'static str { + "gemini-cli" } - fn empty_state_message(&self) -> &'static str { - "Ask questions, edit files, run commands.\nBe specific for the best results." + fn name(&self) -> SharedString { + "Gemini CLI".into() } fn logo(&self) -> ui::IconName { @@ -35,66 +26,68 @@ impl AgentServer for Gemini { fn connect( &self, - root_dir: &Path, - project: &Entity, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, cx: &mut App, - ) -> Task>> { - let project = project.clone(); - let root_dir = root_dir.to_path_buf(); - let server_name = self.name(); - cx.spawn(async move |cx| { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).gemini.clone() - })?; - - let Some(command) = - AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await - else { - anyhow::bail!("Failed to find gemini binary"); - }; - - let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await; - if result.is_err() { - let version_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--version") - .kill_on_drop(true) - .output(); + ) -> Task, Option)>> { + let name = self.name(); + let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); + let is_remote = delegate.project.read(cx).is_via_remote_server(); + let store = delegate.store.downgrade(); + let mut extra_env = load_proxy_env(cx); + let default_mode = self.default_mode(cx); - let help_fut = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .arg("--help") - .kill_on_drop(true) - .output(); - - let (version_output, help_output) = futures::future::join(version_fut, help_fut).await; - - let current_version = String::from_utf8(version_output?.stdout)?; - let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG); - - if !supported { - return Err(LoadError::Unsupported { - error_message: format!( - "Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).", - current_version - ).into(), - upgrade_message: "Upgrade Gemini to Latest".into(), - upgrade_command: "npm install -g @google/gemini-cli@latest".into(), - }.into()) - } + cx.spawn(async move |cx| { + extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); + + if let Some(api_key) = cx + .update(GoogleLanguageModelProvider::api_key_for_gemini_cli)? + .await + .ok() + { + extra_env.insert("GEMINI_API_KEY".into(), api_key); } - result + let (command, root_dir, login) = store + .update(cx, |store, cx| { + let agent = store + .get_external_agent(&GEMINI_NAME.into()) + .context("Gemini CLI is not registered")?; + anyhow::Ok(agent.get_command( + root_dir.as_deref(), + extra_env, + delegate.status_tx, + delegate.new_version_available, + &mut cx.to_async(), + )) + })?? + .await?; + + let connection = crate::acp::connect( + name, + command, + root_dir.as_ref(), + default_mode, + is_remote, + cx, + ) + .await?; + Ok((connection, login)) }) } + + fn into_any(self: Rc) -> Rc { + self + } } #[cfg(test)] pub(crate) mod tests { + use project::agent_server_store::AgentServerCommand; + use super::*; - use crate::AgentServerCommand; use std::path::Path; - crate::common_e2e_tests!(Gemini, allow_option_id = "proceed_once"); + crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once"); pub fn local_command() -> AgentServerCommand { let cli_path = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs deleted file mode 100644 index 645674b5f15087250c2364fb9a8a846e163ad54c..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/settings.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::AgentServerCommand; -use anyhow::Result; -use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; - -pub fn init(cx: &mut App) { - AllAgentServersSettings::register(cx); -} - -#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] -pub struct AllAgentServersSettings { - pub gemini: Option, - pub claude: Option, -} - -#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] -pub struct AgentServerSettings { - #[serde(flatten)] - pub command: AgentServerCommand, -} - -impl settings::Settings for AllAgentServersSettings { - const KEY: Option<&'static str> = Some("agent_servers"); - - type FileContent = Self; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - let mut settings = AllAgentServersSettings::default(); - - for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() { - if gemini.is_some() { - settings.gemini = gemini.clone(); - } - if claude.is_some() { - settings.claude = claude.clone(); - } - } - - Ok(settings) - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index d34396a5d35dd8919e519e804a93b50dfe046133..8ddcac24fe054d1226f2bbac49498fd35d6ed1c3 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -15,12 +15,15 @@ path = "src/agent_settings.rs" anyhow.workspace = true cloud_llm_client.workspace = true collections.workspace = true +convert_case.workspace = true +fs.workspace = true gpui.workspace = true language_model.workspace = true +project.workspace = true schemars.workspace = true serde.workspace = true settings.workspace = true -workspace-hack.workspace = true +util.workspace = true [dev-dependencies] fs.workspace = true diff --git a/crates/agent_settings/src/agent_profile.rs b/crates/agent_settings/src/agent_profile.rs index 402cf81678e02a13c99bf4cdf225406085e3551d..999ddc8083a1a4b4c271ea9bde4c1e45307e9542 100644 --- a/crates/agent_settings/src/agent_profile.rs +++ b/crates/agent_settings/src/agent_profile.rs @@ -1,9 +1,17 @@ use std::sync::Arc; +use anyhow::{Result, bail}; use collections::IndexMap; -use gpui::SharedString; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use convert_case::{Case, Casing as _}; +use fs::Fs; +use gpui::{App, SharedString}; +use settings::{ + AgentProfileContent, ContextServerPresetContent, Settings as _, SettingsContent, + update_settings_file, +}; +use util::ResultExt as _; + +use crate::{AgentProfileId, AgentSettings}; pub mod builtin_profiles { use super::AgentProfileId; @@ -17,24 +25,66 @@ pub mod builtin_profiles { } } -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AgentProfileId(pub Arc); +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AgentProfile { + id: AgentProfileId, +} + +pub type AvailableProfiles = IndexMap; -impl AgentProfileId { - pub fn as_str(&self) -> &str { - &self.0 +impl AgentProfile { + pub fn new(id: AgentProfileId) -> Self { + Self { id } } -} -impl std::fmt::Display for AgentProfileId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + pub fn id(&self) -> &AgentProfileId { + &self.id + } + + /// Saves a new profile to the settings. + pub fn create( + name: String, + base_profile_id: Option, + fs: Arc, + cx: &App, + ) -> AgentProfileId { + let id = AgentProfileId(name.to_case(Case::Kebab).into()); + + let base_profile = + base_profile_id.and_then(|id| AgentSettings::get_global(cx).profiles.get(&id).cloned()); + + let profile_settings = AgentProfileSettings { + name: name.into(), + tools: base_profile + .as_ref() + .map(|profile| profile.tools.clone()) + .unwrap_or_default(), + enable_all_context_servers: base_profile + .as_ref() + .map(|profile| profile.enable_all_context_servers) + .unwrap_or_default(), + context_servers: base_profile + .map(|profile| profile.context_servers) + .unwrap_or_default(), + }; + + update_settings_file(fs, cx, { + let id = id.clone(); + move |settings, _cx| { + profile_settings.save_to_settings(id, settings).log_err(); + } + }); + + id } -} -impl Default for AgentProfileId { - fn default() -> Self { - Self("write".into()) + /// Returns a map of AgentProfileIds to their names + pub fn available_profiles(cx: &App) -> AvailableProfiles { + let mut profiles = AvailableProfiles::default(); + for (id, profile) in AgentSettings::get_global(cx).profiles.iter() { + profiles.insert(id.clone(), profile.name.clone()); + } + profiles } } @@ -58,7 +108,61 @@ impl AgentProfileSettings { || self .context_servers .get(server_id) - .map_or(false, |preset| preset.tools.get(tool_name) == Some(&true)) + .is_some_and(|preset| preset.tools.get(tool_name) == Some(&true)) + } + + pub fn save_to_settings( + &self, + profile_id: AgentProfileId, + content: &mut SettingsContent, + ) -> Result<()> { + let profiles = content + .agent + .get_or_insert_default() + .profiles + .get_or_insert_default(); + if profiles.contains_key(&profile_id.0) { + bail!("profile with ID '{profile_id}' already exists"); + } + + profiles.insert( + profile_id.0, + AgentProfileContent { + name: self.name.clone().into(), + tools: self.tools.clone(), + enable_all_context_servers: Some(self.enable_all_context_servers), + context_servers: self + .context_servers + .clone() + .into_iter() + .map(|(server_id, preset)| { + ( + server_id, + ContextServerPresetContent { + tools: preset.tools, + }, + ) + }) + .collect(), + }, + ); + + Ok(()) + } +} + +impl From for AgentProfileSettings { + fn from(content: AgentProfileContent) -> Self { + Self { + name: content.name.into(), + tools: content.tools, + enable_all_context_servers: content.enable_all_context_servers.unwrap_or_default(), + context_servers: content + .context_servers + .into_iter() + .map(|(server_id, preset)| (server_id, preset.into())) + .collect(), + } } } @@ -66,3 +170,11 @@ impl AgentProfileSettings { pub struct ContextServerPreset { pub tools: IndexMap, bool>, } + +impl From for ContextServerPreset { + fn from(content: settings::ContextServerPresetContent) -> Self { + Self { + tools: content.tools, + } + } +} diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index fd38ba1f7f0df640fbc9dd50976112092acc2db2..c573f2688159619474051e1f7cfefb957f7154a8 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,55 +2,32 @@ mod agent_profile; use std::sync::Arc; -use anyhow::{Result, bail}; use collections::IndexMap; -use gpui::{App, Pixels, SharedString}; +use gpui::{App, Pixels, px}; use language_model::LanguageModel; -use schemars::{JsonSchema, json_schema}; +use project::DisableAiSettings; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; -use std::borrow::Cow; +use settings::{ + DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, + NotifyWhenAgentWaiting, Settings, +}; pub use crate::agent_profile::*; -pub const SUMMARIZE_THREAD_PROMPT: &str = - include_str!("../../agent/src/prompts/summarize_thread_prompt.txt"); +pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt"); +pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = + include_str!("prompts/summarize_thread_detailed_prompt.txt"); pub fn init(cx: &mut App) { AgentSettings::register(cx); } -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum AgentDockPosition { - Left, - #[default] - Right, - Bottom, -} - -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DefaultView { - #[default] - Thread, - TextThread, -} - -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum NotifyWhenAgentWaiting { - #[default] - PrimaryScreen, - AllScreens, - Never, -} - -#[derive(Default, Clone, Debug)] +#[derive(Clone, Debug)] pub struct AgentSettings { pub enabled: bool, pub button: bool, - pub dock: AgentDockPosition, + pub dock: DockPosition, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, @@ -58,14 +35,12 @@ pub struct AgentSettings { pub commit_message_model: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, - pub using_outdated_settings_version: bool, pub default_profile: AgentProfileId, - pub default_view: DefaultView, + pub default_view: DefaultAgentView, pub profiles: IndexMap, pub always_allow_tool_actions: bool, pub notify_when_agent_waiting: NotifyWhenAgentWaiting, pub play_sound_when_agent_done: bool, - pub stream_edits: bool, pub single_file_review: bool, pub model_parameters: Vec, pub preferred_completion_mode: CompletionMode, @@ -73,76 +48,30 @@ pub struct AgentSettings { pub expand_edit_card: bool, pub expand_terminal_card: bool, pub use_modifier_to_send: bool, + pub message_editor_min_lines: usize, } impl AgentSettings { - pub fn temperature_for_model(model: &Arc, cx: &App) -> Option { - let settings = Self::get_global(cx); - settings - .model_parameters - .iter() - .rfind(|setting| setting.matches(model)) - .and_then(|m| m.temperature) - } - - pub fn set_inline_assistant_model(&mut self, provider: String, model: String) { - self.inline_assistant_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - } - - pub fn set_commit_message_model(&mut self, provider: String, model: String) { - self.commit_message_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); + pub fn enabled(&self, cx: &App) -> bool { + self.enabled && !DisableAiSettings::get_global(cx).disable_ai } - pub fn set_thread_summary_model(&mut self, provider: String, model: String) { - self.thread_summary_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] -pub struct LanguageModelParameters { - pub provider: Option, - pub model: Option, - pub temperature: Option, -} - -impl LanguageModelParameters { - pub fn matches(&self, model: &Arc) -> bool { - if let Some(provider) = &self.provider { - if provider.0 != model.provider_id().0 { - return false; + pub fn temperature_for_model(model: &Arc, cx: &App) -> Option { + let settings = Self::get_global(cx); + for setting in settings.model_parameters.iter().rev() { + if let Some(provider) = &setting.provider + && provider.0 != model.provider_id().0 + { + continue; } - } - if let Some(setting_model) = &self.model { - if *setting_model != model.id().0 { - return false; + if let Some(setting_model) = &setting.model + && *setting_model != model.id().0 + { + continue; } + return setting.temperature; } - true - } -} - -impl AgentSettingsContent { - pub fn set_dock(&mut self, dock: AgentDockPosition) { - self.dock = Some(dock); - } - - pub fn set_model(&mut self, language_model: Arc) { - let model = language_model.id().0.to_string(); - let provider = language_model.provider_id().0.to_string(); - - self.default_model = Some(LanguageModelSelection { - provider: provider.into(), - model, - }); + return None; } pub fn set_inline_assistant_model(&mut self, provider: String, model: String) { @@ -166,155 +95,11 @@ impl AgentSettingsContent { }); } - pub fn set_always_allow_tool_actions(&mut self, allow: bool) { - self.always_allow_tool_actions = Some(allow); - } - - pub fn set_play_sound_when_agent_done(&mut self, allow: bool) { - self.play_sound_when_agent_done = Some(allow); - } - - pub fn set_single_file_review(&mut self, allow: bool) { - self.single_file_review = Some(allow); - } - - pub fn set_use_modifier_to_send(&mut self, always_use: bool) { - self.use_modifier_to_send = Some(always_use); - } - - pub fn set_profile(&mut self, profile_id: AgentProfileId) { - self.default_profile = Some(profile_id); - } - - pub fn create_profile( - &mut self, - profile_id: AgentProfileId, - profile_settings: AgentProfileSettings, - ) -> Result<()> { - let profiles = self.profiles.get_or_insert_default(); - if profiles.contains_key(&profile_id) { - bail!("profile with ID '{profile_id}' already exists"); - } - - profiles.insert( - profile_id, - AgentProfileContent { - name: profile_settings.name.into(), - tools: profile_settings.tools, - enable_all_context_servers: Some(profile_settings.enable_all_context_servers), - context_servers: profile_settings - .context_servers - .into_iter() - .map(|(server_id, preset)| { - ( - server_id, - ContextServerPresetContent { - tools: preset.tools, - }, - ) - }) - .collect(), - }, - ); - - Ok(()) + pub fn set_message_editor_max_lines(&self) -> usize { + self.message_editor_min_lines * 2 } } -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)] -pub struct AgentSettingsContent { - /// Whether the Agent is enabled. - /// - /// Default: true - enabled: Option, - /// Whether to show the agent panel button in the status bar. - /// - /// Default: true - button: Option, - /// Where to dock the agent panel. - /// - /// Default: right - dock: Option, - /// Default width in pixels when the agent panel is docked to the left or right. - /// - /// Default: 640 - default_width: Option, - /// Default height in pixels when the agent panel is docked to the bottom. - /// - /// Default: 320 - default_height: Option, - /// The default model to use when creating new chats and for other features when a specific model is not specified. - default_model: Option, - /// Model to use for the inline assistant. Defaults to default_model when not specified. - inline_assistant_model: Option, - /// Model to use for generating git commit messages. Defaults to default_model when not specified. - commit_message_model: Option, - /// Model to use for generating thread summaries. Defaults to default_model when not specified. - thread_summary_model: Option, - /// Additional models with which to generate alternatives when performing inline assists. - inline_alternatives: Option>, - /// The default profile to use in the Agent. - /// - /// Default: write - default_profile: Option, - /// Which view type to show by default in the agent panel. - /// - /// Default: "thread" - default_view: Option, - /// The available agent profiles. - pub profiles: Option>, - /// Whenever a tool action would normally wait for your confirmation - /// that you allow it, always choose to allow it. - /// - /// Default: false - always_allow_tool_actions: Option, - /// Where to show a popup notification when the agent is waiting for user input. - /// - /// Default: "primary_screen" - notify_when_agent_waiting: Option, - /// Whether to play a sound when the agent has either completed its response, or needs user input. - /// - /// Default: false - play_sound_when_agent_done: Option, - /// Whether to stream edits from the agent as they are received. - /// - /// Default: false - stream_edits: Option, - /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. - /// - /// Default: true - single_file_review: Option, - /// Additional parameters for language model requests. When making a request - /// to a model, parameters will be taken from the last entry in this list - /// that matches the model's provider and name. In each entry, both provider - /// and model are optional, so that you can specify parameters for either - /// one. - /// - /// Default: [] - #[serde(default)] - model_parameters: Vec, - /// What completion mode to enable for new threads - /// - /// Default: normal - preferred_completion_mode: Option, - /// Whether to show thumb buttons for feedback in the agent panel. - /// - /// Default: true - enable_feedback: Option, - /// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. - /// - /// Default: true - expand_edit_card: Option, - /// Whether to have terminal cards in the agent panel expanded, showing the whole command output. - /// - /// Default: true - expand_terminal_card: Option, - /// Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel. - /// - /// Default: false - use_modifier_to_send: Option, -} - #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum CompletionMode { @@ -333,206 +118,69 @@ impl From for cloud_llm_client::CompletionMode { } } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] -pub struct LanguageModelSelection { - pub provider: LanguageModelProviderSetting, - pub model: String, +impl From for CompletionMode { + fn from(value: settings::CompletionMode) -> Self { + match value { + settings::CompletionMode::Normal => CompletionMode::Normal, + settings::CompletionMode::Burn => CompletionMode::Burn, + } + } } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct LanguageModelProviderSetting(pub String); +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AgentProfileId(pub Arc); -impl JsonSchema for LanguageModelProviderSetting { - fn schema_name() -> Cow<'static, str> { - "LanguageModelProviderSetting".into() - } - - fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - json_schema!({ - "enum": [ - "anthropic", - "amazon-bedrock", - "google", - "lmstudio", - "ollama", - "openai", - "zed.dev", - "copilot_chat", - "deepseek", - "openrouter", - "mistral", - "vercel" - ] - }) +impl AgentProfileId { + pub fn as_str(&self) -> &str { + &self.0 } } -impl From for LanguageModelProviderSetting { - fn from(provider: String) -> Self { - Self(provider) +impl std::fmt::Display for AgentProfileId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } } -impl From<&str> for LanguageModelProviderSetting { - fn from(provider: &str) -> Self { - Self(provider.to_string()) +impl Default for AgentProfileId { + fn default() -> Self { + Self("write".into()) } } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AgentProfileContent { - pub name: Arc, - #[serde(default)] - pub tools: IndexMap, bool>, - /// Whether all context servers are enabled by default. - pub enable_all_context_servers: Option, - #[serde(default)] - pub context_servers: IndexMap, ContextServerPresetContent>, -} - -#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema)] -pub struct ContextServerPresetContent { - pub tools: IndexMap, bool>, -} - impl Settings for AgentSettings { - const KEY: Option<&'static str> = Some("agent"); - - const FALLBACK_KEY: Option<&'static str> = Some("assistant"); - - const PRESERVED_KEYS: Option<&'static [&'static str]> = Some(&["version"]); - - type FileContent = AgentSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - let mut settings = AgentSettings::default(); - - for value in sources.defaults_and_customizations() { - merge(&mut settings.enabled, value.enabled); - merge(&mut settings.button, value.button); - merge(&mut settings.dock, value.dock); - merge( - &mut settings.default_width, - value.default_width.map(Into::into), - ); - merge( - &mut settings.default_height, - value.default_height.map(Into::into), - ); - settings.default_model = value - .default_model - .clone() - .or(settings.default_model.take()); - settings.inline_assistant_model = value - .inline_assistant_model - .clone() - .or(settings.inline_assistant_model.take()); - settings.commit_message_model = value - .clone() - .commit_message_model - .or(settings.commit_message_model.take()); - settings.thread_summary_model = value - .clone() - .thread_summary_model - .or(settings.thread_summary_model.take()); - merge( - &mut settings.inline_alternatives, - value.inline_alternatives.clone(), - ); - merge( - &mut settings.notify_when_agent_waiting, - value.notify_when_agent_waiting, - ); - merge( - &mut settings.play_sound_when_agent_done, - value.play_sound_when_agent_done, - ); - merge(&mut settings.stream_edits, value.stream_edits); - merge(&mut settings.single_file_review, value.single_file_review); - merge(&mut settings.default_profile, value.default_profile.clone()); - merge(&mut settings.default_view, value.default_view); - merge( - &mut settings.preferred_completion_mode, - value.preferred_completion_mode, - ); - merge(&mut settings.enable_feedback, value.enable_feedback); - merge(&mut settings.expand_edit_card, value.expand_edit_card); - merge( - &mut settings.expand_terminal_card, - value.expand_terminal_card, - ); - merge( - &mut settings.use_modifier_to_send, - value.use_modifier_to_send, - ); - - settings - .model_parameters - .extend_from_slice(&value.model_parameters); - - if let Some(profiles) = value.profiles.as_ref() { - settings - .profiles - .extend(profiles.into_iter().map(|(id, profile)| { - ( - id.clone(), - AgentProfileSettings { - name: profile.name.clone().into(), - tools: profile.tools.clone(), - enable_all_context_servers: profile - .enable_all_context_servers - .unwrap_or_default(), - context_servers: profile - .context_servers - .iter() - .map(|(context_server_id, preset)| { - ( - context_server_id.clone(), - ContextServerPreset { - tools: preset.tools.clone(), - }, - ) - }) - .collect(), - }, - ) - })); - } + fn from_settings(content: &settings::SettingsContent) -> Self { + let agent = content.agent.clone().unwrap(); + Self { + enabled: agent.enabled.unwrap(), + button: agent.button.unwrap(), + dock: agent.dock.unwrap(), + default_width: px(agent.default_width.unwrap()), + default_height: px(agent.default_height.unwrap()), + default_model: Some(agent.default_model.unwrap()), + inline_assistant_model: agent.inline_assistant_model, + commit_message_model: agent.commit_message_model, + thread_summary_model: agent.thread_summary_model, + inline_alternatives: agent.inline_alternatives.unwrap_or_default(), + default_profile: AgentProfileId(agent.default_profile.unwrap()), + default_view: agent.default_view.unwrap(), + profiles: agent + .profiles + .unwrap() + .into_iter() + .map(|(key, val)| (AgentProfileId(key), val.into())) + .collect(), + always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(), + notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(), + play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(), + single_file_review: agent.single_file_review.unwrap(), + model_parameters: agent.model_parameters, + preferred_completion_mode: agent.preferred_completion_mode.unwrap().into(), + enable_feedback: agent.enable_feedback.unwrap(), + expand_edit_card: agent.expand_edit_card.unwrap(), + expand_terminal_card: agent.expand_terminal_card.unwrap(), + use_modifier_to_send: agent.use_modifier_to_send.unwrap(), + message_editor_min_lines: agent.message_editor_min_lines.unwrap(), } - - debug_assert_eq!( - sources.default.always_allow_tool_actions.unwrap_or(false), - false, - "For security, agent.always_allow_tool_actions should always be false in default.json. If it's true, that is a bug that should be fixed!" - ); - - // For security reasons, only trust the user's global settings for whether to always allow tool actions. - // If this could be overridden locally, an attacker could (e.g. by committing to source control and - // convincing you to switch branches) modify your project-local settings to disable the agent's safety checks. - settings.always_allow_tool_actions = sources - .user - .and_then(|setting| setting.always_allow_tool_actions) - .unwrap_or(false); - - Ok(settings) - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) { - if let Some(b) = vscode - .read_value("chat.agent.enabled") - .and_then(|b| b.as_bool()) - { - current.enabled = Some(b); - current.button = Some(b); - } - } -} - -fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; } } diff --git a/crates/agent/src/prompts/summarize_thread_detailed_prompt.txt b/crates/agent_settings/src/prompts/summarize_thread_detailed_prompt.txt similarity index 100% rename from crates/agent/src/prompts/summarize_thread_detailed_prompt.txt rename to crates/agent_settings/src/prompts/summarize_thread_detailed_prompt.txt diff --git a/crates/agent/src/prompts/summarize_thread_prompt.txt b/crates/agent_settings/src/prompts/summarize_thread_prompt.txt similarity index 100% rename from crates/agent/src/prompts/summarize_thread_prompt.txt rename to crates/agent_settings/src/prompts/summarize_thread_prompt.txt diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index fbf8590e681c1d355e7904f171abec8cafff97da..724b53a017911edbd6e9dd88c410daf794889d4e 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -20,15 +20,14 @@ acp_thread.workspace = true action_log.workspace = true agent-client-protocol.workspace = true agent.workspace = true -agent2.workspace = true agent_servers.workspace = true agent_settings.workspace = true ai_onboarding.workspace = true anyhow.workspace = true -assistant_context.workspace = true +arrayvec.workspace = true +assistant_text_thread.workspace = true assistant_slash_command.workspace = true assistant_slash_commands.workspace = true -assistant_tool.workspace = true audio.workspace = true buffer_diff.workspace = true chrono.workspace = true @@ -51,7 +50,6 @@ gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true -inventory.workspace = true itertools.workspace = true jsonschema.workspace = true language.workspace = true @@ -67,9 +65,11 @@ ordered-float.workspace = true parking_lot.workspace = true paths.workspace = true picker.workspace = true +postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +ref-cast.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -95,18 +95,16 @@ ui_input.workspace = true url.workspace = true urlencoding.workspace = true util.workspace = true -uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true [dev-dependencies] acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } -assistant_context = { workspace = true, features = ["test-support"] } -assistant_tools.workspace = true +assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 831d296eebcf7edd29f3f84acbf6a7824be47a1b..2e15cd424d6313d981ff8c000f5eeb958aec9370 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -1,10 +1,14 @@ mod completion_provider; mod entry_view_state; mod message_editor; +mod mode_selector; mod model_selector; mod model_selector_popover; +mod thread_history; mod thread_view; +pub use mode_selector::ModeSelector; pub use model_selector::AcpModelSelector; pub use model_selector_popover::AcpModelSelectorPopover; +pub use thread_history::*; pub use thread_view::AcpThreadView; diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs index 8a413fc91ec1ad3d10a2bfcb7fb5e83cbc12ef92..583d8070d98697f4620bf45a3284d88760ebf9e7 100644 --- a/crates/agent_ui/src/acp/completion_provider.rs +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -1,42 +1,47 @@ +use std::cell::RefCell; use std::ops::Range; +use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use acp_thread::MentionUri; +use agent::{HistoryEntry, HistoryStore}; +use agent_client_protocol as acp; use anyhow::Result; use editor::{CompletionProvider, Editor, ExcerptId}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; -use language::{Buffer, CodeLabel, HighlightId}; +use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; +use project::lsp_store::{CompletionDocumentation, SymbolLocation}; use project::{ - Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId, + Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project, + ProjectPath, Symbol, WorktreeId, }; use prompt_store::PromptStore; use rope::Point; use text::{Anchor, ToPoint as _}; use ui::prelude::*; +use util::rel_path::RelPath; use workspace::Workspace; -use agent::thread_store::{TextThreadStore, ThreadStore}; - +use crate::AgentPanel; use crate::acp::message_editor::MessageEditor; use crate::context_picker::file_context_picker::{FileMatch, search_files}; use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules}; use crate::context_picker::symbol_context_picker::SymbolMatch; use crate::context_picker::symbol_context_picker::search_symbols; -use crate::context_picker::thread_context_picker::{ - ThreadContextEntry, ThreadMatch, search_threads, -}; +use crate::context_picker::thread_context_picker::search_threads; use crate::context_picker::{ - ContextPickerAction, ContextPickerEntry, ContextPickerMode, RecentEntry, - available_context_picker_entries, recent_context_picker_entries, selection_ranges, + ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges, }; pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), - Thread(ThreadMatch), + Thread(HistoryEntry), + RecentThread(HistoryEntry), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), @@ -53,6 +58,7 @@ impl Match { Match::File(file) => file.mat.score, Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.), Match::Thread(_) => 1., + Match::RecentThread(_) => 1., Match::Symbol(_) => 1., Match::Rules(_) => 1., Match::Fetch(_) => 1., @@ -60,209 +66,31 @@ impl Match { } } -fn search( - mode: Option, - query: String, - cancellation_flag: Arc, - recent_entries: Vec, - prompt_store: Option>, - thread_store: WeakEntity, - text_thread_context_store: WeakEntity, - workspace: Entity, - cx: &mut App, -) -> Task> { - match mode { - Some(ContextPickerMode::File) => { - let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); - cx.background_spawn(async move { - search_files_task - .await - .into_iter() - .map(Match::File) - .collect() - }) - } - - Some(ContextPickerMode::Symbol) => { - let search_symbols_task = - search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx); - cx.background_spawn(async move { - search_symbols_task - .await - .into_iter() - .map(Match::Symbol) - .collect() - }) - } - - Some(ContextPickerMode::Thread) => { - if let Some((thread_store, context_store)) = thread_store - .upgrade() - .zip(text_thread_context_store.upgrade()) - { - let search_threads_task = search_threads( - query.clone(), - cancellation_flag.clone(), - thread_store, - context_store, - cx, - ); - cx.background_spawn(async move { - search_threads_task - .await - .into_iter() - .map(Match::Thread) - .collect() - }) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Fetch) => { - if !query.is_empty() { - Task::ready(vec![Match::Fetch(query.into())]) - } else { - Task::ready(Vec::new()) - } - } - - Some(ContextPickerMode::Rules) => { - if let Some(prompt_store) = prompt_store.as_ref() { - let search_rules_task = - search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx); - cx.background_spawn(async move { - search_rules_task - .await - .into_iter() - .map(Match::Rules) - .collect::>() - }) - } else { - Task::ready(Vec::new()) - } - } - - None => { - if query.is_empty() { - let mut matches = recent_entries - .into_iter() - .map(|entry| match entry { - RecentEntry::File { - project_path, - path_prefix, - } => Match::File(FileMatch { - mat: fuzzy::PathMatch { - score: 1., - positions: Vec::new(), - worktree_id: project_path.worktree_id.to_usize(), - path: project_path.path, - path_prefix, - is_dir: false, - distance_to_relative_ancestor: 0, - }, - is_recent: true, - }), - RecentEntry::Thread(thread_context_entry) => Match::Thread(ThreadMatch { - thread: thread_context_entry, - is_recent: true, - }), - }) - .collect::>(); - - matches.extend( - available_context_picker_entries( - &prompt_store, - &Some(thread_store.clone()), - &workspace, - cx, - ) - .into_iter() - .map(|mode| { - Match::Entry(EntryMatch { - entry: mode, - mat: None, - }) - }), - ); - - Task::ready(matches) - } else { - let executor = cx.background_executor().clone(); - - let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); - - let entries = available_context_picker_entries( - &prompt_store, - &Some(thread_store.clone()), - &workspace, - cx, - ); - let entry_candidates = entries - .iter() - .enumerate() - .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) - .collect::>(); - - cx.background_spawn(async move { - let mut matches = search_files_task - .await - .into_iter() - .map(Match::File) - .collect::>(); - - let entry_matches = fuzzy::match_strings( - &entry_candidates, - &query, - false, - true, - 100, - &Arc::new(AtomicBool::default()), - executor, - ) - .await; - - matches.extend(entry_matches.into_iter().map(|mat| { - Match::Entry(EntryMatch { - entry: entries[mat.candidate_id], - mat: Some(mat), - }) - })); - - matches.sort_by(|a, b| { - b.score() - .partial_cmp(&a.score()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - matches - }) - } - } - } -} - pub struct ContextPickerCompletionProvider { - workspace: WeakEntity, - thread_store: WeakEntity, - text_thread_store: WeakEntity, message_editor: WeakEntity, + workspace: WeakEntity, + history_store: Entity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, } impl ContextPickerCompletionProvider { pub fn new( - workspace: WeakEntity, - thread_store: WeakEntity, - text_thread_store: WeakEntity, message_editor: WeakEntity, + workspace: WeakEntity, + history_store: Entity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, ) -> Self { Self { - workspace, - thread_store, - text_thread_store, message_editor, + workspace, + history_store, + prompt_store, + prompt_capabilities, + available_commands, } } @@ -275,7 +103,7 @@ impl ContextPickerCompletionProvider { ) -> Option { match entry { ContextPickerEntry::Mode(mode) => Some(Completion { - replace_range: source_range.clone(), + replace_range: source_range, new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), icon_path: Some(mode.icon().path().into()), @@ -288,83 +116,19 @@ impl ContextPickerCompletionProvider { confirm: Some(Arc::new(|_, _, _| true)), }), ContextPickerEntry::Action(action) => { - let (new_text, on_action) = match action { - ContextPickerAction::AddSelections => { - const PLACEHOLDER: &str = "selection "; - let selections = selection_ranges(workspace, cx) - .into_iter() - .enumerate() - .map(|(ix, (buffer, range))| { - ( - buffer, - range, - (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1), - ) - }) - .collect::>(); - - let new_text: String = PLACEHOLDER.repeat(selections.len()); - - let callback = Arc::new({ - let source_range = source_range.clone(); - move |_, window: &mut Window, cx: &mut App| { - let selections = selections.clone(); - let message_editor = message_editor.clone(); - let source_range = source_range.clone(); - window.defer(cx, move |window, cx| { - message_editor - .update(cx, |message_editor, cx| { - message_editor.confirm_mention_for_selection( - source_range, - selections, - window, - cx, - ) - }) - .ok(); - }); - false - } - }); - - (new_text, callback) - } - }; - - Some(Completion { - replace_range: source_range.clone(), - new_text, - label: CodeLabel::plain(action.label().to_string(), None), - icon_path: Some(action.icon().path().into()), - documentation: None, - source: project::CompletionSource::Custom, - insert_text_mode: None, - // This ensures that when a user accepts this completion, the - // completion menu will still be shown after "@category " is - // inserted - confirm: Some(on_action), - }) + Self::completion_for_action(action, source_range, message_editor, workspace, cx) } } } fn completion_for_thread( - thread_entry: ThreadContextEntry, + thread_entry: HistoryEntry, source_range: Range, recent: bool, editor: WeakEntity, cx: &mut App, ) -> Completion { - let uri = match &thread_entry { - ThreadContextEntry::Thread { id, title } => MentionUri::Thread { - id: id.clone(), - name: title.to_string(), - }, - ThreadContextEntry::Context { path, title } => MentionUri::TextThread { - path: path.to_path_buf(), - name: title.to_string(), - }, - }; + let uri = thread_entry.mention_uri(); let icon_for_completion = if recent { IconName::HistoryRerun.path().into() @@ -382,7 +146,7 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, - icon_path: Some(icon_for_completion.clone()), + icon_path: Some(icon_for_completion), confirm: Some(confirm_completion_callback( thread_entry.title().clone(), source_range.start, @@ -413,9 +177,9 @@ impl ContextPickerCompletionProvider { documentation: None, insert_text_mode: None, source: project::CompletionSource::Custom, - icon_path: Some(icon_path.clone()), + icon_path: Some(icon_path), confirm: Some(confirm_completion_callback( - rule.title.clone(), + rule.title, source_range.start, new_text_len - 1, editor, @@ -426,7 +190,7 @@ impl ContextPickerCompletionProvider { pub(crate) fn completion_for_path( project_path: ProjectPath, - path_prefix: &str, + path_prefix: &RelPath, is_recent: bool, is_directory: bool, source_range: Range, @@ -434,10 +198,12 @@ impl ContextPickerCompletionProvider { project: Entity, cx: &mut App, ) -> Option { + let path_style = project.read(cx).path_style(cx); let (file_name, directory) = crate::context_picker::file_context_picker::extract_file_name_and_directory( &project_path.path, path_prefix, + path_style, ); let label = @@ -445,19 +211,20 @@ impl ContextPickerCompletionProvider { let abs_path = project.read(cx).absolute_path(&project_path, cx)?; - let file_uri = MentionUri::File { - abs_path, - is_directory, + let uri = if is_directory { + MentionUri::Directory { abs_path } + } else { + MentionUri::File { abs_path } }; - let crease_icon_path = file_uri.icon_path(cx); + let crease_icon_path = uri.icon_path(cx); let completion_icon_path = if is_recent { IconName::HistoryRerun.path().into() } else { - crease_icon_path.clone() + crease_icon_path }; - let new_text = format!("{} ", file_uri.as_link()); + let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); Some(Completion { replace_range: source_range.clone(), @@ -472,7 +239,7 @@ impl ContextPickerCompletionProvider { source_range.start, new_text_len - 1, message_editor, - file_uri, + uri, )), }) } @@ -486,13 +253,26 @@ impl ContextPickerCompletionProvider { ) -> Option { let project = workspace.read(cx).project().clone(); - let label = CodeLabel::plain(symbol.name.clone(), None); + let (abs_path, file_name) = match &symbol.path { + SymbolLocation::InProject(project_path) => ( + project.read(cx).absolute_path(&project_path, cx)?, + project_path.path.file_name()?.to_string().into(), + ), + SymbolLocation::OutsideProject { + abs_path, + signature: _, + } => ( + PathBuf::from(abs_path.as_ref()), + abs_path.file_name().map(|f| f.to_string_lossy())?, + ), + }; + + let label = build_symbol_label(&symbol.name, &file_name, symbol.range.start.0.row + 1, cx); - let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?; let uri = MentionUri::Symbol { - path: abs_path, + abs_path, name: symbol.name.clone(), - line_range: symbol.range.start.0.row..symbol.range.end.0.row, + line_range: symbol.range.start.0.row..=symbol.range.end.0.row, }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); @@ -503,10 +283,10 @@ impl ContextPickerCompletionProvider { label, documentation: None, source: project::CompletionSource::Custom, - icon_path: Some(icon_path.clone()), + icon_path: Some(icon_path), insert_text_mode: None, confirm: Some(confirm_completion_callback( - symbol.name.clone().into(), + symbol.name.into(), source_range.start, new_text_len - 1, message_editor, @@ -521,7 +301,7 @@ impl ContextPickerCompletionProvider { message_editor: WeakEntity, cx: &mut App, ) -> Option { - let new_text = format!("@fetch {} ", url_to_fetch.clone()); + let new_text = format!("@fetch {} ", url_to_fetch); let url_to_fetch = url::Url::parse(url_to_fetch.as_ref()) .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}"))) .ok()?; @@ -535,7 +315,7 @@ impl ContextPickerCompletionProvider { label: CodeLabel::plain(url_to_fetch.to_string(), None), documentation: None, source: project::CompletionSource::Custom, - icon_path: Some(icon_path.clone()), + icon_path: Some(icon_path), insert_text_mode: None, confirm: Some(confirm_completion_callback( url_to_fetch.to_string().into(), @@ -546,22 +326,387 @@ impl ContextPickerCompletionProvider { )), }) } + + pub(crate) fn completion_for_action( + action: ContextPickerAction, + source_range: Range, + message_editor: WeakEntity, + workspace: &Entity, + cx: &mut App, + ) -> Option { + let (new_text, on_action) = match action { + ContextPickerAction::AddSelections => { + const PLACEHOLDER: &str = "selection "; + let selections = selection_ranges(workspace, cx) + .into_iter() + .enumerate() + .map(|(ix, (buffer, range))| { + ( + buffer, + range, + (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1), + ) + }) + .collect::>(); + + let new_text: String = PLACEHOLDER.repeat(selections.len()); + + let callback = Arc::new({ + let source_range = source_range.clone(); + move |_, window: &mut Window, cx: &mut App| { + let selections = selections.clone(); + let message_editor = message_editor.clone(); + let source_range = source_range.clone(); + window.defer(cx, move |window, cx| { + message_editor + .update(cx, |message_editor, cx| { + message_editor.confirm_mention_for_selection( + source_range, + selections, + window, + cx, + ) + }) + .ok(); + }); + false + } + }); + + (new_text, callback) + } + }; + + Some(Completion { + replace_range: source_range, + new_text, + label: CodeLabel::plain(action.label().to_string(), None), + icon_path: Some(action.icon().path().into()), + documentation: None, + source: project::CompletionSource::Custom, + insert_text_mode: None, + // This ensures that when a user accepts this completion, the + // completion menu will still be shown after "@category " is + // inserted + confirm: Some(on_action), + }) + } + + fn search_slash_commands( + &self, + query: String, + cx: &mut App, + ) -> Task> { + let commands = self.available_commands.borrow().clone(); + if commands.is_empty() { + return Task::ready(Vec::new()); + } + + cx.spawn(async move |cx| { + let candidates = commands + .iter() + .enumerate() + .map(|(id, command)| StringMatchCandidate::new(id, &command.name)) + .collect::>(); + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &Arc::new(AtomicBool::default()), + cx.background_executor().clone(), + ) + .await; + + matches + .into_iter() + .map(|mat| commands[mat.candidate_id].clone()) + .collect() + }) + } + + fn search_mentions( + &self, + mode: Option, + query: String, + cancellation_flag: Arc, + cx: &mut App, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Vec::default()); + }; + match mode { + Some(ContextPickerMode::File) => { + let search_files_task = search_files(query, cancellation_flag, &workspace, cx); + cx.background_spawn(async move { + search_files_task + .await + .into_iter() + .map(Match::File) + .collect() + }) + } + + Some(ContextPickerMode::Symbol) => { + let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); + cx.background_spawn(async move { + search_symbols_task + .await + .into_iter() + .map(Match::Symbol) + .collect() + }) + } + + Some(ContextPickerMode::Thread) => { + let search_threads_task = + search_threads(query, cancellation_flag, &self.history_store, cx); + cx.background_spawn(async move { + search_threads_task + .await + .into_iter() + .map(Match::Thread) + .collect() + }) + } + + Some(ContextPickerMode::Fetch) => { + if !query.is_empty() { + Task::ready(vec![Match::Fetch(query.into())]) + } else { + Task::ready(Vec::new()) + } + } + + Some(ContextPickerMode::Rules) => { + if let Some(prompt_store) = self.prompt_store.as_ref() { + let search_rules_task = + search_rules(query, cancellation_flag, prompt_store, cx); + cx.background_spawn(async move { + search_rules_task + .await + .into_iter() + .map(Match::Rules) + .collect::>() + }) + } else { + Task::ready(Vec::new()) + } + } + + None if query.is_empty() => { + let mut matches = self.recent_context_picker_entries(&workspace, cx); + + matches.extend( + self.available_context_picker_entries(&workspace, cx) + .into_iter() + .map(|mode| { + Match::Entry(EntryMatch { + entry: mode, + mat: None, + }) + }), + ); + + Task::ready(matches) + } + None => { + let executor = cx.background_executor().clone(); + + let search_files_task = + search_files(query.clone(), cancellation_flag, &workspace, cx); + + let entries = self.available_context_picker_entries(&workspace, cx); + let entry_candidates = entries + .iter() + .enumerate() + .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) + .collect::>(); + + cx.background_spawn(async move { + let mut matches = search_files_task + .await + .into_iter() + .map(Match::File) + .collect::>(); + + let entry_matches = fuzzy::match_strings( + &entry_candidates, + &query, + false, + true, + 100, + &Arc::new(AtomicBool::default()), + executor, + ) + .await; + + matches.extend(entry_matches.into_iter().map(|mat| { + Match::Entry(EntryMatch { + entry: entries[mat.candidate_id], + mat: Some(mat), + }) + })); + + matches.sort_by(|a, b| { + b.score() + .partial_cmp(&a.score()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + matches + }) + } + } + } + + fn recent_context_picker_entries( + &self, + workspace: &Entity, + cx: &mut App, + ) -> Vec { + let mut recent = Vec::with_capacity(6); + + let mut mentions = self + .message_editor + .read_with(cx, |message_editor, _cx| message_editor.mentions()) + .unwrap_or_default(); + let workspace = workspace.read(cx); + let project = workspace.project().read(cx); + let include_root_name = workspace.visible_worktrees(cx).count() > 1; + + if let Some(agent_panel) = workspace.panel::(cx) + && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx) + { + let thread = thread.read(cx); + mentions.insert(MentionUri::Thread { + id: thread.session_id().clone(), + name: thread.title().into(), + }); + } + + recent.extend( + workspace + .recent_navigation_history_iter(cx) + .filter(|(_, abs_path)| { + abs_path.as_ref().is_none_or(|path| { + !mentions.contains(&MentionUri::File { + abs_path: path.clone(), + }) + }) + }) + .take(4) + .filter_map(|(project_path, _)| { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|worktree| { + let path_prefix = if include_root_name { + worktree.read(cx).root_name().into() + } else { + RelPath::empty().into() + }; + Match::File(FileMatch { + mat: fuzzy::PathMatch { + score: 1., + positions: Vec::new(), + worktree_id: project_path.worktree_id.to_usize(), + path: project_path.path, + path_prefix, + is_dir: false, + distance_to_relative_ancestor: 0, + }, + is_recent: true, + }) + }) + }), + ); + + if self.prompt_capabilities.borrow().embedded_context { + const RECENT_COUNT: usize = 2; + let threads = self + .history_store + .read(cx) + .recently_opened_entries(cx) + .into_iter() + .filter(|thread| !mentions.contains(&thread.mention_uri())) + .take(RECENT_COUNT) + .collect::>(); + + recent.extend(threads.into_iter().map(Match::RecentThread)); + } + + recent + } + + fn available_context_picker_entries( + &self, + workspace: &Entity, + cx: &mut App, + ) -> Vec { + let embedded_context = self.prompt_capabilities.borrow().embedded_context; + let mut entries = if embedded_context { + vec![ + ContextPickerEntry::Mode(ContextPickerMode::File), + ContextPickerEntry::Mode(ContextPickerMode::Symbol), + ContextPickerEntry::Mode(ContextPickerMode::Thread), + ] + } else { + // File is always available, but we don't need a mode entry + vec![] + }; + + let has_selection = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + if has_selection { + entries.push(ContextPickerEntry::Action( + ContextPickerAction::AddSelections, + )); + } + + if embedded_context { + if self.prompt_store.is_some() { + entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules)); + } + + entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch)); + } + + entries + } +} + +fn build_symbol_label(symbol_name: &str, file_name: &str, line: u32, cx: &App) -> CodeLabel { + let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); + let mut label = CodeLabelBuilder::default(); + + label.push_str(symbol_name, None); + label.push_str(" ", None); + label.push_str(&format!("{} L{}", file_name, line), comment_id); + + label.build() } fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); - label.push_str(&file_name, None); + label.push_str(file_name, None); label.push_str(" ", None); if let Some(directory) = directory { - label.push_str(&directory, comment_id); + label.push_str(directory, comment_id); } - label.filter_range = 0..label.text().len(); - - label + label.build() } impl CompletionProvider for ContextPickerCompletionProvider { @@ -580,7 +725,11 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); let line = lines.next()?; - MentionCompletion::try_parse(line, offset_to_line) + ContextCompletion::try_parse( + line, + offset_to_line, + self.prompt_capabilities.borrow().embedded_context, + ) }); let Some(state) = state else { return Task::ready(Ok(Vec::new())); @@ -592,124 +741,187 @@ impl CompletionProvider for ContextPickerCompletionProvider { let project = workspace.read(cx).project().clone(); let snapshot = buffer.read(cx).snapshot(); - let source_range = snapshot.anchor_before(state.source_range.start) - ..snapshot.anchor_after(state.source_range.end); + let source_range = snapshot.anchor_before(state.source_range().start) + ..snapshot.anchor_after(state.source_range().end); - let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); let editor = self.message_editor.clone(); - let Ok((exclude_paths, exclude_threads)) = - self.message_editor.update(cx, |message_editor, _cx| { - message_editor.mentioned_path_and_threads() - }) - else { - return Task::ready(Ok(Vec::new())); - }; - - let MentionCompletion { mode, argument, .. } = state; - let query = argument.unwrap_or_else(|| "".to_string()); - - let recent_entries = recent_context_picker_entries( - Some(thread_store.clone()), - Some(text_thread_store.clone()), - workspace.clone(), - &exclude_paths, - &exclude_threads, - cx, - ); - - let prompt_store = thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten(); - - let search_task = search( - mode, - query, - Arc::::default(), - recent_entries, - prompt_store, - thread_store.clone(), - text_thread_store.clone(), - workspace.clone(), - cx, - ); - - cx.spawn(async move |_, cx| { - let matches = search_task.await; - let completions = cx.update(|cx| { - matches - .into_iter() - .filter_map(|mat| match mat { - Match::File(FileMatch { mat, is_recent }) => { - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(mat.worktree_id), - path: mat.path.clone(), + match state { + ContextCompletion::SlashCommand(SlashCommandCompletion { + command, argument, .. + }) => { + let search_task = self.search_slash_commands(command.unwrap_or_default(), cx); + cx.background_spawn(async move { + let completions = search_task + .await + .into_iter() + .map(|command| { + let new_text = if let Some(argument) = argument.as_ref() { + format!("/{} {}", command.name, argument) + } else { + format!("/{} ", command.name) }; - Self::completion_for_path( - project_path, - &mat.path_prefix, - is_recent, - mat.is_dir, - source_range.clone(), - editor.clone(), - project.clone(), - cx, - ) - } - - Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol( - symbol, - source_range.clone(), - editor.clone(), - workspace.clone(), - cx, - ), - - Match::Thread(ThreadMatch { - thread, is_recent, .. - }) => Some(Self::completion_for_thread( - thread, - source_range.clone(), - is_recent, - editor.clone(), - cx, - )), - - Match::Rules(user_rules) => Some(Self::completion_for_rules( - user_rules, - source_range.clone(), - editor.clone(), - cx, - )), + let is_missing_argument = argument.is_none() && command.input.is_some(); + Completion { + replace_range: source_range.clone(), + new_text, + label: CodeLabel::plain(command.name.to_string(), None), + documentation: Some(CompletionDocumentation::MultiLinePlainText( + command.description.into(), + )), + source: project::CompletionSource::Custom, + icon_path: None, + insert_text_mode: None, + confirm: Some(Arc::new({ + let editor = editor.clone(); + move |intent, _window, cx| { + if !is_missing_argument { + cx.defer({ + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + match intent { + CompletionIntent::Complete + | CompletionIntent::CompleteWithInsert + | CompletionIntent::CompleteWithReplace => { + if !is_missing_argument { + editor.send(cx); + } + } + CompletionIntent::Compose => {} + } + }) + .ok(); + } + }); + } + false + } + })), + } + }) + .collect(); + + Ok(vec![CompletionResponse { + completions, + display_options: CompletionDisplayOptions { + dynamic_width: true, + }, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + ContextCompletion::Mention(MentionCompletion { mode, argument, .. }) => { + let query = argument.unwrap_or_default(); + let search_task = + self.search_mentions(mode, query, Arc::::default(), cx); - Match::Fetch(url) => Self::completion_for_fetch( - source_range.clone(), - url, - editor.clone(), - cx, - ), + cx.spawn(async move |_, cx| { + let matches = search_task.await; - Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry( - entry, - source_range.clone(), - editor.clone(), - &workspace, - cx, - ), - }) - .collect() - })?; - - Ok(vec![CompletionResponse { - completions, - // Since this does its own filtering (see `filter_completions()` returns false), - // there is no benefit to computing whether this set of completions is incomplete. - is_incomplete: true, - }]) - }) + let completions = cx.update(|cx| { + matches + .into_iter() + .filter_map(|mat| match mat { + Match::File(FileMatch { mat, is_recent }) => { + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(mat.worktree_id), + path: mat.path.clone(), + }; + + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + let path_prefix = if mat.path.is_empty() { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| mat.path_prefix.clone()) + } else { + mat.path_prefix.clone() + }; + + Self::completion_for_path( + project_path, + &path_prefix, + is_recent, + mat.is_dir, + source_range.clone(), + editor.clone(), + project.clone(), + cx, + ) + } + + Match::Symbol(SymbolMatch { symbol, .. }) => { + Self::completion_for_symbol( + symbol, + source_range.clone(), + editor.clone(), + workspace.clone(), + cx, + ) + } + + Match::Thread(thread) => Some(Self::completion_for_thread( + thread, + source_range.clone(), + false, + editor.clone(), + cx, + )), + + Match::RecentThread(thread) => Some(Self::completion_for_thread( + thread, + source_range.clone(), + true, + editor.clone(), + cx, + )), + + Match::Rules(user_rules) => Some(Self::completion_for_rules( + user_rules, + source_range.clone(), + editor.clone(), + cx, + )), + + Match::Fetch(url) => Self::completion_for_fetch( + source_range.clone(), + url, + editor.clone(), + cx, + ), + + Match::Entry(EntryMatch { entry, .. }) => { + Self::completion_for_entry( + entry, + source_range.clone(), + editor.clone(), + &workspace, + cx, + ) + } + }) + .collect() + })?; + + Ok(vec![CompletionResponse { + completions, + display_options: CompletionDisplayOptions { + dynamic_width: true, + }, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + } } fn is_completion_trigger( @@ -727,12 +939,27 @@ impl CompletionProvider for ContextPickerCompletionProvider { let offset_to_line = buffer.point_to_offset(line_start); let mut lines = buffer.text_for_range(line_start..position).lines(); if let Some(line) = lines.next() { - MentionCompletion::try_parse(line, offset_to_line) - .map(|completion| { - completion.source_range.start <= offset_to_line + position.column as usize - && completion.source_range.end >= offset_to_line + position.column as usize - }) - .unwrap_or(false) + ContextCompletion::try_parse( + line, + offset_to_line, + self.prompt_capabilities.borrow().embedded_context, + ) + .filter(|completion| { + // Right now we don't support completing arguments of slash commands + let is_slash_command_with_argument = matches!( + completion, + ContextCompletion::SlashCommand(SlashCommandCompletion { + argument: Some(_), + .. + }) + ); + !is_slash_command_with_argument + }) + .map(|completion| { + completion.source_range().start <= offset_to_line + position.column as usize + && completion.source_range().end >= offset_to_line + position.column as usize + }) + .unwrap_or(false) } else { false } @@ -762,14 +989,16 @@ fn confirm_completion_callback( message_editor .clone() .update(cx, |message_editor, cx| { - message_editor.confirm_completion( - crease_text, - start, - content_len, - mention_uri, - window, - cx, - ) + message_editor + .confirm_mention_completion( + crease_text, + start, + content_len, + mention_uri, + window, + cx, + ) + .detach(); }) .ok(); }); @@ -777,6 +1006,77 @@ fn confirm_completion_callback( }) } +enum ContextCompletion { + SlashCommand(SlashCommandCompletion), + Mention(MentionCompletion), +} + +impl ContextCompletion { + fn source_range(&self) -> Range { + match self { + Self::SlashCommand(completion) => completion.source_range.clone(), + Self::Mention(completion) => completion.source_range.clone(), + } + } + + fn try_parse(line: &str, offset_to_line: usize, allow_non_file_mentions: bool) -> Option { + if let Some(command) = SlashCommandCompletion::try_parse(line, offset_to_line) { + Some(Self::SlashCommand(command)) + } else if let Some(mention) = + MentionCompletion::try_parse(allow_non_file_mentions, line, offset_to_line) + { + Some(Self::Mention(mention)) + } else { + None + } + } +} + +#[derive(Debug, Default, PartialEq)] +pub struct SlashCommandCompletion { + pub source_range: Range, + pub command: Option, + pub argument: Option, +} + +impl SlashCommandCompletion { + pub fn try_parse(line: &str, offset_to_line: usize) -> Option { + // If we decide to support commands that are not at the beginning of the prompt, we can remove this check + if !line.starts_with('/') || offset_to_line != 0 { + return None; + } + + let (prefix, last_command) = line.rsplit_once('/')?; + if prefix.chars().last().is_some_and(|c| !c.is_whitespace()) + || last_command.starts_with(char::is_whitespace) + { + return None; + } + + let mut argument = None; + let mut command = None; + if let Some((command_text, args)) = last_command.split_once(char::is_whitespace) { + if !args.is_empty() { + argument = Some(args.trim_end().to_string()); + } + command = Some(command_text.to_string()); + } else if !last_command.is_empty() { + command = Some(last_command.to_string()); + }; + + Some(Self { + source_range: prefix.len() + offset_to_line + ..line + .rfind(|c: char| !c.is_whitespace()) + .unwrap_or_else(|| line.len()) + + 1 + + offset_to_line, + command, + argument, + }) + } +} + #[derive(Debug, Default, PartialEq)] struct MentionCompletion { source_range: Range, @@ -785,16 +1085,24 @@ struct MentionCompletion { } impl MentionCompletion { - fn try_parse(line: &str, offset_to_line: usize) -> Option { + fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option { let last_mention_start = line.rfind('@')?; - if last_mention_start >= line.len() { - return Some(Self::default()); + + // No whitespace immediately after '@' + if line[last_mention_start + 1..] + .chars() + .next() + .is_some_and(|c| c.is_whitespace()) + { + return None; } + + // Must be a word boundary before '@' if last_mention_start > 0 - && line + && line[..last_mention_start] .chars() - .nth(last_mention_start - 1) - .map_or(false, |c| !c.is_whitespace()) + .last() + .is_some_and(|c| !c.is_whitespace()) { return None; } @@ -806,10 +1114,14 @@ impl MentionCompletion { let mut parts = rest_of_line.split_whitespace(); let mut end = last_mention_start + 1; + if let Some(mode_text) = parts.next() { + // Safe since we check no leading whitespace above end += mode_text.len(); - if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() { + if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() + && (allow_non_file_mentions || matches!(parsed_mode, ContextPickerMode::File)) + { mode = Some(parsed_mode); } else { argument = Some(mode_text.to_string()); @@ -817,6 +1129,12 @@ impl MentionCompletion { match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) { Some(whitespace_count) => { if let Some(argument_text) = parts.next() { + // If mode wasn't recognized but we have an argument, don't suggest completions + // (e.g. '@something word') + if mode.is_none() && !argument_text.is_empty() { + return None; + } + argument = Some(argument_text.to_string()); end += whitespace_count + argument_text.len(); } @@ -840,12 +1158,79 @@ impl MentionCompletion { mod tests { use super::*; + #[test] + fn test_slash_command_completion_parse() { + assert_eq!( + SlashCommandCompletion::try_parse("/", 0), + Some(SlashCommandCompletion { + source_range: 0..1, + command: None, + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help", 0), + Some(SlashCommandCompletion { + source_range: 0..5, + command: Some("help".to_string()), + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help ", 0), + Some(SlashCommandCompletion { + source_range: 0..5, + command: Some("help".to_string()), + argument: None, + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help arg1", 0), + Some(SlashCommandCompletion { + source_range: 0..10, + command: Some("help".to_string()), + argument: Some("arg1".to_string()), + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/help arg1 arg2", 0), + Some(SlashCommandCompletion { + source_range: 0..15, + command: Some("help".to_string()), + argument: Some("arg1 arg2".to_string()), + }) + ); + + assert_eq!( + SlashCommandCompletion::try_parse("/拿不到命令 拿不到命令 ", 0), + Some(SlashCommandCompletion { + source_range: 0..30, + command: Some("拿不到命令".to_string()), + argument: Some("拿不到命令".to_string()), + }) + ); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem /", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem /help", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("Lorem/", 0), None); + + assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None); + } + #[test] fn test_mention_completion_parse() { - assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); + assert_eq!(MentionCompletion::try_parse(true, "Lorem Ipsum", 0), None); assert_eq!( - MentionCompletion::try_parse("Lorem @", 0), + MentionCompletion::try_parse(true, "Lorem @", 0), Some(MentionCompletion { source_range: 6..7, mode: None, @@ -854,7 +1239,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @file", 0), + MentionCompletion::try_parse(true, "Lorem @file", 0), Some(MentionCompletion { source_range: 6..11, mode: Some(ContextPickerMode::File), @@ -863,7 +1248,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @file ", 0), + MentionCompletion::try_parse(true, "Lorem @file ", 0), Some(MentionCompletion { source_range: 6..12, mode: Some(ContextPickerMode::File), @@ -872,7 +1257,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs", 0), + MentionCompletion::try_parse(true, "Lorem @file main.rs", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), @@ -881,7 +1266,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs ", 0), + MentionCompletion::try_parse(true, "Lorem @file main.rs ", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), @@ -890,7 +1275,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0), + MentionCompletion::try_parse(true, "Lorem @file main.rs Ipsum", 0), Some(MentionCompletion { source_range: 6..19, mode: Some(ContextPickerMode::File), @@ -899,7 +1284,7 @@ mod tests { ); assert_eq!( - MentionCompletion::try_parse("Lorem @main", 0), + MentionCompletion::try_parse(true, "Lorem @main", 0), Some(MentionCompletion { source_range: 6..11, mode: None, @@ -907,6 +1292,52 @@ mod tests { }) ); - assert_eq!(MentionCompletion::try_parse("test@", 0), None); + assert_eq!( + MentionCompletion::try_parse(true, "Lorem @main ", 0), + Some(MentionCompletion { + source_range: 6..12, + mode: None, + argument: Some("main".to_string()), + }) + ); + + assert_eq!(MentionCompletion::try_parse(true, "Lorem @main m", 0), None); + + assert_eq!(MentionCompletion::try_parse(true, "test@", 0), None); + + // Allowed non-file mentions + + assert_eq!( + MentionCompletion::try_parse(true, "Lorem @symbol main", 0), + Some(MentionCompletion { + source_range: 6..18, + mode: Some(ContextPickerMode::Symbol), + argument: Some("main".to_string()), + }) + ); + + // Disallowed non-file mentions + assert_eq!( + MentionCompletion::try_parse(false, "Lorem @symbol main", 0), + None + ); + + assert_eq!( + MentionCompletion::try_parse(true, "Lorem@symbol", 0), + None, + "Should not parse mention inside word" + ); + + assert_eq!( + MentionCompletion::try_parse(true, "Lorem @ file", 0), + None, + "Should not parse with a space after @" + ); + + assert_eq!( + MentionCompletion::try_parse(true, "@ file", 0), + None, + "Should not parse with a space after @ at the start of the line" + ); } } diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index e99d1f6323ef36a8727bc78b69ce76c709324741..4c058b984f4fa24074ea9e9d81e43c1d73d87d1f 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,15 +1,17 @@ -use std::ops::Range; +use std::{cell::RefCell, ops::Range, rc::Rc}; use acp_thread::{AcpThread, AgentThreadEntry}; -use agent::{TextThreadStore, ThreadStore}; +use agent::HistoryStore; +use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, TextStyleRefinement, - WeakEntity, Window, + AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; use project::Project; +use prompt_store::PromptStore; use settings::Settings as _; use terminal_view::TerminalView; use theme::ThemeSettings; @@ -21,24 +23,33 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, + history_store: Entity, + prompt_store: Option>, entries: Vec, + prompt_capabilities: Rc>, + available_commands: Rc>>, + agent_name: SharedString, } impl EntryViewState { pub fn new( workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, + history_store: Entity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, + agent_name: SharedString, ) -> Self { Self { workspace, project, - thread_store, - text_thread_store, + history_store, + prompt_store, entries: Vec::new(), + prompt_capabilities, + available_commands, + agent_name, } } @@ -61,35 +72,50 @@ impl EntryViewState { AgentThreadEntry::UserMessage(message) => { let has_id = message.id.is_some(); let chunks = message.chunks.clone(); - let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - self.workspace.clone(), - self.project.clone(), - self.thread_store.clone(), - self.text_thread_store.clone(), - editor::EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - if !has_id { - editor.set_read_only(true, cx); + if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) { + if !editor.focus_handle(cx).is_focused(window) { + // Only update if we are not editing. + // If we are, cancelling the edit will set the message to the newest content. + editor.update(cx, |editor, cx| { + editor.set_message(chunks, window, cx); + }); } - editor.set_message(chunks, window, cx); - editor - }); - cx.subscribe(&message_editor, move |_, editor, event, cx| { - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::MessageEditorEvent(editor, *event), + } else { + let message_editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + self.workspace.clone(), + self.project.clone(), + self.history_store.clone(), + self.prompt_store.clone(), + self.prompt_capabilities.clone(), + self.available_commands.clone(), + self.agent_name.clone(), + "Edit message - @ to include context", + editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + if !has_id { + editor.set_read_only(true, cx); + } + editor.set_message(chunks, window, cx); + editor + }); + cx.subscribe(&message_editor, move |_, editor, event, cx| { + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::MessageEditorEvent(editor, *event), + }) }) - }) - .detach(); - self.set_entry(index, Entry::UserMessage(message_editor)); + .detach(); + self.set_entry(index, Entry::UserMessage(message_editor)); + } } AgentThreadEntry::ToolCall(tool_call) => { + let id = tool_call.id.clone(); let terminals = tool_call.terminals().cloned().collect::>(); let diffs = tool_call.diffs().cloned().collect::>(); @@ -103,29 +129,64 @@ impl EntryViewState { views }; + let is_tool_call_completed = + matches!(tool_call.status, acp_thread::ToolCallStatus::Completed); + for terminal in terminals { - views.entry(terminal.entity_id()).or_insert_with(|| { - create_terminal( - self.workspace.clone(), - self.project.clone(), - terminal.clone(), - window, - cx, - ) - .into_any() - }); + match views.entry(terminal.entity_id()) { + collections::hash_map::Entry::Vacant(entry) => { + let element = create_terminal( + self.workspace.clone(), + self.project.clone(), + terminal.clone(), + window, + cx, + ) + .into_any(); + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::NewTerminal(id.clone()), + }); + entry.insert(element); + } + collections::hash_map::Entry::Occupied(_entry) => { + if is_tool_call_completed && terminal.read(cx).output().is_none() { + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::TerminalMovedToBackground(id.clone()), + }); + } + } + } } for diff in diffs { - views - .entry(diff.entity_id()) - .or_insert_with(|| create_editor_diff(diff.clone(), window, cx).into_any()); + views.entry(diff.entity_id()).or_insert_with(|| { + let element = create_editor_diff(diff.clone(), window, cx).into_any(); + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::NewDiff(id.clone()), + }); + element + }); } } - AgentThreadEntry::AssistantMessage(_) => { - if index == self.entries.len() { - self.entries.push(Entry::empty()) - } + AgentThreadEntry::AssistantMessage(message) => { + let entry = if let Some(Entry::AssistantMessage(entry)) = + self.entries.get_mut(index) + { + entry + } else { + self.set_entry( + index, + Entry::AssistantMessage(AssistantMessageEntry::default()), + ); + let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { + unreachable!() + }; + entry + }; + entry.sync(message); } }; } @@ -142,10 +203,10 @@ impl EntryViewState { self.entries.drain(range); } - pub fn settings_changed(&mut self, cx: &mut App) { + pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } => {} + Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} Entry::Content(response_views) => { for view in response_views.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -171,19 +232,50 @@ pub struct EntryViewEvent { } pub enum ViewEvent { + NewDiff(ToolCallId), + NewTerminal(ToolCallId), + TerminalMovedToBackground(ToolCallId), MessageEditorEvent(Entity, MessageEditorEvent), } +#[derive(Default, Debug)] +pub struct AssistantMessageEntry { + scroll_handles_by_chunk_index: HashMap, +} + +impl AssistantMessageEntry { + pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { + self.scroll_handles_by_chunk_index.get(&ix).cloned() + } + + pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { + if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { + let ix = message.chunks.len() - 1; + let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); + handle.scroll_to_bottom(); + } + } +} + +#[derive(Debug)] pub enum Entry { UserMessage(Entity), + AssistantMessage(AssistantMessageEntry), Content(HashMap), } impl Entry { + pub fn focus_handle(&self, cx: &App) -> Option { + match self { + Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), + Self::AssistantMessage(_) | Self::Content(_) => None, + } + } + pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Entry::Content(_) => None, + Self::AssistantMessage(_) | Self::Content(_) => None, } } @@ -204,6 +296,16 @@ impl Entry { .map(|entity| entity.downcast::().unwrap()) } + pub fn scroll_handle_for_assistant_message_chunk( + &self, + chunk_ix: usize, + ) -> Option { + match self { + Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), + Self::UserMessage(_) | Self::Content(_) => None, + } + } + fn content_map(&self) -> Option<&HashMap> { match self { Self::Content(map) => Some(map), @@ -219,7 +321,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::Content(map) => !map.is_empty(), - Self::UserMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) => false, } } } @@ -285,7 +387,7 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { font_size: Some( TextSize::Small .rems(cx) - .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) + .to_pixels(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .into(), ), ..Default::default() @@ -297,9 +399,10 @@ mod tests { use std::{path::Path, rc::Rc}; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent::{TextThreadStore, ThreadStore}; + use agent::HistoryStore; use agent_client_protocol as acp; use agent_settings::AgentSettings; + use assistant_text_thread::TextThreadStore; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::{EditorSettings, RowInfo}; use fs::FakeFs; @@ -311,7 +414,6 @@ mod tests { use project::Project; use serde_json::json; use settings::{Settings as _, SettingsStore}; - use theme::ThemeSettings; use util::path; use workspace::Workspace; @@ -341,11 +443,13 @@ mod tests { path: "/project/hello.txt".into(), old_text: Some("hi world".into()), new_text: "hello world".into(), + meta: None, }, }], locations: vec![], raw_input: None, raw_output: None, + meta: None, }; let connection = Rc::new(StubAgentConnection::new()); let thread = cx @@ -362,15 +466,18 @@ mod tests { connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) }); - let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx)); let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), project.clone(), - thread_store, - text_thread_store, + history_store, + None, + Default::default(), + Default::default(), + "Test Agent".into(), ) }); @@ -436,7 +543,7 @@ mod tests { Project::init_settings(cx); AgentSettings::register(cx); workspace::init_settings(cx); - ThemeSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); release_channel::init(SemanticVersion::default(), cx); EditorSettings::register(cx); }); diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 12766ef458d112d277b9b22c80ffa959fe0e2a16..90991182dc77e00c07fb7c7330695f72da9a2f44 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1,50 +1,56 @@ use crate::{ - acp::completion_provider::ContextPickerCompletionProvider, - context_picker::fetch_context_picker::fetch_url_content, + ChatWithFollow, + acp::completion_provider::{ContextPickerCompletionProvider, SlashCommandCompletion}, + context_picker::{ContextPickerAction, fetch_context_picker::fetch_url_content}, }; use acp_thread::{MentionUri, selection_name}; -use agent::{TextThreadStore, ThreadId, ThreadStore}; +use agent::{HistoryStore, outline}; use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::{Result, anyhow}; +use assistant_slash_commands::codeblock_fence_for_path; use collections::{HashMap, HashSet}; use editor::{ - Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, ToOffset, + Addon, Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, + EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, Inlay, + MultiBuffer, ToOffset, actions::Paste, display_map::{Crease, CreaseId, FoldId}, }; use futures::{ - FutureExt as _, TryFutureExt as _, - future::{Shared, try_join_all}, + FutureExt as _, + future::{Shared, join_all}, }; use gpui::{ - AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image, - ImageFormat, Img, Task, TextStyle, WeakEntity, + Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId, + EventEmitter, FocusHandle, Focusable, Image, ImageFormat, Img, KeyContext, SharedString, + Subscription, Task, TextStyle, WeakEntity, pulsating_between, }; -use language::{Buffer, Language}; +use language::{Buffer, Language, language_settings::InlayHintKind}; use language_model::LanguageModelImage; -use project::{CompletionIntent, Project}; +use postage::stream::Stream as _; +use project::{ + CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectItem, ProjectPath, + Worktree, +}; +use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::Settings; use std::{ + cell::RefCell, ffi::OsStr, fmt::Write, - ops::Range, + ops::{Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use text::OffsetRangeExt; use theme::ThemeSettings; -use ui::{ - ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName, - IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement, - Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div, - h_flex, -}; -use url::Url; -use util::ResultExt; -use workspace::{Workspace, notifications::NotifyResultExt as _}; +use ui::{ButtonLike, TintColor, Toggleable, prelude::*}; +use util::{ResultExt, debug_panic, rel_path::RelPath}; +use workspace::{CollaboratorId, Workspace, notifications::NotifyResultExt as _}; use zed_actions::agent::Chat; pub struct MessageEditor { @@ -52,25 +58,37 @@ pub struct MessageEditor { editor: Entity, project: Entity, workspace: WeakEntity, - thread_store: Entity, - text_thread_store: Entity, + history_store: Entity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, + agent_name: SharedString, + _subscriptions: Vec, + _parse_slash_command_task: Task<()>, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub enum MessageEditorEvent { Send, Cancel, Focus, + LostFocus, } impl EventEmitter for MessageEditor {} +const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); + impl MessageEditor { pub fn new( workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, + history_store: Entity, + prompt_store: Option>, + prompt_capabilities: Rc>, + available_commands: Rc>>, + agent_name: SharedString, + placeholder: &str, mode: EditorMode, window: &mut Window, cx: &mut Context, @@ -82,44 +100,161 @@ impl MessageEditor { }, None, ); - let completion_provider = ContextPickerCompletionProvider::new( - workspace.clone(), - thread_store.downgrade(), - text_thread_store.downgrade(), + let completion_provider = Rc::new(ContextPickerCompletionProvider::new( cx.weak_entity(), - ); + workspace.clone(), + history_store.clone(), + prompt_store.clone(), + prompt_capabilities.clone(), + available_commands.clone(), + )); let mention_set = MentionSet::default(); let editor = cx.new(|cx| { let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let mut editor = Editor::new(mode, buffer, None, window, cx); - editor.set_placeholder_text("Message the agent - @ to include files", cx); + editor.set_placeholder_text(placeholder, window, cx); editor.set_show_indent_guides(false, cx); editor.set_soft_wrap(); editor.set_use_modal_editing(true); - editor.set_completion_provider(Some(Rc::new(completion_provider))); + editor.set_completion_provider(Some(completion_provider.clone())); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, placement: Some(ContextMenuPlacement::Above), }); + editor.register_addon(MessageEditorAddon::new()); editor }); - cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| { + cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| { cx.emit(MessageEditorEvent::Focus) }) .detach(); + cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| { + cx.emit(MessageEditorEvent::LostFocus) + }) + .detach(); + + let mut has_hint = false; + let mut subscriptions = Vec::new(); + + subscriptions.push(cx.subscribe_in(&editor, window, { + move |this, editor, event, window, cx| { + if let EditorEvent::Edited { .. } = event + && !editor.read(cx).read_only(cx) + { + let snapshot = editor.update(cx, |editor, cx| { + let new_hints = this + .command_hint(editor.buffer(), cx) + .into_iter() + .collect::>(); + let has_new_hint = !new_hints.is_empty(); + editor.splice_inlays( + if has_hint { + &[COMMAND_HINT_INLAY_ID] + } else { + &[] + }, + new_hints, + cx, + ); + has_hint = has_new_hint; + + editor.snapshot(window, cx) + }); + this.mention_set.remove_invalid(snapshot); + + cx.notify(); + } + } + })); Self { editor, project, mention_set, - thread_store, - text_thread_store, workspace, + history_store, + prompt_store, + prompt_capabilities, + available_commands, + agent_name, + _subscriptions: subscriptions, + _parse_slash_command_task: Task::ready(()), + } + } + + fn command_hint(&self, buffer: &Entity, cx: &App) -> Option { + let available_commands = self.available_commands.borrow(); + if available_commands.is_empty() { + return None; + } + + let snapshot = buffer.read(cx).snapshot(cx); + let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?; + if parsed_command.argument.is_some() { + return None; + } + + let command_name = parsed_command.command?; + let available_command = available_commands + .iter() + .find(|command| command.name == command_name)?; + + let acp::AvailableCommandInput::Unstructured { mut hint } = + available_command.input.clone()?; + + let mut hint_pos = parsed_command.source_range.end + 1; + if hint_pos > snapshot.len() { + hint_pos = snapshot.len(); + hint.insert(0, ' '); } + + let hint_pos = snapshot.anchor_after(hint_pos); + + Some(Inlay::hint( + COMMAND_HINT_INLAY_ID, + hint_pos, + &InlayHint { + position: hint_pos.text_anchor, + label: InlayHintLabel::String(hint), + kind: Some(InlayHintKind::Parameter), + padding_left: false, + padding_right: false, + tooltip: None, + resolve_state: project::ResolveState::Resolved, + }, + )) + } + + pub fn insert_thread_summary( + &mut self, + thread: agent::DbThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + let uri = MentionUri::Thread { + id: thread.id.clone(), + name: thread.title.to_string(), + }; + let content = format!("{}\n", uri.as_link()); + + let content_len = content.len() - 1; + + let start = self.editor.update(cx, |editor, cx| { + editor.set_text(content, window, cx); + editor + .buffer() + .read(cx) + .snapshot(cx) + .anchor_before(Point::zero()) + .text_anchor + }); + + self.confirm_mention_completion(thread.title, start, content_len, uri, window, cx) + .detach(); } #[cfg(test)] @@ -136,26 +271,15 @@ impl MessageEditor { self.editor.read(cx).is_empty(cx) } - pub fn mentioned_path_and_threads(&self) -> (HashSet, HashSet) { - let mut excluded_paths = HashSet::default(); - let mut excluded_threads = HashSet::default(); - - for uri in self.mention_set.uri_by_crease_id.values() { - match uri { - MentionUri::File { abs_path, .. } => { - excluded_paths.insert(abs_path.clone()); - } - MentionUri::Thread { id, .. } => { - excluded_threads.insert(id.clone()); - } - _ => {} - } - } - - (excluded_paths, excluded_threads) + pub fn mentions(&self) -> HashSet { + self.mention_set + .mentions + .values() + .map(|(uri, _)| uri.clone()) + .collect() } - pub fn confirm_completion( + pub fn confirm_mention_completion( &mut self, crease_text: SharedString, start: text::Anchor, @@ -163,149 +287,249 @@ impl MessageEditor { mention_uri: MentionUri, window: &mut Window, cx: &mut Context, - ) { + ) -> Task<()> { let snapshot = self .editor .update(cx, |editor, cx| editor.snapshot(window, cx)); - let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot.as_singleton() else { - return; + let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else { + return Task::ready(()); }; - let Some(anchor) = snapshot - .buffer_snapshot - .anchor_in_excerpt(*excerpt_id, start) - else { - return; + let excerpt_id = start_anchor.excerpt_id; + let end_anchor = snapshot + .buffer_snapshot() + .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1); + + let crease = if let MentionUri::File { abs_path } = &mention_uri + && let Some(extension) = abs_path.extension() + && let Some(extension) = extension.to_str() + && Img::extensions().contains(&extension) + && !extension.contains("svg") + { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + log::error!("project path not found"); + return Task::ready(()); + }; + let image = self + .project + .update(cx, |project, cx| project.open_image(project_path, cx)); + let image = cx + .spawn(async move |_, cx| { + let image = image.await.map_err(|e| e.to_string())?; + let image = image + .update(cx, |image, _| image.image.clone()) + .map_err(|e| e.to_string())?; + Ok(image) + }) + .shared(); + insert_crease_for_mention( + excerpt_id, + start, + content_len, + mention_uri.name().into(), + IconName::Image.path().into(), + Some(image), + self.editor.clone(), + window, + cx, + ) + } else { + insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text, + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) }; - - let Some(crease_id) = crate::context_picker::insert_crease_for_mention( - *excerpt_id, - start, - content_len, - crease_text.clone(), - mention_uri.icon_path(cx), - self.editor.clone(), - window, - cx, - ) else { - return; + let Some((crease_id, tx)) = crease else { + return Task::ready(()); }; - match mention_uri { - MentionUri::Fetch { url } => { - self.confirm_mention_for_fetch(crease_id, anchor, url, window, cx); - } - MentionUri::File { + let task = match mention_uri.clone() { + MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, cx), + MentionUri::Directory { .. } => Task::ready(Ok(Mention::UriOnly)), + MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx), + MentionUri::TextThread { path, .. } => self.confirm_mention_for_text_thread(path, cx), + MentionUri::File { abs_path } => self.confirm_mention_for_file(abs_path, cx), + MentionUri::Symbol { abs_path, - is_directory, - } => { - self.confirm_mention_for_file( - crease_id, - anchor, - abs_path, - is_directory, - window, - cx, - ); + line_range, + .. + } => self.confirm_mention_for_symbol(abs_path, line_range, cx), + MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx), + MentionUri::PastedImage => { + debug_panic!("pasted image URI should not be included in completions"); + Task::ready(Err(anyhow!( + "pasted imaged URI should not be included in completions" + ))) } - MentionUri::Thread { id, name } => { - self.confirm_mention_for_thread(crease_id, anchor, id, name, window, cx); + MentionUri::Selection { .. } => { + // Handled elsewhere + debug_panic!("unexpected selection URI"); + Task::ready(Err(anyhow!("unexpected selection URI"))) } - MentionUri::TextThread { path, name } => { - self.confirm_mention_for_text_thread(crease_id, anchor, path, name, window, cx); - } - MentionUri::Symbol { .. } | MentionUri::Rule { .. } | MentionUri::Selection { .. } => { - self.mention_set.insert_uri(crease_id, mention_uri.clone()); + }; + let task = cx + .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) + .shared(); + self.mention_set + .mentions + .insert(crease_id, (mention_uri, task.clone())); + + // Notify the user if we failed to load the mentioned context + cx.spawn_in(window, async move |this, cx| { + let result = task.await.notify_async_err(cx); + drop(tx); + if result.is_none() { + this.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + // Remove mention + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mention_set.mentions.remove(&crease_id); + }) + .ok(); } - } + }) } fn confirm_mention_for_file( &mut self, - crease_id: CreaseId, - anchor: Anchor, abs_path: PathBuf, - is_directory: bool, - window: &mut Window, cx: &mut Context, - ) { + ) -> Task> { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; let extension = abs_path .extension() .and_then(OsStr::to_str) .unwrap_or_default(); if Img::extensions().contains(&extension) && !extension.contains("svg") { - let project = self.project.clone(); - let Some(project_path) = project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return; - }; - let image = cx.spawn(async move |_, cx| { - let image = project - .update(cx, |project, cx| project.open_image(project_path, cx))? - .await?; - image.read_with(cx, |image, _cx| image.image.clone()) + if !self.prompt_capabilities.borrow().image { + return Task::ready(Err(anyhow!("This model does not support images yet"))); + } + let task = self + .project + .update(cx, |project, cx| project.open_image(project_path, cx)); + return cx.spawn(async move |_, cx| { + let image = task.await?; + let image = image.update(cx, |image, _| image.image.clone())?; + let format = image.format; + let image = cx + .update(|cx| LanguageModelImage::from_image(image, cx))? + .await; + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err(anyhow!("Failed to convert image")) + } }); - self.confirm_mention_for_image(crease_id, anchor, Some(abs_path), image, window, cx); - } else { - self.mention_set.insert_uri( - crease_id, - MentionUri::File { - abs_path, - is_directory, - }, - ); } + + let buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&abs_path.to_string_lossy()), + &cx, + ) + .await?; + + Ok(Mention::Text { + content: buffer_content.text, + tracked_buffers: vec![buffer], + }) + }) } fn confirm_mention_for_fetch( &mut self, - crease_id: CreaseId, - anchor: Anchor, url: url::Url, - window: &mut Window, cx: &mut Context, - ) { - let Some(http_client) = self + ) -> Task> { + let http_client = match self .workspace - .update(cx, |workspace, _cx| workspace.client().http_client()) - .ok() - else { - return; + .update(cx, |workspace, _| workspace.client().http_client()) + { + Ok(http_client) => http_client, + Err(e) => return Task::ready(Err(e)), }; - - let url_string = url.to_string(); - let fetch = cx - .background_executor() - .spawn(async move { - fetch_url_content(http_client, url_string) - .map_err(|e| e.to_string()) - .await + cx.background_executor().spawn(async move { + let content = fetch_url_content(http_client, url.to_string()).await?; + Ok(Mention::Text { + content, + tracked_buffers: Vec::new(), }) - .shared(); - self.mention_set - .add_fetch_result(url.clone(), fetch.clone()); + }) + } - cx.spawn_in(window, async move |this, cx| { - let fetch = fetch.await.notify_async_err(cx); - this.update(cx, |this, cx| { - let mention_uri = MentionUri::Fetch { url }; - if fetch.is_some() { - this.mention_set.insert_uri(crease_id, mention_uri.clone()); - } else { - // Remove crease if we failed to fetch - this.editor.update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }); + fn confirm_mention_for_symbol( + &mut self, + abs_path: PathBuf, + line_range: RangeInclusive, + cx: &mut Context, + ) -> Task> { + let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let buffer = self + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)); + cx.spawn(async move |_, cx| { + let buffer = buffer.await?; + let mention = buffer.update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0).min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], } + })?; + anyhow::Ok(mention) + }) + } + + fn confirm_mention_for_rule( + &mut self, + id: PromptId, + cx: &mut Context, + ) -> Task> { + let Some(prompt_store) = self.prompt_store.clone() else { + return Task::ready(Err(anyhow!("missing prompt store"))); + }; + let prompt = prompt_store.read(cx).load(id, cx); + cx.spawn(async move |_, _| { + let prompt = prompt.await?; + Ok(Mention::Text { + content: prompt, + tracked_buffers: Vec::new(), }) - .ok(); }) - .detach(); } pub fn confirm_mention_for_selection( @@ -316,10 +540,7 @@ impl MessageEditor { cx: &mut Context, ) { let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); - let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else { - return; - }; - let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else { + let Some(start) = snapshot.as_singleton_anchor(source_range.start) else { return; }; @@ -329,21 +550,24 @@ impl MessageEditor { let range = snapshot.anchor_after(offset + range_to_fold.start) ..snapshot.anchor_after(offset + range_to_fold.end); - let path = buffer + let abs_path = buffer .read(cx) - .file() - .map_or(PathBuf::from("untitled"), |file| file.path().to_path_buf()); + .project_path(cx) + .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx)); let snapshot = buffer.read(cx).snapshot(); + let text = snapshot + .text_for_range(selection_range.clone()) + .collect::(); let point_range = selection_range.to_point(&snapshot); - let line_range = point_range.start.row..point_range.end.row; + let line_range = point_range.start.row..=point_range.end.row; let uri = MentionUri::Selection { - path: path.clone(), + abs_path: abs_path.clone(), line_range: line_range.clone(), }; let crease = crate::context_picker::crease_for_mention( - selection_name(&path, &line_range).into(), + selection_name(abs_path.as_deref(), &line_range).into(), uri.icon_path(cx), range, self.editor.downgrade(), @@ -355,46 +579,149 @@ impl MessageEditor { crease_ids.first().copied().unwrap() }); - self.mention_set - .insert_uri(crease_id, MentionUri::Selection { path, line_range }); + self.mention_set.mentions.insert( + crease_id, + ( + uri, + Task::ready(Ok(Mention::Text { + content: text, + tracked_buffers: vec![buffer], + })) + .shared(), + ), + ); } } + fn confirm_mention_for_thread( + &mut self, + id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + let server = Rc::new(agent::NativeAgentServer::new( + self.project.read(cx).fs().clone(), + self.history_store.clone(), + )); + let delegate = AgentServerDelegate::new( + self.project.read(cx).agent_server_store().clone(), + self.project.clone(), + None, + None, + ); + let connection = server.connect(None, delegate, cx); + cx.spawn(async move |_, cx| { + let (agent, _) = connection.await?; + let agent = agent.downcast::().unwrap(); + let summary = agent + .0 + .update(cx, |agent, cx| agent.thread_summary(id, cx))? + .await?; + anyhow::Ok(Mention::Text { + content: summary.to_string(), + tracked_buffers: Vec::new(), + }) + }) + } + + fn confirm_mention_for_text_thread( + &mut self, + path: PathBuf, + cx: &mut Context, + ) -> Task> { + let text_thread_task = self.history_store.update(cx, |store, cx| { + store.load_text_thread(path.as_path().into(), cx) + }); + cx.spawn(async move |_, cx| { + let text_thread = text_thread_task.await?; + let xml = text_thread.update(cx, |text_thread, cx| text_thread.to_xml(cx))?; + Ok(Mention::Text { + content: xml, + tracked_buffers: Vec::new(), + }) + }) + } + + fn validate_slash_commands( + text: &str, + available_commands: &[acp::AvailableCommand], + agent_name: &str, + ) -> Result<()> { + if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) { + if let Some(command_name) = parsed_command.command { + // Check if this command is in the list of available commands from the server + let is_supported = available_commands + .iter() + .any(|cmd| cmd.name == command_name); + + if !is_supported { + return Err(anyhow!( + "The /{} command is not supported by {}.\n\nAvailable commands: {}", + command_name, + agent_name, + if available_commands.is_empty() { + "none".to_string() + } else { + available_commands + .iter() + .map(|cmd| format!("/{}", cmd.name)) + .collect::>() + .join(", ") + } + )); + } + } + } + Ok(()) + } + pub fn contents( &self, - window: &mut Window, + full_mention_content: bool, cx: &mut Context, - ) -> Task>> { - let contents = - self.mention_set - .contents(self.project.clone(), self.thread_store.clone(), window, cx); + ) -> Task, Vec>)>> { + // Check for unsupported slash commands before spawning async task + let text = self.editor.read(cx).text(cx); + let available_commands = self.available_commands.borrow().clone(); + if let Err(err) = + Self::validate_slash_commands(&text, &available_commands, &self.agent_name) + { + return Task::ready(Err(err)); + } + + let contents = self.mention_set.contents( + &self.prompt_capabilities.borrow(), + full_mention_content, + self.project.clone(), + cx, + ); let editor = self.editor.clone(); cx.spawn(async move |_, cx| { let contents = contents.await?; + let mut all_tracked_buffers = Vec::new(); - editor.update(cx, |editor, cx| { - let mut ix = 0; + let result = editor.update(cx, |editor, cx| { + let mut ix = text.chars().position(|c| !c.is_whitespace()).unwrap_or(0); let mut chunks: Vec = Vec::new(); let text = editor.text(cx); editor.display_map.update(cx, |map, cx| { let snapshot = map.snapshot(cx); for (crease_id, crease) in snapshot.crease_snapshot.creases() { - // Skip creases that have been edited out of the message buffer. - if !crease.range().start.is_valid(&snapshot.buffer_snapshot) { - continue; - } - - let Some(mention) = contents.get(&crease_id) else { + let Some((uri, mention)) = contents.get(&crease_id) else { continue; }; - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); + let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); if crease_range.start > ix { - chunks.push(text[ix..crease_range.start].into()); + let chunk = text[ix..crease_range.start].into(); + chunks.push(chunk); } let chunk = match mention { - Mention::Text { uri, content } => { + Mention::Text { + content, + tracked_buffers, + } => { + all_tracked_buffers.extend(tracked_buffers.iter().cloned()); acp::ContentBlock::Resource(acp::EmbeddedResource { annotations: None, resource: acp::EmbeddedResourceResource::TextResourceContents( @@ -402,19 +729,42 @@ impl MessageEditor { mime_type: None, text: content.clone(), uri: uri.to_uri().to_string(), + meta: None, }, ), + meta: None, }) } Mention::Image(mention_image) => { + let uri = match uri { + MentionUri::File { .. } => Some(uri.to_uri().to_string()), + MentionUri::PastedImage => None, + other => { + debug_panic!( + "unexpected mention uri for image: {:?}", + other + ); + None + } + }; acp::ContentBlock::Image(acp::ImageContent { annotations: None, data: mention_image.data.to_string(), mime_type: mention_image.format.mime_type().into(), - uri: mention_image - .abs_path - .as_ref() - .map(|path| format!("file://{}", path.display())), + uri, + meta: None, + }) + } + Mention::UriOnly => { + acp::ContentBlock::ResourceLink(acp::ResourceLink { + name: uri.name(), + uri: uri.to_uri().to_string(), + annotations: None, + description: None, + mime_type: None, + size: None, + title: None, + meta: None, }) } }; @@ -423,34 +773,69 @@ impl MessageEditor { } if ix < text.len() { - let last_chunk = text[ix..].trim_end(); + let last_chunk = text[ix..].trim_end().to_owned(); if !last_chunk.is_empty() { chunks.push(last_chunk.into()); } } }); - - chunks - }) + Ok((chunks, all_tracked_buffers)) + })?; + result }) } pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); - editor.remove_creases(self.mention_set.drain(), cx) + editor.remove_creases( + self.mention_set + .mentions + .drain() + .map(|(crease_id, _)| crease_id), + cx, + ) }); } - fn send(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { + pub fn send(&mut self, cx: &mut Context) { + if self.is_empty(cx) { + return; + } + self.editor.update(cx, |editor, cx| { + editor.clear_inlay_hints(cx); + }); cx.emit(MessageEditorEvent::Send) } + fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context) { + self.send(cx); + } + + fn chat_with_follow( + &mut self, + _: &ChatWithFollow, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace + .update(cx, |this, cx| { + this.follow(CollaboratorId::Agent, window, cx) + }) + .log_err(); + + self.send(cx); + } + fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context) { cx.emit(MessageEditorEvent::Cancel) } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if !self.prompt_capabilities.borrow().image { + return; + } + let images = cx .read_from_clipboard() .map(|item| { @@ -471,17 +856,17 @@ impl MessageEditor { } cx.stop_propagation(); - let replacement_text = "image"; + let replacement_text = MentionUri::PastedImage.as_link().to_string(); for image in images { let (excerpt_id, text_anchor, multibuffer_anchor) = self.editor.update(cx, |message_editor, cx| { let snapshot = message_editor.snapshot(window, cx); let (excerpt_id, _, buffer_snapshot) = - snapshot.buffer_snapshot.as_singleton().unwrap(); + snapshot.buffer_snapshot().as_singleton().unwrap(); let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len()); let multibuffer_anchor = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_in_excerpt(*excerpt_id, text_anchor); message_editor.edit( [( @@ -494,249 +879,168 @@ impl MessageEditor { }); let content_len = replacement_text.len(); - let Some(anchor) = multibuffer_anchor else { - return; + let Some(start_anchor) = multibuffer_anchor else { + continue; }; - let Some(crease_id) = insert_crease_for_image( + let end_anchor = self.editor.update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len) + }); + let image = Arc::new(image); + let Some((crease_id, tx)) = insert_crease_for_mention( excerpt_id, text_anchor, content_len, - None.clone(), + MentionUri::PastedImage.name().into(), + IconName::Image.path().into(), + Some(Task::ready(Ok(image.clone())).shared()), self.editor.clone(), window, cx, ) else { - return; + continue; }; - self.confirm_mention_for_image( - crease_id, - anchor, - None, - Task::ready(Ok(Arc::new(image))), - window, - cx, - ); + let task = cx + .spawn_in(window, { + async move |_, cx| { + let format = image.format; + let image = cx + .update(|_, cx| LanguageModelImage::from_image(image, cx)) + .map_err(|e| e.to_string())? + .await; + drop(tx); + if let Some(image) = image { + Ok(Mention::Image(MentionImage { + data: image.source, + format, + })) + } else { + Err("Failed to convert image".into()) + } + } + }) + .shared(); + + self.mention_set + .mentions + .insert(crease_id, (MentionUri::PastedImage, task.clone())); + + cx.spawn_in(window, async move |this, cx| { + if task.await.notify_async_err(cx).is_none() { + this.update(cx, |this, cx| { + this.editor.update(cx, |editor, cx| { + editor.edit([(start_anchor..end_anchor, "")], cx); + }); + this.mention_set.mentions.remove(&crease_id); + }) + .ok(); + } + }) + .detach(); } } pub fn insert_dragged_files( - &self, + &mut self, paths: Vec, + added_worktrees: Vec>, window: &mut Window, cx: &mut Context, ) { + let path_style = self.project.read(cx).path_style(cx); let buffer = self.editor.read(cx).buffer().clone(); let Some(buffer) = buffer.read(cx).as_singleton() else { return; }; + let mut tasks = Vec::new(); for path in paths { let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { continue; }; - let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else { + let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else { continue; }; + let abs_path = worktree.read(cx).absolutize(&path.path); + let (file_name, _) = + crate::context_picker::file_context_picker::extract_file_name_and_directory( + &path.path, + worktree.read(cx).root_name(), + path_style, + ); - let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); - let path_prefix = abs_path - .file_name() - .unwrap_or(path.path.as_os_str()) - .display() - .to_string(); - let Some(completion) = ContextPickerCompletionProvider::completion_for_path( - path, - &path_prefix, - false, - entry.is_dir(), - anchor..anchor, - cx.weak_entity(), - self.project.clone(), - cx, - ) else { - continue; + let uri = if entry.is_dir() { + MentionUri::Directory { abs_path } + } else { + MentionUri::File { abs_path } }; + let new_text = format!("{} ", uri.as_link()); + let content_len = new_text.len() - 1; + + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + self.editor.update(cx, |message_editor, cx| { message_editor.edit( [( multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - completion.new_text, + new_text, )], cx, ); }); - if let Some(confirm) = completion.confirm.clone() { - confirm(CompletionIntent::Complete, window, cx); - } + tasks.push(self.confirm_mention_completion( + file_name, + anchor, + content_len, + uri, + window, + cx, + )); } - } - - pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { - self.editor.update(cx, |message_editor, cx| { - message_editor.set_read_only(read_only); - cx.notify() - }) - } - - fn confirm_mention_for_image( - &mut self, - crease_id: CreaseId, - anchor: Anchor, - abs_path: Option, - image: Task>>, - window: &mut Window, - cx: &mut Context, - ) { - let editor = self.editor.clone(); - let task = cx - .spawn_in(window, { - let abs_path = abs_path.clone(); - async move |_, cx| { - let image = image.await.map_err(|e| e.to_string())?; - let format = image.format; - let image = cx - .update(|_, cx| LanguageModelImage::from_image(image, cx)) - .map_err(|e| e.to_string())? - .await; - if let Some(image) = image { - Ok(MentionImage { - abs_path, - data: image.source, - format, - }) - } else { - Err("Failed to convert image".into()) - } - } - }) - .shared(); - - self.mention_set.insert_image(crease_id, task.clone()); - - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - if let Some(abs_path) = abs_path.clone() { - this.update(cx, |this, _cx| { - this.mention_set.insert_uri( - crease_id, - MentionUri::File { - abs_path, - is_directory: false, - }, - ); - }) - .ok(); - } - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - } + cx.spawn(async move |_, _| { + join_all(tasks).await; + drop(added_worktrees); }) .detach(); } - fn confirm_mention_for_thread( - &mut self, - crease_id: CreaseId, - anchor: Anchor, - id: ThreadId, - name: String, - window: &mut Window, - cx: &mut Context, - ) { - let uri = MentionUri::Thread { - id: id.clone(), - name, + pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context) { + let editor = self.editor.read(cx); + let editor_buffer = editor.buffer().read(cx); + let Some(buffer) = editor_buffer.as_singleton() else { + return; }; - let open_task = self.thread_store.update(cx, |thread_store, cx| { - thread_store.open_thread(&id, window, cx) + let cursor_anchor = editor.selections.newest_anchor().head(); + let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx)); + let anchor = buffer.update(cx, |buffer, _cx| { + buffer.anchor_before(cursor_offset.min(buffer.len())) }); - let task = cx - .spawn(async move |_, cx| { - let thread = open_task.await.map_err(|e| e.to_string())?; - let content = thread - .read_with(cx, |thread, _cx| thread.latest_detailed_summary_or_text()) - .map_err(|e| e.to_string())?; - Ok(content) - }) - .shared(); - - self.mention_set.insert_thread(id, task.clone()); - - let editor = self.editor.clone(); - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - this.update(cx, |this, _| { - this.mention_set.insert_uri(crease_id, uri); - }) - .ok(); - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - } - }) - .detach(); - } - - fn confirm_mention_for_text_thread( - &mut self, - crease_id: CreaseId, - anchor: Anchor, - path: PathBuf, - name: String, - window: &mut Window, - cx: &mut Context, - ) { - let uri = MentionUri::TextThread { - path: path.clone(), - name, + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(completion) = ContextPickerCompletionProvider::completion_for_action( + ContextPickerAction::AddSelections, + anchor..anchor, + cx.weak_entity(), + &workspace, + cx, + ) else { + return; }; - let context = self.text_thread_store.update(cx, |text_thread_store, cx| { - text_thread_store.open_local_context(path.as_path().into(), cx) - }); - let task = cx - .spawn(async move |_, cx| { - let context = context.await.map_err(|e| e.to_string())?; - let xml = context - .update(cx, |context, cx| context.to_xml(cx)) - .map_err(|e| e.to_string())?; - Ok(xml) - }) - .shared(); - self.mention_set.insert_text_thread(path, task.clone()); + self.editor.update(cx, |message_editor, cx| { + message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx); + }); + if let Some(confirm) = completion.confirm { + confirm(CompletionIntent::Complete, window, cx); + } + } - let editor = self.editor.clone(); - cx.spawn_in(window, async move |this, cx| { - if task.await.notify_async_err(cx).is_some() { - this.update(cx, |this, _| { - this.mention_set.insert_uri(crease_id, uri); - }) - .ok(); - } else { - editor - .update(cx, |editor, cx| { - editor.display_map.update(cx, |display_map, cx| { - display_map.unfold_intersecting(vec![anchor..anchor], true, cx); - }); - editor.remove_creases([crease_id], cx); - }) - .ok(); - } + pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { + self.editor.update(cx, |message_editor, cx| { + message_editor.set_read_only(read_only); + cx.notify() }) - .detach(); } pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context) { @@ -754,9 +1058,9 @@ impl MessageEditor { ) { self.clear(window, cx); + let path_style = self.project.read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); - let mut images = Vec::new(); for chunk in message { match chunk { @@ -767,22 +1071,64 @@ impl MessageEditor { resource: acp::EmbeddedResourceResource::TextResourceContents(resource), .. }) => { - if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() { + let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err() + else { + continue; + }; + let start = text.len(); + write!(&mut text, "{}", mention_uri.as_link()).ok(); + let end = text.len(); + mentions.push(( + start..end, + mention_uri, + Mention::Text { + content: resource.text, + tracked_buffers: Vec::new(), + }, + )); + } + acp::ContentBlock::ResourceLink(resource) => { + if let Some(mention_uri) = + MentionUri::parse(&resource.uri, path_style).log_err() + { let start = text.len(); write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - mentions.push((start..end, mention_uri, resource.text)); + mentions.push((start..end, mention_uri, Mention::UriOnly)); } } - acp::ContentBlock::Image(content) => { + acp::ContentBlock::Image(acp::ImageContent { + uri, + data, + mime_type, + annotations: _, + meta: _, + }) => { + let mention_uri = if let Some(uri) = uri { + MentionUri::parse(&uri, path_style) + } else { + Ok(MentionUri::PastedImage) + }; + let Some(mention_uri) = mention_uri.log_err() else { + continue; + }; + let Some(format) = ImageFormat::from_mime_type(&mime_type) else { + log::error!("failed to parse MIME type for image: {mime_type:?}"); + continue; + }; let start = text.len(); - text.push_str("image"); + write!(&mut text, "{}", mention_uri.as_link()).ok(); let end = text.len(); - images.push((start..end, content)); + mentions.push(( + start..end, + mention_uri, + Mention::Image(MentionImage { + data: data.into(), + format, + }), + )); } - acp::ContentBlock::Audio(_) - | acp::ContentBlock::Resource(_) - | acp::ContentBlock::ResourceLink(_) => {} + acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} } } @@ -791,99 +1137,146 @@ impl MessageEditor { editor.buffer().read(cx).snapshot(cx) }); - for (range, mention_uri, text) in mentions { + for (range, mention_uri, mention) in mentions { let anchor = snapshot.anchor_before(range.start); - let crease_id = crate::context_picker::insert_crease_for_mention( + let Some((crease_id, tx)) = insert_crease_for_mention( anchor.excerpt_id, anchor.text_anchor, range.end - range.start, mention_uri.name().into(), mention_uri.icon_path(cx), + None, self.editor.clone(), window, cx, - ); - - if let Some(crease_id) = crease_id { - self.mention_set.insert_uri(crease_id, mention_uri.clone()); - } - - match mention_uri { - MentionUri::Thread { id, .. } => { - self.mention_set - .insert_thread(id, Task::ready(Ok(text.into())).shared()); - } - MentionUri::TextThread { path, .. } => { - self.mention_set - .insert_text_thread(path, Task::ready(Ok(text)).shared()); - } - MentionUri::Fetch { url } => { - self.mention_set - .add_fetch_result(url, Task::ready(Ok(text)).shared()); - } - MentionUri::File { .. } - | MentionUri::Symbol { .. } - | MentionUri::Rule { .. } - | MentionUri::Selection { .. } => {} - } - } - for (range, content) in images { - let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else { + ) else { continue; }; - let anchor = snapshot.anchor_before(range.start); - let abs_path = content - .uri - .as_ref() - .and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into())); - - let name = content - .uri - .as_ref() - .and_then(|uri| { - uri.strip_prefix("file://") - .and_then(|path| Path::new(path).file_name()) - }) - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or("Image".to_owned()); - let crease_id = crate::context_picker::insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - range.end - range.start, - name.into(), - IconName::Image.path().into(), - self.editor.clone(), - window, - cx, + drop(tx); + + self.mention_set.mentions.insert( + crease_id, + (mention_uri.clone(), Task::ready(Ok(mention)).shared()), ); - let data: SharedString = content.data.to_string().into(); - - if let Some(crease_id) = crease_id { - self.mention_set.insert_image( - crease_id, - Task::ready(Ok(MentionImage { - abs_path, - data, - format, - })) - .shared(), - ); - } } cx.notify(); } + pub fn text(&self, cx: &App) -> String { + self.editor.read(cx).text(cx) + } + #[cfg(test)] pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.set_text(text, window, cx); }); } +} - #[cfg(test)] - pub fn text(&self, cx: &App) -> String { - self.editor.read(cx).text(cx) +fn full_mention_for_directory( + project: &Entity, + abs_path: &Path, + cx: &mut App, +) -> Task> { + fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc, String)> { + let mut files = Vec::new(); + + for entry in worktree.child_entries(path) { + if entry.is_dir() { + files.extend(collect_files_in_path(worktree, &entry.path)); + } else if entry.is_file() { + files.push(( + entry.path.clone(), + worktree + .full_path(&entry.path) + .to_string_lossy() + .to_string(), + )); + } + } + + files + } + + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(&abs_path, cx) + else { + return Task::ready(Err(anyhow!("project path not found"))); + }; + let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else { + return Task::ready(Err(anyhow!("project entry not found"))); + }; + let directory_path = entry.path.clone(); + let worktree_id = project_path.worktree_id; + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return Task::ready(Err(anyhow!("worktree not found"))); + }; + let project = project.clone(); + cx.spawn(async move |cx| { + let file_paths = worktree.read_with(cx, |worktree, _cx| { + collect_files_in_path(worktree, &directory_path) + })?; + let descendants_future = cx.update(|cx| { + join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { + let rel_path = worktree_path + .strip_prefix(&directory_path) + .log_err() + .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); + + let open_task = project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + let project_path = ProjectPath { + worktree_id, + path: worktree_path, + }; + buffer_store.open_buffer(project_path, cx) + }) + }); + + cx.spawn(async move |cx| { + let buffer = open_task.await.log_err()?; + let buffer_content = outline::get_buffer_content_or_outline( + buffer.clone(), + Some(&full_path), + &cx, + ) + .await + .ok()?; + + Some((rel_path, full_path, buffer_content.text, buffer)) + }) + })) + })?; + + let contents = cx + .background_spawn(async move { + let (contents, tracked_buffers) = descendants_future + .await + .into_iter() + .flatten() + .map(|(rel_path, full_path, rope, buffer)| { + ((rel_path, full_path, rope), buffer) + }) + .unzip(); + Mention::Text { + content: render_directory_contents(contents), + tracked_buffers, + } + }) + .await; + anyhow::Ok(contents) + }) +} + +fn render_directory_contents(entries: Vec<(Arc, String, String)>) -> String { + let mut output = String::new(); + for (_relative_path, full_path, content) in entries { + let fence = codeblock_fence_for_path(Some(&full_path), None); + write!(output, "\n{fence}\n{content}\n```").unwrap(); } + output } impl Focusable for MessageEditor { @@ -896,24 +1289,21 @@ impl Render for MessageEditor { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .key_context("MessageEditor") - .on_action(cx.listener(Self::send)) + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::paste)) .flex_1() .child({ let settings = ThemeSettings::get_global(cx); - let font_size = TextSize::Small - .rems(cx) - .to_pixels(settings.agent_font_size(cx)); - let line_height = settings.buffer_line_height.value() * font_size; let text_style = TextStyle { color: cx.theme().colors().text, font_family: settings.buffer_font.family.clone(), font_fallbacks: settings.buffer_font.fallbacks.clone(), font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), + font_size: settings.agent_buffer_font_size(cx).into(), + line_height: relative(settings.buffer_line_height.value()), ..Default::default() }; @@ -924,6 +1314,7 @@ impl Render for MessageEditor { local_player: cx.theme().players().local(), text: text_style, syntax: cx.theme().syntax().clone(), + inlay_hints_style: editor::make_inlay_hints_style(cx), ..Default::default() }, ) @@ -931,22 +1322,21 @@ impl Render for MessageEditor { } } -pub(crate) fn insert_crease_for_image( +pub(crate) fn insert_crease_for_mention( excerpt_id: ExcerptId, anchor: text::Anchor, content_len: usize, - abs_path: Option>, + crease_label: SharedString, + crease_icon: SharedString, + // abs_path: Option>, + image: Option, String>>>>, editor: Entity, window: &mut Window, cx: &mut App, -) -> Option { - let crease_label = abs_path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string().into()) - .unwrap_or(SharedString::from("Image")); - - editor.update(cx, |editor, cx| { +) -> Option<(CreaseId, postage::barrier::Sender)> { + let (tx, rx) = postage::barrier::channel(); + + let crease_id = editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?; @@ -955,7 +1345,15 @@ pub(crate) fn insert_crease_for_image( let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let placeholder = FoldPlaceholder { - render: render_image_fold_icon_button(crease_label, cx.weak_entity()), + render: render_mention_fold_button( + crease_label, + crease_icon, + start..end, + rx, + image, + cx.weak_entity(), + cx, + ), merge_adjacent: false, ..Default::default() }; @@ -972,288 +1370,248 @@ pub(crate) fn insert_crease_for_image( editor.fold_creases(vec![crease], false, window, cx); Some(ids[0]) - }) + })?; + + Some((crease_id, tx)) } -fn render_image_fold_icon_button( +fn render_mention_fold_button( label: SharedString, + icon: SharedString, + range: Range, + mut loading_finished: postage::barrier::Receiver, + image_task: Option, String>>>>, editor: WeakEntity, + cx: &mut App, ) -> Arc, &mut App) -> AnyElement> { - Arc::new({ - move |fold_id, fold_range, cx| { - let is_in_text_selection = editor - .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx)) - .unwrap_or_default(); - - ButtonLike::new(fold_id) - .style(ButtonStyle::Filled) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_in_text_selection) - .child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Image) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new(label.clone()) - .size(LabelSize::Small) - .buffer_font(cx) - .single_line(), - ), - ) - .into_any_element() + let loading = cx.new(|cx| { + let loading = cx.spawn(async move |this, cx| { + loading_finished.recv().await; + this.update(cx, |this: &mut LoadingContext, cx| { + this.loading = None; + cx.notify(); + }) + .ok(); + }); + LoadingContext { + id: cx.entity_id(), + label, + icon, + range, + editor, + loading: Some(loading), + image: image_task.clone(), } - }) + }); + Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element()) +} + +struct LoadingContext { + id: EntityId, + label: SharedString, + icon: SharedString, + range: Range, + editor: WeakEntity, + loading: Option>, + image: Option, String>>>>, +} + +impl Render for LoadingContext { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_in_text_selection = self + .editor + .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx)) + .unwrap_or_default(); + ButtonLike::new(("loading-context", self.id)) + .style(ButtonStyle::Filled) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_in_text_selection) + .when_some(self.image.clone(), |el, image_task| { + el.hoverable_tooltip(move |_, cx| { + let image = image_task.peek().cloned().transpose().ok().flatten(); + let image_task = image_task.clone(); + cx.new::(|cx| ImageHover { + image, + _task: cx.spawn(async move |this, cx| { + if let Ok(image) = image_task.clone().await { + this.update(cx, |this, cx| { + if this.image.replace(image).is_none() { + cx.notify(); + } + }) + .ok(); + } + }), + }) + .into() + }) + }) + .child( + h_flex() + .gap_1() + .child( + Icon::from_path(self.icon.clone()) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(self.label.clone()) + .size(LabelSize::Small) + .buffer_font(cx) + .single_line(), + ) + .map(|el| { + if self.loading.is_some() { + el.with_animation( + "loading-context-crease", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any() + } else { + el.into_any() + } + }), + ) + } +} + +struct ImageHover { + image: Option>, + _task: Task<()>, +} + +impl Render for ImageHover { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + if let Some(image) = self.image.clone() { + gpui::img(image).max_w_96().max_h_96().into_any_element() + } else { + gpui::Empty.into_any_element() + } + } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub enum Mention { - Text { uri: MentionUri, content: String }, + Text { + content: String, + tracked_buffers: Vec>, + }, Image(MentionImage), + UriOnly, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MentionImage { - pub abs_path: Option, pub data: SharedString, pub format: ImageFormat, } #[derive(Default)] pub struct MentionSet { - uri_by_crease_id: HashMap, - fetch_results: HashMap>>>, - images: HashMap>>>, - thread_summaries: HashMap>>>, - text_thread_summaries: HashMap>>>, + mentions: HashMap>>)>, } impl MentionSet { - pub fn insert_uri(&mut self, crease_id: CreaseId, uri: MentionUri) { - self.uri_by_crease_id.insert(crease_id, uri); + fn contents( + &self, + prompt_capabilities: &acp::PromptCapabilities, + full_mention_content: bool, + project: Entity, + cx: &mut App, + ) -> Task>> { + if !prompt_capabilities.embedded_context { + let mentions = self + .mentions + .iter() + .map(|(crease_id, (uri, _))| (*crease_id, (uri.clone(), Mention::UriOnly))) + .collect(); + + return Task::ready(Ok(mentions)); + } + + let mentions = self.mentions.clone(); + cx.spawn(async move |cx| { + let mut contents = HashMap::default(); + for (crease_id, (mention_uri, task)) in mentions { + let content = if full_mention_content + && let MentionUri::Directory { abs_path } = &mention_uri + { + cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))? + .await? + } else { + task.await.map_err(|e| anyhow!("{e}"))? + }; + + contents.insert(crease_id, (mention_uri, content)); + } + Ok(contents) + }) } - pub fn add_fetch_result(&mut self, url: Url, content: Shared>>) { - self.fetch_results.insert(url, content); + fn remove_invalid(&mut self, snapshot: EditorSnapshot) { + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if !crease.range().start.is_valid(&snapshot.buffer_snapshot()) { + self.mentions.remove(&crease_id); + } + } } +} - pub fn insert_image( - &mut self, - crease_id: CreaseId, - task: Shared>>, - ) { - self.images.insert(crease_id, task); +pub struct MessageEditorAddon {} + +impl MessageEditorAddon { + pub fn new() -> Self { + Self {} } +} - fn insert_thread(&mut self, id: ThreadId, task: Shared>>) { - self.thread_summaries.insert(id, task); +impl Addon for MessageEditorAddon { + fn to_any(&self) -> &dyn std::any::Any { + self } - fn insert_text_thread(&mut self, path: PathBuf, task: Shared>>) { - self.text_thread_summaries.insert(path, task); + fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) } - pub fn drain(&mut self) -> impl Iterator { - self.fetch_results.clear(); - self.thread_summaries.clear(); - self.text_thread_summaries.clear(); - self.uri_by_crease_id - .drain() - .map(|(id, _)| id) - .chain(self.images.drain().map(|(id, _)| id)) + fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) { + let settings = agent_settings::AgentSettings::get_global(cx); + if settings.use_modifier_to_send { + key_context.add("use_modifier_to_send"); + } } +} - pub fn contents( - &self, - project: Entity, - thread_store: Entity, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - let mut processed_image_creases = HashSet::default(); +#[cfg(test)] +mod tests { + use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; - let mut contents = self - .uri_by_crease_id - .iter() - .map(|(&crease_id, uri)| { - match uri { - MentionUri::File { abs_path, .. } => { - // TODO directories - let uri = uri.clone(); - let abs_path = abs_path.to_path_buf(); - - if let Some(task) = self.images.get(&crease_id).cloned() { - processed_image_creases.insert(crease_id); - return cx.spawn(async move |_| { - let image = task.await.map_err(|e| anyhow!("{e}"))?; - anyhow::Ok((crease_id, Mention::Image(image))) - }); - } + use acp_thread::MentionUri; + use agent::{HistoryStore, outline}; + use agent_client_protocol as acp; + use assistant_text_thread::TextThreadStore; + use editor::{AnchorRangeExt as _, Editor, EditorMode}; + use fs::FakeFs; + use futures::StreamExt as _; + use gpui::{ + AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext, + }; + use language_model::LanguageModelRegistry; + use lsp::{CompletionContext, CompletionTriggerKind}; + use project::{CompletionIntent, Project, ProjectPath}; + use serde_json::json; + use text::Point; + use ui::{App, Context, IntoElement, Render, SharedString, Window}; + use util::{path, paths::PathStyle, rel_path::rel_path}; + use workspace::{AppState, Item, Workspace}; - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(abs_path, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?; - - anyhow::Ok((crease_id, Mention::Text { uri, content })) - }) - } - MentionUri::Symbol { - path, line_range, .. - } - | MentionUri::Selection { - path, line_range, .. - } => { - let uri = uri.clone(); - let path_buf = path.clone(); - let line_range = line_range.clone(); - - let buffer_task = project.update(cx, |project, cx| { - let path = project - .find_project_path(&path_buf, cx) - .context("Failed to find project path")?; - anyhow::Ok(project.open_buffer(path, cx)) - }); - - cx.spawn(async move |cx| { - let buffer = buffer_task?.await?; - let content = buffer.read_with(cx, |buffer, _cx| { - buffer - .text_for_range( - Point::new(line_range.start, 0) - ..Point::new( - line_range.end, - buffer.line_len(line_range.end), - ), - ) - .collect() - })?; - - anyhow::Ok((crease_id, Mention::Text { uri, content })) - }) - } - MentionUri::Thread { id, .. } => { - let Some(content) = self.thread_summaries.get(id).cloned() else { - return Task::ready(Err(anyhow!("missing thread summary"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content - .await - .map_err(|e| anyhow::anyhow!("{e}"))? - .to_string(), - }, - )) - }) - } - MentionUri::TextThread { path, .. } => { - let Some(content) = self.text_thread_summaries.get(path).cloned() else { - return Task::ready(Err(anyhow!("missing text thread summary"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content - .await - .map_err(|e| anyhow::anyhow!("{e}"))? - .to_string(), - }, - )) - }) - } - MentionUri::Rule { id: prompt_id, .. } => { - let Some(prompt_store) = thread_store.read(cx).prompt_store().clone() - else { - return Task::ready(Err(anyhow!("missing prompt store"))); - }; - let text_task = prompt_store.read(cx).load(*prompt_id, cx); - let uri = uri.clone(); - cx.spawn(async move |_| { - // TODO: report load errors instead of just logging - let text = text_task.await?; - anyhow::Ok((crease_id, Mention::Text { uri, content: text })) - }) - } - MentionUri::Fetch { url } => { - let Some(content) = self.fetch_results.get(&url).cloned() else { - return Task::ready(Err(anyhow!("missing fetch result"))); - }; - let uri = uri.clone(); - cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Text { - uri, - content: content.await.map_err(|e| anyhow::anyhow!("{e}"))?, - }, - )) - }) - } - } - }) - .collect::>(); - - // Handle images that didn't have a mention URI (because they were added by the paste handler). - contents.extend(self.images.iter().filter_map(|(crease_id, image)| { - if processed_image_creases.contains(crease_id) { - return None; - } - let crease_id = *crease_id; - let image = image.clone(); - Some(cx.spawn(async move |_| { - Ok(( - crease_id, - Mention::Image(image.await.map_err(|e| anyhow::anyhow!("{e}"))?), - )) - })) - })); - - cx.spawn(async move |_cx| { - let contents = try_join_all(contents).await?.into_iter().collect(); - anyhow::Ok(contents) - }) - } -} - -#[cfg(test)] -mod tests { - use std::{ops::Range, path::Path, sync::Arc}; - - use agent::{TextThreadStore, ThreadStore}; - use agent_client_protocol as acp; - use editor::{AnchorRangeExt as _, Editor, EditorMode}; - use fs::FakeFs; - use futures::StreamExt as _; - use gpui::{ - AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext, - }; - use lsp::{CompletionContext, CompletionTriggerKind}; - use project::{CompletionIntent, Project, ProjectPath}; - use serde_json::json; - use text::Point; - use ui::{App, Context, IntoElement, Render, SharedString, Window}; - use util::path; - use workspace::{AppState, Item, Workspace}; - - use crate::acp::{ - message_editor::{Mention, MessageEditor}, - thread_view::tests::init_test, - }; + use crate::acp::{ + message_editor::{Mention, MessageEditor}, + thread_view::tests::init_test, + }; #[gpui::test] async fn test_at_mention_removal(cx: &mut TestAppContext) { @@ -1266,16 +1624,20 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx)); let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), project.clone(), - thread_store.clone(), - text_thread_store.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", EditorMode::AutoHeight { min_lines: 1, max_lines: None, @@ -1325,13 +1687,10 @@ mod tests { editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - let start = snapshot - .anchor_in_excerpt(excerpt_id, completion.replace_range.start) - .unwrap(); - let end = snapshot - .anchor_in_excerpt(excerpt_id, completion.replace_range.end) + let range = snapshot + .anchor_range_in_excerpt(excerpt_id, completion.replace_range) .unwrap(); - editor.edit([(start..end, completion.new_text)], cx); + editor.edit([(range, completion.new_text)], cx); (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx); }); @@ -1343,10 +1702,8 @@ mod tests { editor.backspace(&Default::default(), window, cx); }); - let content = message_editor - .update_in(cx, |message_editor, window, cx| { - message_editor.contents(window, cx) - }) + let (content, _) = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) .await .unwrap(); @@ -1354,6 +1711,141 @@ mod tests { pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]); } + #[gpui::test] + async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + ".zed": { + "tasks.json": r#"[{"label": "test", "command": "echo"}]"# + }, + "src": { + "main.rs": "fn main() {}", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); + // Start with no available commands - simulating Claude which doesn't support slash commands + let available_commands = Rc::new(RefCell::new(vec![])); + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace_handle = workspace.downgrade(); + let message_editor = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace_handle.clone(), + project.clone(), + history_store.clone(), + None, + prompt_capabilities.clone(), + available_commands.clone(), + "Claude Code".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); + + // Test that slash commands fail when no available_commands are set (empty list means no commands supported) + editor.update_in(cx, |editor, window, cx| { + editor.set_text("/file test.txt", window, cx); + }); + + let contents_result = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await; + + // Should fail because available_commands is empty (no commands supported) + assert!(contents_result.is_err()); + let error_message = contents_result.unwrap_err().to_string(); + assert!(error_message.contains("not supported by Claude Code")); + assert!(error_message.contains("Available commands: none")); + + // Now simulate Claude providing its list of available commands (which doesn't include file) + available_commands.replace(vec![acp::AvailableCommand { + name: "help".to_string(), + description: "Get help".to_string(), + input: None, + meta: None, + }]); + + // Test that unsupported slash commands trigger an error when we have a list of available commands + editor.update_in(cx, |editor, window, cx| { + editor.set_text("/file test.txt", window, cx); + }); + + let contents_result = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await; + + assert!(contents_result.is_err()); + let error_message = contents_result.unwrap_err().to_string(); + assert!(error_message.contains("not supported by Claude Code")); + assert!(error_message.contains("/file")); + assert!(error_message.contains("Available commands: /help")); + + // Test that supported commands work fine + editor.update_in(cx, |editor, window, cx| { + editor.set_text("/help", window, cx); + }); + + let contents_result = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await; + + // Should succeed because /help is in available_commands + assert!(contents_result.is_ok()); + + // Test that regular text works fine + editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello Claude!", window, cx); + }); + + let (content, _) = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await + .unwrap(); + + assert_eq!(content.len(), 1); + if let acp::ContentBlock::Text(text) = &content[0] { + assert_eq!(text.text, "Hello Claude!"); + } else { + panic!("Expected ContentBlock::Text"); + } + + // Test that @ mentions still work + editor.update_in(cx, |editor, window, cx| { + editor.set_text("Check this @", window, cx); + }); + + // The @ mention functionality should not be affected + let (content, _) = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await + .unwrap(); + + assert_eq!(content.len(), 1); + if let acp::ContentBlock::Text(text) = &content[0] { + assert_eq!(text.text, "Check this @"); + } else { + panic!("Expected ContentBlock::Text"); + } + } + struct MessageEditorItem(Entity); impl Item for MessageEditorItem { @@ -1372,7 +1864,7 @@ mod tests { impl Focusable for MessageEditorItem { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx).clone() + self.0.read(cx).focus_handle(cx) } } @@ -1383,7 +1875,183 @@ mod tests { } #[gpui::test] - async fn test_context_completion_provider(cx: &mut TestAppContext) { + async fn test_completion_provider_commands(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let mut cx = VisualTestContext::from_window(*window, cx); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); + let available_commands = Rc::new(RefCell::new(vec![ + acp::AvailableCommand { + name: "quick-math".to_string(), + description: "2 + 2 = 4 - 1 = 3".to_string(), + input: None, + meta: None, + }, + acp::AvailableCommand { + name: "say-hello".to_string(), + description: "Say hello to whoever you want".to_string(), + input: Some(acp::AvailableCommandInput::Unstructured { + hint: "".to_string(), + }), + meta: None, + }, + ])); + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.clone(), + history_store.clone(), + None, + prompt_capabilities.clone(), + available_commands.clone(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).editor().clone() + }); + + cx.simulate_input("/"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[ + ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()), + ("say-hello".into(), "Say hello to whoever you want".into()) + ] + ); + editor.set_text("", window, cx); + }); + + cx.simulate_input("/qui"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/qui"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())] + ); + editor.set_text("", window, cx); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.display_text(cx), "/quick-math "); + assert!(!editor.has_visible_completions_menu()); + editor.set_text("", window, cx); + }); + + cx.simulate_input("/say"); + + editor.update_in(&mut cx, |editor, _window, cx| { + assert_eq!(editor.display_text(cx), "/say"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels_with_documentation(editor), + &[("say-hello".into(), "Say hello to whoever you want".into())] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, _window, cx| { + assert_eq!(editor.text(cx), "/say-hello "); + assert_eq!(editor.display_text(cx), "/say-hello "); + assert!(!editor.has_visible_completions_menu()); + }); + + cx.simulate_input("GPT5"); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/say-hello GPT5"); + assert_eq!(editor.display_text(cx), "/say-hello GPT5"); + assert!(!editor.has_visible_completions_menu()); + + // Delete argument + for _ in 0..5 { + editor.backspace(&editor::actions::Backspace, window, cx); + } + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "/say-hello"); + // Hint is visible because argument was deleted + assert_eq!(editor.display_text(cx), "/say-hello "); + + // Delete last command letter + editor.backspace(&editor::actions::Backspace, window, cx); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, _window, cx| { + // Hint goes away once command no longer matches an available one + assert_eq!(editor.text(cx), "/say-hell"); + assert_eq!(editor.display_text(cx), "/say-hell"); + assert!(!editor.has_visible_completions_menu()); + }); + } + + #[gpui::test] + async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) { init_test(cx); let app_state = cx.update(AppState::test); @@ -1413,7 +2081,8 @@ mod tests { "six.txt": "6", "seven.txt": "7", "eight.txt": "8", - } + }, + "x.png": "", }), ) .await; @@ -1432,16 +2101,18 @@ mod tests { let mut cx = VisualTestContext::from_window(*window, cx); let paths = vec![ - path!("a/one.txt"), - path!("a/two.txt"), - path!("a/three.txt"), - path!("a/four.txt"), - path!("b/five.txt"), - path!("b/six.txt"), - path!("b/seven.txt"), - path!("b/eight.txt"), + rel_path("a/one.txt"), + rel_path("a/two.txt"), + rel_path("a/three.txt"), + rel_path("a/four.txt"), + rel_path("b/five.txt"), + rel_path("b/six.txt"), + rel_path("b/seven.txt"), + rel_path("b/eight.txt"), ]; + let slash = PathStyle::local().separator(); + let mut opened_editors = Vec::new(); for path in paths { let buffer = workspace @@ -1449,7 +2120,7 @@ mod tests { workspace.open_path( ProjectPath { worktree_id, - path: Path::new(path).into(), + path: path.into(), }, None, false, @@ -1462,8 +2133,9 @@ mod tests { opened_editors.push(buffer); } - let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx)); let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -1471,8 +2143,12 @@ mod tests { MessageEditor::new( workspace_handle, project.clone(), - thread_store.clone(), - text_thread_store.clone(), + history_store.clone(), + None, + prompt_capabilities.clone(), + Default::default(), + "Test Agent".into(), + "Test", EditorMode::AutoHeight { max_lines: None, min_lines: 1, @@ -1496,6 +2172,31 @@ mod tests { (message_editor, editor) }); + cx.simulate_input("Lorem @"); + + editor.update_in(&mut cx, |editor, window, cx| { + assert_eq!(editor.text(cx), "Lorem @"); + assert!(editor.has_visible_completions_menu()); + + assert_eq!( + current_completion_labels(editor), + &[ + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), + ] + ); + editor.set_text("", window, cx); + }); + + prompt_capabilities.replace(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }); + cx.simulate_input("Lorem "); editor.update(&mut cx, |editor, cx| { @@ -1511,14 +2212,14 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - "eight.txt dir/b/", - "seven.txt dir/b/", - "six.txt dir/b/", - "five.txt dir/b/", - "Files & Directories", - "Symbols", - "Threads", - "Fetch" + format!("eight.txt b{slash}"), + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), + "Files & Directories".into(), + "Symbols".into(), + "Threads".into(), + "Fetch".into() ] ); }); @@ -1545,7 +2246,10 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem @file one"); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]); + assert_eq!( + current_completion_labels(editor), + vec![format!("one.txt a{slash}")] + ); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -1553,21 +2257,31 @@ mod tests { editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); + let url_one = MentionUri::File { + abs_path: path!("/dir/a/one.txt").into(), + } + .to_uri() + .to_string(); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) "); + let text = editor.text(cx); + assert_eq!(text, format!("Lorem [@one.txt]({url_one}) ")); assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); + assert_eq!(fold_ranges(editor, cx).len(), 1); }); - let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { + let all_prompt_capabilities = acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }; + + let contents = message_editor + .update(&mut cx, |message_editor, cx| { message_editor.mention_set().contents( + &all_prompt_capabilities, + false, project.clone(), - thread_store.clone(), - window, cx, ) }) @@ -1576,51 +2290,66 @@ mod tests { .into_values() .collect::>(); - pretty_assertions::assert_eq!( - contents, - [Mention::Text { - content: "1".into(), - uri: "file:///dir/a/one.txt".parse().unwrap() - }] - ); + { + let [(uri, Mention::Text { content, .. })] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + pretty_assertions::assert_eq!(content, "1"); + pretty_assertions::assert_eq!( + uri, + &MentionUri::parse(&url_one, PathStyle::local()).unwrap() + ); + } + + let contents = message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.mention_set().contents( + &acp::PromptCapabilities::default(), + false, + project.clone(), + cx, + ) + }) + .await + .unwrap() + .into_values() + .collect::>(); + + { + let [(uri, Mention::UriOnly)] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + pretty_assertions::assert_eq!( + uri, + &MentionUri::parse(&url_one, PathStyle::local()).unwrap() + ); + } cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) "); + let text = editor.text(cx); + assert_eq!(text, format!("Lorem [@one.txt]({url_one}) ")); assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); + assert_eq!(fold_ranges(editor, cx).len(), 1); }); cx.simulate_input("Ipsum "); editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum ", - ); + let text = editor.text(cx); + assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),); assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); + assert_eq!(fold_ranges(editor, cx).len(), 1); }); cx.simulate_input("@file "); editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum @file ", - ); + let text = editor.text(cx); + assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),); assert!(editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 39)] - ); + assert_eq!(fold_ranges(editor, cx).len(), 1); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -1630,11 +2359,11 @@ mod tests { cx.run_until_parked(); let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { + .update(&mut cx, |message_editor, cx| { message_editor.mention_set().contents( + &all_prompt_capabilities, + false, project.clone(), - thread_store.clone(), - window, cx, ) }) @@ -1643,29 +2372,31 @@ mod tests { .into_values() .collect::>(); - assert_eq!(contents.len(), 2); - pretty_assertions::assert_eq!( - contents[1], - Mention::Text { - content: "8".to_string(), - uri: "file:///dir/b/eight.txt".parse().unwrap(), - } - ); + let url_eight = MentionUri::File { + abs_path: path!("/dir/b/eight.txt").into(), + } + .to_uri() + .to_string(); + + { + let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + pretty_assertions::assert_eq!(content, "8"); + pretty_assertions::assert_eq!( + uri, + &MentionUri::parse(&url_eight, PathStyle::local()).unwrap() + ); + } editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) " - ); - assert!(!editor.has_visible_completions_menu()); - assert_eq!( - fold_ranges(editor, cx), - vec![ - Point::new(0, 6)..Point::new(0, 39), - Point::new(0, 47)..Point::new(0, 84) - ] - ); - }); + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ") + ); + assert!(!editor.has_visible_completions_menu()); + assert_eq!(fold_ranges(editor, cx).len(), 2); + }); let plain_text_language = Arc::new(language::Language::new( language::LanguageConfig { @@ -1711,13 +2442,13 @@ mod tests { let fake_language_server = fake_language_servers.next().await.unwrap(); fake_language_server.set_request_handler::( - |_, _| async move { + move |_, _| async move { Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![ #[allow(deprecated)] lsp::SymbolInformation { name: "MySymbol".into(), location: lsp::Location { - uri: lsp::Url::from_file_path(path!("/dir/a/one.txt")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(), range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 1), @@ -1735,53 +2466,143 @@ mod tests { cx.simulate_input("@symbol "); editor.update(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) @symbol " - ); - assert!(editor.has_visible_completions_menu()); - assert_eq!( - current_completion_labels(editor), - &[ - "MySymbol", - ] - ); - }); + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ") + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]); + }); editor.update_in(&mut cx, |editor, window, cx| { editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); }); + let symbol = MentionUri::Symbol { + abs_path: path!("/dir/a/one.txt").into(), + name: "MySymbol".into(), + line_range: 0..=0, + }; + let contents = message_editor - .update_in(&mut cx, |message_editor, window, cx| { - message_editor - .mention_set() - .contents(project.clone(), thread_store, window, cx) + .update(&mut cx, |message_editor, cx| { + message_editor.mention_set().contents( + &all_prompt_capabilities, + false, + project.clone(), + cx, + ) }) .await .unwrap() .into_values() .collect::>(); - assert_eq!(contents.len(), 3); - pretty_assertions::assert_eq!( - contents[2], - Mention::Text { - content: "1".into(), - uri: "file:///dir/a/one.txt?symbol=MySymbol#L1:1" - .parse() - .unwrap(), - } - ); + { + let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + pretty_assertions::assert_eq!(content, "1"); + pretty_assertions::assert_eq!(uri, &symbol); + } cx.run_until_parked(); - editor.read_with(&mut cx, |editor, cx| { - assert_eq!( - editor.text(cx), - "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) [@MySymbol](file:///dir/a/one.txt?symbol=MySymbol#L1:1) " - ); - }); + editor.read_with(&cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", + symbol.to_uri(), + ) + ); + }); + + // Try to mention an "image" file that will fail to load + cx.simulate_input("@file x.png"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!(current_completion_labels(editor), &["x.png "]); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + // Getting the message contents fails + message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.mention_set().contents( + &all_prompt_capabilities, + false, + project.clone(), + cx, + ) + }) + .await + .expect_err("Should fail to load x.png"); + + cx.run_until_parked(); + + // Mention was removed + editor.read_with(&cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", + symbol.to_uri() + ) + ); + }); + + // Once more + cx.simulate_input("@file x.png"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri()) + ); + assert!(editor.has_visible_completions_menu()); + assert_eq!(current_completion_labels(editor), &["x.png "]); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + // This time don't immediately get the contents, just let the confirmed completion settle + cx.run_until_parked(); + + // Mention was removed + editor.read_with(&cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!( + "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ", + symbol.to_uri() + ) + ); + }); + + // Now getting the contents succeeds, because the invalid mention was removed + let contents = message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.mention_set().contents( + &all_prompt_capabilities, + false, + project.clone(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(contents.len(), 3); } fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { @@ -1799,7 +2620,262 @@ mod tests { let completions = editor.current_completions().expect("Missing completions"); completions .into_iter() - .map(|completion| completion.label.text.to_string()) + .map(|completion| completion.label.text) + .collect::>() + } + + fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| { + ( + completion.label.text, + completion + .documentation + .map(|d| d.text().to_string()) + .unwrap_or_default(), + ) + }) .collect::>() } + + #[gpui::test] + async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create a large file that exceeds AUTO_OUTLINE_SIZE + const LINE: &str = "fn example_function() { /* some code */ }\n"; + let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len())); + assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE); + + // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE + let small_content = "fn small_function() { /* small */ }\n"; + assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE); + + fs.insert_tree( + "/project", + json!({ + "large_file.rs": large_content.clone(), + "small_file.rs": small_content, + }), + ) + .await; + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + let editor = MessageEditor::new( + workspace.downgrade(), + project.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + // Enable embedded context so files are actually included + editor.prompt_capabilities.replace(acp::PromptCapabilities { + embedded_context: true, + meta: None, + ..Default::default() + }); + editor + }) + }); + + // Test large file mention + // Get the absolute path using the project's worktree + let large_file_abs_path = project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + let worktree_root = worktree.read(cx).abs_path(); + worktree_root.join("large_file.rs") + }); + let large_file_task = message_editor.update(cx, |editor, cx| { + editor.confirm_mention_for_file(large_file_abs_path, cx) + }); + + let large_file_mention = large_file_task.await.unwrap(); + match large_file_mention { + Mention::Text { content, .. } => { + // Should contain outline header for large files + assert!(content.contains("File outline for")); + assert!(content.contains("file too large to show full content")); + // Should not contain the full repeated content + assert!(!content.contains(&LINE.repeat(100))); + } + _ => panic!("Expected Text mention for large file"), + } + + // Test small file mention + // Get the absolute path using the project's worktree + let small_file_abs_path = project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + let worktree_root = worktree.read(cx).abs_path(); + worktree_root.join("small_file.rs") + }); + let small_file_task = message_editor.update(cx, |editor, cx| { + editor.confirm_mention_for_file(small_file_abs_path, cx) + }); + + let small_file_mention = small_file_task.await.unwrap(); + match small_file_mention { + Mention::Text { content, .. } => { + // Should contain the actual content + assert_eq!(content, small_content); + // Should not contain outline header + assert!(!content.contains("File outline for")); + } + _ => panic!("Expected Text mention for small file"), + } + } + #[gpui::test] + async fn test_insert_thread_summary(cx: &mut TestAppContext) { + init_test(cx); + cx.update(LanguageModelRegistry::test); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Create a thread metadata to insert as summary + let thread_metadata = agent::DbThreadMetadata { + id: acp::SessionId("thread-123".into()), + title: "Previous Conversation".into(), + updated_at: chrono::Utc::now(), + }; + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + let mut editor = MessageEditor::new( + workspace.downgrade(), + project.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + editor.insert_thread_summary(thread_metadata.clone(), window, cx); + editor + }) + }); + + // Construct expected values for verification + let expected_uri = MentionUri::Thread { + id: thread_metadata.id.clone(), + name: thread_metadata.title.to_string(), + }; + let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri()); + + message_editor.read_with(cx, |editor, cx| { + let text = editor.text(cx); + + assert!( + text.contains(&expected_link), + "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}", + expected_link, + text + ); + + let mentions = editor.mentions(); + assert_eq!( + mentions.len(), + 1, + "Expected exactly one mention after inserting thread summary" + ); + + assert!( + mentions.contains(&expected_uri), + "Expected mentions to contain the thread URI" + ); + }); + } + + #[gpui::test] + async fn test_whitespace_trimming(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file.rs": "fn main() {}"})) + .await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.clone(), + history_store.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone()); + + cx.run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text(" hello world ", window, cx); + }); + + let (content, _) = message_editor + .update(cx, |message_editor, cx| message_editor.contents(false, cx)) + .await + .unwrap(); + + assert_eq!( + content, + vec![acp::ContentBlock::Text(acp::TextContent { + text: "hello world".into(), + annotations: None, + meta: None + })] + ); + } } diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs new file mode 100644 index 0000000000000000000000000000000000000000..36970a29ab7fd30f175d8128f8bbd3c55b71b605 --- /dev/null +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -0,0 +1,239 @@ +use acp_thread::AgentSessionModes; +use agent_client_protocol as acp; +use agent_servers::AgentServer; +use fs::Fs; +use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*}; +use std::{rc::Rc, sync::Arc}; +use ui::{ + Button, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, KeyBinding, + PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, +}; + +use crate::{CycleModeSelector, ToggleProfileSelector}; + +pub struct ModeSelector { + connection: Rc, + agent_server: Rc, + menu_handle: PopoverMenuHandle, + focus_handle: FocusHandle, + fs: Arc, + setting_mode: bool, +} + +impl ModeSelector { + pub fn new( + session_modes: Rc, + agent_server: Rc, + fs: Arc, + focus_handle: FocusHandle, + ) -> Self { + Self { + connection: session_modes, + agent_server, + menu_handle: PopoverMenuHandle::default(), + fs, + setting_mode: false, + focus_handle, + } + } + + pub fn menu_handle(&self) -> PopoverMenuHandle { + self.menu_handle.clone() + } + + pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context) { + let all_modes = self.connection.all_modes(); + let current_mode = self.connection.current_mode(); + + let current_index = all_modes + .iter() + .position(|mode| mode.id.0 == current_mode.0) + .unwrap_or(0); + + let next_index = (current_index + 1) % all_modes.len(); + self.set_mode(all_modes[next_index].id.clone(), cx); + } + + pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context) { + let task = self.connection.set_mode(mode, cx); + self.setting_mode = true; + cx.notify(); + + cx.spawn(async move |this: WeakEntity, cx| { + if let Err(err) = task.await { + log::error!("Failed to set session mode: {:?}", err); + } + this.update(cx, |this, cx| { + this.setting_mode = false; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn build_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let weak_self = cx.weak_entity(); + + ContextMenu::build(window, cx, move |mut menu, _window, cx| { + let all_modes = self.connection.all_modes(); + let current_mode = self.connection.current_mode(); + let default_mode = self.agent_server.default_mode(cx); + + for mode in all_modes { + let is_selected = &mode.id == ¤t_mode; + let is_default = Some(&mode.id) == default_mode.as_ref(); + let entry = ContextMenuEntry::new(mode.name.clone()) + .toggleable(IconPosition::End, is_selected); + + let entry = if let Some(description) = &mode.description { + entry.documentation_aside(DocumentationSide::Left, DocumentationEdge::Bottom, { + let description = description.clone(); + + move |cx| { + v_flex() + .gap_1() + .child(Label::new(description.clone())) + .child( + h_flex() + .pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .gap_0p5() + .text_sm() + .text_color(Color::Muted.color(cx)) + .child("Hold") + .child(h_flex().flex_shrink_0().children( + ui::render_modifiers( + &gpui::Modifiers::secondary_key(), + PlatformStyle::platform(), + None, + Some(ui::TextSize::Default.rems(cx).into()), + true, + ), + )) + .child(div().map(|this| { + if is_default { + this.child("to also unset as default") + } else { + this.child("to also set as default") + } + })), + ) + .into_any_element() + } + }) + } else { + entry + }; + + menu.push_item(entry.handler({ + let mode_id = mode.id.clone(); + let weak_self = weak_self.clone(); + move |window, cx| { + weak_self + .update(cx, |this, cx| { + if window.modifiers().secondary() { + this.agent_server.set_default_mode( + if is_default { + None + } else { + Some(mode_id.clone()) + }, + this.fs.clone(), + cx, + ); + } + + this.set_mode(mode_id.clone(), cx); + }) + .ok(); + } + })); + } + + menu.key_context("ModeSelector") + }) + } +} + +impl Render for ModeSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let current_mode_id = self.connection.current_mode(); + let current_mode_name = self + .connection + .all_modes() + .iter() + .find(|mode| mode.id == current_mode_id) + .map(|mode| mode.name.clone()) + .unwrap_or_else(|| "Unknown".into()); + + let this = cx.entity(); + + let icon = if self.menu_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let trigger_button = Button::new("mode-selector-trigger", current_mode_name) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .disabled(self.setting_mode); + + PopoverMenu::new("mode-selector") + .trigger_with_tooltip( + trigger_button, + Tooltip::element({ + let focus_handle = self.focus_handle.clone(); + move |_window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .pb_1() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Modes")) + .child(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + cx, + )), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Toggle Mode Menu")) + .child(KeyBinding::for_action_in( + &ToggleProfileSelector, + &focus_handle, + cx, + )), + ) + .into_any() + } + }), + ) + .anchor(gpui::Corner::BottomRight) + .with_handle(self.menu_handle.clone()) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) + .menu(move |window, cx| { + Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + }) + } +} diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 563afee65f0168232c0461092272f3af4bbb77dd..45fec558720fc5e88548f6dd7bc24fe624a908f5 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,29 +1,27 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector}; -use agent_client_protocol as acp; use anyhow::Result; use collections::IndexMap; use futures::FutureExt; use fuzzy::{StringMatchCandidate, match_strings}; -use gpui::{Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity}; +use gpui::{AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use ui::{ - AnyElement, App, Context, IntoElement, ListItem, ListItemSpacing, SharedString, Window, - prelude::*, rems, + DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, ListItem, + ListItemSpacing, prelude::*, }; use util::ResultExt; pub type AcpModelSelector = Picker; pub fn acp_model_selector( - session_id: acp::SessionId, selector: Rc, window: &mut Window, cx: &mut Context, ) -> AcpModelSelector { - let delegate = AcpModelPickerDelegate::new(session_id, selector, window, cx); + let delegate = AcpModelPickerDelegate::new(selector, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) .width(rems(20.)) @@ -36,64 +34,63 @@ enum AcpModelPickerEntry { } pub struct AcpModelPickerDelegate { - session_id: acp::SessionId, selector: Rc, filtered_entries: Vec, models: Option, selected_index: usize, + selected_description: Option<(usize, SharedString)>, selected_model: Option, _refresh_models_task: Task<()>, } impl AcpModelPickerDelegate { fn new( - session_id: acp::SessionId, selector: Rc, window: &mut Window, cx: &mut Context, ) -> Self { - let mut rx = selector.watch(cx); - let refresh_models_task = cx.spawn_in(window, { - let session_id = session_id.clone(); - async move |this, cx| { - async fn refresh( - this: &WeakEntity>, - session_id: &acp::SessionId, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let (models_task, selected_model_task) = this.update(cx, |this, cx| { - ( - this.delegate.selector.list_models(cx), - this.delegate.selector.selected_model(session_id, cx), - ) - })?; - - let (models, selected_model) = futures::join!(models_task, selected_model_task); - - this.update_in(cx, |this, window, cx| { - this.delegate.models = models.ok(); - this.delegate.selected_model = selected_model.ok(); - this.delegate.update_matches(this.query(cx), window, cx) - })? - .await; + let rx = selector.watch(cx); + let refresh_models_task = { + cx.spawn_in(window, { + async move |this, cx| { + async fn refresh( + this: &WeakEntity>, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let (models_task, selected_model_task) = this.update(cx, |this, cx| { + ( + this.delegate.selector.list_models(cx), + this.delegate.selector.selected_model(cx), + ) + })?; - Ok(()) - } + let (models, selected_model) = + futures::join!(models_task, selected_model_task); - refresh(&this, &session_id, cx).await.log_err(); - while let Ok(()) = rx.recv().await { - refresh(&this, &session_id, cx).await.log_err(); + this.update_in(cx, |this, window, cx| { + this.delegate.models = models.ok(); + this.delegate.selected_model = selected_model.ok(); + this.refresh(window, cx) + }) + } + + refresh(&this, cx).await.log_err(); + if let Some(mut rx) = rx { + while let Ok(()) = rx.recv().await { + refresh(&this, cx).await.log_err(); + } + } } - } - }); + }) + }; Self { - session_id, selector, filtered_entries: Vec::new(), models: None, selected_model: None, selected_index: 0, + selected_description: None, _refresh_models_task: refresh_models_task, } } @@ -185,7 +182,7 @@ impl PickerDelegate for AcpModelPickerDelegate { self.filtered_entries.get(self.selected_index) { self.selector - .select_model(self.session_id.clone(), model_info.id.clone(), cx) + .select_model(model_info.id.clone(), cx) .detach_and_log_err(cx); self.selected_model = Some(model_info.clone()); let current_index = self.selected_index; @@ -195,8 +192,10 @@ impl PickerDelegate for AcpModelPickerDelegate { } } - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - cx.emit(DismissEvent); + fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + cx.defer_in(window, |picker, window, cx| { + picker.set_query("", window, cx); + }); } fn render_match( @@ -234,64 +233,64 @@ impl PickerDelegate for AcpModelPickerDelegate { }; Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .start_slot::(model_info.icon.map(|icon| { - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small) - })) + div() + .id(("model-picker-menu-child", ix)) + .when_some(model_info.description.clone(), |this, description| { + this + .on_hover(cx.listener(move |menu, hovered, _, cx| { + if *hovered { + menu.delegate.selected_description = Some((ix, description.clone())); + } else if matches!(menu.delegate.selected_description, Some((id, _)) if id == ix) { + menu.delegate.selected_description = None; + } + cx.notify(); + })) + }) .child( - h_flex() - .w_full() - .pl_0p5() - .gap_1p5() - .w(px(240.)) - .child(Label::new(model_info.name.clone()).truncate()), + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .start_slot::(model_info.icon.map(|icon| { + Icon::new(icon) + .color(model_icon_color) + .size(IconSize::Small) + })) + .child( + h_flex() + .w_full() + .pl_0p5() + .gap_1p5() + .w(px(240.)) + .child(Label::new(model_info.name.clone()).truncate()), + ) + .end_slot(div().pr_3().when(is_selected, |this| { + this.child( + Icon::new(IconName::Check) + .color(Color::Accent) + .size(IconSize::Small), + ) + })), ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })) - .into_any_element(), + .into_any_element() ) } } } - fn render_footer( + fn documentation_aside( &self, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - Some( - h_flex() - .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .p_1() - .gap_4() - .justify_between() - .child( - Button::new("configure", "Configure") - .icon(IconName::Settings) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(|_, window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }), - ) - .into_any(), - ) + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + self.selected_description.as_ref().map(|(_, description)| { + let description = description.clone(); + DocumentationAside::new( + DocumentationSide::Left, + DocumentationEdge::Top, + Rc::new(move |_| Label::new(description.clone()).into_any_element()), + ) + }) } } @@ -330,7 +329,7 @@ async fn fuzzy_search( .collect::>(); let mut matches = match_strings( &candidates, - &query, + query, false, true, 100, @@ -372,6 +371,7 @@ async fn fuzzy_search( #[cfg(test)] mod tests { + use agent_client_protocol as acp; use gpui::TestAppContext; use super::*; @@ -384,8 +384,9 @@ mod tests { models .into_iter() .map(|model| acp_thread::AgentModelInfo { - id: acp_thread::AgentModelId(model.to_string().into()), + id: acp::ModelId(model.to_string().into()), name: model.to_string().into(), + description: None, icon: None, }) .collect::>(), diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index e52101113a61c7379be54e25f1784ac16b660200..bd64756483032bee00ba8f56794bcb228bf91246 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -1,11 +1,11 @@ use std::rc::Rc; use acp_thread::AgentModelSelector; -use agent_client_protocol as acp; use gpui::{Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; use ui::{ - ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, Tooltip, Window, prelude::*, + ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window, + prelude::*, }; use zed_actions::agent::ToggleModelSelector; @@ -19,7 +19,6 @@ pub struct AcpModelSelectorPopover { impl AcpModelSelectorPopover { pub(crate) fn new( - session_id: acp::SessionId, selector: Rc, menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, @@ -27,7 +26,7 @@ impl AcpModelSelectorPopover { cx: &mut Context, ) -> Self { Self { - selector: cx.new(move |cx| acp_model_selector(session_id, selector, window, cx)), + selector: cx.new(move |cx| acp_model_selector(selector, window, cx)), menu_handle, focus_handle, } @@ -36,6 +35,14 @@ impl AcpModelSelectorPopover { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model_name(&self, cx: &App) -> Option { + self.selector + .read(cx) + .delegate + .active_model() + .map(|model| model.name.clone()) + } } impl Render for AcpModelSelectorPopover { @@ -50,31 +57,28 @@ impl Render for AcpModelSelectorPopover { let focus_handle = self.focus_handle.clone(); + let (color, icon) = if self.menu_handle.is_deployed() { + (Color::Accent, IconName::ChevronUp) + } else { + (Color::Muted, IconName::ChevronDown) + }; + PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { - this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)) + this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) }) .child( Label::new(model_name) - .color(Color::Muted) + .color(color) .size(LabelSize::Small) .ml_0p5(), ) - .child( - Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, gpui::Corner::BottomRight, cx, diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..9cfe30278e1e46d95c00b3c881358a4b00786801 --- /dev/null +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -0,0 +1,783 @@ +use crate::acp::AcpThreadView; +use crate::{AgentPanel, RemoveSelectedThread}; +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, WeakEntity, Window, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, WithScrollbar, + prelude::*, +}; + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub(crate) fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(thread_timestamp) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click( + cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)), + ), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .when(!self.history_store.read(cx).is_empty(cx), |parent| { + parent.child( + h_flex() + .h(px(41.)) // Match the toolbar perfectly + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + }) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if self.history_store.read(cx).is_empty(cx) { + view.justify_center() + .child( + h_flex().w_full().justify_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small), + ), + ) + } else if self.search_produced_no_matches() { + view.justify_center().child( + h_flex().w_full().justify_center().child( + Label::new("No threads match your search.").size(LabelSize::Small), + ), + ) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(self.scroll_handle.clone()) + .flex_grow(), + ) + .vertical_scrollbar_for( + self.scroll_handle.clone(), + window, + cx, + ) + } + }) + } +} + +#[derive(IntoElement)] +pub struct AcpHistoryEntryElement { + entry: HistoryEntry, + thread_view: WeakEntity, + selected: bool, + hovered: bool, + on_hover: Box, +} + +impl AcpHistoryEntryElement { + pub fn new(entry: HistoryEntry, thread_view: WeakEntity) -> Self { + Self { + entry, + thread_view, + selected: false, + hovered: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for AcpHistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = self.entry.id(); + let title = self.entry.title(); + let timestamp = self.entry.updated_at(); + + let formatted_time = { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }; + + ListItem::new(id) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if self.hovered || self.selected { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry.clone(); + + move |_event, _window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.update(cx, |thread_view, cx| { + thread_view.delete_history_entry(entry.clone(), cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let thread_view = self.thread_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = thread_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + match &entry { + HistoryEntry::AcpThread(thread_metadata) => { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + thread_metadata.clone(), + window, + cx, + ); + }); + } + } + HistoryEntry::TextThread(text_thread) => { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + panel + .open_saved_text_thread( + text_thread.path.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + } + } + } + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7c1f3cf4ae51b562cbbe3eb52eac48038221b95c..3638faf9336f79d692f820df39266ab7b85360a8 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,42 +1,52 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - LoadError, MentionUri, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, + AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, + ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::ActionLog; -use agent::{TextThreadStore, ThreadStore}; -use agent_client_protocol::{self as acp}; -use agent_servers::AgentServer; -use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting}; -use anyhow::bail; +use agent::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_client_protocol::{self as acp, PromptCapabilities}; +use agent_servers::{AgentServer, AgentServerDelegate}; +use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; +use anyhow::{Result, anyhow, bail}; +use arrayvec::ArrayVec; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; use client::zed_urls; +use cloud_llm_client::PlanV1; use collections::{HashMap, HashSet}; use editor::scroll::Autoscroll; -use editor::{Editor, EditorMode, MultiBuffer, PathKey, SelectionEffects}; +use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects}; use file_icons::FileIcons; use fs::Fs; +use futures::FutureExt as _; use gpui::{ - Action, Animation, AnimationExt, App, BorderStyle, ClickEvent, ClipboardItem, EdgesRefinement, - Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton, - PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle, - TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div, - linear_color_stop, linear_gradient, list, percentage, point, prelude::*, pulsating_between, + Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, + CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, + ListOffset, ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task, + TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div, + ease_in_out, linear_color_stop, linear_gradient, list, point, pulsating_between, }; use language::Buffer; + +use language_model::LanguageModelRegistry; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; -use project::Project; -use prompt_store::PromptId; +use project::{Project, ProjectEntryId}; +use prompt_store::{PromptId, PromptStore}; use rope::Point; -use settings::{Settings as _, SettingsStore}; +use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; +use std::cell::RefCell; +use std::path::Path; use std::sync::Arc; -use std::{collections::BTreeMap, process::ExitStatus, rc::Rc, time::Duration}; +use std::time::Instant; +use std::{collections::BTreeMap, rc::Rc, time::Duration}; +use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; -use theme::ThemeSettings; +use theme::{AgentFontSize, ThemeSettings}; use ui::{ - Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, - Scrollbar, ScrollbarState, Tooltip, prelude::*, + Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, + PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; @@ -45,29 +55,40 @@ use zed_actions::assistant::OpenRulesLibrary; use super::entry_view_state::EntryViewState; use crate::acp::AcpModelSelectorPopover; +use crate::acp::ModeSelector; use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip}; + +use crate::ui::{ + AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, + UsageCallout, +}; use crate::{ - AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow, - KeepAll, OpenAgentDiff, RejectAll, ToggleBurnMode, ToggleProfileSelector, + AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, + CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll, + RejectOnce, ToggleBurnMode, ToggleProfileSelector, }; -const RESPONSE_PADDING_X: Pixels = px(19.); -pub const MIN_EDITOR_LINES: usize = 4; -pub const MAX_EDITOR_LINES: usize = 8; +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum ThreadFeedback { + Positive, + Negative, +} +#[derive(Debug)] enum ThreadError { PaymentRequired, ModelRequestLimitReached(cloud_llm_client::Plan), ToolUseLimitReached, + Refusal, + AuthenticationRequired(SharedString), Other(SharedString), } impl ThreadError { - fn from_err(error: anyhow::Error) -> Self { + fn from_err(error: anyhow::Error, agent: &Rc) -> Self { if error.is::() { Self::PaymentRequired } else if error.is::() { @@ -76,13 +97,27 @@ impl ThreadError { error.downcast_ref::() { Self::ModelRequestLimitReached(error.plan) + } else if let Some(acp_error) = error.downcast_ref::() + && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code + { + Self::AuthenticationRequired(acp_error.message.clone().into()) } else { - Self::Other(error.to_string().into()) + let string = error.to_string(); + // TODO: we should have Gemini return better errors here. + if agent.clone().downcast::().is_some() + && string.contains("Could not load the default credentials") + || string.contains("API key not valid") + || string.contains("Request had invalid authentication credentials") + { + Self::AuthenticationRequired(string.into()) + } else { + Self::Other(error.to_string().into()) + } } } } -impl ProfileProvider for Entity { +impl ProfileProvider for Entity { fn profile_id(&self, cx: &App) -> AgentProfileId { self.read(cx).profile().clone() } @@ -94,7 +129,132 @@ impl ProfileProvider for Entity { } fn profiles_supported(&self, cx: &App) -> bool { - self.read(cx).model().supports_tools() + self.read(cx) + .model() + .is_some_and(|model| model.supports_tools()) + } +} + +#[derive(Default)] +struct ThreadFeedbackState { + feedback: Option, + comments_editor: Option>, +} + +impl ThreadFeedbackState { + pub fn submit( + &mut self, + thread: Entity, + feedback: ThreadFeedback, + window: &mut Window, + cx: &mut App, + ) { + let Some(telemetry) = thread.read(cx).connection().telemetry() else { + return; + }; + + if self.feedback == Some(feedback) { + return; + } + + self.feedback = Some(feedback); + match feedback { + ThreadFeedback::Positive => { + self.comments_editor = None; + } + ThreadFeedback::Negative => { + self.comments_editor = Some(Self::build_feedback_comments_editor(window, cx)); + } + } + let session_id = thread.read(cx).session_id().clone(); + let agent_name = telemetry.agent_name(); + let task = telemetry.thread_data(&session_id, cx); + let rating = match feedback { + ThreadFeedback::Positive => "positive", + ThreadFeedback::Negative => "negative", + }; + cx.background_spawn(async move { + let thread = task.await?; + telemetry::event!( + "Agent Thread Rated", + session_id = session_id, + rating = rating, + agent = agent_name, + thread = thread + ); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn submit_comments(&mut self, thread: Entity, cx: &mut App) { + let Some(telemetry) = thread.read(cx).connection().telemetry() else { + return; + }; + + let Some(comments) = self + .comments_editor + .as_ref() + .map(|editor| editor.read(cx).text(cx)) + .filter(|text| !text.trim().is_empty()) + else { + return; + }; + + self.comments_editor.take(); + + let session_id = thread.read(cx).session_id().clone(); + let agent_name = telemetry.agent_name(); + let task = telemetry.thread_data(&session_id, cx); + cx.background_spawn(async move { + let thread = task.await?; + telemetry::event!( + "Agent Thread Feedback Comments", + session_id = session_id, + comments = comments, + agent = agent_name, + thread = thread + ); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + pub fn clear(&mut self) { + *self = Self::default() + } + + pub fn dismiss_comments(&mut self) { + self.comments_editor.take(); + } + + fn build_feedback_comments_editor(window: &mut Window, cx: &mut App) -> Entity { + let buffer = cx.new(|cx| { + let empty_string = String::new(); + MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx) + }); + + let editor = cx.new(|cx| { + let mut editor = Editor::new( + editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(4), + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text( + "What went wrong? Share your feedback so we can improve.", + window, + cx, + ); + editor + }); + + editor.read(cx).focus_handle(cx).focus(window); + editor } } @@ -103,67 +263,113 @@ pub struct AcpThreadView { workspace: WeakEntity, project: Entity, thread_state: ThreadState, + login: Option, + history_store: Entity, + hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, + focus_handle: FocusHandle, model_selector: Option>, profile_selector: Option>, notifications: Vec>, notification_subscriptions: HashMap, Vec>, + thread_retry_status: Option, thread_error: Option, + thread_feedback: ThreadFeedbackState, list_state: ListState, - scrollbar_state: ScrollbarState, auth_task: Option>, expanded_tool_calls: HashSet, expanded_thinking_blocks: HashSet<(usize, usize)>, edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, - terminal_expanded: bool, + should_be_following: bool, editing_message: Option, + prompt_capabilities: Rc>, + available_commands: Rc>>, + is_loading_contents: bool, + new_server_version_available: Option, + resume_thread_metadata: Option, _cancel_task: Option>, - _subscriptions: [Subscription; 3], + _subscriptions: [Subscription; 5], + #[cfg(target_os = "windows")] + show_codex_windows_warning: bool, } enum ThreadState { - Loading { - _task: Task<()>, - }, + Loading(Entity), Ready { thread: Entity, - _subscription: [Subscription; 2], + title_editor: Option>, + mode_selector: Option>, + _subscriptions: Vec, }, LoadError(LoadError), Unauthenticated { connection: Rc, - }, - ServerExited { - status: ExitStatus, + description: Option>, + configuration_view: Option, + pending_auth_method: Option, + _subscription: Option, }, } +struct LoadingView { + title: SharedString, + _load_task: Task<()>, + _update_title_task: Task>, +} + impl AcpThreadView { pub fn new( agent: Rc, + resume_thread: Option, + summarize_thread: Option, workspace: WeakEntity, project: Entity, - thread_store: Entity, - text_thread_store: Entity, + history_store: Entity, + prompt_store: Option>, window: &mut Window, cx: &mut Context, ) -> Self { + let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); + let available_commands = Rc::new(RefCell::new(vec![])); + + let placeholder = if agent.name() == "Zed Agent" { + format!("Message the {} — @ to include context", agent.name()) + } else if agent.name() == "Claude Code" + || agent.name() == "Codex" + || !available_commands.borrow().is_empty() + { + format!( + "Message {} — @ to include context, / for commands", + agent.name() + ) + } else { + format!("Message {} — @ to include context", agent.name()) + }; + let message_editor = cx.new(|cx| { - MessageEditor::new( + let mut editor = MessageEditor::new( workspace.clone(), project.clone(), - thread_store.clone(), - text_thread_store.clone(), + history_store.clone(), + prompt_store.clone(), + prompt_capabilities.clone(), + available_commands.clone(), + agent.name(), + &placeholder, editor::EditorMode::AutoHeight { - min_lines: MIN_EDITOR_LINES, - max_lines: Some(MAX_EDITOR_LINES), + min_lines: AgentSettings::get_global(cx).message_editor_min_lines, + max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), }, window, cx, - ) + ); + if let Some(entry) = summarize_thread { + editor.insert_thread_summary(entry, window, cx); + } + editor }); let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); @@ -172,65 +378,148 @@ impl AcpThreadView { EntryViewState::new( workspace.clone(), project.clone(), - thread_store.clone(), - text_thread_store.clone(), + history_store.clone(), + prompt_store.clone(), + prompt_capabilities.clone(), + available_commands.clone(), + agent.name(), ) }); + let agent_server_store = project.read(cx).agent_server_store().clone(); let subscriptions = [ - cx.observe_global_in::(window, Self::settings_changed), + cx.observe_global_in::(window, Self::agent_ui_font_size_changed), + cx.observe_global_in::(window, Self::agent_ui_font_size_changed), cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event), cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event), + cx.subscribe_in( + &agent_server_store, + window, + Self::handle_agent_servers_updated, + ), ]; + #[cfg(target_os = "windows")] + let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) + == Some(crate::ExternalAgent::Codex); + Self { agent: agent.clone(), workspace: workspace.clone(), project: project.clone(), entry_view_state, - thread_state: Self::initial_state(agent, workspace, project, window, cx), + thread_state: Self::initial_state( + agent.clone(), + resume_thread.clone(), + workspace.clone(), + project.clone(), + window, + cx, + ), + login: None, message_editor, model_selector: None, profile_selector: None, + notifications: Vec::new(), notification_subscriptions: HashMap::default(), - list_state: list_state.clone(), - scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()), + list_state: list_state, + thread_retry_status: None, thread_error: None, + thread_feedback: Default::default(), auth_task: None, expanded_tool_calls: HashSet::default(), expanded_thinking_blocks: HashSet::default(), editing_message: None, edits_expanded: false, plan_expanded: false, + prompt_capabilities, + available_commands, editor_expanded: false, - terminal_expanded: true, + should_be_following: false, + history_store, + hovered_recent_history_item: None, + is_loading_contents: false, _subscriptions: subscriptions, _cancel_task: None, + focus_handle: cx.focus_handle(), + new_server_version_available: None, + resume_thread_metadata: resume_thread, + #[cfg(target_os = "windows")] + show_codex_windows_warning, } } + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + self.thread_state = Self::initial_state( + self.agent.clone(), + self.resume_thread_metadata.clone(), + self.workspace.clone(), + self.project.clone(), + window, + cx, + ); + self.available_commands.replace(vec![]); + self.new_server_version_available.take(); + cx.notify(); + } + fn initial_state( agent: Rc, + resume_thread: Option, workspace: WeakEntity, project: Entity, window: &mut Window, cx: &mut Context, ) -> ThreadState { - let root_dir = project - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).abs_path()) - .unwrap_or_else(|| paths::home_dir().as_path().into()); + if project.read(cx).is_via_collab() + && agent.clone().downcast::().is_none() + { + return ThreadState::LoadError(LoadError::Other( + "External agents are not yet supported in shared projects.".into(), + )); + } + let mut worktrees = project.read(cx).visible_worktrees(cx).collect::>(); + // Pick the first non-single-file worktree for the root directory if there are any, + // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees. + worktrees.sort_by(|l, r| { + l.read(cx) + .is_single_file() + .cmp(&r.read(cx).is_single_file()) + }); + let root_dir = worktrees + .into_iter() + .filter_map(|worktree| { + if worktree.read(cx).is_single_file() { + Some(worktree.read(cx).abs_path().parent()?.into()) + } else { + Some(worktree.read(cx).abs_path()) + } + }) + .next(); + let (status_tx, mut status_rx) = watch::channel("Loading…".into()); + let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); + let delegate = AgentServerDelegate::new( + project.read(cx).agent_server_store().clone(), + project.clone(), + Some(status_tx), + Some(new_version_available_tx), + ); - let connect_task = agent.connect(&root_dir, &project, cx); + let connect_task = agent.connect(root_dir.as_deref(), delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { - Ok(connection) => connection, + Ok((connection, login)) => { + this.update(cx, |this, _| this.login = login).ok(); + connection + } Err(err) => { - this.update(cx, |this, cx| { - this.handle_load_error(err, cx); + this.update_in(cx, |this, window, cx| { + if err.downcast_ref::().is_some() { + this.handle_load_error(err, window, cx); + } else { + this.handle_thread_error(err, cx); + } cx.notify(); }) .log_err(); @@ -238,85 +527,144 @@ impl AcpThreadView { } }; - // this.update_in(cx, |_this, _window, cx| { - // let status = connection.exit_status(cx); - // cx.spawn(async move |this, cx| { - // let status = status.await.ok(); - // this.update(cx, |this, cx| { - // this.thread_state = ThreadState::ServerExited { status }; - // cx.notify(); - // }) - // .ok(); - // }) - // .detach(); - // }) - // .ok(); - - let Some(result) = cx - .update(|_, cx| { + let result = if let Some(native_agent) = connection + .clone() + .downcast::() + && let Some(resume) = resume_thread.clone() + { + cx.update(|_, cx| { + native_agent + .0 + .update(cx, |agent, cx| agent.open_thread(resume.id, cx)) + }) + .log_err() + } else { + let root_dir = if let Some(acp_agent) = connection + .clone() + .downcast::() + { + acp_agent.root_dir().into() + } else { + root_dir.unwrap_or(paths::home_dir().as_path().into()) + }; + cx.update(|_, cx| { connection .clone() .new_thread(project.clone(), &root_dir, cx) }) .log_err() - else { + }; + + let Some(result) = result else { return; }; let result = match result.await { - Err(e) => { - let mut cx = cx.clone(); - if e.is::() { - this.update(&mut cx, |this, cx| { - this.thread_state = ThreadState::Unauthenticated { connection }; - cx.notify(); + Err(e) => match e.downcast::() { + Ok(err) => { + cx.update(|window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx) }) - .ok(); + .log_err(); return; - } else { - Err(e) } - } + Err(err) => Err(err), + }, Ok(thread) => Ok(thread), }; this.update_in(cx, |this, window, cx| { match result { Ok(thread) => { - let thread_subscription = - cx.subscribe_in(&thread, window, Self::handle_thread_event); - let action_log = thread.read(cx).action_log().clone(); - let action_log_subscription = - cx.observe(&action_log, |_, _, cx| cx.notify()); - this.list_state - .splice(0..0, thread.read(cx).entries().len()); + this.prompt_capabilities + .replace(thread.read(cx).prompt_capabilities()); + + let count = thread.read(cx).entries().len(); + this.entry_view_state.update(cx, |view_state, cx| { + for ix in 0..count { + view_state.sync_entry(ix, &thread, window, cx); + } + this.list_state.splice_focusable( + 0..0, + (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)), + ); + }); + + if let Some(resume) = resume_thread { + this.history_store.update(cx, |history, cx| { + history.push_recently_opened_entry( + HistoryEntryId::AcpThread(resume.id), + cx, + ); + }); + } AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); - this.model_selector = - thread - .read(cx) - .connection() - .model_selector() - .map(|selector| { - cx.new(|cx| { - AcpModelSelectorPopover::new( - thread.read(cx).session_id().clone(), - selector, - PopoverMenuHandle::default(), - this.focus_handle(cx), - window, - cx, - ) - }) + this.model_selector = thread + .read(cx) + .connection() + .model_selector(thread.read(cx).session_id()) + .map(|selector| { + cx.new(|cx| { + AcpModelSelectorPopover::new( + selector, + PopoverMenuHandle::default(), + this.focus_handle(cx), + window, + cx, + ) + }) + }); + + let mode_selector = thread + .read(cx) + .connection() + .session_modes(thread.read(cx).session_id(), cx) + .map(|session_modes| { + let fs = this.project.read(cx).fs().clone(); + let focus_handle = this.focus_handle(cx); + cx.new(|_cx| { + ModeSelector::new( + session_modes, + this.agent.clone(), + fs, + focus_handle, + ) + }) + }); + + let mut subscriptions = vec![ + cx.subscribe_in(&thread, window, Self::handle_thread_event), + cx.observe(&action_log, |_, _, cx| cx.notify()), + ]; + + let title_editor = + if thread.update(cx, |thread, cx| thread.can_set_title(cx)) { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(thread.read(cx).title(), window, cx); + editor }); + subscriptions.push(cx.subscribe_in( + &editor, + window, + Self::handle_title_editor_event, + )); + Some(editor) + } else { + None + }; this.thread_state = ThreadState::Ready { thread, - _subscription: [thread_subscription, action_log_subscription], + title_editor, + mode_selector, + _subscriptions: subscriptions, }; + this.message_editor.focus_handle(cx).focus(window); this.profile_selector = this.as_native_thread(cx).map(|thread| { cx.new(|cx| { @@ -332,47 +680,194 @@ impl AcpThreadView { cx.notify(); } Err(err) => { - this.handle_load_error(err, cx); + this.handle_load_error(err, window, cx); } }; }) .log_err(); }); - ThreadState::Loading { _task: load_task } + cx.spawn(async move |this, cx| { + while let Ok(new_version) = new_version_available_rx.recv().await { + if let Some(new_version) = new_version { + this.update(cx, |this, cx| { + this.new_server_version_available = Some(new_version.into()); + cx.notify(); + }) + .log_err(); + } + } + }) + .detach(); + + let loading_view = cx.new(|cx| { + let update_title_task = cx.spawn(async move |this, cx| { + loop { + let status = status_rx.recv().await?; + this.update(cx, |this: &mut LoadingView, cx| { + this.title = status; + cx.notify(); + })?; + } + }); + + LoadingView { + title: "Loading…".into(), + _load_task: load_task, + _update_title_task: update_title_task, + } + }); + + ThreadState::Loading(loading_view) + } + + fn handle_auth_required( + this: WeakEntity, + err: AuthRequired, + agent: Rc, + connection: Rc, + window: &mut Window, + cx: &mut App, + ) { + let agent_name = agent.name(); + let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id { + let registry = LanguageModelRegistry::global(cx); + + let sub = window.subscribe(®istry, cx, { + let provider_id = provider_id.clone(); + let this = this.clone(); + move |_, ev, window, cx| { + if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev + && &provider_id == updated_provider_id + && LanguageModelRegistry::global(cx) + .read(cx) + .provider(&provider_id) + .map_or(false, |provider| provider.is_authenticated(cx)) + { + this.update(cx, |this, cx| { + this.reset(window, cx); + }) + .ok(); + } + } + }); + + let view = registry.read(cx).provider(&provider_id).map(|provider| { + provider.configuration_view( + language_model::ConfigurationViewTargetAgent::Other(agent_name.clone()), + window, + cx, + ) + }); + + (view, Some(sub)) + } else { + (None, None) + }; + + this.update(cx, |this, cx| { + this.thread_state = ThreadState::Unauthenticated { + pending_auth_method: None, + connection, + configuration_view, + description: err + .description + .clone() + .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))), + _subscription: subscription, + }; + if this.message_editor.focus_handle(cx).is_focused(window) { + this.focus_handle.focus(window) + } + cx.notify(); + }) + .ok(); } - fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context) { + fn handle_load_error( + &mut self, + err: anyhow::Error, + window: &mut Window, + cx: &mut Context, + ) { if let Some(load_err) = err.downcast_ref::() { self.thread_state = ThreadState::LoadError(load_err.clone()); } else { self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into())) } + if self.message_editor.focus_handle(cx).is_focused(window) { + self.focus_handle.focus(window) + } cx.notify(); } + fn handle_agent_servers_updated( + &mut self, + _agent_server_store: &Entity, + _event: &project::AgentServersUpdated, + window: &mut Window, + cx: &mut Context, + ) { + // If we're in a LoadError state OR have a thread_error set (which can happen + // when agent.connect() fails during loading), retry loading the thread. + // This handles the case where a thread is restored before authentication completes. + let should_retry = + matches!(&self.thread_state, ThreadState::LoadError(_)) || self.thread_error.is_some(); + + if should_retry { + self.thread_error = None; + self.reset(window, cx); + } + } + + pub fn workspace(&self) -> &WeakEntity { + &self.workspace + } + pub fn thread(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { thread, .. } => Some(thread), ThreadState::Unauthenticated { .. } | ThreadState::Loading { .. } - | ThreadState::LoadError(..) - | ThreadState::ServerExited { .. } => None, + | ThreadState::LoadError { .. } => None, + } + } + + pub fn mode_selector(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(), + ThreadState::Unauthenticated { .. } + | ThreadState::Loading { .. } + | ThreadState::LoadError { .. } => None, } } pub fn title(&self, cx: &App) -> SharedString { match &self.thread_state { - ThreadState::Ready { thread, .. } => thread.read(cx).title(), - ThreadState::Loading { .. } => "Loading…".into(), - ThreadState::LoadError(_) => "Failed to load".into(), - ThreadState::Unauthenticated { .. } => "Not authenticated".into(), - ThreadState::ServerExited { .. } => "Server exited unexpectedly".into(), + ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), + ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(), + ThreadState::LoadError(error) => match error { + LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::FailedToInstall(_) => { + format!("Failed to Install {}", self.agent.name()).into() + } + LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + }, + } + } + + pub fn title_editor(&self) -> Option> { + if let ThreadState::Ready { title_editor, .. } = &self.thread_state { + title_editor.clone() + } else { + None } } pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_error.take(); + self.thread_retry_status.take(); if let Some(thread) = self.thread() { self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx))); @@ -402,10 +897,11 @@ impl AcpThreadView { cx, ) } else { + let agent_settings = AgentSettings::get_global(cx); editor.set_mode( EditorMode::AutoHeight { - min_lines: MIN_EDITOR_LINES, - max_lines: Some(MAX_EDITOR_LINES), + min_lines: agent_settings.message_editor_min_lines, + max_lines: Some(agent_settings.set_message_editor_max_lines()), }, cx, ) @@ -414,6 +910,35 @@ impl AcpThreadView { cx.notify(); } + pub fn handle_title_editor_event( + &mut self, + title_editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread) = self.thread() else { return }; + + match event { + EditorEvent::BufferEdited => { + let new_title = title_editor.read(cx).text(cx); + thread.update(cx, |thread, cx| { + thread + .set_title(new_title.into(), cx) + .detach_and_log_err(cx); + }) + } + EditorEvent::Blurred => { + if title_editor.read(cx).text(cx).is_empty() { + title_editor.update(cx, |editor, cx| { + editor.set_text("New Thread", window, cx); + }); + } + } + _ => {} + } + } + pub fn handle_message_editor_event( &mut self, _: &Entity, @@ -424,7 +949,10 @@ impl AcpThreadView { match event { MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::Cancel => self.cancel_generation(cx), - MessageEditorEvent::Focus => {} + MessageEditorEvent::Focus => { + self.cancel_editing(&Default::default(), window, cx); + } + MessageEditorEvent::LostFocus => {} } } @@ -436,12 +964,43 @@ impl AcpThreadView { cx: &mut Context, ) { match &event.view_event { + ViewEvent::NewDiff(tool_call_id) => { + if AgentSettings::get_global(cx).expand_edit_card { + self.expanded_tool_calls.insert(tool_call_id.clone()); + } + } + ViewEvent::NewTerminal(tool_call_id) => { + if AgentSettings::get_global(cx).expand_terminal_card { + self.expanded_tool_calls.insert(tool_call_id.clone()); + } + } + ViewEvent::TerminalMovedToBackground(tool_call_id) => { + self.expanded_tool_calls.remove(tool_call_id); + } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Focus) => { - self.editing_message = Some(event.entry_index); - cx.notify(); + if let Some(thread) = self.thread() + && let Some(AgentThreadEntry::UserMessage(user_message)) = + thread.read(cx).entries().get(event.entry_index) + && user_message.id.is_some() + { + self.editing_message = Some(event.entry_index); + cx.notify(); + } + } + ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::LostFocus) => { + if let Some(thread) = self.thread() + && let Some(AgentThreadEntry::UserMessage(user_message)) = + thread.read(cx).entries().get(event.entry_index) + && user_message.id.is_some() + { + if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { + self.editing_message = None; + cx.notify(); + } + } } ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { - self.regenerate(event.entry_index, editor, window, cx); + self.regenerate(event.entry_index, editor.clone(), window, cx); } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); @@ -454,6 +1013,9 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + if !thread.read(cx).can_resume(cx) { + return; + } let task = thread.update(cx, |thread, cx| thread.resume(cx)); cx.spawn(async move |this, cx| { @@ -469,30 +1031,136 @@ impl AcpThreadView { } fn send(&mut self, window: &mut Window, cx: &mut Context) { - let contents = self - .message_editor - .update(cx, |message_editor, cx| message_editor.contents(window, cx)); - self.send_impl(contents, window, cx) - } + let Some(thread) = self.thread() else { return }; - fn send_impl( - &mut self, - contents: Task>>, - window: &mut Window, - cx: &mut Context, - ) { - self.thread_error.take(); - self.editing_message.take(); - - let Some(thread) = self.thread().cloned() else { + if self.is_loading_contents { return; - }; - let task = cx.spawn_in(window, async move |this, cx| { - let contents = contents.await?; - - if contents.is_empty() { - return Ok(()); - } + } + + self.history_store.update(cx, |history, cx| { + history.push_recently_opened_entry( + HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()), + cx, + ); + }); + + if thread.read(cx).status() != ThreadStatus::Idle { + self.stop_current_and_send_new_message(window, cx); + return; + } + + let text = self.message_editor.read(cx).text(cx); + let text = text.trim(); + if text == "/login" || text == "/logout" { + let ThreadState::Ready { thread, .. } = &self.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let can_login = !connection.auth_methods().is_empty() || self.login.is_some(); + // Does the agent have a specific logout command? Prefer that in case they need to reset internal state. + let logout_supported = text == "/logout" + && self + .available_commands + .borrow() + .iter() + .any(|command| command.name == "logout"); + if can_login && !logout_supported { + self.message_editor + .update(cx, |editor, cx| editor.clear(window, cx)); + + let this = cx.weak_entity(); + let agent = self.agent.clone(); + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: None, + provider_id: None, + }, + agent, + connection, + window, + cx, + ); + }); + cx.notify(); + return; + } + } + + self.send_impl(self.message_editor.clone(), window, cx) + } + + fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); + + cx.spawn_in(window, async move |this, cx| { + cancelled.await; + + this.update_in(cx, |this, window, cx| { + this.send_impl(this.message_editor.clone(), window, cx); + }) + .ok(); + }) + .detach(); + } + + fn send_impl( + &mut self, + message_editor: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { + // Include full contents when using minimal profile + let thread = thread.read(cx); + AgentSettings::get_global(cx) + .profiles + .get(thread.profile()) + .is_some_and(|profile| profile.tools.is_empty()) + }); + + let contents = message_editor.update(cx, |message_editor, cx| { + message_editor.contents(full_mention_content, cx) + }); + + let agent_telemetry_id = self.agent.telemetry_id(); + + self.thread_error.take(); + self.editing_message.take(); + self.thread_feedback.clear(); + + let Some(thread) = self.thread() else { + return; + }; + let thread = thread.downgrade(); + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } + + self.is_loading_contents = true; + let guard = cx.new(|_| ()); + cx.observe_release(&guard, |this, _guard, cx| { + this.is_loading_contents = false; + cx.notify(); + }) + .detach(); + + let task = cx.spawn_in(window, async move |this, cx| { + let (contents, tracked_buffers) = contents.await?; + + if contents.is_empty() { + return Ok(()); + } this.update_in(cx, |this, window, cx| { this.set_editor_is_expanded(false, cx); @@ -501,7 +1169,18 @@ impl AcpThreadView { message_editor.clear(window, cx); }); })?; - let send = thread.update(cx, |thread, cx| thread.send(contents, cx))?; + let send = thread.update(cx, |thread, cx| { + thread.action_log().update(cx, |action_log, cx| { + for buffer in tracked_buffers { + action_log.buffer_read(buffer, cx) + } + }); + drop(guard); + + telemetry::event!("Agent Message Sent", agent = agent_telemetry_id); + + thread.send(contents, cx) + })?; send.await }); @@ -511,6 +1190,16 @@ impl AcpThreadView { this.handle_thread_error(err, cx); }) .ok(); + } else { + this.update(cx, |this, cx| { + this.should_be_following = this + .workspace + .update(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or_default(); + }) + .ok(); } }) .detach(); @@ -521,25 +1210,24 @@ impl AcpThreadView { return; }; - if let Some(index) = self.editing_message.take() { - if let Some(editor) = self + if let Some(index) = self.editing_message.take() + && let Some(editor) = self .entry_view_state .read(cx) .entry(index) .and_then(|e| e.message_editor()) .cloned() - { - editor.update(cx, |editor, cx| { - if let Some(user_message) = thread - .read(cx) - .entries() - .get(index) - .and_then(|e| e.user_message()) - { - editor.set_message(user_message.chunks.clone(), window, cx); - } - }) - } + { + editor.update(cx, |editor, cx| { + if let Some(user_message) = thread + .read(cx) + .entries() + .get(index) + .and_then(|e| e.user_message()) + { + editor.set_message(user_message.chunks.clone(), window, cx); + } + }) }; self.focus_handle(cx).focus(window); cx.notify(); @@ -548,35 +1236,34 @@ impl AcpThreadView { fn regenerate( &mut self, entry_ix: usize, - message_editor: &Entity, + message_editor: Entity, window: &mut Window, cx: &mut Context, ) { let Some(thread) = self.thread().cloned() else { return; }; + if self.is_loading_contents { + return; + } - let Some(rewind) = thread.update(cx, |thread, cx| { - let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?; - Some(thread.rewind(user_message_id, cx)) + let Some(user_message_id) = thread.update(cx, |thread, _| { + thread.entries().get(entry_ix)?.user_message()?.id.clone() }) else { return; }; - let contents = - message_editor.update(cx, |message_editor, cx| message_editor.contents(window, cx)); - - let task = cx.foreground_executor().spawn(async move { - rewind.await?; - contents.await - }); - self.send_impl(task, window, cx); - } - - fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context) { - if let Some(thread) = self.thread() { - AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err(); - } + cx.spawn_in(window, async move |this, cx| { + thread + .update(cx, |thread, cx| thread.rewind(user_message_id, cx))? + .await?; + this.update_in(cx, |this, window, cx| { + this.send_impl(message_editor, window, cx); + this.focus_handle(cx).focus(window); + })?; + anyhow::Ok(()) + }) + .detach(); } fn open_edited_buffer( @@ -596,12 +1283,44 @@ impl AcpThreadView { }; diff.update(cx, |diff, cx| { - diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx) + diff.move_to_path(PathKey::for_buffer(buffer, cx), window, cx) }) } + fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.as_native_thread(cx) else { + return; + }; + let project_context = thread.read(cx).project_context().read(cx); + + let project_entry_ids = project_context + .worktrees + .iter() + .flat_map(|worktree| worktree.rules_file.as_ref()) + .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id)) + .collect::>(); + + self.workspace + .update(cx, move |workspace, cx| { + // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules + // files clear. For example, if rules file 1 is already open but rules file 2 is not, + // this would open and focus rules file 2 in a tab that is not next to rules file 1. + let project = workspace.project().read(cx); + let project_paths = project_entry_ids + .into_iter() + .flat_map(|entry_id| project.path_for_entry(entry_id, cx)) + .collect::>(); + for project_path in project_paths { + workspace + .open_path(project_path, None, true, window, cx) + .detach_and_log_err(cx); + } + }) + .ok(); + } + fn handle_thread_error(&mut self, error: anyhow::Error, cx: &mut Context) { - self.thread_error = Some(ThreadError::from_err(error)); + self.thread_error = Some(ThreadError::from_err(error, &self.agent)); cx.notify(); } @@ -622,15 +1341,19 @@ impl AcpThreadView { let len = thread.read(cx).entries().len(); let index = len - 1; self.entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(index, &thread, window, cx) + view_state.sync_entry(index, thread, window, cx); + self.list_state.splice_focusable( + index..index, + [view_state + .entry(index) + .and_then(|entry| entry.focus_handle(cx))], + ); }); - self.list_state.splice(index..index, 1); } AcpThreadEvent::EntryUpdated(index) => { self.entry_view_state.update(cx, |view_state, cx| { - view_state.sync_entry(*index, &thread, window, cx) + view_state.sync_entry(*index, thread, window, cx) }); - self.list_state.splice(*index..index + 1, 1); } AcpThreadEvent::EntriesRemoved(range) => { self.entry_view_state @@ -640,7 +1363,11 @@ impl AcpThreadView { AcpThreadEvent::ToolAuthorizationRequired => { self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } + AcpThreadEvent::Retry(retry) => { + self.thread_retry_status = Some(retry.clone()); + } AcpThreadEvent::Stopped => { + self.thread_retry_status.take(); let used_tools = thread.read(cx).used_tools_since_last_user_message(); self.notify_with_sound( if used_tools { @@ -653,7 +1380,16 @@ impl AcpThreadView { cx, ); } + AcpThreadEvent::Refusal => { + self.thread_retry_status.take(); + self.thread_error = Some(ThreadError::Refusal); + let model_or_agent_name = self.get_current_model_name(cx); + let notification_message = + format!("{} refused to respond to this request", model_or_agent_name); + self.notify_with_sound(¬ification_message, IconName::Warning, window, cx); + } AcpThreadEvent::Error => { + self.thread_retry_status.take(); self.notify_with_sound( "Agent stopped due to an error", IconName::Warning, @@ -661,8 +1397,57 @@ impl AcpThreadView { cx, ); } - AcpThreadEvent::ServerExited(status) => { - self.thread_state = ThreadState::ServerExited { status: *status }; + AcpThreadEvent::LoadError(error) => { + self.thread_retry_status.take(); + self.thread_state = ThreadState::LoadError(error.clone()); + if self.message_editor.focus_handle(cx).is_focused(window) { + self.focus_handle.focus(window) + } + } + AcpThreadEvent::TitleUpdated => { + let title = thread.read(cx).title(); + if let Some(title_editor) = self.title_editor() { + title_editor.update(cx, |editor, cx| { + if editor.text(cx) != title { + editor.set_text(title, window, cx); + } + }); + } + } + AcpThreadEvent::PromptCapabilitiesUpdated => { + self.prompt_capabilities + .replace(thread.read(cx).prompt_capabilities()); + } + AcpThreadEvent::TokenUsageUpdated => {} + AcpThreadEvent::AvailableCommandsUpdated(available_commands) => { + let mut available_commands = available_commands.clone(); + + if thread + .read(cx) + .connection() + .auth_methods() + .iter() + .any(|method| method.id.0.as_ref() == "claude-login") + { + available_commands.push(acp::AvailableCommand { + name: "login".to_owned(), + description: "Authenticate".to_owned(), + input: None, + meta: None, + }); + available_commands.push(acp::AvailableCommand { + name: "logout".to_owned(), + description: "Authenticate".to_owned(), + input: None, + meta: None, + }); + } + + self.available_commands.replace(available_commands); + } + AcpThreadEvent::ModeUpdated(_mode) => { + // The connection keeps track of the mode + cx.notify(); } } cx.notify(); @@ -674,35 +1459,231 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) { - let ThreadState::Unauthenticated { ref connection } = self.thread_state else { + let ThreadState::Unauthenticated { + connection, + pending_auth_method, + configuration_view, + .. + } = &mut self.thread_state + else { return; }; - self.thread_error.take(); - let authenticate = connection.authenticate(method, cx); - self.auth_task = Some(cx.spawn_in(window, { - let project = self.project.clone(); + if method.0.as_ref() == "gemini-api-key" { + let registry = LanguageModelRegistry::global(cx); + let provider = registry + .read(cx) + .provider(&language_model::GOOGLE_PROVIDER_ID) + .unwrap(); + if !provider.is_authenticated(cx) { + let this = cx.weak_entity(); + let agent = self.agent.clone(); + let connection = connection.clone(); + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: Some("GEMINI_API_KEY must be set".to_owned()), + provider_id: Some(language_model::GOOGLE_PROVIDER_ID), + }, + agent, + connection, + window, + cx, + ); + }); + return; + } + } else if method.0.as_ref() == "anthropic-api-key" { + let registry = LanguageModelRegistry::global(cx); + let provider = registry + .read(cx) + .provider(&language_model::ANTHROPIC_PROVIDER_ID) + .unwrap(); + let this = cx.weak_entity(); let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; - - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - this.handle_thread_error(err, cx); - } else { + let connection = connection.clone(); + window.defer(cx, move |window, cx| { + if !provider.is_authenticated(cx) { + Self::handle_auth_required( + this, + AuthRequired { + description: Some("ANTHROPIC_API_KEY must be set".to_owned()), + provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID), + }, + agent, + connection, + window, + cx, + ); + } else { + this.update(cx, |this, cx| { this.thread_state = Self::initial_state( agent, + None, this.workspace.clone(), - project.clone(), + this.project.clone(), window, cx, ) + }) + .ok(); + } + }); + return; + } else if method.0.as_ref() == "vertex-ai" + && std::env::var("GOOGLE_API_KEY").is_err() + && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() + || (std::env::var("GOOGLE_CLOUD_PROJECT").is_err())) + { + let this = cx.weak_entity(); + let agent = self.agent.clone(); + let connection = connection.clone(); + + window.defer(cx, |window, cx| { + Self::handle_auth_required( + this, + AuthRequired { + description: Some( + "GOOGLE_API_KEY must be set in the environment to use Vertex AI authentication for Gemini CLI. Please export it and restart Zed." + .to_owned(), + ), + provider_id: None, + }, + agent, + connection, + window, + cx, + ) + }); + return; + } + + self.thread_error.take(); + configuration_view.take(); + pending_auth_method.replace(method.clone()); + let authenticate = if (method.0.as_ref() == "claude-login" + || method.0.as_ref() == "spawn-gemini-cli") + && let Some(login) = self.login.clone() + { + if let Some(workspace) = self.workspace.upgrade() { + Self::spawn_external_agent_login(login, workspace, false, window, cx) + } else { + Task::ready(Ok(())) + } + } else { + connection.authenticate(method, cx) + }; + cx.notify(); + self.auth_task = + Some(cx.spawn_in(window, { + let agent = self.agent.clone(); + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent.telemetry_id() + ), + Err(_) => { + telemetry::event!( + "Authenticate Agent Failed", + agent = agent.telemetry_id(), + ) + } + } + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); + } + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); + } + + fn spawn_external_agent_login( + login: task::SpawnInTerminal, + workspace: Entity, + previous_attempt: bool, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { + return Task::ready(Ok(())); + }; + let project = workspace.read(cx).project().clone(); + + window.spawn(cx, async move |cx| { + let mut task = login.clone(); + task.shell = task::Shell::WithArguments { + program: task.command.take().expect("login command should be set"), + args: std::mem::take(&mut task.args), + title_override: None + }; + task.full_label = task.label.clone(); + task.id = task::TaskId(format!("external-agent-{}-login", task.label)); + task.command_label = task.label.clone(); + task.use_new_terminal = true; + task.allow_concurrent_runs = true; + task.hide = task::HideStrategy::Always; + + let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| { + terminal_panel.spawn_task(&task, window, cx) + })?; + + let terminal = terminal.await?; + let mut exit_status = terminal + .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? + .fuse(); + + let logged_in = cx + .spawn({ + let terminal = terminal.clone(); + async move |cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let content = + terminal.update(cx, |terminal, _cx| terminal.get_content())?; + if content.contains("Login successful") + || content.contains("Type your message") + { + return anyhow::Ok(()); + } + } } - this.auth_task.take() }) - .ok(); + .fuse(); + futures::pin_mut!(logged_in); + futures::select_biased! { + result = logged_in => { + if let Err(e) = result { + log::error!("{e}"); + return Err(anyhow!("exited before logging in")); + } + } + _ = exit_status => { + if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") { + return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await + } + return Err(anyhow!("exited before logging in")); + } } - })); + terminal.update(cx, |terminal, _| terminal.kill_active_task())?; + Ok(()) + }) } fn authorize_tool_call( @@ -710,6 +1691,7 @@ impl AcpThreadView { tool_call_id: acp::ToolCallId, option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind, + window: &mut Window, cx: &mut Context, ) { let Some(thread) = self.thread() else { @@ -718,17 +1700,26 @@ impl AcpThreadView { thread.update(cx, |thread, cx| { thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); }); + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } cx.notify(); } - fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context) { + fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context) { let Some(thread) = self.thread() else { return; }; + thread - .update(cx, |thread, cx| thread.rewind(message_id.clone(), cx)) + .update(cx, |thread, cx| { + thread.restore_checkpoint(message_id.clone(), cx) + }) .detach_and_log_err(cx); - cx.notify(); } fn render_entry( @@ -740,49 +1731,171 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { let primary = match &entry { - AgentThreadEntry::UserMessage(message) => div() - .id(("user_message", entry_ix)) - .py_4() - .px_2() - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { - Button::new("restore-checkpoint", "Restore Checkpoint") - .icon(IconName::Undo) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) - .label_size(LabelSize::XSmall) - .on_click(cx.listener(move |this, _, _window, cx| { - this.rewind(&message_id, cx); - })) + AgentThreadEntry::UserMessage(message) => { + let Some(editor) = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.message_editor()) + .cloned() + else { + return Empty.into_any_element(); + }; + + let editing = self.editing_message == Some(entry_ix); + let editor_focus = editor.focus_handle(cx).is_focused(window); + let focus_border = cx.theme().colors().border_focused; + + let rules_item = if entry_ix == 0 { + self.render_rules_item(cx) + } else { + None + }; + + let has_checkpoint_button = message + .checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.show); + + let agent_name = self.agent.name(); + + v_flex() + .id(("user_message", entry_ix)) + .map(|this| { + if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + this.pt(rems_from_px(18.)) + } else if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + } }) - })) - .child( - v_flex() - .p_3() - .gap_1p5() - .rounded_lg() - .shadow_md() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .text_xs() - .children( - self.entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.message_editor()) - .map(|editor| { - self.render_sent_message_editor(entry_ix, editor, cx) - .into_any_element() - }), - ), - ) - .into_any(), + .pb_3() + .px_2() + .gap_1p5() + .w_full() + .children(rules_item) + .children(message.id.clone().and_then(|message_id| { + message.checkpoint.as_ref()?.show.then(|| { + h_flex() + .px_3() + .gap_2() + .child(Divider::horizontal()) + .child( + Button::new("restore-checkpoint", "Restore Checkpoint") + .icon(IconName::Undo) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .label_size(LabelSize::XSmall) + .icon_color(Color::Muted) + .color(Color::Muted) + .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation.")) + .on_click(cx.listener(move |this, _, _window, cx| { + this.restore_checkpoint(&message_id, cx); + })) + ) + .child(Divider::horizontal()) + }) + })) + .child( + div() + .relative() + .child( + div() + .py_3() + .px_2() + .rounded_md() + .shadow_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .when(editing && !editor_focus, |this| this.border_dashed()) + .border_color(cx.theme().colors().border) + .map(|this|{ + if editing && editor_focus { + this.border_color(focus_border) + } else if message.id.is_some() { + this.hover(|s| s.border_color(focus_border.opacity(0.8))) + } else { + this + } + }) + .text_xs() + .child(editor.clone().into_any_element()), + ) + .when(editor_focus, |this| { + let base_container = h_flex() + .absolute() + .top_neg_3p5() + .right_3() + .gap_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .overflow_hidden(); + + if message.id.is_some() { + this.child( + base_container + .child( + IconButton::new("cancel", IconName::Close) + .disabled(self.is_loading_contents) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::cancel_editing)) + ) + .child( + if self.is_loading_contents { + div() + .id("loading-edited-message-content") + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::XSmall)) + .into_any_element() + } else { + IconButton::new("regenerate", IconName::Return) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text( + "Editing will restart the thread from this point." + )) + .on_click(cx.listener({ + let editor = editor.clone(); + move |this, _, window, cx| { + this.regenerate( + entry_ix, editor.clone(), window, cx, + ); + } + })).into_any_element() + } + ) + ) + } else { + this.child( + base_container + .border_dashed() + .child( + IconButton::new("editing_unavailable", IconName::PencilUnavailable) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .style(ButtonStyle::Transparent) + .tooltip(move |_window, cx| { + cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) + .into() + }) + ) + ) + } + }), + ) + .into_any() + } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { - let style = default_markdown_style(false, window, cx); + let is_last = entry_ix + 1 == total_entries; + + let style = default_markdown_style(false, false, window, cx); let message_body = v_flex() .w_full() - .gap_2p5() + .gap_3() .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { @@ -809,8 +1922,8 @@ impl AcpThreadView { v_flex() .px_5() - .py_1() - .when(entry_ix + 1 == total_entries, |this| this.pb_4()) + .py_1p5() + .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) .child(message_body) @@ -819,7 +1932,7 @@ impl AcpThreadView { AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().py_1p5().px_5().map(|this| { + div().w_full().map(|this| { if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( @@ -838,12 +1951,15 @@ impl AcpThreadView { return primary; }; - let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - let primary = if entry_ix == total_entries - 1 && !is_generating { + let primary = if entry_ix == total_entries - 1 { v_flex() .w_full() .child(primary) - .child(self.render_thread_controls(cx)) + .child(self.render_thread_controls(&thread, cx)) + .when_some( + self.thread_feedback.comments_editor.clone(), + |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), + ) .into_any_element() } else { primary @@ -880,7 +1996,7 @@ impl AcpThreadView { } fn tool_card_border_color(&self, cx: &Context) -> Hsla { - cx.theme().colors().border.opacity(0.6) + cx.theme().colors().border.opacity(0.8) } fn tool_name_font_size(&self) -> Rems { @@ -897,60 +2013,72 @@ impl AcpThreadView { ) -> AnyElement { let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-card-header"); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + let scroll_handle = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); + + let thinking_content = { + div() + .id(("thinking-content", chunk_ix)) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .text_ui_sm(cx) + .overflow_hidden() + .child( + self.render_markdown(chunk, default_markdown_style(false, false, window, cx)), + ) + }; + v_flex() + .gap_1() .child( h_flex() .id(header_id) .group(&card_header_id) .relative() .w_full() - .gap_1p5() - .opacity(0.8) - .hover(|style| style.opacity(1.)) + .pr_1() + .justify_between() .child( h_flex() - .size_4() - .justify_center() + .h(window.line_height() - px(2.)) + .gap_1p5() + .overflow_hidden() .child( - div() - .group_hover(&card_header_id, |s| s.invisible().w_0()) - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::ToolThink) + .size(IconSize::Small) + .color(Color::Muted), ) .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&card_header_id, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), - ), + div() + .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) + .child("Thinking"), ), ) .child( - div() - .text_size(self.tool_name_font_size()) - .child("Thinking"), + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), ) .on_click(cx.listener({ move |this, _event, _window, cx| { @@ -966,82 +2094,16 @@ impl AcpThreadView { .when(is_open, |this| { this.child( div() - .relative() - .mt_1p5() - .ml(px(7.)) - .pl_4() + .ml_1p5() + .pl_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) - .text_ui_sm(cx) - .child( - self.render_markdown(chunk, default_markdown_style(false, window, cx)), - ), + .child(thinking_content), ) }) .into_any_element() } - fn render_tool_call_icon( - &self, - group_name: SharedString, - entry_ix: usize, - is_collapsible: bool, - is_open: bool, - tool_call: &ToolCall, - cx: &Context, - ) -> Div { - let tool_icon = Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolRead, - acp::ToolKind::Edit => IconName::ToolPencil, - acp::ToolKind::Delete => IconName::ToolDeleteFile, - acp::ToolKind::Move => IconName::ArrowRightLeft, - acp::ToolKind::Search => IconName::ToolSearch, - acp::ToolKind::Execute => IconName::ToolTerminal, - acp::ToolKind::Think => IconName::ToolThink, - acp::ToolKind::Fetch => IconName::ToolWeb, - acp::ToolKind::Other => IconName::ToolHammer, - }) - .size(IconSize::Small) - .color(Color::Muted); - - let base_container = h_flex().size_4().justify_center(); - - if is_collapsible { - base_container - .child( - div() - .group_hover(&group_name, |s| s.invisible().w_0()) - .child(tool_icon), - ) - .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&group_name, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ), - ) - } else { - base_container.child(tool_icon) - } - } - fn render_tool_call( &self, entry_ix: usize, @@ -1049,225 +2111,314 @@ impl AcpThreadView { window: &Window, cx: &Context, ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); + let has_location = tool_call.locations.len() == 1; let card_header_id = SharedString::from("inner-tool-call-header"); - let status_icon = match &tool_call.status { - ToolCallStatus::Pending - | ToolCallStatus::WaitingForConfirmation { .. } - | ToolCallStatus::Completed => None, - ToolCallStatus::InProgress => Some( - Icon::new(IconName::ArrowCircle) - .color(Color::Accent) - .size(IconSize::Small) - .with_animation( - "running", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) - .into_any(), - ), - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => Some( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small) - .into_any_element(), - ), + let failed_or_canceled = match &tool_call.status { + ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, + _ => false, }; let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } ); - let is_edit = matches!(tool_call.kind, acp::ToolKind::Edit); - let has_diff = tool_call - .content - .iter() - .any(|content| matches!(content, ToolCallContent::Diff { .. })); - let has_nonempty_diff = tool_call.content.iter().any(|content| match content { - ToolCallContent::Diff(diff) => diff.read(cx).has_revealed_range(cx), - _ => false, - }); - let use_card_layout = needs_confirmation || is_edit || has_diff; + let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute); + let is_edit = + matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); + + let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; + + let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; + + let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); + + let tool_output_display = + if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() + .w_full() + .children(tool_call.content.iter().enumerate().map( + |(content_ix, content)| { + div() + .child(self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + window, + cx, + )) + .into_any_element() + }, + )) + .child(self.render_permission_buttons( + tool_call.kind, + options, + entry_ix, + tool_call.id.clone(), + cx, + )) + .into_any(), + ToolCallStatus::Pending | ToolCallStatus::InProgress + if is_edit + && tool_call.content.is_empty() + && self.as_native_connection(cx).is_some() => + { + self.render_diff_loading(cx).into_any() + } + ToolCallStatus::Pending + | ToolCallStatus::InProgress + | ToolCallStatus::Completed + | ToolCallStatus::Failed + | ToolCallStatus::Canceled => v_flex() + .w_full() + .children(tool_call.content.iter().enumerate().map( + |(content_ix, content)| { + div().child(self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + window, + cx, + )) + }, + )) + .into_any(), + ToolCallStatus::Rejected => Empty.into_any(), + } + .into() + } else { + None + }; + + v_flex() + .map(|this| { + if use_card_layout { + this.my_1p5() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } + }) + .map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() + .map(|this| { + if is_terminal_tool { + this.child( + v_flex() + .p_1p5() + .gap_0p5() + .text_ui_sm(cx) + .bg(self.tool_card_header_bg(cx)) + .child( + Label::new("Run Command") + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + MarkdownElement::new( + tool_call.label.clone(), + terminal_command_markdown_style(window, cx), + ) + .code_block_renderer( + markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + ), + ) + } else { + this.child( + h_flex() + .group(&card_header_id) + .relative() + .w_full() + .gap_1() + .justify_between() + .when(use_card_layout, |this| { + this.p_0p5() + .rounded_t(rems_from_px(5.)) + .bg(self.tool_card_header_bg(cx)) + }) + .child(self.render_tool_call_label( + entry_ix, + tool_call, + is_edit, + use_card_layout, + window, + cx, + )) + .when(is_collapsible || failed_or_canceled, |this| { + this.child( + h_flex() + .px_1() + .gap_px() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ) + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ) + }), + ) + } + }) + .children(tool_output_display) + } - let is_collapsible = !tool_call.content.is_empty() && !use_card_layout; + fn render_tool_call_label( + &self, + entry_ix: usize, + tool_call: &ToolCall, + is_edit: bool, + use_card_layout: bool, + window: &Window, + cx: &Context, + ) -> Div { + let has_location = tool_call.locations.len() == 1; - let is_open = tool_call.content.is_empty() - || needs_confirmation - || has_nonempty_diff - || self.expanded_tool_calls.contains(&tool_call.id); + let tool_icon = if tool_call.kind == acp::ToolKind::Edit && has_location { + FileIcons::get_icon(&tool_call.locations[0].path, cx) + .map(Icon::from_path) + .unwrap_or(Icon::new(IconName::ToolPencil)) + } else { + Icon::new(match tool_call.kind { + acp::ToolKind::Read => IconName::ToolSearch, + acp::ToolKind::Edit => IconName::ToolPencil, + acp::ToolKind::Delete => IconName::ToolDeleteFile, + acp::ToolKind::Move => IconName::ArrowRightLeft, + acp::ToolKind::Search => IconName::ToolSearch, + acp::ToolKind::Execute => IconName::ToolTerminal, + acp::ToolKind::Think => IconName::ToolThink, + acp::ToolKind::Fetch => IconName::ToolWeb, + acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, + acp::ToolKind::Other => IconName::ToolHammer, + }) + } + .size(IconSize::Small) + .color(Color::Muted); - let gradient_overlay = |color: Hsla| { + let gradient_overlay = { div() .absolute() .top_0() .right_0() .w_12() .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(color, 1.), - linear_color_stop(color.opacity(0.2), 0.), - )) - }; - let gradient_color = if use_card_layout { - self.tool_card_header_bg(cx) - } else { - cx.theme().colors().panel_background - }; - - let tool_output_display = match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() - .w_full() - .children(tool_call.content.iter().map(|content| { - div() - .child( - self.render_tool_call_content(entry_ix, content, tool_call, window, cx), - ) - .into_any_element() - })) - .child(self.render_permission_buttons( - options, - entry_ix, - tool_call.id.clone(), - tool_call.content.is_empty(), - cx, - )), - ToolCallStatus::Pending - | ToolCallStatus::InProgress - | ToolCallStatus::Completed - | ToolCallStatus::Failed - | ToolCallStatus::Canceled => { - v_flex() - .w_full() - .children(tool_call.content.iter().map(|content| { - div() - .child( - self.render_tool_call_content( - entry_ix, content, tool_call, window, cx, - ), - ) - .into_any_element() - })) - } - ToolCallStatus::Rejected => v_flex().size_0(), + .map(|this| { + if use_card_layout { + this.bg(linear_gradient( + 90., + linear_color_stop(self.tool_card_header_bg(cx), 1.), + linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), + )) + } else { + this.bg(linear_gradient( + 90., + linear_color_stop(cx.theme().colors().panel_background, 1.), + linear_color_stop( + cx.theme().colors().panel_background.opacity(0.2), + 0., + ), + )) + } + }) }; - v_flex() - .when(use_card_layout, |this| { - this.rounded_lg() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() + h_flex() + .relative() + .w_full() + .h(window.line_height() - px(2.)) + .text_size(self.tool_name_font_size()) + .gap_1p5() + .when(has_location || use_card_layout, |this| this.px_1()) + .when(has_location, |this| { + this.cursor(CursorStyle::PointingHand) + .rounded(rems_from_px(3.)) // Concentric border radius + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) }) - .child( + .overflow_hidden() + .child(tool_icon) + .child(if has_location { h_flex() - .id(header_id) + .id(("open-tool-call-location", entry_ix)) .w_full() - .gap_1() - .justify_between() .map(|this| { if use_card_layout { - this.pl_2() - .pr_1() - .py_1() - .rounded_t_md() - .bg(self.tool_card_header_bg(cx)) + this.text_color(cx.theme().colors().text) } else { - this.opacity(0.8).hover(|style| style.opacity(1.)) + this.text_color(cx.theme().colors().text_muted) } }) - .child( - h_flex() - .group(&card_header_id) - .relative() - .w_full() - .text_size(self.tool_name_font_size()) - .child(self.render_tool_call_icon( - card_header_id, - entry_ix, - is_collapsible, - is_open, - tool_call, - cx, - )) - .child(if tool_call.locations.len() == 1 { - let name = tool_call.locations[0] - .path - .file_name() - .unwrap_or_default() - .display() - .to_string(); - - h_flex() - .id(("open-tool-call-location", entry_ix)) - .w_full() - .max_w_full() - .px_1p5() - .rounded_sm() - .overflow_x_scroll() - .opacity(0.8) - .hover(|label| { - label.opacity(1.).bg(cx - .theme() - .colors() - .element_hover - .opacity(0.5)) - }) - .child(name) - .tooltip(Tooltip::text("Jump to File")) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tool_call_location(entry_ix, 0, window, cx); - })) - .into_any_element() - } else { - h_flex() - .id("non-card-label-container") - .w_full() - .relative() - .ml_1p5() - .overflow_hidden() - .child( - h_flex() - .id("non-card-label") - .pr_8() - .w_full() - .overflow_x_scroll() - .child(self.render_markdown( - tool_call.label.clone(), - default_markdown_style( - needs_confirmation || is_edit || has_diff, - window, - cx, - ), - )), - ) - .child(gradient_overlay(gradient_color)) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })) - .into_any() - }), - ) - .children(status_icon), - ) - .when(is_open, |this| this.child(tool_output_display)) + .child(self.render_markdown( + tool_call.label.clone(), + MarkdownStyle { + prevent_mouse_interaction: true, + ..default_markdown_style(false, true, window, cx) + }, + )) + .tooltip(Tooltip::text("Jump to File")) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tool_call_location(entry_ix, 0, window, cx); + })) + .into_any_element() + } else { + h_flex() + .w_full() + .child(self.render_markdown( + tool_call.label.clone(), + default_markdown_style(false, true, window, cx), + )) + .into_any() + }) + .when(!is_edit, |this| this.child(gradient_overlay)) } fn render_tool_call_content( &self, entry_ix: usize, content: &ToolCallContent, + context_ix: usize, tool_call: &ToolCall, + card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { @@ -1276,12 +2427,19 @@ impl AcpThreadView { if let Some(resource_link) = content.resource_link() { self.render_resource_link(resource_link, cx) } else if let Some(markdown) = content.markdown() { - self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx) + self.render_markdown_output( + markdown.clone(), + tool_call.id.clone(), + context_ix, + card_layout, + window, + cx, + ) } else { Empty.into_any_element() } } - ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, &diff, cx), + ToolCallContent::Diff(diff) => self.render_diff_editor(entry_ix, diff, tool_call, cx), ToolCallContent::Terminal(terminal) => { self.render_terminal_tool_call(entry_ix, terminal, tool_call, window, cx) } @@ -1292,37 +2450,46 @@ impl AcpThreadView { &self, markdown: Entity, tool_call_id: acp::ToolCallId, + context_ix: usize, + card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { - let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id.clone())); + let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); v_flex() .mt_1p5() - .ml(px(7.)) - .px_3p5() .gap_2() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .text_sm() + .when(!card_layout, |this| { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + }) + .when(card_layout, |this| { + this.px_2().pb_2().when(context_ix > 0, |this| { + this.border_t_1() + .pt_2() + .border_color(self.tool_card_border_color(cx)) + }) + }) + .text_xs() .text_color(cx.theme().colors().text_muted) - .child(self.render_markdown(markdown, default_markdown_style(false, window, cx))) - .child( - Button::new(button_id, "Collapse Output") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::ChevronUp) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener({ - let id = tool_call_id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - this.expanded_tool_calls.remove(&id); - cx.notify(); - } - })), - ) + .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx))) + .when(!card_layout, |this| { + this.child( + IconButton::new(button_id, IconName::ChevronUp) + .full_width() + .style(ButtonStyle::Outlined) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |this: &mut Self, _, _, cx: &mut Context| { + this.expanded_tool_calls.remove(&tool_call_id); + cx.notify(); + } + })), + ) + }) .into_any_element() } @@ -1332,17 +2499,35 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { let uri: SharedString = resource_link.uri.clone().into(); + let is_file = resource_link.uri.strip_prefix("file://"); - let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") { - path.to_string().into() + let label: SharedString = if let Some(abs_path) = is_file { + if let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&Path::new(abs_path), cx) + && let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + { + worktree + .read(cx) + .full_path(&project_path.path) + .to_string_lossy() + .to_string() + .into() + } else { + abs_path.to_string().into() + } } else { uri.clone() }; - let button_id = SharedString::from(format!("item-{}", uri.clone())); + let button_id = SharedString::from(format!("item-{}", uri)); div() - .ml(px(7.)) + .ml(rems(0.4)) .pl_2p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) @@ -1351,10 +2536,12 @@ impl AcpThreadView { Button::new(button_id, label) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) .truncate(true) + .when(is_file.is_none(), |this| { + this.icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + }) .on_click(cx.listener({ let workspace = self.workspace.clone(); move |_, _, window, cx: &mut Context| { @@ -1367,44 +2554,69 @@ impl AcpThreadView { fn render_permission_buttons( &self, + kind: acp::ToolKind, options: &[acp::PermissionOption], entry_ix: usize, - tool_call_id: acp::ToolCallId, - empty_content: bool, - cx: &Context, - ) -> Div { - h_flex() - .py_1() - .pl_2() - .pr_1() - .gap_1() - .justify_between() - .flex_wrap() - .when(!empty_content, |this| { - this.border_t_1() - .border_color(self.tool_card_border_color(cx)) + tool_call_id: acp::ToolCallId, + cx: &Context, + ) -> Div { + let is_first = self.thread().is_some_and(|thread| { + thread + .read(cx) + .first_tool_awaiting_confirmation() + .is_some_and(|call| call.id == tool_call_id) + }); + let mut seen_kinds: ArrayVec = ArrayVec::new(); + + div() + .p_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .w_full() + .map(|this| { + if kind == acp::ToolKind::SwitchMode { + this.v_flex() + } else { + this.h_flex().justify_end().flex_wrap() + } }) - .child( - div() - .min_w(rems_from_px(145.)) - .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)), - ) - .child(h_flex().gap_0p5().children(options.iter().map(|option| { + .gap_0p5() + .children(options.iter().map(move |option| { let option_id = SharedString::from(option.id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) - .map(|this| match option.kind { - acp::PermissionOptionKind::AllowOnce => { - this.icon(IconName::Check).icon_color(Color::Success) - } - acp::PermissionOptionKind::AllowAlways => { - this.icon(IconName::CheckDouble).icon_color(Color::Success) - } - acp::PermissionOptionKind::RejectOnce => { - this.icon(IconName::Close).icon_color(Color::Error) - } - acp::PermissionOptionKind::RejectAlways => { - this.icon(IconName::Close).icon_color(Color::Error) + .map(|this| { + let (this, action) = match option.kind { + acp::PermissionOptionKind::AllowOnce => ( + this.icon(IconName::Check).icon_color(Color::Success), + Some(&AllowOnce as &dyn Action), + ), + acp::PermissionOptionKind::AllowAlways => ( + this.icon(IconName::CheckDouble).icon_color(Color::Success), + Some(&AllowAlways as &dyn Action), + ), + acp::PermissionOptionKind::RejectOnce => ( + this.icon(IconName::Close).icon_color(Color::Error), + Some(&RejectOnce as &dyn Action), + ), + acp::PermissionOptionKind::RejectAlways => { + (this.icon(IconName::Close).icon_color(Color::Error), None) + } + }; + + let Some(action) = action else { + return this; + }; + + if !is_first || seen_kinds.contains(&option.kind) { + return this; } + + seen_kinds.push(option.kind); + + this.key_binding( + KeyBinding::for_action_in(action, &self.focus_handle, cx) + .map(|kb| kb.size(rems_from_px(10.))), + ) }) .icon_position(IconPosition::Start) .icon_size(IconSize::XSmall) @@ -1413,33 +2625,84 @@ impl AcpThreadView { let tool_call_id = tool_call_id.clone(); let option_id = option.id.clone(); let option_kind = option.kind; - move |this, _, _, cx| { + move |this, _, window, cx| { this.authorize_tool_call( tool_call_id.clone(), option_id.clone(), option_kind, + window, cx, ); } })) - }))) + })) + } + + fn render_diff_loading(&self, cx: &Context) -> AnyElement { + let bar = |n: u64, width_class: &str| { + let bg_color = cx.theme().colors().element_active; + let base = h_flex().h_1().rounded_full(); + + let modified = match width_class { + "w_4_5" => base.w_3_4(), + "w_1_4" => base.w_1_4(), + "w_2_4" => base.w_2_4(), + "w_3_5" => base.w_3_5(), + "w_2_5" => base.w_2_5(), + _ => base.w_1_2(), + }; + + modified.with_animation( + ElementId::Integer(n), + Animation::new(Duration::from_secs(2)).repeat(), + move |tab, delta| { + let delta = (delta - 0.15 * n as f32) / 0.7; + let delta = 1.0 - (0.5 - delta).abs() * 2.; + let delta = ease_in_out(delta.clamp(0., 1.)); + let delta = 0.1 + 0.9 * delta; + + tab.bg(bg_color.opacity(delta)) + }, + ) + }; + + v_flex() + .p_3() + .gap_1() + .rounded_b_md() + .bg(cx.theme().colors().editor_background) + .child(bar(0, "w_4_5")) + .child(bar(1, "w_1_4")) + .child(bar(2, "w_2_4")) + .child(bar(3, "w_3_5")) + .child(bar(4, "w_2_5")) + .into_any_element() } fn render_diff_editor( &self, entry_ix: usize, diff: &Entity, + tool_call: &ToolCall, cx: &Context, ) -> AnyElement { + let tool_progress = matches!( + &tool_call.status, + ToolCallStatus::InProgress | ToolCallStatus::Pending + ); + v_flex() .h_full() .border_t_1() .border_color(self.tool_card_border_color(cx)) .child( if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix) - && let Some(editor) = entry.editor_for_diff(&diff) + && let Some(editor) = entry.editor_for_diff(diff) + && diff.read(cx).has_revealed_range(cx) { - editor.clone().into_any_element() + editor.into_any_element() + } else if tool_progress && self.as_native_connection(cx).is_some() { + self.render_diff_loading(cx) } else { Empty.into_any() }, @@ -1467,11 +2730,12 @@ impl AcpThreadView { let output = terminal_data.output(); let command_finished = output.is_some(); - let truncated_output = output.is_some_and(|output| output.was_content_truncated); + let truncated_output = + output.is_some_and(|output| output.original_content_len > output.content.len()); let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); let command_failed = command_finished - && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success())); + && output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success())); let time_elapsed = if let Some(output) = output { output.ended_at.duration_since(started_at) @@ -1479,6 +2743,12 @@ impl AcpThreadView { started_at.elapsed() }; + let header_id = + SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); + let header_group = SharedString::from(format!( + "terminal-tool-header-group-{}", + terminal.entity_id() + )); let header_bg = cx .theme() .colors() @@ -1488,14 +2758,13 @@ impl AcpThreadView { let working_dir = working_dir .as_ref() - .map(|path| format!("{}", path.display())) + .map(|path| path.display().to_string()) .unwrap_or_else(|| "current directory".to_string()); + let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); + let header = h_flex() - .id(SharedString::from(format!( - "terminal-tool-header-{}", - terminal.entity_id() - ))) + .id(header_id) .flex_none() .gap_1() .justify_between() @@ -1526,12 +2795,11 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Error) .label_size(LabelSize::Small) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( "Stop This Command", None, "Also possible by placing your cursor inside the terminal and using regular terminal bindings.", - window, cx, ) }) @@ -1550,43 +2818,20 @@ impl AcpThreadView { Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ), + .with_rotate_animation(2) ) }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) - }) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - "Output exceeded terminal max lines and was \ - truncated, the model received the first 16 KB." - .to_string() + format!("Output exceeded terminal max lines and was \ + truncated, the model received the first {}.", format_file_size(output.content.len() as u64, true)) } else { format!( - "Output is {} long—to avoid unexpected token usage, \ - only 16 KB was sent back to the model.", + "Output is {} long, and to avoid unexpected token usage, \ + only {} was sent back to the agent.", format_file_size(output.original_content_len as u64, true), + format_file_size(output.content.len() as u64, true) ) } } else { @@ -1618,18 +2863,43 @@ impl AcpThreadView { .size(LabelSize::XSmall), ) }) + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", terminal.entity_id())) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when_some(output.and_then(|o| o.exit_status), |this, status| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + status.code().unwrap_or(-1), + ))) + }), + ) + }) .child( Disclosure::new( SharedString::from(format!( "terminal-tool-disclosure-{}", terminal.entity_id() )), - self.terminal_expanded, + is_expanded, ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) - .on_click(cx.listener(move |this, _event, _window, _cx| { - this.terminal_expanded = !this.terminal_expanded; + .visible_on_hover(&header_group) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, _window, _cx| { + if is_expanded { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + } })), ); @@ -1637,21 +2907,23 @@ impl AcpThreadView { .entry_view_state .read(cx) .entry(entry_ix) - .and_then(|entry| entry.terminal(&terminal)); - let show_output = self.terminal_expanded && terminal_view.is_some(); + .and_then(|entry| entry.terminal(terminal)); + let show_output = is_expanded && terminal_view.is_some(); v_flex() - .mb_2() + .my_1p5() + .mx_5() .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) - .rounded_lg() + .rounded_md() .overflow_hidden() .child( v_flex() + .group(&header_group) .py_1p5() - .pl_2() .pr_1p5() + .pl_2() .gap_0p5() .bg(header_bg) .text_xs() @@ -1680,188 +2952,458 @@ impl AcpThreadView { .bg(cx.theme().colors().editor_background) .rounded_b_md() .text_ui_sm(cx) - .children(terminal_view.clone()), + .h_full() + .children(terminal_view.map(|terminal_view| { + if terminal_view + .read(cx) + .content_mode(window, cx) + .is_scrollable() + { + div().h_72().child(terminal_view).into_any_element() + } else { + terminal_view.into_any_element() + } + })), ) }) .into_any() } - fn render_agent_logo(&self) -> AnyElement { - Icon::new(self.agent.logo()) - .color(Color::Muted) - .size(IconSize::XLarge) - .into_any_element() - } + fn render_rules_item(&self, cx: &Context) -> Option { + let project_context = self + .as_native_thread(cx)? + .read(cx) + .project_context() + .read(cx); - fn render_error_agent_logo(&self) -> AnyElement { - let logo = Icon::new(self.agent.logo()) - .color(Color::Muted) - .size(IconSize::XLarge) - .into_any_element(); + let user_rules_text = if project_context.user_rules.is_empty() { + None + } else if project_context.user_rules.len() == 1 { + let user_rules = &project_context.user_rules[0]; - h_flex() - .relative() - .justify_center() - .child(div().opacity(0.3).child(logo)) - .child( - h_flex().absolute().right_1().bottom_0().child( - Icon::new(IconName::XCircle) - .color(Color::Error) - .size(IconSize::Small), - ), - ) - .into_any_element() + match user_rules.title.as_ref() { + Some(title) => Some(format!("Using \"{title}\" user rule")), + None => Some("Using user rule".into()), + } + } else { + Some(format!( + "Using {} user rules", + project_context.user_rules.len() + )) + }; + + let first_user_rules_id = project_context + .user_rules + .first() + .map(|user_rules| user_rules.uuid.0); + + let rules_files = project_context + .worktrees + .iter() + .filter_map(|worktree| worktree.rules_file.as_ref()) + .collect::>(); + + let rules_file_text = match rules_files.as_slice() { + &[] => None, + &[rules_file] => Some(format!( + "Using project {:?} file", + rules_file.path_in_worktree + )), + rules_files => Some(format!("Using {} project rules files", rules_files.len())), + }; + + if user_rules_text.is_none() && rules_file_text.is_none() { + return None; + } + + let has_both = user_rules_text.is_some() && rules_file_text.is_some(); + + Some( + h_flex() + .px_2p5() + .child( + Icon::new(IconName::Attach) + .size(IconSize::XSmall) + .color(Color::Disabled), + ) + .when_some(user_rules_text, |parent, user_rules_text| { + parent.child( + h_flex() + .id("user-rules") + .ml_1() + .mr_1p5() + .child( + Label::new(user_rules_text) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View User Rules")) + .on_click(move |_event, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ) + }), + ) + }) + .when(has_both, |this| { + this.child( + Label::new("•") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + }) + .when_some(rules_file_text, |parent, rules_file_text| { + parent.child( + h_flex() + .id("project-rules") + .ml_1p5() + .child( + Label::new(rules_file_text) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .tooltip(Tooltip::text("View Project Rules")) + .on_click(cx.listener(Self::handle_open_rules)), + ) + }) + .into_any(), + ) + } + + fn render_empty_state_section_header( + &self, + label: impl Into, + action_slot: Option, + cx: &mut Context, + ) -> impl IntoElement { + div().pl_1().pr_1p5().child( + h_flex() + .mt_2() + .pl_1p5() + .pb_1() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.into()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(action_slot), + ) } - fn render_empty_state(&self, cx: &App) -> AnyElement { - let loading = matches!(&self.thread_state, ThreadState::Loading { .. }); + fn render_recent_history(&self, cx: &mut Context) -> AnyElement { + let render_history = self + .agent + .clone() + .downcast::() + .is_some() + && self + .history_store + .update(cx, |history_store, cx| !history_store.is_empty(cx)); v_flex() .size_full() - .items_center() - .justify_center() - .child(if loading { - h_flex() - .justify_center() - .child(self.render_agent_logo()) - .with_animation( - "pulsating_icon", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 1.0)), - |icon, delta| icon.opacity(delta), - ) - .into_any() - } else { - self.render_agent_logo().into_any_element() + .when(render_history, |this| { + let recent_history: Vec<_> = self.history_store.update(cx, |history_store, _| { + history_store.entries().take(3).collect() + }); + this.justify_end().child( + v_flex() + .child( + self.render_empty_state_section_header( + "Recent", + Some( + Button::new("view-history", "View All") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_event, window, cx| { + window.dispatch_action(OpenHistory.boxed_clone(), cx); + }) + .into_any_element(), + ), + cx, + ), + ) + .child( + v_flex().p_1().pr_1p5().gap_1().children( + recent_history + .into_iter() + .enumerate() + .map(|(index, entry)| { + // TODO: Add keyboard navigation. + let is_hovered = + self.hovered_recent_history_item == Some(index); + crate::acp::thread_history::AcpHistoryEntryElement::new( + entry, + cx.entity().downgrade(), + ) + .hovered(is_hovered) + .on_hover(cx.listener( + move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_recent_history_item = Some(index); + } else if this.hovered_recent_history_item + == Some(index) + { + this.hovered_recent_history_item = None; + } + cx.notify(); + }, + )) + .into_any_element() + }), + ), + ), + ) }) - .child(h_flex().mt_4().mb_1().justify_center().child(if loading { - div() - .child(LoadingLabel::new("").size(LabelSize::Large)) - .into_any_element() - } else { - Headline::new(self.agent.empty_state_headline()) - .size(HeadlineSize::Medium) - .into_any_element() - })) - .child( - div() - .max_w_1_2() - .text_sm() - .text_center() - .map(|this| { - if loading { - this.invisible() - } else { - this.text_color(cx.theme().colors().text_muted) - } - }) - .child(self.agent.empty_state_message()), - ) .into_any() } - fn render_pending_auth_state(&self) -> AnyElement { - v_flex() - .items_center() - .justify_center() - .child(self.render_error_agent_logo()) - .child( - h_flex() - .mt_4() - .mb_1() - .justify_center() - .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)), - ) - .into_any() + fn render_auth_required_state( + &self, + connection: &Rc, + description: Option<&Entity>, + configuration_view: Option<&AnyView>, + pending_auth_method: Option<&acp::AuthMethodId>, + window: &mut Window, + cx: &Context, + ) -> Div { + let show_description = + configuration_view.is_none() && description.is_none() && pending_auth_method.is_none(); + + let auth_methods = connection.auth_methods(); + + v_flex().flex_1().size_full().justify_end().child( + v_flex() + .p_2() + .pr_3() + .w_full() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().status().warning.opacity(0.04)) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ) + .child(Label::new("Authentication Required").size(LabelSize::Small)), + ) + .children(description.map(|desc| { + div().text_ui(cx).child(self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + )) + })) + .children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .when(show_description, |el| { + el.child( + Label::new(format!( + "You are not currently authenticated with {}.{}", + self.agent.name(), + if auth_methods.len() > 1 { + " Please choose one of the following options:" + } else { + "" + } + )) + .size(LabelSize::Small) + .color(Color::Muted) + .mb_1() + .ml_5(), + ) + }) + .when_some(pending_auth_method, |el, _| { + el.child( + h_flex() + .py_4() + .w_full() + .justify_center() + .gap_1() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child(Label::new("Authenticating…").size(LabelSize::Small)), + ) + }) + .when(!auth_methods.is_empty(), |this| { + this.child( + h_flex() + .justify_end() + .flex_wrap() + .gap_1() + .when(!show_description, |this| { + this.border_t_1() + .mt_1() + .pt_2() + .border_color(cx.theme().colors().border.opacity(0.8)) + }) + .children(connection.auth_methods().iter().enumerate().rev().map( + |(ix, method)| { + let (method_id, name) = if self + .project + .read(cx) + .is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; + + Button::new(SharedString::from(method_id.clone()), name) + .label_size(LabelSize::Small) + .map(|this| { + if ix == 0 { + this.style(ButtonStyle::Tinted(TintColor::Warning)) + } else { + this.style(ButtonStyle::Outlined) + } + }) + .when_some( + method.description.clone(), + |this, description| { + this.tooltip(Tooltip::text(description)) + }, + ) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = this.agent.telemetry_id(), + method = method_id + ); + + this.authenticate( + acp::AuthMethodId(method_id.clone()), + window, + cx, + ) + }) + }) + }, + )), + ) + }), + ) + } + + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (title, message, action_slot): (_, SharedString, _) = match e { + LoadError::Unsupported { + command: path, + current_version, + minimum_version, + } => { + return self.render_unsupported(path, current_version, minimum_version, window, cx); + } + LoadError::FailedToInstall(msg) => ( + "Failed to Install", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + LoadError::Exited { status } => ( + "Failed to Launch", + format!("Server exited with status {status}").into(), + None, + ), + LoadError::Other(msg) => ( + "Failed to Launch", + msg.into(), + Some(self.create_copy_button(msg.to_string()).into_any_element()), + ), + }; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircleFilled) + .title(title) + .description(message) + .actions_slot(div().children(action_slot)) + .into_any_element() } - fn render_server_exited(&self, status: ExitStatus, _cx: &Context) -> AnyElement { + fn render_unsupported( + &self, + path: &SharedString, + version: &SharedString, + minimum_version: &SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (heading_label, description_label) = ( + format!("Upgrade {} to work with Zed", self.agent.name()), + if version.is_empty() { + format!( + "Currently using {}, which does not report a valid --version", + path, + ) + } else { + format!( + "Currently using {}, which is only version {} (need at least {minimum_version})", + path, version + ) + }, + ); + v_flex() - .items_center() - .justify_center() - .child(self.render_error_agent_logo()) + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) .child( - v_flex() - .mt_4() - .mb_2() - .gap_0p5() - .text_center() - .items_center() - .child(Headline::new("Server exited unexpectedly").size(HeadlineSize::Medium)) - .child( - Label::new(format!("Exit status: {}", status.code().unwrap_or(-127))) - .size(LabelSize::Small) - .color(Color::Muted), - ), + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), ) .into_any_element() } - fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { - let mut container = v_flex() - .items_center() - .justify_center() - .child(self.render_error_agent_logo()) - .child( - v_flex() - .mt_4() - .mb_2() - .gap_0p5() - .text_center() - .items_center() - .child(Headline::new("Failed to launch").size(HeadlineSize::Medium)) - .child( - Label::new(e.to_string()) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ); - - if let LoadError::Unsupported { - upgrade_message, - upgrade_command, - .. - } = &e - { - let upgrade_message = upgrade_message.clone(); - let upgrade_command = upgrade_command.clone(); - container = container.child(Button::new("upgrade", upgrade_message).on_click( - cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("install".to_string()), - full_label: upgrade_command.clone(), - label: upgrade_command.clone(), - command: Some(upgrade_command.clone()), - args: Vec::new(), - command_label: upgrade_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace - .spawn_in_terminal(spawn_in_terminal, window, cx) - .detach(); - }) - .ok(); - }), - )); - } - - container.into_any() + fn activity_bar_bg(&self, cx: &Context) -> Hsla { + let editor_bg_color = cx.theme().colors().editor_background; + let active_color = cx.theme().colors().element_selected; + editor_bg_color.blend(active_color.opacity(0.3)) } fn render_activity_bar( @@ -1879,16 +3421,17 @@ impl AcpThreadView { return None; } - let editor_bg_color = cx.theme().colors().editor_background; - let active_color = cx.theme().colors().element_selected; - let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); - - let pending_edits = thread.has_pending_edit_tool_calls(); + // Temporarily always enable ACP edit controls. This is temporary, to lessen the + // impact of a nasty bug that causes them to sometimes be disabled when they shouldn't + // be, which blocks you from being able to accept or reject edits. This switches the + // bug to be that sometimes it's enabled when it shouldn't be, which at least doesn't + // block you from using the panel. + let pending_edits = false; v_flex() .mt_1() .mx_2() - .bg(bg_edit_files_disclosure) + .bg(self.activity_bar_bg(cx)) .border_1() .border_b_0() .border_color(cx.theme().colors().border) @@ -1910,11 +3453,9 @@ impl AcpThreadView { }) .when(!changed_buffers.is_empty(), |this| { this.child(self.render_edits_summary( - action_log, &changed_buffers, self.edits_expanded, pending_edits, - window, cx, )) .when(self.edits_expanded, |parent| { @@ -1930,27 +3471,33 @@ impl AcpThreadView { .into() } - fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div { + fn render_plan_summary( + &self, + plan: &Plan, + window: &mut Window, + cx: &Context, + ) -> impl IntoElement { let stats = plan.stats(); let title = if let Some(entry) = stats.in_progress_entry && !self.plan_expanded { h_flex() - .w_full() .cursor_default() + .relative() + .w_full() .gap_1() - .text_xs() - .text_color(cx.theme().colors().text_muted) - .justify_between() + .truncate() .child( - h_flex() - .gap_1() - .child( - Label::new("Current:") - .size(LabelSize::Small) - .color(Color::Muted), - ) + Label::new("Current:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .line_clamp(1) .child(MarkdownElement::new( entry.content.clone(), plan_label_markdown_style(&entry.status, window, cx), @@ -1958,10 +3505,23 @@ impl AcpThreadView { ) .when(stats.pending > 0, |this| { this.child( - Label::new(format!("{} left", stats.pending)) - .size(LabelSize::Small) - .color(Color::Muted) - .mr_1(), + h_flex() + .absolute() + .top_0() + .right_0() + .h_full() + .child(div().min_w_8().h_full().bg(linear_gradient( + 90., + linear_color_stop(self.activity_bar_bg(cx), 1.), + linear_color_stop(self.activity_bar_bg(cx).opacity(0.2), 0.), + ))) + .child( + div().pr_0p5().bg(self.activity_bar_bg(cx)).child( + Label::new(format!("{} left", stats.pending)) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), ) }) } else { @@ -1991,23 +3551,19 @@ impl AcpThreadView { }; h_flex() + .id("plan_summary") .p_1() - .justify_between() + .w_full() + .gap_1() .when(self.plan_expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) - .child( - h_flex() - .id("plan_summary") - .w_full() - .gap_1() - .child(Disclosure::new("plan_disclosure", self.plan_expanded)) - .child(title) - .on_click(cx.listener(|this, _, _, cx| { - this.plan_expanded = !this.plan_expanded; - cx.notify(); - })), - ) + .child(Disclosure::new("plan_disclosure", self.plan_expanded)) + .child(title) + .on_click(cx.listener(|this, _, _, cx| { + this.plan_expanded = !this.plan_expanded; + cx.notify(); + })) } fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div { @@ -2037,13 +3593,7 @@ impl AcpThreadView { acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress) .size(IconSize::Small) .color(Color::Accent) - .with_animation( - "running", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ) + .with_rotate_animation(2) .into_any_element(), acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete) .size(IconSize::Small) @@ -2062,11 +3612,9 @@ impl AcpThreadView { fn render_edits_summary( &self, - action_log: &Entity, changed_buffers: &BTreeMap, Entity>, expanded: bool, pending_edits: bool, - window: &mut Window, cx: &Context, ) -> Div { const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; @@ -2076,13 +3624,13 @@ impl AcpThreadView { h_flex() .p_1() .justify_between() + .flex_wrap() .when(expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .id("edits-container") - .w_full() .gap_1() .child(Disclosure::new("edits-disclosure", expanded)) .map(|this| { @@ -2142,12 +3690,11 @@ impl AcpThreadView { .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Review Changes", &OpenAgentDiff, &focus_handle, - window, cx, ) } @@ -2165,22 +3712,12 @@ impl AcpThreadView { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( - KeyBinding::for_action_in( - &RejectAll, - &focus_handle.clone(), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), + KeyBinding::for_action_in(&RejectAll, &focus_handle.clone(), cx) + .map(|kb| kb.size(rems_from_px(10.))), ) - .on_click({ - let action_log = action_log.clone(); - cx.listener(move |_, _, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log.reject_all_edits(cx).detach(); - }) - }) - }), + .on_click(cx.listener(move |this, _, window, cx| { + this.reject_all(&RejectAll, window, cx); + })), ) .child( Button::new("keep-all-changes", "Keep All") @@ -2190,17 +3727,12 @@ impl AcpThreadView { this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) }) .key_binding( - KeyBinding::for_action_in(&KeepAll, &focus_handle, window, cx) + KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(10.))), ) - .on_click({ - let action_log = action_log.clone(); - cx.listener(move |_, _, _, cx| { - action_log.update(cx, |action_log, cx| { - action_log.keep_all_edits(cx); - }) - }) - }), + .on_click(cx.listener(move |this, _, window, cx| { + this.keep_all(&KeepAll, window, cx); + })), ), ) } @@ -2214,19 +3746,19 @@ impl AcpThreadView { ) -> Div { let editor_bg_color = cx.theme().colors().editor_background; - v_flex().children(changed_buffers.into_iter().enumerate().flat_map( + v_flex().children(changed_buffers.iter().enumerate().flat_map( |(index, (buffer, _diff))| { let file = buffer.read(cx).file()?; let path = file.path(); + let path_style = file.path_style(cx); + let separator = file.path_style(cx).separator(); let file_path = path.parent().and_then(|parent| { - let parent_str = parent.to_string_lossy(); - - if parent_str.is_empty() { + if parent.is_empty() { None } else { Some( - Label::new(format!("/{}{}", parent_str, std::path::MAIN_SEPARATOR_STR)) + Label::new(format!("{}{separator}", parent.display(path_style))) .color(Color::Muted) .size(LabelSize::XSmall) .buffer_font(cx), @@ -2235,12 +3767,12 @@ impl AcpThreadView { }); let file_name = path.file_name().map(|name| { - Label::new(name.to_string_lossy().to_string()) + Label::new(name.to_string()) .size(LabelSize::XSmall) .buffer_font(cx) }); - let file_icon = FileIcons::get_icon(&path, cx) + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) .map(Icon::from_path) .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) .unwrap_or_else(|| { @@ -2258,7 +3790,6 @@ impl AcpThreadView { let element = h_flex() .group("edited-code") .id(("file-container", index)) - .relative() .py_1() .pl_2() .pr_1() @@ -2270,13 +3801,24 @@ impl AcpThreadView { }) .child( h_flex() + .relative() .id(("file-name", index)) .pr_8() .gap_1p5() - .max_w_full() + .w_full() .overflow_x_scroll() .child(file_icon) .child(h_flex().gap_0p5().children(file_name).children(file_path)) + .child( + div() + .absolute() + .h_full() + .w_12() + .top_0() + .bottom_0() + .right_0() + .bg(overlay_gradient), + ) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { @@ -2337,17 +3879,6 @@ impl AcpThreadView { } }), ), - ) - .child( - div() - .id("gradient-overlay") - .absolute() - .h_full() - .w_12() - .top_0() - .bottom_0() - .right(px(152.)) - .bg(overlay_gradient), ); Some(element) @@ -2364,11 +3895,33 @@ impl AcpThreadView { (IconName::Maximize, "Expand Message Editor") }; + let backdrop = div() + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll(); + + let enable_editor = match self.thread_state { + ThreadState::Loading { .. } | ThreadState::Ready { .. } => true, + ThreadState::Unauthenticated { .. } | ThreadState::LoadError(..) => false, + }; + v_flex() .on_action(cx.listener(Self::expand_message_editor)) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { if let Some(profile_selector) = this.profile_selector.as_ref() { profile_selector.read(cx).menu_handle().toggle(window, cx); + } else if let Some(mode_selector) = this.mode_selector() { + mode_selector.read(cx).menu_handle().toggle(window, cx); + } + })) + .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if let Some(mode_selector) = this.mode_selector() { + mode_selector.update(cx, |mode_selector, cx| { + mode_selector.cycle_mode(window, cx); + }); } })) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { @@ -2404,13 +3957,11 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( expand_tooltip, &ExpandMessageEditor, &focus_handle, - window, cx, ) } @@ -2424,35 +3975,89 @@ impl AcpThreadView { .child( h_flex() .flex_none() + .flex_wrap() .justify_between() .child( h_flex() - .gap_1() .child(self.render_follow_toggle(cx)) .children(self.render_burn_mode_toggle(cx)), ) .child( h_flex() .gap_1() + .children(self.render_token_usage(cx)) .children(self.profile_selector.clone()) + .children(self.mode_selector().cloned()) .children(self.model_selector.clone()) .child(self.render_send_button(cx)), ), ) + .when(!enable_editor, |this| this.child(backdrop)) .into_any() } - fn as_native_connection(&self, cx: &App) -> Option> { + pub(crate) fn as_native_connection( + &self, + cx: &App, + ) -> Option> { let acp_thread = self.thread()?.read(cx); acp_thread.connection().clone().downcast() } - fn as_native_thread(&self, cx: &App) -> Option> { + pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { let acp_thread = self.thread()?.read(cx); self.as_native_connection(cx)? .thread(acp_thread.session_id(), cx) } + fn is_using_zed_ai_models(&self, cx: &App) -> bool { + self.as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .is_some_and(|model| model.provider_id() == language_model::ZED_CLOUD_PROVIDER_ID) + } + + fn render_token_usage(&self, cx: &mut Context) -> Option
{ + let thread = self.thread()?.read(cx); + let usage = thread.token_usage()?; + let is_generating = thread.status() != ThreadStatus::Idle; + + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + + Some( + h_flex() + .flex_shrink_0() + .gap_0p5() + .mr_1p5() + .child( + Label::new(used) + .size(LabelSize::Small) + .color(Color::Muted) + .map(|label| { + if is_generating { + label + .with_animation( + "used-tokens-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + label.into_any_element() + } + }), + ) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))), + ) + .child(Label::new(max).size(LabelSize::Small).color(Color::Muted)), + ) + } + fn toggle_burn_mode( &mut self, _: &ToggleBurnMode, @@ -2463,19 +4068,79 @@ impl AcpThreadView { return; }; - thread.update(cx, |thread, _cx| { + thread.update(cx, |thread, cx| { let current_mode = thread.completion_mode(); - thread.set_completion_mode(match current_mode { - CompletionMode::Burn => CompletionMode::Normal, - CompletionMode::Normal => CompletionMode::Burn, - }); + thread.set_completion_mode( + match current_mode { + CompletionMode::Burn => CompletionMode::Normal, + CompletionMode::Normal => CompletionMode::Burn, + }, + cx, + ); }); } + fn keep_all(&mut self, _: &KeepAll, _window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + let action_log = thread.read(cx).action_log().clone(); + action_log.update(cx, |action_log, cx| action_log.keep_all_edits(cx)); + } + + fn reject_all(&mut self, _: &RejectAll, _window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + let action_log = thread.read(cx).action_log().clone(); + action_log + .update(cx, |action_log, cx| action_log.reject_all_edits(cx)) + .detach(); + } + + fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context) { + self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx); + } + + fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context) { + self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowOnce, window, cx); + } + + fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context) { + self.authorize_pending_tool_call(acp::PermissionOptionKind::RejectOnce, window, cx); + } + + fn authorize_pending_tool_call( + &mut self, + kind: acp::PermissionOptionKind, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let thread = self.thread()?.read(cx); + let tool_call = thread.first_tool_awaiting_confirmation()?; + let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else { + return None; + }; + let option = options.iter().find(|o| o.kind == kind)?; + + self.authorize_tool_call( + tool_call.id.clone(), + option.id.clone(), + option.kind, + window, + cx, + ); + + Some(()) + } + fn render_burn_mode_toggle(&self, cx: &mut Context) -> Option { let thread = self.as_native_thread(cx)?.read(cx); - if !thread.model().supports_burn_mode() { + if thread + .model() + .is_none_or(|model| !model.supports_burn_mode()) + { return None; } @@ -2496,165 +4161,129 @@ impl AcpThreadView { .on_click(cx.listener(|this, _event, window, cx| { this.toggle_burn_mode(&ToggleBurnMode, window, cx); })) - .tooltip(move |_window, cx| { - cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled)) - .into() - }) - .into_any_element(), - ) - } - - fn render_sent_message_editor( - &self, - entry_ix: usize, - editor: &Entity, - cx: &Context, - ) -> Div { - v_flex().w_full().gap_2().child(editor.clone()).when( - self.editing_message == Some(entry_ix), - |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall), - ) - .child( - Label::new("Editing will restart the thread from this point.") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .child(self.render_sent_message_editor_buttons(entry_ix, editor, cx)), - ) - }, - ) - } - - fn render_sent_message_editor_buttons( - &self, - entry_ix: usize, - editor: &Entity, - cx: &Context, - ) -> Div { - h_flex() - .gap_0p5() - .flex_1() - .justify_end() - .child( - IconButton::new("cancel-edit-message", IconName::Close) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Error) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &menu::Cancel, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(Self::cancel_editing)), - ) - .child( - IconButton::new("confirm-edit-message", IconName::Return) - .disabled(editor.read(cx).is_empty(cx)) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Regenerate", - &menu::Confirm, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate(entry_ix, &editor, window, cx); - } - })), - ) + .tooltip(move |_window, cx| { + cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled)) + .into() + }) + .into_any_element(), + ) } fn render_send_button(&self, cx: &mut Context) -> AnyElement { - if self.thread().map_or(true, |thread| { - thread.read(cx).status() == ThreadStatus::Idle - }) { - let is_editor_empty = self.message_editor.read(cx).is_empty(cx); + let is_editor_empty = self.message_editor.read(cx).is_empty(cx); + let is_generating = self + .thread() + .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); + + if self.is_loading_contents { + div() + .id("loading-message-content") + .px_1() + .tooltip(Tooltip::text("Loading Added Context…")) + .child(loading_contents_spinner(IconSize::default())) + .into_any_element() + } else if is_generating && is_editor_empty { + IconButton::new("stop-generation", IconName::Stop) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .tooltip(move |_window, cx| { + Tooltip::for_action("Stop Generation", &editor::actions::Cancel, cx) + }) + .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) + .into_any_element() + } else { + let send_btn_tooltip = if is_editor_empty && !is_generating { + "Type to Send" + } else if is_generating { + "Stop and Send Message" + } else { + "Send" + }; + IconButton::new("send-message", IconName::Send) - .icon_color(Color::Accent) .style(ButtonStyle::Filled) - .disabled(self.thread().is_none() || is_editor_empty) - .when(!is_editor_empty, |button| { - button.tooltip(move |window, cx| Tooltip::for_action("Send", &Chat, window, cx)) - }) - .when(is_editor_empty, |button| { - button.tooltip(Tooltip::text("Type a message to submit")) + .map(|this| { + if is_editor_empty && !is_generating { + this.disabled(true).icon_color(Color::Muted) + } else { + this.icon_color(Color::Accent) + } }) + .tooltip(move |_window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, cx)) .on_click(cx.listener(|this, _, window, cx| { this.send(window, cx); })) .into_any_element() - } else { - IconButton::new("stop-generation", IconName::Stop) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .tooltip(move |window, cx| { - Tooltip::for_action("Stop Generation", &editor::actions::Cancel, window, cx) + } + } + + fn is_following(&self, cx: &App) -> bool { + match self.thread().map(|thread| thread.read(cx).status()) { + Some(ThreadStatus::Generating) => self + .workspace + .read_with(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) }) - .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) - .into_any_element() + .unwrap_or(false), + _ => self.should_be_following, } } + fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { + let following = self.is_following(cx); + + self.should_be_following = !following; + if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) { + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } + }) + .ok(); + } + + telemetry::event!("Follow Agent Selected", following = !following); + } + fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { - let following = self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) - }) - .unwrap_or(false); + let following = self.is_following(cx); + + let tooltip_label = if following { + if self.agent.name() == "Zed Agent" { + format!("Stop Following the {}", self.agent.name()) + } else { + format!("Stop Following {}", self.agent.name()) + } + } else { + if self.agent.name() == "Zed Agent" { + format!("Follow the {}", self.agent.name()) + } else { + format!("Follow {}", self.agent.name()) + } + }; IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) .icon_color(Color::Muted) .toggle_state(following) .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if following { - Tooltip::for_action("Stop Following Agent", &Follow, window, cx) + Tooltip::for_action(tooltip_label.clone(), &Follow, cx) } else { Tooltip::with_meta( - "Follow Agent", + tooltip_label.clone(), Some(&Follow), "Track the agent's location as it reads and edits files.", - window, cx, ) } }) .on_click(cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); + this.toggle_following(window, cx); })) } @@ -2676,38 +4305,48 @@ impl AcpThreadView { return; }; - if let Some(mention) = MentionUri::parse(&url).log_err() { + if let Some(mention) = MentionUri::parse(&url, workspace.read(cx).path_style(cx)).log_err() + { workspace.update(cx, |workspace, cx| match mention { - MentionUri::File { abs_path, .. } => { + MentionUri::File { abs_path } => { let project = workspace.project(); - let Some((path, entry)) = project.update(cx, |project, cx| { + let Some(path) = + project.update(cx, |project, cx| project.find_project_path(abs_path, cx)) + else { + return; + }; + + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + MentionUri::PastedImage => {} + MentionUri::Directory { abs_path } => { + let project = workspace.project(); + let Some(entry_id) = project.update(cx, |project, cx| { let path = project.find_project_path(abs_path, cx)?; - let entry = project.entry_for_path(&path, cx)?; - Some((path, entry)) + project.entry_for_path(&path, cx).map(|entry| entry.id) }) else { return; }; - if entry.is_dir() { - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry.id)); - }); - } else { - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); - } + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)); + }); } MentionUri::Symbol { - path, line_range, .. + abs_path: path, + line_range, + .. } - | MentionUri::Selection { path, line_range } => { + | MentionUri::Selection { + abs_path: Some(path), + line_range, + } => { let project = workspace.project(); - let Some((path, _)) = project.update(cx, |project, cx| { - let path = project.find_project_path(path, cx)?; - let entry = project.entry_for_path(&path, cx)?; - Some((path, entry)) - }) else { + let Some(path) = + project.update(cx, |project, cx| project.find_project_path(path, cx)) + else { return; }; @@ -2717,8 +4356,8 @@ impl AcpThreadView { let Some(editor) = item.await?.downcast::() else { return Ok(()); }; - let range = - Point::new(line_range.start, 0)..Point::new(line_range.start, 0); + let range = Point::new(*line_range.start(), 0) + ..Point::new(*line_range.start(), 0); editor .update_in(cx, |editor, window, cx| { editor.change_selections( @@ -2733,12 +4372,19 @@ impl AcpThreadView { }) .detach_and_log_err(cx); } - MentionUri::Thread { id, .. } => { + MentionUri::Selection { abs_path: None, .. } => {} + MentionUri::Thread { id, name } => { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel - .open_thread_by_id(&id, window, cx) - .detach_and_log_err(cx) + panel.load_agent_thread( + DbThreadMetadata { + id, + title: name.into(), + updated_at: Default::default(), + }, + window, + cx, + ) }); } } @@ -2746,7 +4392,7 @@ impl AcpThreadView { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel - .open_saved_prompt_editor(path.as_path().into(), window, cx) + .open_saved_text_thread(path.as_path().into(), window, cx) .detach_and_log_err(cx); }); } @@ -2837,7 +4483,7 @@ impl AcpThreadView { workspace: Entity, window: &mut Window, cx: &mut App, - ) -> Task> { + ) -> Task> { let markdown_language_task = workspace .read(cx) .app_state() @@ -2862,7 +4508,7 @@ impl AcpThreadView { } let buffer = project.update(cx, |project, cx| { - project.create_local_buffer(&markdown, Some(markdown_language), cx) + project.create_local_buffer(&markdown, Some(markdown_language), true, cx) }); let buffer = cx.new(|cx| { MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone()) @@ -2929,7 +4575,8 @@ impl AcpThreadView { return; } - let title = self.title(cx); + // TODO: Change this once we have title summarization for external agents. + let title = self.agent.name(); match AgentSettings::get_global(cx).notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { @@ -2967,7 +4614,7 @@ impl AcpThreadView { .read(cx) .visible_worktrees(cx) .next() - .map(|worktree| worktree.read(cx).root_name().to_string()) + .map(|worktree| worktree.read(cx).root_name_str().to_string()) }); if let Some(screen_window) = cx @@ -2977,62 +4624,61 @@ impl AcpThreadView { }) }) .log_err() + && let Some(pop_up) = screen_window.entity(cx).log_err() { - if let Some(pop_up) = screen_window.entity(cx).log_err() { - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push(cx.subscribe_in(&pop_up, window, { - |this, _, event, window, cx| match event { - AgentNotificationEvent::Accepted => { - let handle = window.window_handle(); - cx.activate(true); - - let workspace_handle = this.workspace.clone(); - - // If there are multiple Zed windows, activate the correct one. - cx.defer(move |cx| { - handle - .update(cx, |_view, window, _cx| { - window.activate_window(); - - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(_cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); - } - }) - .log_err(); - }); + self.notification_subscriptions + .entry(screen_window) + .or_insert_with(Vec::new) + .push(cx.subscribe_in(&pop_up, window, { + |this, _, event, window, cx| match event { + AgentNotificationEvent::Accepted => { + let handle = window.window_handle(); + cx.activate(true); + + let workspace_handle = this.workspace.clone(); + + // If there are multiple Zed windows, activate the correct one. + cx.defer(move |cx| { + handle + .update(cx, |_view, window, _cx| { + window.activate_window(); + + if let Some(workspace) = workspace_handle.upgrade() { + workspace.update(_cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + }) + .log_err(); + }); - this.dismiss_notifications(cx); - } - AgentNotificationEvent::Dismissed => { - this.dismiss_notifications(cx); - } + this.dismiss_notifications(cx); } - })); - - self.notifications.push(screen_window); - - // If the user manually refocuses the original window, dismiss the popup. - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push({ - let pop_up_weak = pop_up.downgrade(); - - cx.observe_window_activation(window, move |_, window, cx| { - if window.is_window_active() { - if let Some(pop_up) = pop_up_weak.upgrade() { - pop_up.update(cx, |_, cx| { - cx.emit(AgentNotificationEvent::Dismissed); - }); - } - } - }) - }); - } + AgentNotificationEvent::Dismissed => { + this.dismiss_notifications(cx); + } + } + })); + + self.notifications.push(screen_window); + + // If the user manually refocuses the original window, dismiss the popup. + self.notification_subscriptions + .entry(screen_window) + .or_insert_with(Vec::new) + .push({ + let pop_up_weak = pop_up.downgrade(); + + cx.observe_window_activation(window, move |_, window, cx| { + if window.is_window_active() + && let Some(pop_up) = pop_up_weak.upgrade() + { + pop_up.update(cx, |_, cx| { + cx.emit(AgentNotificationEvent::Dismissed); + }); + } + }) + }); } } @@ -3048,7 +4694,21 @@ impl AcpThreadView { } } - fn render_thread_controls(&self, cx: &Context) -> impl IntoElement { + fn render_thread_controls( + &self, + thread: &Entity, + cx: &Context, + ) -> impl IntoElement { + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { + return h_flex().id("thread-controls-container").child( + div() + .py_2() + .px(rems_from_px(22.)) + .child(SpinnerLabel::new().size(LabelSize::Small)), + ); + } + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -3070,55 +4730,235 @@ impl AcpThreadView { this.scroll_to_top(cx); })); - h_flex() + let mut container = h_flex() + .id("thread-controls-container") + .group("thread-controls-container") .w_full() - .mr_1() - .pb_2() - .px(RESPONSE_PADDING_X) - .opacity(0.4) + .py_2() + .px_5() + .gap_px() + .opacity(0.6) .hover(|style| style.opacity(1.)) .flex_wrap() - .justify_end() - .child(open_as_markdown) - .child(scroll_to_top) + .justify_end(); + + if AgentSettings::get_global(cx).enable_feedback + && self + .thread() + .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some()) + { + let feedback = self.thread_feedback.feedback; + + container = container + .child( + div().visible_on_hover("thread-controls-container").child( + Label::new(match feedback { + Some(ThreadFeedback::Positive) => "Thanks for your feedback!", + Some(ThreadFeedback::Negative) => { + "We appreciate your feedback and will use it to improve." + } + None => { + "Rating the thread sends all of your current conversation to the Zed team." + } + }) + .color(Color::Muted) + .size(LabelSize::XSmall) + .truncate(), + ), + ) + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ); + } + + container.child(open_as_markdown).child(scroll_to_top) } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .id("acp-thread-scrollbar") - .occlude() - .on_mouse_move(cx.listener(|_, _, _, cx| { + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { + h_flex() + .key_context("AgentFeedbackMessageEditor") + .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { + this.thread_feedback.dismiss_comments(); cx.notify(); - cx.stop_propagation() })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); + .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { + this.submit_feedback_message(cx); })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) + .p_2() + .mb_2() + .mx_5() + .gap_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .child(div().w_full().child(editor)) + .child( + h_flex() + .child( + IconButton::new("dismiss-feedback-message", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .on_click(cx.listener(move |this, _, _window, cx| { + this.thread_feedback.dismiss_comments(); + cx.notify(); + })), + ) + .child( + IconButton::new("submit-feedback-message", IconName::Return) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .on_click(cx.listener(move |this, _, _window, cx| { + this.submit_feedback_message(cx); + })), + ), + ) + } + + fn handle_feedback_click( + &mut self, + feedback: ThreadFeedback, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + self.thread_feedback.submit(thread, feedback, window, cx); + cx.notify(); + } + + fn submit_feedback_message(&mut self, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + self.thread_feedback.submit_comments(thread, cx); + cx.notify(); + } + + fn render_token_limit_callout( + &self, + line_height: Pixels, + cx: &mut Context, + ) -> Option { + let token_usage = self.thread()?.read(cx).token_usage()?; + let ratio = token_usage.ratio(); + + let (severity, title) = match ratio { + acp_thread::TokenUsageRatio::Normal => return None, + acp_thread::TokenUsageRatio::Warning => { + (Severity::Warning, "Thread reaching the token limit soon") + } + acp_thread::TokenUsageRatio::Exceeded => { + (Severity::Error, "Thread reached the token limit") + } + }; + + let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| { + thread.read(cx).completion_mode() == CompletionMode::Normal + && thread + .read(cx) + .model() + .is_some_and(|model| model.supports_burn_mode()) + }); + + let description = if burn_mode_available { + "To continue, start a new thread from a summary or turn Burn Mode on." + } else { + "To continue, start a new thread from a summary." + }; + + Some( + Callout::new() + .severity(severity) + .line_height(line_height) + .title(title) + .description(description) + .actions_slot( + h_flex() + .gap_0p5() + .child( + Button::new("start-new-thread", "Start New Thread") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + let Some(thread) = this.thread() else { + return; + }; + let session_id = thread.read(cx).session_id().clone(); + window.dispatch_action( + crate::NewNativeAgentThreadFromSummary { + from_session_id: session_id, + } + .boxed_clone(), + cx, + ); + })), + ) + .when(burn_mode_available, |this| { + this.child( + IconButton::new("burn-mode-callout", IconName::ZedBurnMode) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_burn_mode(&ToggleBurnMode, window, cx); + })), + ) + }), + ), + ) + } + + fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context) -> Option
{ + if !self.is_using_zed_ai_models(cx) { + return None; + } + + let user_store = self.project.read(cx).user_store().read(cx); + if user_store.is_usage_based_billing_enabled() { + return None; + } + + let plan = user_store + .plan() + .unwrap_or(cloud_llm_client::Plan::V1(PlanV1::ZedFree)); + + let usage = user_store.model_request_usage()?; + + Some( + div() + .child(UsageCallout::new(plan, usage)) + .line_height(line_height), + ) } - fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context) { + fn agent_ui_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context) { self.entry_view_state.update(cx, |entry_view_state, cx| { - entry_view_state.settings_changed(cx); + entry_view_state.agent_ui_font_size_changed(cx); }); } @@ -3130,61 +4970,275 @@ impl AcpThreadView { cx: &mut Context, ) { self.message_editor.update(cx, |message_editor, cx| { - message_editor.insert_dragged_files(paths, window, cx); - drop(added_worktrees); + message_editor.insert_dragged_files(paths, added_worktrees, window, cx); }) } - fn render_thread_error(&self, window: &mut Window, cx: &mut Context<'_, Self>) -> Option
{ + /// Inserts the selected text into the message editor or the message being + /// edited, if any. + pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { + self.active_editor(cx).update(cx, |editor, cx| { + editor.insert_selections(window, cx); + }); + } + + fn render_thread_retry_status_callout( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let state = self.thread_retry_status.as_ref()?; + + let next_attempt_in = state + .duration + .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); + if next_attempt_in.is_zero() { + return None; + } + + let next_attempt_in_secs = next_attempt_in.as_secs() + 1; + + let retry_message = if state.max_attempts == 1 { + if next_attempt_in_secs == 1 { + "Retrying. Next attempt in 1 second.".to_string() + } else { + format!("Retrying. Next attempt in {next_attempt_in_secs} seconds.") + } + } else if next_attempt_in_secs == 1 { + format!( + "Retrying. Next attempt in 1 second (Attempt {} of {}).", + state.attempt, state.max_attempts, + ) + } else { + format!( + "Retrying. Next attempt in {next_attempt_in_secs} seconds (Attempt {} of {}).", + state.attempt, state.max_attempts, + ) + }; + + Some( + Callout::new() + .severity(Severity::Warning) + .title(state.last_error.clone()) + .description(retry_message), + ) + } + + #[cfg(target_os = "windows")] + fn render_codex_windows_warning(&self, cx: &mut Context) -> Option { + if self.show_codex_windows_warning { + Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description( + "For best performance, run Codex in Windows Subsystem for Linux (WSL2)", + ) + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, window, cx| { + window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), + ), + ) + } else { + None + } + } + + fn render_thread_error(&self, cx: &mut Context) -> Option
{ let content = match self.thread_error.as_ref()? { ThreadError::Other(error) => self.render_any_thread_error(error.clone(), cx), + ThreadError::Refusal => self.render_refusal_error(cx), + ThreadError::AuthenticationRequired(error) => { + self.render_authentication_required_error(error.clone(), cx) + } ThreadError::PaymentRequired => self.render_payment_required_error(cx), ThreadError::ModelRequestLimitReached(plan) => { self.render_model_request_limit_reached_error(*plan, cx) } - ThreadError::ToolUseLimitReached => { - self.render_tool_use_limit_reached_error(window, cx)? - } + ThreadError::ToolUseLimitReached => self.render_tool_use_limit_reached_error(cx)?, }; - Some( - div() + Some(div().child(content)) + } + + fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { + v_flex().w_full().justify_end().child( + h_flex() + .p_2() + .pr_3() + .w_full() + .gap_1p5() .border_t_1() .border_color(cx.theme().colors().border) - .child(content), + .bg(cx.theme().colors().element_background) + .child( + h_flex() + .flex_1() + .gap_1p5() + .child( + Icon::new(IconName::Download) + .color(Color::Accent) + .size(IconSize::Small), + ) + .child(Label::new("New version available").size(LabelSize::Small)), + ) + .child( + Button::new("update-button", format!("Update to v{}", version)) + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .on_click(cx.listener(|this, _, window, cx| { + this.reset(window, cx); + })), + ), ) } + fn get_current_model_name(&self, cx: &App) -> SharedString { + // For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet") + // For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI") + // This provides better clarity about what refused the request + if self + .agent + .clone() + .downcast::() + .is_some() + { + // Native agent - use the model name + self.model_selector + .as_ref() + .and_then(|selector| selector.read(cx).active_model_name(cx)) + .unwrap_or_else(|| SharedString::from("The model")) + } else { + // ACP agent - use the agent name (e.g., "Claude Code", "Gemini CLI") + self.agent.name() + } + } + + fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { + let model_or_agent_name = self.get_current_model_name(cx); + let refusal_message = format!( + "{} refused to respond to this prompt. This can happen when a model believes the prompt violates its content policy or safety guidelines, so rephrasing it can sometimes address the issue.", + model_or_agent_name + ); + + Callout::new() + .severity(Severity::Error) + .title("Request Refused") + .icon(IconName::XCircle) + .description(refusal_message.clone()) + .actions_slot(self.create_copy_button(&refusal_message)) + .dismiss_action(self.dismiss_error_button(cx)) + } + fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout { - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); + let can_resume = self + .thread() + .map_or(false, |thread| thread.read(cx).can_resume(cx)); + + let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| { + let thread = thread.read(cx); + let supports_burn_mode = thread + .model() + .map_or(false, |model| model.supports_burn_mode()); + supports_burn_mode && thread.completion_mode() == CompletionMode::Normal + }); Callout::new() - .icon(icon) + .severity(Severity::Error) .title("Error") + .icon(IconName::XCircle) .description(error.clone()) - .secondary_action(self.create_copy_button(error.to_string())) - .primary_action(self.dismiss_error_button(cx)) - .bg_color(self.error_callout_bg(cx)) + .actions_slot( + h_flex() + .gap_0p5() + .when(can_resume && can_enable_burn_mode, |this| { + this.child( + Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry") + .icon(IconName::ZedBurnMode) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_burn_mode(&ToggleBurnMode, window, cx); + this.resume_chat(cx); + })), + ) + }) + .when(can_resume, |this| { + this.child( + Button::new("retry", "Retry") + .icon(IconName::RotateCw) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.resume_chat(cx); + })), + ) + }) + .child(self.create_copy_button(error.to_string())), + ) + .dismiss_action(self.dismiss_error_button(cx)) } fn render_payment_required_error(&self, cx: &mut Context) -> Callout { const ERROR_MESSAGE: &str = "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - Callout::new() - .icon(icon) + .severity(Severity::Error) + .icon(IconName::XCircle) .title("Free Usage Exceeded") .description(ERROR_MESSAGE) - .tertiary_action(self.upgrade_button(cx)) - .secondary_action(self.create_copy_button(ERROR_MESSAGE)) - .primary_action(self.dismiss_error_button(cx)) - .bg_color(self.error_callout_bg(cx)) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.upgrade_button(cx)) + .child(self.create_copy_button(ERROR_MESSAGE)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_authentication_required_error( + &self, + error: SharedString, + cx: &mut Context, + ) -> Callout { + Callout::new() + .severity(Severity::Error) + .title("Authentication Required") + .icon(IconName::XCircle) + .description(error.clone()) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.authenticate_button(cx)) + .child(self.create_copy_button(error)), + ) + .dismiss_action(self.dismiss_error_button(cx)) } fn render_model_request_limit_reached_error( @@ -3193,82 +5247,85 @@ impl AcpThreadView { cx: &mut Context, ) -> Callout { let error_message = match plan { - cloud_llm_client::Plan::ZedPro => "Upgrade to usage-based billing for more prompts.", - cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => { - "Upgrade to Zed Pro for more prompts." + cloud_llm_client::Plan::V1(PlanV1::ZedPro) => { + "Upgrade to usage-based billing for more prompts." } + cloud_llm_client::Plan::V1(PlanV1::ZedProTrial) + | cloud_llm_client::Plan::V1(PlanV1::ZedFree) => "Upgrade to Zed Pro for more prompts.", + cloud_llm_client::Plan::V2(_) => "", }; - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - Callout::new() - .icon(icon) + .severity(Severity::Error) .title("Model Prompt Limit Reached") + .icon(IconName::XCircle) .description(error_message) - .tertiary_action(self.upgrade_button(cx)) - .secondary_action(self.create_copy_button(error_message)) - .primary_action(self.dismiss_error_button(cx)) - .bg_color(self.error_callout_bg(cx)) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.upgrade_button(cx)) + .child(self.create_copy_button(error_message)), + ) + .dismiss_action(self.dismiss_error_button(cx)) } - fn render_tool_use_limit_reached_error( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Option { + fn render_tool_use_limit_reached_error(&self, cx: &mut Context) -> Option { let thread = self.as_native_thread(cx)?; - let supports_burn_mode = thread.read(cx).model().supports_burn_mode(); + let supports_burn_mode = thread + .read(cx) + .model() + .is_some_and(|model| model.supports_burn_mode()); let focus_handle = self.focus_handle(cx); - let icon = Icon::new(IconName::Info) - .size(IconSize::Small) - .color(Color::Info); - Some( Callout::new() - .icon(icon) + .icon(IconName::Info) .title("Consecutive tool use limit reached.") - .when(supports_burn_mode, |this| { - this.secondary_action( - Button::new("continue-burn-mode", "Continue with Burn Mode") - .style(ButtonStyle::Filled) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .layer(ElevationIndex::ModalSurface) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &ContinueWithBurnMode, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), + .actions_slot( + h_flex() + .gap_0p5() + .when(supports_burn_mode, |this| { + this.child( + Button::new("continue-burn-mode", "Continue with Burn Mode") + .style(ButtonStyle::Filled) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in( + &ContinueWithBurnMode, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .tooltip(Tooltip::text( + "Enable Burn Mode for unlimited tool use.", + )) + .on_click({ + cx.listener(move |this, _, _window, cx| { + thread.update(cx, |thread, cx| { + thread + .set_completion_mode(CompletionMode::Burn, cx); + }); + this.resume_chat(cx); + }) + }), ) - .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use.")) - .on_click({ - cx.listener(move |this, _, _window, cx| { - thread.update(cx, |thread, _cx| { - thread.set_completion_mode(CompletionMode::Burn); - }); + }) + .child( + Button::new("continue-conversation", "Continue") + .layer(ElevationIndex::ModalSurface) + .label_size(LabelSize::Small) + .key_binding( + KeyBinding::for_action_in(&ContinueThread, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(|this, _, _window, cx| { this.resume_chat(cx); - }) - }), - ) - }) - .primary_action( - Button::new("continue-conversation", "Continue") - .layer(ElevationIndex::ModalSurface) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in(&ContinueThread, &focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(|this, _, _window, cx| { - this.resume_chat(cx); - })), + })), + ), ), ) } @@ -3298,6 +5355,49 @@ impl AcpThreadView { })) } + fn authenticate_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("authenticate", "Authenticate") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener({ + move |this, _, window, cx| { + let agent = this.agent.clone(); + let ThreadState::Ready { thread, .. } = &this.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let err = AuthRequired { + description: None, + provider_id: None, + }; + this.clear_thread_error(cx); + let this = cx.weak_entity(); + window.defer(cx, |window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx); + }) + } + })) + } + + pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { + let agent = self.agent.clone(); + let ThreadState::Ready { thread, .. } = &self.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let err = AuthRequired { + description: None, + provider_id: None, + }; + self.clear_thread_error(cx); + let this = cx.weak_entity(); + window.defer(cx, |window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx); + }) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) @@ -3310,100 +5410,128 @@ impl AcpThreadView { })) } - fn error_callout_bg(&self, cx: &Context) -> Hsla { - cx.theme().status().error.opacity(0.08) + pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) { + let task = match entry { + HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| { + history.delete_thread(thread.id.clone(), cx) + }), + HistoryEntry::TextThread(text_thread) => { + self.history_store.update(cx, |history, cx| { + history.delete_text_thread(text_thread.path.clone(), cx) + }) + } + }; + task.detach_and_log_err(cx); + } + + /// Returns the currently active editor, either for a message that is being + /// edited or the editor for a new message. + fn active_editor(&self, cx: &App) -> Entity { + if let Some(index) = self.editing_message + && let Some(editor) = self + .entry_view_state + .read(cx) + .entry(index) + .and_then(|e| e.message_editor()) + .cloned() + { + editor + } else { + self.message_editor.clone() + } } } +fn loading_contents_spinner(size: IconSize) -> AnyElement { + Icon::new(IconName::LoadCircle) + .size(size) + .color(Color::Accent) + .with_rotate_animation(3) + .into_any_element() +} + impl Focusable for AcpThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.message_editor.focus_handle(cx) + match self.thread_state { + ThreadState::Loading { .. } | ThreadState::Ready { .. } => { + self.active_editor(cx).focus_handle(cx) + } + ThreadState::LoadError(_) | ThreadState::Unauthenticated { .. } => { + self.focus_handle.clone() + } + } } } impl Render for AcpThreadView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_messages = self.list_state.item_count() > 0; + let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; v_flex() .size_full() .key_context("AcpThread") - .on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::toggle_burn_mode)) + .on_action(cx.listener(Self::keep_all)) + .on_action(cx.listener(Self::reject_all)) + .on_action(cx.listener(Self::allow_always)) + .on_action(cx.listener(Self::allow_once)) + .on_action(cx.listener(Self::reject_once)) + .track_focus(&self.focus_handle) .bg(cx.theme().colors().panel_background) .child(match &self.thread_state { - ThreadState::Unauthenticated { connection } => v_flex() - .p_2() + ThreadState::Unauthenticated { + connection, + description, + configuration_view, + pending_auth_method, + .. + } => self + .render_auth_required_state( + connection, + description.as_ref(), + configuration_view.as_ref(), + pending_auth_method.as_ref(), + window, + cx, + ) + .into_any(), + ThreadState::Loading { .. } => v_flex() .flex_1() - .items_center() - .justify_center() - .child(self.render_pending_auth_state()) - .child(h_flex().mt_1p5().justify_center().children( - connection.auth_methods().into_iter().map(|method| { - Button::new( - SharedString::from(method.id.0.clone()), - method.name.clone(), - ) - .on_click({ - let method_id = method.id.clone(); - cx.listener(move |this, _, window, cx| { - this.authenticate(method_id.clone(), window, cx) - }) - }) - }), - )), - ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)), + .child(self.render_recent_history(cx)) + .into_any(), ThreadState::LoadError(e) => v_flex() - .p_2() - .flex_1() - .items_center() - .justify_center() - .child(self.render_load_error(e, cx)), - ThreadState::ServerExited { status } => v_flex() - .p_2() .flex_1() + .size_full() .items_center() - .justify_center() - .child(self.render_server_exited(*status, cx)), - ThreadState::Ready { thread, .. } => { - let thread_clone = thread.clone(); - - v_flex().flex_1().map(|this| { - if has_messages { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, index: usize, window, cx| { - let Some((entry, len)) = this.thread().and_then(|thread| { - let entries = &thread.read(cx).entries(); - Some((entries.get(index)?, entries.len())) - }) else { - return Empty.into_any(); - }; - this.render_entry(index, len, entry, window, cx) - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - .into_any(), - ) - .child(self.render_vertical_scrollbar(cx)) - .children( - match thread_clone.read(cx).status() { - ThreadStatus::Idle - | ThreadStatus::WaitingForToolConfirmation => None, - ThreadStatus::Generating => div() - .px_5() - .py_2() - .child(LoadingLabel::new("").size(LabelSize::Small)) - .into(), - }, + .justify_end() + .child(self.render_load_error(e, window, cx)) + .into_any(), + ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { + if has_messages { + this.child( + list( + self.list_state.clone(), + cx.processor(|this, index: usize, window, cx| { + let Some((entry, len)) = this.thread().and_then(|thread| { + let entries = &thread.read(cx).entries(); + Some((entries.get(index)?, entries.len())) + }) else { + return Empty.into_any(); + }; + this.render_entry(index, len, entry, window, cx) + }), ) - } else { - this.child(self.render_empty_state(cx)) - } - }) - } + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .vertical_scrollbar_for(self.list_state.clone(), window, cx) + .into_any() + } else { + this.child(self.render_recent_history(cx)).into_any() + } + }), }) // The activity bar is intentionally rendered outside of the ThreadState::Ready match // above so that the scrollbar doesn't render behind it. The current setup allows @@ -3414,12 +5542,42 @@ impl Render for AcpThreadView { } _ => this, }) - .children(self.render_thread_error(window, cx)) + .children(self.render_thread_retry_status_callout(window, cx)) + .children({ + #[cfg(target_os = "windows")] + { + self.render_codex_windows_warning(cx) + } + #[cfg(not(target_os = "windows"))] + { + Vec::::new() + } + }) + .children(self.render_thread_error(cx)) + .when_some( + self.new_server_version_available.as_ref().filter(|_| { + !has_messages || !matches!(self.thread_state, ThreadState::Ready { .. }) + }), + |this, version| this.child(self.render_new_version_callout(&version, cx)), + ) + .children( + if let Some(usage_callout) = self.render_usage_callout(line_height, cx) { + Some(usage_callout.into_any_element()) + } else { + self.render_token_limit_callout(line_height, cx) + .map(|token_limit_callout| token_limit_callout.into_any_element()) + }, + ) .child(self.render_message_editor(window, cx)) } } -fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { +fn default_markdown_style( + buffer_font: bool, + muted_text: bool, + window: &Window, + cx: &App, +) -> MarkdownStyle { let theme_settings = ThemeSettings::get_global(cx); let colors = cx.theme().colors(); @@ -3440,20 +5598,26 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd TextSize::Default.rems(cx) }; + let text_color = if muted_text { + colors.text_muted + } else { + colors.text + }; + text_style.refine(&TextStyleRefinement { font_family: Some(font_family), font_fallbacks: theme_settings.ui_font.fallbacks.clone(), font_features: Some(theme_settings.ui_font.features.clone()), font_size: Some(font_size.into()), line_height: Some(line_height.into()), - color: Some(cx.theme().colors().text), + color: Some(text_color), ..Default::default() }); MarkdownStyle { base_text_style: text_style.clone(), syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().colors().element_selection_background, + selection_background_color: colors.element_selection_background, code_block_overflow_x_scroll: true, table_overflow_x_scroll: true, heading_level_styles: Some(HeadingLevelStyles { @@ -3484,23 +5648,23 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd }), code_block: StyleRefinement { padding: EdgesRefinement { - top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))), + left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))), + right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))), + bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(8.)))), }, margin: EdgesRefinement { - top: Some(Length::Definite(Pixels(8.).into())), - left: Some(Length::Definite(Pixels(0.).into())), - right: Some(Length::Definite(Pixels(0.).into())), - bottom: Some(Length::Definite(Pixels(12.).into())), + top: Some(Length::Definite(px(8.).into())), + left: Some(Length::Definite(px(0.).into())), + right: Some(Length::Definite(px(0.).into())), + bottom: Some(Length::Definite(px(12.).into())), }, border_style: Some(BorderStyle::Solid), border_widths: EdgesRefinement { - top: Some(AbsoluteLength::Pixels(Pixels(1.))), - left: Some(AbsoluteLength::Pixels(Pixels(1.))), - right: Some(AbsoluteLength::Pixels(Pixels(1.))), - bottom: Some(AbsoluteLength::Pixels(Pixels(1.))), + top: Some(AbsoluteLength::Pixels(px(1.))), + left: Some(AbsoluteLength::Pixels(px(1.))), + right: Some(AbsoluteLength::Pixels(px(1.))), + bottom: Some(AbsoluteLength::Pixels(px(1.))), }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), @@ -3539,7 +5703,7 @@ fn plan_label_markdown_style( window: &Window, cx: &App, ) -> MarkdownStyle { - let default_md_style = default_markdown_style(false, window, cx); + let default_md_style = default_markdown_style(false, false, window, cx); MarkdownStyle { base_text_style: TextStyle { @@ -3559,7 +5723,7 @@ fn plan_label_markdown_style( } fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let default_md_style = default_markdown_style(true, window, cx); + let default_md_style = default_markdown_style(true, false, window, cx); MarkdownStyle { base_text_style: TextStyle { @@ -3573,8 +5737,8 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { #[cfg(test)] pub(crate) mod tests { use acp_thread::StubAgentConnection; - use agent::{TextThreadStore, ThreadStore}; use agent_client_protocol::SessionId; + use assistant_text_thread::TextThreadStore; use editor::EditorSettings; use fs::FakeFs; use gpui::{EventEmitter, SemanticVersion, TestAppContext, VisualTestContext}; @@ -3591,7 +5755,7 @@ pub(crate) mod tests { async fn test_drop(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, _cx) = setup_thread_view(StubAgentServer::default(), cx).await; + let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; let weak_view = thread_view.downgrade(); drop(thread_view); assert!(!weak_view.is_upgradable()); @@ -3601,7 +5765,7 @@ pub(crate) mod tests { async fn test_notification_for_stop_event(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default(), cx).await; + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); message_editor.update_in(cx, |editor, window, cx| { @@ -3650,6 +5814,33 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_refusal_handling(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await; + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Do something harmful", window, cx); + }); + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Check that the refusal error is set + thread_view.read_with(cx, |thread_view, _cx| { + assert!( + matches!(thread_view.thread_error, Some(ThreadError::Refusal)), + "Expected refusal error to be set" + ); + }); + } + #[gpui::test] async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); @@ -3664,6 +5855,7 @@ pub(crate) mod tests { locations: vec![], raw_input: None, raw_output: None, + meta: None, }; let connection = StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( @@ -3672,6 +5864,7 @@ pub(crate) mod tests { id: acp::PermissionOptionId("1".into()), name: "Allow".into(), kind: acp::PermissionOptionKind::AllowOnce, + meta: None, }], )])); @@ -3708,19 +5901,21 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let thread_store = - cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx))); let text_thread_store = cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); + let history_store = + cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); let thread_view = cx.update(|window, cx| { cx.new(|cx| { AcpThreadView::new( Rc::new(agent), + None, + None, workspace.downgrade(), project, - thread_store.clone(), - text_thread_store.clone(), + history_store, + None, window, cx, ) @@ -3764,7 +5959,7 @@ pub(crate) mod tests { impl Focusable for ThreadViewItem { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx).clone() + self.0.read(cx).focus_handle(cx) } } @@ -3785,8 +5980,15 @@ pub(crate) mod tests { } impl StubAgentServer { - fn default() -> Self { - Self::new(StubAgentConnection::default()) + fn default_response() -> Self { + let conn = StubAgentConnection::new(); + conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: "Default response".into(), + meta: None, + }, + )]); + Self::new(conn) } } @@ -3794,36 +5996,96 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { + fn telemetry_id(&self) -> &'static str { + "test" + } + fn logo(&self) -> ui::IconName { ui::IconName::Ai } - fn name(&self) -> &'static str { - "Test" + fn name(&self) -> SharedString { + "Test".into() } - fn empty_state_headline(&self) -> &'static str { - "Test" + fn connect( + &self, + _root_dir: Option<&Path>, + _delegate: AgentServerDelegate, + _cx: &mut App, + ) -> Task, Option)>> { + Task::ready(Ok((Rc::new(self.connection.clone()), None))) } - fn empty_state_message(&self) -> &'static str { - "Test" + fn into_any(self: Rc) -> Rc { + self } + } - fn connect( + #[derive(Clone)] + struct SaboteurAgentConnection; + + impl AgentConnection for SaboteurAgentConnection { + fn new_thread( + self: Rc, + project: Entity, + _cwd: &Path, + cx: &mut gpui::App, + ) -> Task>> { + Task::ready(Ok(cx.new(|cx| { + let action_log = cx.new(|_| ActionLog::new(project.clone())); + AcpThread::new( + "SaboteurAgentConnection", + self, + project, + action_log, + SessionId("test".into()), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }), + cx, + ) + }))) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + unimplemented!() + } + + fn prompt( &self, - _root_dir: &Path, - _project: &Entity, + _id: Option, + _params: acp::PromptRequest, _cx: &mut App, - ) -> Task>> { - Task::ready(Ok(Rc::new(self.connection.clone()))) + ) -> Task> { + Task::ready(Err(anyhow::anyhow!("Error prompting"))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { + unimplemented!() + } + + fn into_any(self: Rc) -> Rc { + self } } + /// Simulates a model which always returns a refusal response #[derive(Clone)] - struct SaboteurAgentConnection; + struct RefusalAgentConnection; - impl AgentConnection for SaboteurAgentConnection { + impl AgentConnection for RefusalAgentConnection { fn new_thread( self: Rc, project: Entity, @@ -3831,11 +6093,19 @@ pub(crate) mod tests { cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { + let action_log = cx.new(|_| ActionLog::new(project.clone())); AcpThread::new( - "SaboteurAgentConnection", + "RefusalAgentConnection", self, project, + action_log, SessionId("test".into()), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + meta: None, + }), cx, ) }))) @@ -3859,7 +6129,10 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - Task::ready(Err(anyhow::anyhow!("Error prompting"))) + Task::ready(Ok(acp::PromptResponse { + stop_reason: acp::StopReason::Refusal, + meta: None, + })) } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { @@ -3879,9 +6152,10 @@ pub(crate) mod tests { Project::init_settings(cx); AgentSettings::register(cx); workspace::init_settings(cx); - ThemeSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); release_channel::init(SemanticVersion::default(), cx); EditorSettings::register(cx); + prompt_store::init(cx) }); } @@ -3902,20 +6176,22 @@ pub(crate) mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let thread_store = - cx.update(|_window, cx| cx.new(|cx| ThreadStore::fake(project.clone(), cx))); let text_thread_store = cx.update(|_window, cx| cx.new(|cx| TextThreadStore::fake(project.clone(), cx))); + let history_store = + cx.update(|_window, cx| cx.new(|cx| HistoryStore::new(text_thread_store, cx))); let connection = Rc::new(StubAgentConnection::new()); let thread_view = cx.update(|window, cx| { cx.new(|cx| { AcpThreadView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), + None, + None, workspace.downgrade(), project.clone(), - thread_store.clone(), - text_thread_store.clone(), + history_store.clone(), + None, window, cx, ) @@ -3939,11 +6215,13 @@ pub(crate) mod tests { path: "/project/test1.txt".into(), old_text: Some("old content 1".into()), new_text: "new content 1".into(), + meta: None, }, }], locations: vec![], raw_input: None, raw_output: None, + meta: None, })]); thread @@ -3980,11 +6258,13 @@ pub(crate) mod tests { path: "/project/test2.txt".into(), old_text: Some("old content 2".into()), new_text: "new content 2".into(), + meta: None, }, }], locations: vec![], raw_input: None, raw_output: None, + meta: None, })]); thread @@ -4058,12 +6338,16 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }, + )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; add_to_workspace(thread_view.clone(), cx); @@ -4115,18 +6399,48 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_message_doesnt_send_if_empty(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + let mut events = cx.events(&message_editor); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("", window, cx); + }); + + message_editor.update_in(cx, |_editor, window, cx| { + window.dispatch_action(Box::new(Chat), cx); + }); + cx.run_until_parked(); + // We shouldn't have received any messages + assert!(matches!( + events.try_next(), + Err(futures::channel::mpsc::TryRecvError { .. }) + )); + } + #[gpui::test] async fn test_message_editing_regenerate(cx: &mut TestAppContext) { init_test(cx); let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }, + )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; @@ -4164,12 +6478,16 @@ pub(crate) mod tests { }); // Send - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "New Response".into(), - annotations: None, - }), - }]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "New Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }, + )]); user_message_editor.update_in(cx, |_editor, window, cx| { window.dispatch_action(Box::new(Chat), cx); @@ -4199,4 +6517,374 @@ pub(crate) mod tests { assert_eq!(new_editor.read(cx).text(cx), "Edited message content"); }) } + + #[gpui::test] + async fn test_message_editing_while_generating(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| { + let thread = view.thread().unwrap().read(cx); + assert_eq!(thread.entries().len(), 1); + + let editor = view + .entry_view_state + .read(cx) + .entry(0) + .unwrap() + .message_editor() + .unwrap() + .clone(); + + (editor, thread.session_id().clone()) + }); + + // Focus + cx.focus(&user_message_editor); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + // Edit + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Edited message content", window, cx); + }); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + // Finish streaming response + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }), + cx, + ); + connection.end_turn(session_id, acp::StopReason::EndTurn); + }); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + cx.run_until_parked(); + + // Should still be editing + cx.update(|window, cx| { + assert!(user_message_editor.focus_handle(cx).is_focused(window)); + assert_eq!(thread_view.read(cx).editing_message, Some(0)); + assert_eq!( + user_message_editor.read(cx).text(cx), + "Edited message content" + ); + }); + } + + #[gpui::test] + async fn test_interrupt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Message 1", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + let (thread, session_id) = thread_view.read_with(cx, |view, cx| { + let thread = view.thread().unwrap(); + + (thread.clone(), thread.read(cx).session_id().clone()) + }); + + cx.run_until_parked(); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content: "Message 1 resp".into(), + meta: None, + }), + cx, + ); + }); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 resp + + "} + ) + }); + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Message 2", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.update(|_, cx| { + // Simulate a response sent after beginning to cancel + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content: "onse".into(), + meta: None, + }), + cx, + ); + }); + + cx.run_until_parked(); + + // Last Message 1 response should appear before Message 2 + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + + ## User + + Message 2 + + "} + ) + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { + content: "Message 2 response".into(), + meta: None, + }), + cx, + ); + connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); + }); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + + ## User + + Message 2 + + ## Assistant + + Message 2 response + + "} + ) + }); + } + + #[gpui::test] + async fn test_message_editing_insert_selections(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }, + )]); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit", window, cx) + }); + thread_view.update_in(cx, |thread_view, window, cx| thread_view.send(window, cx)); + cx.run_until_parked(); + + let user_message_editor = thread_view.read_with(cx, |thread_view, cx| { + thread_view + .entry_view_state + .read(cx) + .entry(0) + .expect("Should have at least one entry") + .message_editor() + .expect("Should have message editor") + .clone() + }); + + cx.focus(&user_message_editor); + thread_view.read_with(cx, |thread_view, _cx| { + assert_eq!(thread_view.editing_message, Some(0)); + }); + + // Ensure to edit the focused message before proceeding otherwise, since + // its content is not different from what was sent, focus will be lost. + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit with ", window, cx) + }); + + // Create a simple buffer with some text so we can create a selection + // that will then be added to the message being edited. + let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { + (thread_view.workspace.clone(), thread_view.project.clone()) + }); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("let a = 10 + 10;", None, false, cx) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([8..15]); + }); + + editor + }); + workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); + }) + .unwrap(); + + thread_view.update_in(cx, |thread_view, window, cx| { + assert_eq!(thread_view.editing_message, Some(0)); + thread_view.insert_selections(window, cx); + }); + + user_message_editor.read_with(cx, |editor, cx| { + let text = editor.editor().read(cx).text(cx); + let expected_text = String::from("Original message to edit with selection "); + + assert_eq!(text, expected_text); + }); + } + + #[gpui::test] + async fn test_insert_selections(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + meta: None, + }), + meta: None, + }, + )]); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Can you review this snippet ", window, cx) + }); + + // Create a simple buffer with some text so we can create a selection + // that will then be added to the message being edited. + let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { + (thread_view.workspace.clone(), thread_view.project.clone()) + }); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("let a = 10 + 10;", None, false, cx) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([8..15]); + }); + + editor + }); + workspace.add_item_to_active_pane(Box::new(editor), None, false, window, cx); + }) + .unwrap(); + + thread_view.update_in(cx, |thread_view, window, cx| { + assert_eq!(thread_view.editing_message, None); + thread_view.insert_selections(window, cx); + }); + + thread_view.read_with(cx, |thread_view, cx| { + let text = thread_view.message_editor.read(cx).text(cx); + let expected_txt = String::from("Can you review this snippet selection "); + + assert_eq!(text, expected_txt); + }) + } } diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs deleted file mode 100644 index 116c2b901bb5624a387017db22883f5b03d6db4f..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/active_thread.rs +++ /dev/null @@ -1,4132 +0,0 @@ -use crate::context_picker::{ContextPicker, MentionLink}; -use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::message_editor::{extract_message_creases, insert_message_creases}; -use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill}; -use crate::{AgentPanel, ModelUsageContext}; -use agent::{ - ContextStore, LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, TextThreadStore, - Thread, ThreadError, ThreadEvent, ThreadFeedback, ThreadStore, ThreadSummary, - context::{self, AgentContextHandle, RULES_ICON}, - thread_store::RulesLoadingError, - tool_use::{PendingToolUseStatus, ToolUse}, -}; -use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; -use anyhow::Context as _; -use assistant_tool::ToolUseStatus; -use audio::{Audio, Sound}; -use cloud_llm_client::CompletionIntent; -use collections::{HashMap, HashSet}; -use editor::actions::{MoveUp, Paste}; -use editor::scroll::Autoscroll; -use editor::{Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, SelectionEffects}; -use gpui::{ - AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry, - ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla, - ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful, - StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation, - UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage, - pulsating_between, -}; -use language::{Buffer, Language, LanguageRegistry}; -use language_model::{ - LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, Role, StopReason, -}; -use markdown::parser::{CodeBlockKind, CodeBlockMetadata}; -use markdown::{ - HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown, PathWithRange, -}; -use project::{ProjectEntryId, ProjectItem as _}; -use rope::Point; -use settings::{Settings as _, SettingsStore, update_settings_file}; -use std::ffi::OsStr; -use std::path::Path; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; -use text::ToPoint; -use theme::ThemeSettings; -use ui::{ - Banner, Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize, - Tooltip, prelude::*, -}; -use util::ResultExt as _; -use util::markdown::MarkdownCodeBlock; -use workspace::{CollaboratorId, Workspace}; -use zed_actions::assistant::OpenRulesLibrary; - -const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container"; -const EDIT_PREVIOUS_MESSAGE_MIN_LINES: usize = 1; -const RESPONSE_PADDING_X: Pixels = px(19.); - -pub struct ActiveThread { - context_store: Entity, - language_registry: Arc, - thread_store: Entity, - text_thread_store: Entity, - thread: Entity, - workspace: WeakEntity, - save_thread_task: Option>, - messages: Vec, - list_state: ListState, - scrollbar_state: ScrollbarState, - rendered_messages_by_id: HashMap, - rendered_tool_uses: HashMap, - editing_message: Option<(MessageId, EditingMessageState)>, - expanded_tool_uses: HashMap, - expanded_thinking_segments: HashMap<(MessageId, usize), bool>, - expanded_code_blocks: HashMap<(MessageId, usize), bool>, - last_error: Option, - notifications: Vec>, - copied_code_block_ids: HashSet<(MessageId, usize)>, - _subscriptions: Vec, - notification_subscriptions: HashMap, Vec>, - open_feedback_editors: HashMap>, - _load_edited_message_context_task: Option>, -} - -struct RenderedMessage { - language_registry: Arc, - segments: Vec, -} - -#[derive(Clone)] -struct RenderedToolUse { - label: Entity, - input: Entity, - output: Entity, -} - -impl RenderedMessage { - fn from_segments( - segments: &[MessageSegment], - language_registry: Arc, - cx: &mut App, - ) -> Self { - let mut this = Self { - language_registry, - segments: Vec::with_capacity(segments.len()), - }; - for segment in segments { - this.push_segment(segment, cx); - } - this - } - - fn append_thinking(&mut self, text: &String, cx: &mut App) { - if let Some(RenderedMessageSegment::Thinking { - content, - scroll_handle, - }) = self.segments.last_mut() - { - content.update(cx, |markdown, cx| { - markdown.append(text, cx); - }); - scroll_handle.scroll_to_bottom(); - } else { - self.segments.push(RenderedMessageSegment::Thinking { - content: parse_markdown(text.into(), self.language_registry.clone(), cx), - scroll_handle: ScrollHandle::default(), - }); - } - } - - fn append_text(&mut self, text: &String, cx: &mut App) { - if let Some(RenderedMessageSegment::Text(markdown)) = self.segments.last_mut() { - markdown.update(cx, |markdown, cx| markdown.append(text, cx)); - } else { - self.segments - .push(RenderedMessageSegment::Text(parse_markdown( - SharedString::from(text), - self.language_registry.clone(), - cx, - ))); - } - } - - fn push_segment(&mut self, segment: &MessageSegment, cx: &mut App) { - match segment { - MessageSegment::Thinking { text, .. } => { - self.segments.push(RenderedMessageSegment::Thinking { - content: parse_markdown(text.into(), self.language_registry.clone(), cx), - scroll_handle: ScrollHandle::default(), - }) - } - MessageSegment::Text(text) => { - self.segments - .push(RenderedMessageSegment::Text(parse_markdown( - text.into(), - self.language_registry.clone(), - cx, - ))) - } - MessageSegment::RedactedThinking(_) => {} - }; - } -} - -enum RenderedMessageSegment { - Thinking { - content: Entity, - scroll_handle: ScrollHandle, - }, - Text(Entity), -} - -fn parse_markdown( - text: SharedString, - language_registry: Arc, - cx: &mut App, -) -> Entity { - cx.new(|cx| Markdown::new(text, Some(language_registry), None, cx)) -} - -pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let colors = cx.theme().colors(); - let ui_font_size = TextSize::Default.rems(cx); - let buffer_font_size = TextSize::Small.rems(cx); - let mut text_style = window.text_style(); - let line_height = buffer_font_size * 1.75; - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(ui_font_size.into()), - line_height: Some(line_height.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().colors().element_selection_background, - code_block_overflow_x_scroll: true, - table_overflow_x_scroll: true, - heading_level_styles: Some(HeadingLevelStyles { - h1: Some(TextStyleRefinement { - font_size: Some(rems(1.15).into()), - ..Default::default() - }), - h2: Some(TextStyleRefinement { - font_size: Some(rems(1.1).into()), - ..Default::default() - }), - h3: Some(TextStyleRefinement { - font_size: Some(rems(1.05).into()), - ..Default::default() - }), - h4: Some(TextStyleRefinement { - font_size: Some(rems(1.).into()), - ..Default::default() - }), - h5: Some(TextStyleRefinement { - font_size: Some(rems(0.95).into()), - ..Default::default() - }), - h6: Some(TextStyleRefinement { - font_size: Some(rems(0.875).into()), - ..Default::default() - }), - }), - code_block: StyleRefinement { - padding: EdgesRefinement { - top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), - }, - background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(buffer_font_size.into()), - ..Default::default() - }), - ..Default::default() - }, - inline_code: TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(buffer_font_size.into()), - background_color: Some(colors.editor_foreground.opacity(0.08)), - ..Default::default() - }, - link: TextStyleRefinement { - background_color: Some(colors.editor_foreground.opacity(0.025)), - underline: Some(UnderlineStyle { - color: Some(colors.text_accent.opacity(0.5)), - thickness: px(1.), - ..Default::default() - }), - ..Default::default() - }, - link_callback: Some(Rc::new(move |url, cx| { - if MentionLink::is_valid(url) { - let colors = cx.theme().colors(); - Some(TextStyleRefinement { - background_color: Some(colors.element_background), - ..Default::default() - }) - } else { - None - } - })), - ..Default::default() - } -} - -fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let colors = cx.theme().colors(); - let ui_font_size = TextSize::Default.rems(cx); - let buffer_font_size = TextSize::Small.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style, - syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().colors().element_selection_background, - code_block_overflow_x_scroll: false, - code_block: StyleRefinement { - margin: EdgesRefinement::default(), - padding: EdgesRefinement::default(), - background: Some(colors.editor_background.into()), - border_color: None, - border_widths: EdgesRefinement::default(), - text: Some(TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(buffer_font_size.into()), - ..Default::default() - }), - ..Default::default() - }, - inline_code: TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(TextSize::XSmall.rems(cx).into()), - ..Default::default() - }, - heading: StyleRefinement { - text: Some(TextStyleRefinement { - font_size: Some(ui_font_size.into()), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - } -} - -fn render_markdown_code_block( - message_id: MessageId, - ix: usize, - kind: &CodeBlockKind, - parsed_markdown: &ParsedMarkdown, - metadata: CodeBlockMetadata, - active_thread: Entity, - workspace: WeakEntity, - _window: &Window, - cx: &App, -) -> Div { - let label_size = rems(0.8125); - - let label = match kind { - CodeBlockKind::Indented => None, - CodeBlockKind::Fenced => Some( - h_flex() - .px_1() - .gap_1() - .child( - Icon::new(IconName::Code) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(div().text_size(label_size).child("Plain Text")) - .into_any_element(), - ), - CodeBlockKind::FencedLang(raw_language_name) => Some(render_code_language( - parsed_markdown.languages_by_name.get(raw_language_name), - raw_language_name.clone(), - cx, - )), - CodeBlockKind::FencedSrc(path_range) => path_range.path.file_name().map(|file_name| { - // We tell the model to use /dev/null for the path instead of using ```language - // because otherwise it consistently fails to use code citations. - if path_range.path.starts_with("/dev/null") { - let ext = path_range - .path - .extension() - .and_then(OsStr::to_str) - .map(|str| SharedString::new(str.to_string())) - .unwrap_or_default(); - - render_code_language( - parsed_markdown - .languages_by_path - .get(&path_range.path) - .or_else(|| parsed_markdown.languages_by_name.get(&ext)), - ext, - cx, - ) - } else { - let content = if let Some(parent) = path_range.path.parent() { - let file_name = file_name.to_string_lossy().to_string(); - let path = parent.to_string_lossy().to_string(); - let path_and_file = format!("{}/{}", path, file_name); - - h_flex() - .id(("code-block-header-label", ix)) - .ml_1() - .gap_1() - .child(div().text_size(label_size).child(file_name)) - .child(Label::new(path).color(Color::Muted).size(LabelSize::Small)) - .tooltip(move |window, cx| { - Tooltip::with_meta( - "Jump to File", - None, - path_and_file.clone(), - window, - cx, - ) - }) - .into_any_element() - } else { - div() - .ml_1() - .text_size(label_size) - .child(path_range.path.to_string_lossy().to_string()) - .into_any_element() - }; - - h_flex() - .id(("code-block-header-button", ix)) - .w_full() - .max_w_full() - .px_1() - .gap_0p5() - .cursor_pointer() - .rounded_sm() - .hover(|item| item.bg(cx.theme().colors().element_hover.opacity(0.5))) - .child( - h_flex() - .gap_0p5() - .children( - file_icons::FileIcons::get_icon(&path_range.path, cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)), - ) - .child(content) - .child( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Ignored), - ), - ) - .on_click({ - let path_range = path_range.clone(); - move |_, window, cx| { - workspace - .update(cx, |workspace, cx| { - open_path(&path_range, window, workspace, cx) - }) - .ok(); - } - }) - .into_any_element() - } - }), - }; - - let codeblock_was_copied = active_thread - .read(cx) - .copied_code_block_ids - .contains(&(message_id, ix)); - - let is_expanded = active_thread.read(cx).is_codeblock_expanded(message_id, ix); - - let codeblock_header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let control_buttons = h_flex() - .visible_on_hover(CODEBLOCK_CONTAINER_GROUP) - .absolute() - .top_0() - .right_0() - .h_full() - .bg(codeblock_header_bg) - .rounded_tr_md() - .px_1() - .gap_1() - .child( - IconButton::new( - ("copy-markdown-code", ix), - if codeblock_was_copied { - IconName::Check - } else { - IconName::Copy - }, - ) - .icon_color(Color::Muted) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) - .on_click({ - let active_thread = active_thread.clone(); - let parsed_markdown = parsed_markdown.clone(); - let code_block_range = metadata.content_range.clone(); - move |_event, _window, cx| { - active_thread.update(cx, |this, cx| { - this.copied_code_block_ids.insert((message_id, ix)); - - let code = parsed_markdown.source()[code_block_range.clone()].to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(code)); - - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - cx.update(|cx| { - this.update(cx, |this, cx| { - this.copied_code_block_ids.remove(&(message_id, ix)); - cx.notify(); - }) - }) - .ok(); - }) - .detach(); - }); - } - }), - ) - .child( - IconButton::new( - ("expand-collapse-code", ix), - if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }, - ) - .icon_color(Color::Muted) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text(if is_expanded { - "Collapse Code" - } else { - "Expand Code" - })) - .on_click({ - let active_thread = active_thread.clone(); - move |_event, _window, cx| { - active_thread.update(cx, |this, cx| { - this.toggle_codeblock_expanded(message_id, ix); - cx.notify(); - }); - } - }), - ); - - let codeblock_header = h_flex() - .relative() - .p_1() - .gap_1() - .justify_between() - .bg(codeblock_header_bg) - .map(|this| { - if !is_expanded { - this.rounded_md() - } else { - this.rounded_t_md() - .border_b_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - } - }) - .children(label) - .child(control_buttons); - - v_flex() - .group(CODEBLOCK_CONTAINER_GROUP) - .my_2() - .overflow_hidden() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.6)) - .bg(cx.theme().colors().editor_background) - .child(codeblock_header) - .when(!is_expanded, |this| this.h(rems_from_px(31.))) -} - -fn open_path( - path_range: &PathWithRange, - window: &mut Window, - workspace: &mut Workspace, - cx: &mut Context<'_, Workspace>, -) { - let Some(project_path) = workspace - .project() - .read(cx) - .find_project_path(&path_range.path, cx) - else { - return; // TODO instead of just bailing out, open that path in a buffer. - }; - - let Some(target) = path_range.range.as_ref().map(|range| { - Point::new( - // Line number is 1-based - range.start.line.saturating_sub(1), - range.start.col.unwrap_or(0), - ) - }) else { - return; - }; - let open_task = workspace.open_path(project_path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = item.downcast::() { - active_editor - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(target, window, cx); - }) - .ok(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); -} - -fn render_code_language( - language: Option<&Arc>, - name_fallback: SharedString, - cx: &App, -) -> AnyElement { - let icon_path = language.and_then(|language| { - language - .config() - .matcher - .path_suffixes - .iter() - .find_map(|extension| file_icons::FileIcons::get_icon(Path::new(extension), cx)) - .map(Icon::from_path) - }); - - let language_label = language - .map(|language| language.name().into()) - .unwrap_or(name_fallback); - - let label_size = rems(0.8125); - - h_flex() - .px_1() - .gap_1p5() - .children(icon_path.map(|icon| icon.color(Color::Muted).size(IconSize::XSmall))) - .child(div().text_size(label_size).child(language_label)) - .into_any_element() -} - -fn open_markdown_link( - text: SharedString, - workspace: WeakEntity, - window: &mut Window, - cx: &mut App, -) { - let Some(workspace) = workspace.upgrade() else { - cx.open_url(&text); - return; - }; - - match MentionLink::try_parse(&text, &workspace, cx) { - Some(MentionLink::File(path, entry)) => workspace.update(cx, |workspace, cx| { - if entry.is_dir() { - workspace.project().update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry.id)); - }) - } else { - workspace - .open_path(path, None, true, window, cx) - .detach_and_log_err(cx); - } - }), - Some(MentionLink::Symbol(path, symbol_name)) => { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(path, None, true, window, cx) - }); - window - .spawn(cx, async move |cx| { - let active_editor = open_task - .await? - .downcast::() - .context("Item is not an editor")?; - active_editor.update_in(cx, |editor, window, cx| { - let symbol_range = editor - .buffer() - .read(cx) - .snapshot(cx) - .outline(None) - .and_then(|outline| { - outline - .find_most_similar(&symbol_name) - .map(|(_, item)| item.range.clone()) - }) - .context("Could not find matching symbol")?; - - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_anchor_ranges([symbol_range.start..symbol_range.start]), - ); - anyhow::Ok(()) - }) - }) - .detach_and_log_err(cx); - } - Some(MentionLink::Selection(path, line_range)) => { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(path, None, true, window, cx) - }); - window - .spawn(cx, async move |cx| { - let active_editor = open_task - .await? - .downcast::() - .context("Item is not an editor")?; - active_editor.update_in(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| { - s.select_ranges([Point::new(line_range.start as u32, 0) - ..Point::new(line_range.start as u32, 0)]) - }, - ); - anyhow::Ok(()) - }) - }) - .detach_and_log_err(cx); - } - Some(MentionLink::Thread(thread_id)) => workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_thread_by_id(&thread_id, window, cx) - .detach_and_log_err(cx) - }); - } - }), - Some(MentionLink::TextThread(path)) => workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel - .open_saved_prompt_editor(path, window, cx) - .detach_and_log_err(cx); - }); - } - }), - Some(MentionLink::Fetch(url)) => cx.open_url(&url), - Some(MentionLink::Rule(prompt_id)) => window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(prompt_id.0), - }), - cx, - ), - None => cx.open_url(&text), - } -} - -struct EditingMessageState { - editor: Entity, - context_strip: Entity, - context_picker_menu_handle: PopoverMenuHandle, - last_estimated_token_count: Option, - _subscriptions: [Subscription; 2], - _update_token_count_task: Option>, -} - -impl ActiveThread { - pub fn new( - thread: Entity, - thread_store: Entity, - text_thread_store: Entity, - context_store: Entity, - language_registry: Arc, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let subscriptions = vec![ - cx.observe(&thread, |_, _, cx| cx.notify()), - cx.subscribe_in(&thread, window, Self::handle_thread_event), - cx.subscribe(&thread_store, Self::handle_rules_loading_error), - cx.observe_global::(|_, cx| cx.notify()), - ]; - - let list_state = ListState::new(0, ListAlignment::Bottom, px(2048.)); - - let workspace_subscription = if let Some(workspace) = workspace.upgrade() { - Some(cx.observe_release(&workspace, |this, _, cx| { - this.dismiss_notifications(cx); - })) - } else { - None - }; - - let mut this = Self { - language_registry, - thread_store, - text_thread_store, - context_store, - thread: thread.clone(), - workspace, - save_thread_task: None, - messages: Vec::new(), - rendered_messages_by_id: HashMap::default(), - rendered_tool_uses: HashMap::default(), - expanded_tool_uses: HashMap::default(), - expanded_thinking_segments: HashMap::default(), - expanded_code_blocks: HashMap::default(), - list_state: list_state.clone(), - scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()), - editing_message: None, - last_error: None, - copied_code_block_ids: HashSet::default(), - notifications: Vec::new(), - _subscriptions: subscriptions, - notification_subscriptions: HashMap::default(), - open_feedback_editors: HashMap::default(), - _load_edited_message_context_task: None, - }; - - for message in thread.read(cx).messages().cloned().collect::>() { - let rendered_message = RenderedMessage::from_segments( - &message.segments, - this.language_registry.clone(), - cx, - ); - this.push_rendered_message(message.id, rendered_message); - - for tool_use in thread.read(cx).tool_uses_for_message(message.id, cx) { - this.render_tool_use_markdown( - tool_use.id.clone(), - tool_use.ui_text.clone(), - &serde_json::to_string_pretty(&tool_use.input).unwrap_or_default(), - tool_use.status.text(), - cx, - ); - } - } - - if let Some(subscription) = workspace_subscription { - this._subscriptions.push(subscription); - } - - this - } - - pub fn thread(&self) -> &Entity { - &self.thread - } - - pub fn is_empty(&self) -> bool { - self.messages.is_empty() - } - - pub fn summary<'a>(&'a self, cx: &'a App) -> &'a ThreadSummary { - self.thread.read(cx).summary() - } - - pub fn regenerate_summary(&self, cx: &mut App) { - self.thread.update(cx, |thread, cx| thread.summarize(cx)) - } - - pub fn cancel_last_completion(&mut self, window: &mut Window, cx: &mut App) -> bool { - self.last_error.take(); - self.thread.update(cx, |thread, cx| { - thread.cancel_last_completion(Some(window.window_handle()), cx) - }) - } - - pub fn last_error(&self) -> Option { - self.last_error.clone() - } - - pub fn clear_last_error(&mut self) { - self.last_error.take(); - } - - /// Returns the editing message id and the estimated token count in the content - pub fn editing_message_id(&self) -> Option<(MessageId, u64)> { - self.editing_message - .as_ref() - .map(|(id, state)| (*id, state.last_estimated_token_count.unwrap_or(0))) - } - - pub fn context_store(&self) -> &Entity { - &self.context_store - } - - pub fn thread_store(&self) -> &Entity { - &self.thread_store - } - - pub fn text_thread_store(&self) -> &Entity { - &self.text_thread_store - } - - fn push_rendered_message(&mut self, id: MessageId, rendered_message: RenderedMessage) { - let old_len = self.messages.len(); - self.messages.push(id); - self.list_state.splice(old_len..old_len, 1); - self.rendered_messages_by_id.insert(id, rendered_message); - } - - fn deleted_message(&mut self, id: &MessageId) { - let Some(index) = self.messages.iter().position(|message_id| message_id == id) else { - return; - }; - self.messages.remove(index); - self.list_state.splice(index..index + 1, 0); - self.rendered_messages_by_id.remove(id); - } - - fn render_tool_use_markdown( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_label: impl Into, - tool_input: &str, - tool_output: SharedString, - cx: &mut Context, - ) { - let rendered = self - .rendered_tool_uses - .entry(tool_use_id.clone()) - .or_insert_with(|| RenderedToolUse { - label: cx.new(|cx| { - Markdown::new("".into(), Some(self.language_registry.clone()), None, cx) - }), - input: cx.new(|cx| { - Markdown::new("".into(), Some(self.language_registry.clone()), None, cx) - }), - output: cx.new(|cx| { - Markdown::new("".into(), Some(self.language_registry.clone()), None, cx) - }), - }); - - rendered.label.update(cx, |this, cx| { - this.replace(tool_label, cx); - }); - rendered.input.update(cx, |this, cx| { - this.replace( - MarkdownCodeBlock { - tag: "json", - text: tool_input, - } - .to_string(), - cx, - ); - }); - rendered.output.update(cx, |this, cx| { - this.replace(tool_output, cx); - }); - } - - fn handle_thread_event( - &mut self, - _thread: &Entity, - event: &ThreadEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ThreadEvent::CancelEditing => { - if self.editing_message.is_some() { - self.cancel_editing_message(&menu::Cancel, window, cx); - } - } - ThreadEvent::ShowError(error) => { - self.last_error = Some(error.clone()); - } - ThreadEvent::NewRequest => { - cx.notify(); - } - ThreadEvent::CompletionCanceled => { - self.thread.update(cx, |thread, cx| { - thread.project().update(cx, |project, cx| { - project.set_agent_location(None, cx); - }) - }); - self.workspace - .update(cx, |workspace, cx| { - if workspace.is_being_followed(CollaboratorId::Agent) { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); - cx.notify(); - } - ThreadEvent::StreamedCompletion - | ThreadEvent::SummaryGenerated - | ThreadEvent::SummaryChanged => { - self.save_thread(cx); - } - ThreadEvent::Stopped(reason) => { - match reason { - Ok(StopReason::EndTurn | StopReason::MaxTokens) => { - let used_tools = self.thread.read(cx).used_tools_since_last_user_message(); - self.notify_with_sound( - if used_tools { - "Finished running tools" - } else { - "New message" - }, - IconName::ZedAssistant, - window, - cx, - ); - } - Ok(StopReason::ToolUse) => { - // Don't notify for intermediate tool use - } - Ok(StopReason::Refusal) => { - self.notify_with_sound( - "Language model refused to respond", - IconName::Warning, - window, - cx, - ); - } - Err(error) => { - self.notify_with_sound( - "Agent stopped due to an error", - IconName::Warning, - window, - cx, - ); - - let error_message = error - .chain() - .map(|err| err.to_string()) - .collect::>() - .join("\n"); - self.last_error = Some(ThreadError::Message { - header: "Error".into(), - message: error_message.into(), - }); - } - } - } - ThreadEvent::ToolConfirmationNeeded => { - self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); - } - ThreadEvent::ToolUseLimitReached => { - self.notify_with_sound( - "Consecutive tool use limit reached.", - IconName::Warning, - window, - cx, - ); - } - ThreadEvent::StreamedAssistantText(message_id, text) => { - if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { - rendered_message.append_text(text, cx); - } - } - ThreadEvent::StreamedAssistantThinking(message_id, text) => { - if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { - rendered_message.append_thinking(text, cx); - } - } - ThreadEvent::MessageAdded(message_id) => { - self.clear_last_error(); - if let Some(rendered_message) = self.thread.update(cx, |thread, cx| { - thread.message(*message_id).map(|message| { - RenderedMessage::from_segments( - &message.segments, - self.language_registry.clone(), - cx, - ) - }) - }) { - self.push_rendered_message(*message_id, rendered_message); - } - - self.save_thread(cx); - cx.notify(); - } - ThreadEvent::MessageEdited(message_id) => { - self.clear_last_error(); - if let Some(index) = self.messages.iter().position(|id| id == message_id) { - if let Some(rendered_message) = self.thread.update(cx, |thread, cx| { - thread.message(*message_id).map(|message| { - let mut rendered_message = RenderedMessage { - language_registry: self.language_registry.clone(), - segments: Vec::with_capacity(message.segments.len()), - }; - for segment in &message.segments { - rendered_message.push_segment(segment, cx); - } - rendered_message - }) - }) { - self.list_state.splice(index..index + 1, 1); - self.rendered_messages_by_id - .insert(*message_id, rendered_message); - self.scroll_to_bottom(cx); - self.save_thread(cx); - cx.notify(); - } - } - } - ThreadEvent::MessageDeleted(message_id) => { - self.deleted_message(message_id); - self.save_thread(cx); - cx.notify(); - } - ThreadEvent::UsePendingTools { tool_uses } => { - for tool_use in tool_uses { - self.render_tool_use_markdown( - tool_use.id.clone(), - tool_use.ui_text.clone(), - &serde_json::to_string_pretty(&tool_use.input).unwrap_or_default(), - "".into(), - cx, - ); - } - } - ThreadEvent::StreamedToolUse { - tool_use_id, - ui_text, - input, - } => { - self.render_tool_use_markdown( - tool_use_id.clone(), - ui_text.clone(), - &serde_json::to_string_pretty(&input).unwrap_or_default(), - "".into(), - cx, - ); - } - ThreadEvent::ToolFinished { - pending_tool_use, .. - } => { - if let Some(tool_use) = pending_tool_use { - self.render_tool_use_markdown( - tool_use.id.clone(), - tool_use.ui_text.clone(), - &serde_json::to_string_pretty(&tool_use.input).unwrap_or_default(), - self.thread - .read(cx) - .output_for_tool(&tool_use.id) - .map(|output| output.clone().into()) - .unwrap_or("".into()), - cx, - ); - } - } - ThreadEvent::CheckpointChanged => cx.notify(), - ThreadEvent::ReceivedTextChunk => {} - ThreadEvent::InvalidToolInput { - tool_use_id, - ui_text, - invalid_input_json, - } => { - self.render_tool_use_markdown( - tool_use_id.clone(), - ui_text, - invalid_input_json, - self.thread - .read(cx) - .output_for_tool(tool_use_id) - .map(|output| output.clone().into()) - .unwrap_or("".into()), - cx, - ); - } - ThreadEvent::MissingToolUse { - tool_use_id, - ui_text, - } => { - self.render_tool_use_markdown( - tool_use_id.clone(), - ui_text, - "", - self.thread - .read(cx) - .output_for_tool(tool_use_id) - .map(|output| output.clone().into()) - .unwrap_or("".into()), - cx, - ); - } - ThreadEvent::ProfileChanged => { - self.save_thread(cx); - cx.notify(); - } - } - } - - fn handle_rules_loading_error( - &mut self, - _thread_store: Entity, - error: &RulesLoadingError, - cx: &mut Context, - ) { - self.last_error = Some(ThreadError::Message { - header: "Error loading rules file".into(), - message: error.message.clone(), - }); - cx.notify(); - } - - fn play_notification_sound(&self, window: &Window, cx: &mut App) { - let settings = AgentSettings::get_global(cx); - if settings.play_sound_when_agent_done && !window.is_window_active() { - Audio::play_sound(Sound::AgentDone, cx); - } - } - - fn show_notification( - &mut self, - caption: impl Into, - icon: IconName, - window: &mut Window, - cx: &mut Context, - ) { - if window.is_window_active() || !self.notifications.is_empty() { - return; - } - - let title = self.thread.read(cx).summary().unwrap_or("Agent Panel"); - - match AgentSettings::get_global(cx).notify_when_agent_waiting { - NotifyWhenAgentWaiting::PrimaryScreen => { - if let Some(primary) = cx.primary_display() { - self.pop_up(icon, caption.into(), title.clone(), window, primary, cx); - } - } - NotifyWhenAgentWaiting::AllScreens => { - let caption = caption.into(); - for screen in cx.displays() { - self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx); - } - } - NotifyWhenAgentWaiting::Never => { - // Don't show anything - } - } - } - - fn notify_with_sound( - &mut self, - caption: impl Into, - icon: IconName, - window: &mut Window, - cx: &mut Context, - ) { - self.play_notification_sound(window, cx); - self.show_notification(caption, icon, window, cx); - } - - fn pop_up( - &mut self, - icon: IconName, - caption: SharedString, - title: SharedString, - window: &mut Window, - screen: Rc, - cx: &mut Context<'_, ActiveThread>, - ) { - let options = AgentNotification::window_options(screen, cx); - - let project_name = self.workspace.upgrade().and_then(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .visible_worktrees(cx) - .next() - .map(|worktree| worktree.read(cx).root_name().to_string()) - }); - - if let Some(screen_window) = cx - .open_window(options, |_, cx| { - cx.new(|_| { - AgentNotification::new(title.clone(), caption.clone(), icon, project_name) - }) - }) - .log_err() - { - if let Some(pop_up) = screen_window.entity(cx).log_err() { - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push(cx.subscribe_in(&pop_up, window, { - |this, _, event, window, cx| match event { - AgentNotificationEvent::Accepted => { - let handle = window.window_handle(); - cx.activate(true); - - let workspace_handle = this.workspace.clone(); - - // If there are multiple Zed windows, activate the correct one. - cx.defer(move |cx| { - handle - .update(cx, |_view, window, _cx| { - window.activate_window(); - - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(_cx, |workspace, cx| { - workspace.focus_panel::(window, cx); - }); - } - }) - .log_err(); - }); - - this.dismiss_notifications(cx); - } - AgentNotificationEvent::Dismissed => { - this.dismiss_notifications(cx); - } - } - })); - - self.notifications.push(screen_window); - - // If the user manually refocuses the original window, dismiss the popup. - self.notification_subscriptions - .entry(screen_window) - .or_insert_with(Vec::new) - .push({ - let pop_up_weak = pop_up.downgrade(); - - cx.observe_window_activation(window, move |_, window, cx| { - if window.is_window_active() { - if let Some(pop_up) = pop_up_weak.upgrade() { - pop_up.update(cx, |_, cx| { - cx.emit(AgentNotificationEvent::Dismissed); - }); - } - } - }) - }); - } - } - } - - /// Spawns a task to save the active thread. - /// - /// Only one task to save the thread will be in flight at a time. - fn save_thread(&mut self, cx: &mut Context) { - let thread = self.thread.clone(); - self.save_thread_task = Some(cx.spawn(async move |this, cx| { - let task = this - .update(cx, |this, cx| { - this.thread_store - .update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx)) - }) - .ok(); - - if let Some(task) = task { - task.await.log_err(); - } - })); - } - - fn start_editing_message( - &mut self, - message_id: MessageId, - message_text: impl Into>, - message_creases: &[MessageCrease], - window: &mut Window, - cx: &mut Context, - ) { - let editor = crate::message_editor::create_editor( - self.workspace.clone(), - self.context_store.downgrade(), - self.thread_store.downgrade(), - self.text_thread_store.downgrade(), - EDIT_PREVIOUS_MESSAGE_MIN_LINES, - None, - window, - cx, - ); - editor.update(cx, |editor, cx| { - editor.set_text(message_text, window, cx); - insert_message_creases(editor, message_creases, &self.context_store, window, cx); - editor.focus_handle(cx).focus(window); - editor.move_to_end(&editor::actions::MoveToEnd, window, cx); - }); - let buffer_edited_subscription = cx.subscribe(&editor, |this, _, event, cx| match event { - EditorEvent::BufferEdited => { - this.update_editing_message_token_count(true, cx); - } - _ => {} - }); - - let context_picker_menu_handle = PopoverMenuHandle::default(); - let context_strip = cx.new(|cx| { - ContextStrip::new( - self.context_store.clone(), - self.workspace.clone(), - Some(self.thread_store.downgrade()), - Some(self.text_thread_store.downgrade()), - context_picker_menu_handle.clone(), - SuggestContextKind::File, - ModelUsageContext::Thread(self.thread.clone()), - window, - cx, - ) - }); - - let context_strip_subscription = - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event); - - self.editing_message = Some(( - message_id, - EditingMessageState { - editor: editor.clone(), - context_strip, - context_picker_menu_handle, - last_estimated_token_count: None, - _subscriptions: [buffer_edited_subscription, context_strip_subscription], - _update_token_count_task: None, - }, - )); - self.update_editing_message_token_count(false, cx); - cx.notify(); - } - - fn handle_context_strip_event( - &mut self, - _context_strip: &Entity, - event: &ContextStripEvent, - window: &mut Window, - cx: &mut Context, - ) { - if let Some((_, state)) = self.editing_message.as_ref() { - match event { - ContextStripEvent::PickerDismissed - | ContextStripEvent::BlurredEmpty - | ContextStripEvent::BlurredDown => { - let editor_focus_handle = state.editor.focus_handle(cx); - window.focus(&editor_focus_handle); - } - ContextStripEvent::BlurredUp => {} - } - } - } - - fn update_editing_message_token_count(&mut self, debounce: bool, cx: &mut Context) { - let Some((message_id, state)) = self.editing_message.as_mut() else { - return; - }; - - cx.emit(ActiveThreadEvent::EditingMessageTokenCountChanged); - state._update_token_count_task.take(); - - let Some(configured_model) = self.thread.read(cx).configured_model() else { - state.last_estimated_token_count.take(); - return; - }; - - let editor = state.editor.clone(); - let thread = self.thread.clone(); - let message_id = *message_id; - - state._update_token_count_task = Some(cx.spawn(async move |this, cx| { - if debounce { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - } - - let token_count = if let Some(task) = cx - .update(|cx| { - let Some(message) = thread.read(cx).message(message_id) else { - log::error!("Message that was being edited no longer exists"); - return None; - }; - let message_text = editor.read(cx).text(cx); - - if message_text.is_empty() && message.loaded_context.is_empty() { - return None; - } - - let mut request_message = LanguageModelRequestMessage { - role: language_model::Role::User, - content: Vec::new(), - cache: false, - }; - - message - .loaded_context - .add_to_request_message(&mut request_message); - - if !message_text.is_empty() { - request_message - .content - .push(MessageContent::Text(message_text)); - } - - let request = language_model::LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: None, - mode: None, - messages: vec![request_message], - tools: vec![], - tool_choice: None, - stop: vec![], - temperature: AgentSettings::temperature_for_model( - &configured_model.model, - cx, - ), - thinking_allowed: true, - }; - - Some(configured_model.model.count_tokens(request, cx)) - }) - .ok() - .flatten() - { - task.await.log_err() - } else { - Some(0) - }; - - if let Some(token_count) = token_count { - this.update(cx, |this, cx| { - let Some((_message_id, state)) = this.editing_message.as_mut() else { - return; - }; - - state.last_estimated_token_count = Some(token_count); - cx.emit(ActiveThreadEvent::EditingMessageTokenCountChanged); - }) - .ok(); - }; - })); - } - - fn toggle_context_picker( - &mut self, - _: &crate::ToggleContextPicker, - window: &mut Window, - cx: &mut Context, - ) { - if let Some((_, state)) = self.editing_message.as_mut() { - let handle = state.context_picker_menu_handle.clone(); - window.defer(cx, move |window, cx| { - handle.toggle(window, cx); - }); - } - } - - fn remove_all_context( - &mut self, - _: &crate::RemoveAllContext, - _window: &mut Window, - cx: &mut Context, - ) { - self.context_store.update(cx, |store, cx| store.clear(cx)); - cx.notify(); - } - - fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { - if let Some((_, state)) = self.editing_message.as_mut() { - if state.context_picker_menu_handle.is_deployed() { - cx.propagate(); - } else { - state.context_strip.focus_handle(cx).focus(window); - } - } - } - - fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context) { - attach_pasted_images_as_context(&self.context_store, cx); - } - - fn cancel_editing_message( - &mut self, - _: &menu::Cancel, - window: &mut Window, - cx: &mut Context, - ) { - self.editing_message.take(); - cx.notify(); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.focus_handle(cx).focus(window); - } - }); - } - } - - fn confirm_editing_message( - &mut self, - _: &menu::Confirm, - window: &mut Window, - cx: &mut Context, - ) { - let Some((message_id, state)) = self.editing_message.take() else { - return; - }; - - let Some(model) = self - .thread - .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)) - else { - return; - }; - - if model.provider.must_accept_terms(cx) { - cx.notify(); - return; - } - - let edited_text = state.editor.read(cx).text(cx); - - let creases = state.editor.update(cx, extract_message_creases); - - let new_context = self - .context_store - .read(cx) - .new_context_for_thread(self.thread.read(cx), Some(message_id)); - - let project = self.thread.read(cx).project().clone(); - let prompt_store = self.thread_store.read(cx).prompt_store().clone(); - - let git_store = project.read(cx).git_store().clone(); - let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx)); - - let load_context_task = context::load_context(new_context, &project, &prompt_store, cx); - self._load_edited_message_context_task = - Some(cx.spawn_in(window, async move |this, cx| { - let (context, checkpoint) = - futures::future::join(load_context_task, checkpoint).await; - let _ = this - .update_in(cx, |this, window, cx| { - this.thread.update(cx, |thread, cx| { - thread.edit_message( - message_id, - Role::User, - vec![MessageSegment::Text(edited_text)], - creases, - Some(context.loaded_context), - checkpoint.ok(), - cx, - ); - for message_id in this.messages_after(message_id) { - thread.delete_message(*message_id, cx); - } - }); - - this.thread.update(cx, |thread, cx| { - thread.advance_prompt_id(); - thread.cancel_last_completion(Some(window.window_handle()), cx); - thread.send_to_model( - model.model, - CompletionIntent::UserPrompt, - Some(window.window_handle()), - cx, - ); - }); - this._load_edited_message_context_task = None; - cx.notify(); - }) - .log_err(); - })); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.focus_handle(cx).focus(window); - } - }); - } - } - - fn messages_after(&self, message_id: MessageId) -> &[MessageId] { - self.messages - .iter() - .position(|id| *id == message_id) - .map(|index| &self.messages[index + 1..]) - .unwrap_or(&[]) - } - - fn handle_cancel_click(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - self.cancel_editing_message(&menu::Cancel, window, cx); - } - - fn handle_regenerate_click( - &mut self, - _: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - self.confirm_editing_message(&menu::Confirm, window, cx); - } - - fn handle_feedback_click( - &mut self, - message_id: MessageId, - feedback: ThreadFeedback, - window: &mut Window, - cx: &mut Context, - ) { - let report = self.thread.update(cx, |thread, cx| { - thread.report_message_feedback(message_id, feedback, cx) - }); - - cx.spawn(async move |this, cx| { - report.await?; - this.update(cx, |_this, cx| cx.notify()) - }) - .detach_and_log_err(cx); - - match feedback { - ThreadFeedback::Positive => { - self.open_feedback_editors.remove(&message_id); - } - ThreadFeedback::Negative => { - self.handle_show_feedback_comments(message_id, window, cx); - } - } - } - - fn handle_show_feedback_comments( - &mut self, - message_id: MessageId, - window: &mut Window, - cx: &mut Context, - ) { - let buffer = cx.new(|cx| { - let empty_string = String::new(); - MultiBuffer::singleton(cx.new(|cx| Buffer::local(empty_string, cx)), cx) - }); - - let editor = cx.new(|cx| { - let mut editor = Editor::new( - editor::EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(4), - }, - buffer, - None, - window, - cx, - ); - editor.set_placeholder_text( - "What went wrong? Share your feedback so we can improve.", - cx, - ); - editor - }); - - editor.read(cx).focus_handle(cx).focus(window); - self.open_feedback_editors.insert(message_id, editor); - cx.notify(); - } - - fn submit_feedback_message(&mut self, message_id: MessageId, cx: &mut Context) { - let Some(editor) = self.open_feedback_editors.get(&message_id) else { - return; - }; - - let report_task = self.thread.update(cx, |thread, cx| { - thread.report_message_feedback(message_id, ThreadFeedback::Negative, cx) - }); - - let comments = editor.read(cx).text(cx); - if !comments.is_empty() { - let thread_id = self.thread.read(cx).id().clone(); - let comments_value = String::from(comments.as_str()); - - let message_content = self - .thread - .read(cx) - .message(message_id) - .map(|msg| msg.to_string()) - .unwrap_or_default(); - - telemetry::event!( - "Assistant Thread Feedback Comments", - thread_id, - message_id = message_id.as_usize(), - message_content, - comments = comments_value - ); - - self.open_feedback_editors.remove(&message_id); - - cx.spawn(async move |this, cx| { - report_task.await?; - this.update(cx, |_this, cx| cx.notify()) - }) - .detach_and_log_err(cx); - } - } - - fn render_edit_message_editor( - &self, - state: &EditingMessageState, - _window: &mut Window, - cx: &Context, - ) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let font_size = TextSize::Small - .rems(cx) - .to_pixels(settings.agent_font_size(cx)); - let line_height = font_size * 1.75; - - let colors = cx.theme().colors(); - - let text_style = TextStyle { - color: colors.text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), - ..Default::default() - }; - - v_flex() - .key_context("EditMessageEditor") - .on_action(cx.listener(Self::toggle_context_picker)) - .on_action(cx.listener(Self::remove_all_context)) - .on_action(cx.listener(Self::move_up)) - .on_action(cx.listener(Self::cancel_editing_message)) - .on_action(cx.listener(Self::confirm_editing_message)) - .capture_action(cx.listener(Self::paste)) - .min_h_6() - .w_full() - .flex_grow() - .gap_2() - .child(state.context_strip.clone()) - .child(div().pt(px(-3.)).px_neg_0p5().child(EditorElement::new( - &state.editor, - EditorStyle { - background: colors.editor_background, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ))) - } - - fn render_message( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let message_id = self.messages[ix]; - let workspace = self.workspace.clone(); - let thread = self.thread.read(cx); - - let is_first_message = ix == 0; - let is_last_message = ix == self.messages.len() - 1; - - let Some(message) = thread.message(message_id) else { - return Empty.into_any(); - }; - - let is_generating = thread.is_generating(); - let is_generating_stale = thread.is_generation_stale().unwrap_or(false); - - let loading_dots = (is_generating && is_last_message).then(|| { - h_flex() - .h_8() - .my_3() - .mx_5() - .when(is_generating_stale || message.is_hidden, |this| { - this.child(LoadingLabel::new("").size(LabelSize::Small)) - }) - }); - - if message.is_hidden { - return div().children(loading_dots).into_any(); - } - - let Some(rendered_message) = self.rendered_messages_by_id.get(&message_id) else { - return Empty.into_any(); - }; - - // Get all the data we need from thread before we start using it in closures - let checkpoint = thread.checkpoint_for_message(message_id); - let configured_model = thread.configured_model().map(|m| m.model); - let added_context = thread - .context_for_message(message_id) - .map(|context| AddedContext::new_attached(context, configured_model.as_ref(), cx)) - .collect::>(); - - let tool_uses = thread.tool_uses_for_message(message_id, cx); - let has_tool_uses = !tool_uses.is_empty(); - - let editing_message_state = self - .editing_message - .as_ref() - .filter(|(id, _)| *id == message_id) - .map(|(_, state)| state); - - let (editor_bg_color, panel_bg) = { - let colors = cx.theme().colors(); - (colors.editor_background, colors.panel_background) - }; - - let open_as_markdown = IconButton::new(("open-as-markdown", ix), IconName::FileMarkdown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Open Thread as Markdown")) - .on_click({ - let thread = self.thread.clone(); - let workspace = self.workspace.clone(); - move |_, window, cx| { - if let Some(workspace) = workspace.upgrade() { - open_active_thread_as_markdown(thread.clone(), workspace, window, cx) - .detach_and_log_err(cx); - } - } - }); - - let scroll_to_top = IconButton::new(("scroll_to_top", ix), IconName::ArrowUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Scroll To Top")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_top(cx); - })); - - let show_feedback = thread.is_turn_end(ix); - let feedback_container = h_flex() - .group("feedback_container") - .mt_1() - .py_2() - .px(RESPONSE_PADDING_X) - .mr_1() - .gap_1() - .opacity(0.4) - .hover(|style| style.opacity(1.)) - .gap_1p5() - .flex_wrap() - .justify_end(); - let feedback_items = match self.thread.read(cx).message_feedback(message_id) { - Some(feedback) => feedback_container - .child( - div().visible_on_hover("feedback_container").child( - Label::new(match feedback { - ThreadFeedback::Positive => "Thanks for your feedback!", - ThreadFeedback::Negative => { - "We appreciate your feedback and will use it to improve." - } - }) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate()) - ) - .child( - h_flex() - .child( - IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - ThreadFeedback::Positive => Color::Accent, - ThreadFeedback::Negative => Color::Ignored, - }) - .tooltip(Tooltip::text("Helpful Response")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - message_id, - ThreadFeedback::Positive, - window, - cx, - ); - })), - ) - .child( - IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - ThreadFeedback::Positive => Color::Ignored, - ThreadFeedback::Negative => Color::Accent, - }) - .tooltip(Tooltip::text("Not Helpful")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - message_id, - ThreadFeedback::Negative, - window, - cx, - ); - })), - ) - .child(open_as_markdown), - ) - .into_any_element(), - None if AgentSettings::get_global(cx).enable_feedback => - feedback_container - .child( - div().visible_on_hover("feedback_container").child( - Label::new( - "Rating the thread sends all of your current conversation to the Zed team.", - ) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate()) - ) - .child( - h_flex() - .child( - IconButton::new(("feedback-thumbs-up", ix), IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Helpful Response")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - message_id, - ThreadFeedback::Positive, - window, - cx, - ); - })), - ) - .child( - IconButton::new(("feedback-thumbs-down", ix), IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Ignored) - .tooltip(Tooltip::text("Not Helpful")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - message_id, - ThreadFeedback::Negative, - window, - cx, - ); - })), - ) - .child(open_as_markdown) - .child(scroll_to_top), - ) - .into_any_element(), - None => feedback_container - .child(h_flex() - .child(open_as_markdown)) - .child(scroll_to_top) - .into_any_element(), - }; - - let message_is_empty = message.should_display_content(); - let has_content = !message_is_empty || !added_context.is_empty(); - - let message_content = has_content.then(|| { - if let Some(state) = editing_message_state.as_ref() { - self.render_edit_message_editor(state, window, cx) - .into_any_element() - } else { - v_flex() - .w_full() - .gap_1() - .when(!added_context.is_empty(), |parent| { - parent.child(h_flex().flex_wrap().gap_1().children( - added_context.into_iter().map(|added_context| { - let context = added_context.handle.clone(); - ContextPill::added(added_context, false, false, None).on_click( - Rc::new(cx.listener({ - let workspace = workspace.clone(); - move |_, _, window, cx| { - if let Some(workspace) = workspace.upgrade() { - open_context(&context, workspace, window, cx); - cx.notify(); - } - } - })), - ) - }), - )) - }) - .when(!message_is_empty, |parent| { - parent.child(div().pt_0p5().min_h_6().child(self.render_message_content( - message_id, - rendered_message, - has_tool_uses, - workspace.clone(), - window, - cx, - ))) - }) - .into_any_element() - } - }); - - let styled_message = if message.ui_only { - self.render_ui_notification(message_content, ix, cx) - } else { - match message.role { - Role::User => { - let colors = cx.theme().colors(); - v_flex() - .id(("message-container", ix)) - .pt_2() - .pl_2() - .pr_2p5() - .pb_4() - .child( - v_flex() - .id(("user-message", ix)) - .bg(editor_bg_color) - .rounded_lg() - .shadow_md() - .border_1() - .border_color(colors.border) - .hover(|hover| hover.border_color(colors.text_accent.opacity(0.5))) - .child( - v_flex() - .p_2p5() - .gap_1() - .children(message_content) - .when_some(editing_message_state, |this, state| { - let focus_handle = state.editor.focus_handle(cx).clone(); - - this.child( - h_flex() - .w_full() - .gap_1() - .justify_between() - .flex_wrap() - .child( - h_flex() - .gap_1p5() - .child( - div() - .opacity(0.8) - .child( - Icon::new(IconName::Warning) - .size(IconSize::Indicator) - .color(Color::Warning) - ), - ) - .child( - Label::new("Editing will restart the thread from this point.") - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .child( - h_flex() - .gap_0p5() - .child( - IconButton::new( - "cancel-edit-message", - IconName::Close, - ) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Error) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &menu::Cancel, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(Self::handle_cancel_click)), - ) - .child( - IconButton::new( - "confirm-edit-message", - IconName::Return, - ) - .disabled(state.editor.read(cx).is_empty(cx)) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Regenerate", - &menu::Confirm, - &focus_handle, - window, - cx, - ) - } - }) - .on_click( - cx.listener(Self::handle_regenerate_click), - ), - ), - ) - ) - }), - ) - .on_click(cx.listener({ - let message_creases = message.creases.clone(); - move |this, _, window, cx| { - if let Some(message_text) = - this.thread.read(cx).message(message_id).and_then(|message| { - message.segments.first().and_then(|segment| { - match segment { - MessageSegment::Text(message_text) => { - Some(Into::>::into(message_text.as_str())) - } - _ => { - None - } - } - }) - }) - { - this.start_editing_message( - message_id, - message_text, - &message_creases, - window, - cx, - ); - } - } - })), - ) - } - Role::Assistant => v_flex() - .id(("message-container", ix)) - .px(RESPONSE_PADDING_X) - .gap_2() - .children(message_content) - .when(has_tool_uses, |parent| { - parent.children(tool_uses.into_iter().map(|tool_use| { - self.render_tool_use(tool_use, window, workspace.clone(), cx) - })) - }), - Role::System => { - let colors = cx.theme().colors(); - div().id(("message-container", ix)).py_1().px_2().child( - v_flex() - .bg(colors.editor_background) - .rounded_sm() - .child(div().p_4().children(message_content)), - ) - } - } - }; - - let after_editing_message = self - .editing_message - .as_ref() - .map_or(false, |(editing_message_id, _)| { - message_id > *editing_message_id - }); - - let backdrop = div() - .id(("backdrop", ix)) - .size_full() - .absolute() - .inset_0() - .bg(panel_bg) - .opacity(0.8) - .block_mouse_except_scroll() - .on_click(cx.listener(Self::handle_cancel_click)); - - v_flex() - .w_full() - .map(|parent| { - if let Some(checkpoint) = checkpoint.filter(|_| !is_generating) { - let mut is_pending = false; - let mut error = None; - if let Some(last_restore_checkpoint) = - self.thread.read(cx).last_restore_checkpoint() - { - if last_restore_checkpoint.message_id() == message_id { - match last_restore_checkpoint { - LastRestoreCheckpoint::Pending { .. } => is_pending = true, - LastRestoreCheckpoint::Error { error: err, .. } => { - error = Some(err.clone()); - } - } - } - } - - let restore_checkpoint_button = - Button::new(("restore-checkpoint", ix), "Restore Checkpoint") - .icon(if error.is_some() { - IconName::XCircle - } else { - IconName::Undo - }) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) - .icon_color(if error.is_some() { - Some(Color::Error) - } else { - None - }) - .label_size(LabelSize::XSmall) - .disabled(is_pending) - .on_click(cx.listener(move |this, _, _window, cx| { - this.thread.update(cx, |thread, cx| { - thread - .restore_checkpoint(checkpoint.clone(), cx) - .detach_and_log_err(cx); - }); - })); - - let restore_checkpoint_button = if is_pending { - restore_checkpoint_button - .with_animation( - ("pulsating-restore-checkpoint-button", ix), - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - |label, delta| label.alpha(delta), - ) - .into_any_element() - } else if let Some(error) = error { - restore_checkpoint_button - .tooltip(Tooltip::text(error.to_string())) - .into_any_element() - } else { - restore_checkpoint_button.into_any_element() - }; - - parent.child( - h_flex() - .pt_2p5() - .px_2p5() - .w_full() - .gap_1() - .child(ui::Divider::horizontal()) - .child(restore_checkpoint_button) - .child(ui::Divider::horizontal()), - ) - } else { - parent - } - }) - .when(is_first_message, |parent| { - parent.child(self.render_rules_item(cx)) - }) - .child(styled_message) - .children(loading_dots) - .when(show_feedback, move |parent| { - parent.child(feedback_items).when_some( - self.open_feedback_editors.get(&message_id), - move |parent, feedback_editor| { - let focus_handle = feedback_editor.focus_handle(cx); - parent.child( - v_flex() - .key_context("AgentFeedbackMessageEditor") - .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { - this.open_feedback_editors.remove(&message_id); - cx.notify(); - })) - .on_action(cx.listener(move |this, _: &menu::Confirm, _, cx| { - this.submit_feedback_message(message_id, cx); - cx.notify(); - })) - .on_action(cx.listener(Self::confirm_editing_message)) - .mb_2() - .mx_4() - .p_2() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child(feedback_editor.clone()) - .child( - h_flex() - .gap_1() - .justify_end() - .child( - Button::new("dismiss-feedback-message", "Cancel") - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener( - move |this, _, _window, cx| { - this.open_feedback_editors - .remove(&message_id); - cx.notify(); - }, - )), - ) - .child( - Button::new( - "submit-feedback-message", - "Share Feedback", - ) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click( - cx.listener(move |this, _, _window, cx| { - this.submit_feedback_message(message_id, cx); - cx.notify() - }), - ), - ), - ), - ) - }, - ) - }) - .when(after_editing_message, |parent| { - // Backdrop to dim out the whole thread below the editing user message - parent.relative().child(backdrop) - }) - .into_any() - } - - fn render_message_content( - &self, - message_id: MessageId, - rendered_message: &RenderedMessage, - has_tool_uses: bool, - workspace: WeakEntity, - window: &Window, - cx: &Context, - ) -> impl IntoElement { - let is_last_message = self.messages.last() == Some(&message_id); - let is_generating = self.thread.read(cx).is_generating(); - let pending_thinking_segment_index = if is_generating && is_last_message && !has_tool_uses { - rendered_message - .segments - .iter() - .enumerate() - .next_back() - .filter(|(_, segment)| matches!(segment, RenderedMessageSegment::Thinking { .. })) - .map(|(index, _)| index) - } else { - None - }; - - let message_role = self - .thread - .read(cx) - .message(message_id) - .map(|m| m.role) - .unwrap_or(Role::User); - - let is_assistant_message = message_role == Role::Assistant; - let is_user_message = message_role == Role::User; - - v_flex() - .text_ui(cx) - .gap_2() - .when(is_user_message, |this| this.text_xs()) - .children( - rendered_message.segments.iter().enumerate().map( - |(index, segment)| match segment { - RenderedMessageSegment::Thinking { - content, - scroll_handle, - } => self - .render_message_thinking_segment( - message_id, - index, - content.clone(), - &scroll_handle, - Some(index) == pending_thinking_segment_index, - window, - cx, - ) - .into_any_element(), - RenderedMessageSegment::Text(markdown) => { - let markdown_element = MarkdownElement::new( - markdown.clone(), - if is_user_message { - let mut style = default_markdown_style(window, cx); - let mut text_style = window.text_style(); - let theme_settings = ThemeSettings::get_global(cx); - - let buffer_font = theme_settings.buffer_font.family.clone(); - let buffer_font_size = TextSize::Small.rems(cx); - - text_style.refine(&TextStyleRefinement { - font_family: Some(buffer_font), - font_size: Some(buffer_font_size.into()), - ..Default::default() - }); - - style.base_text_style = text_style; - style - } else { - default_markdown_style(window, cx) - }, - ); - - let markdown_element = if is_assistant_message { - markdown_element.code_block_renderer( - markdown::CodeBlockRenderer::Custom { - render: Arc::new({ - let workspace = workspace.clone(); - let active_thread = cx.entity(); - move |kind, - parsed_markdown, - range, - metadata, - window, - cx| { - render_markdown_code_block( - message_id, - range.start, - kind, - parsed_markdown, - metadata, - active_thread.clone(), - workspace.clone(), - window, - cx, - ) - } - }), - transform: Some(Arc::new({ - let active_thread = cx.entity(); - - move |element, range, _, _, cx| { - let is_expanded = active_thread - .read(cx) - .is_codeblock_expanded(message_id, range.start); - - if is_expanded { - return element; - } - - element - } - })), - }, - ) - } else { - markdown_element.code_block_renderer( - markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, - border: true, - }, - ) - }; - - div() - .child(markdown_element.on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - })) - .into_any_element() - } - }, - ), - ) - } - - fn tool_card_border_color(&self, cx: &Context) -> Hsla { - cx.theme().colors().border.opacity(0.5) - } - - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { - cx.theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)) - } - - fn render_ui_notification( - &self, - message_content: impl IntoIterator, - ix: usize, - cx: &mut Context, - ) -> Stateful
{ - let message = div() - .flex_1() - .min_w_0() - .text_size(TextSize::XSmall.rems(cx)) - .text_color(cx.theme().colors().text_muted) - .children(message_content); - - div() - .id(("message-container", ix)) - .py_1() - .px_2p5() - .child(Banner::new().severity(ui::Severity::Warning).child(message)) - } - - fn render_message_thinking_segment( - &self, - message_id: MessageId, - ix: usize, - markdown: Entity, - scroll_handle: &ScrollHandle, - pending: bool, - window: &Window, - cx: &Context, - ) -> impl IntoElement { - let is_open = self - .expanded_thinking_segments - .get(&(message_id, ix)) - .copied() - .unwrap_or_default(); - - let editor_bg = cx.theme().colors().panel_background; - - div().map(|this| { - if pending { - this.v_flex() - .mt_neg_2() - .mb_1p5() - .child( - h_flex() - .group("disclosure-header") - .justify_between() - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(LoadingLabel::new("Thinking").size(LabelSize::Small)), - ) - .child( - h_flex() - .gap_1() - .child( - div().visible_on_hover("disclosure-header").child( - Disclosure::new("thinking-disclosure", is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - move |this, _event, _window, _cx| { - let is_open = this - .expanded_thinking_segments - .entry((message_id, ix)) - .or_insert(false); - - *is_open = !*is_open; - } - })), - ), - ) - .child({ - Icon::new(IconName::ArrowCircle) - .color(Color::Accent) - .size(IconSize::Small) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate( - percentage(delta), - )) - }, - ) - }), - ), - ) - .when(!is_open, |this| { - let gradient_overlay = div() - .rounded_b_lg() - .h_full() - .absolute() - .w_full() - .bottom_0() - .left_0() - .bg(linear_gradient( - 180., - linear_color_stop(editor_bg, 1.), - linear_color_stop(editor_bg.opacity(0.2), 0.), - )); - - this.child( - div() - .relative() - .bg(editor_bg) - .rounded_b_lg() - .mt_2() - .pl_4() - .child( - div() - .id(("thinking-content", ix)) - .max_h_20() - .track_scroll(scroll_handle) - .text_ui_sm(cx) - .overflow_hidden() - .child( - MarkdownElement::new( - markdown.clone(), - default_markdown_style(window, cx), - ) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link( - text, - workspace.clone(), - window, - cx, - ); - } - }), - ), - ) - .child(gradient_overlay), - ) - }) - .when(is_open, |this| { - this.child( - div() - .id(("thinking-content", ix)) - .h_full() - .bg(editor_bg) - .text_ui_sm(cx) - .child( - MarkdownElement::new( - markdown.clone(), - default_markdown_style(window, cx), - ) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - }), - ), - ) - }) - } else { - this.v_flex() - .mt_neg_2() - .child( - h_flex() - .group("disclosure-header") - .pr_1() - .justify_between() - .opacity(0.8) - .hover(|style| style.opacity(1.)) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new("Thought Process").size(LabelSize::Small)), - ) - .child( - div().visible_on_hover("disclosure-header").child( - Disclosure::new("thinking-disclosure", is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - move |this, _event, _window, _cx| { - let is_open = this - .expanded_thinking_segments - .entry((message_id, ix)) - .or_insert(false); - - *is_open = !*is_open; - } - })), - ), - ), - ) - .child( - div() - .id(("thinking-content", ix)) - .relative() - .mt_1p5() - .ml_1p5() - .pl_2p5() - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .text_ui_sm(cx) - .when(is_open, |this| { - this.child( - MarkdownElement::new( - markdown.clone(), - default_markdown_style(window, cx), - ) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - }), - ) - }), - ) - } - }) - } - - fn render_tool_use( - &self, - tool_use: ToolUse, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement + use<> { - if let Some(card) = self.thread.read(cx).card_for_tool(&tool_use.id) { - return card.render(&tool_use.status, window, workspace, cx); - } - - let is_open = self - .expanded_tool_uses - .get(&tool_use.id) - .copied() - .unwrap_or_default(); - - let is_status_finished = matches!(&tool_use.status, ToolUseStatus::Finished(_)); - - let fs = self - .workspace - .upgrade() - .map(|workspace| workspace.read(cx).app_state().fs.clone()); - let needs_confirmation = matches!(&tool_use.status, ToolUseStatus::NeedsConfirmation); - let needs_confirmation_tools = tool_use.needs_confirmation; - - let status_icons = div().child(match &tool_use.status { - ToolUseStatus::NeedsConfirmation => { - let icon = Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::Small); - icon.into_any_element() - } - ToolUseStatus::Pending - | ToolUseStatus::InputStillStreaming - | ToolUseStatus::Running => { - let icon = Icon::new(IconName::ArrowCircle) - .color(Color::Accent) - .size(IconSize::Small); - icon.with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) - .into_any_element() - } - ToolUseStatus::Finished(_) => div().w_0().into_any_element(), - ToolUseStatus::Error(_) => { - let icon = Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small); - icon.into_any_element() - } - }); - - let rendered_tool_use = self.rendered_tool_uses.get(&tool_use.id).cloned(); - let results_content_container = || v_flex().p_2().gap_0p5(); - - let results_content = v_flex() - .gap_1() - .child( - results_content_container() - .child( - Label::new("Input") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .child( - div() - .w_full() - .text_ui_sm(cx) - .children(rendered_tool_use.as_ref().map(|rendered| { - MarkdownElement::new( - rendered.input.clone(), - tool_use_markdown_style(window, cx), - ) - .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, - border: false, - }) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - }) - })), - ), - ) - .map(|container| match tool_use.status { - ToolUseStatus::Finished(_) => container.child( - results_content_container() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - Label::new("Result") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(div().w_full().text_ui_sm(cx).children( - rendered_tool_use.as_ref().map(|rendered| { - MarkdownElement::new( - rendered.output.clone(), - tool_use_markdown_style(window, cx), - ) - .code_block_renderer(markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: false, - border: false, - }) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - }) - .into_any_element() - }), - )), - ), - ToolUseStatus::InputStillStreaming | ToolUseStatus::Running => container.child( - results_content_container() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Accent) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage( - delta, - ))) - }, - ), - ) - .child( - Label::new("Running…") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ), - ), - ), - ToolUseStatus::Error(_) => container.child( - results_content_container() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - Label::new("Error") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .child( - div() - .text_ui_sm(cx) - .children(rendered_tool_use.as_ref().map(|rendered| { - MarkdownElement::new( - rendered.output.clone(), - tool_use_markdown_style(window, cx), - ) - .on_url_click({ - let workspace = self.workspace.clone(); - move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - } - }) - .into_any_element() - })), - ), - ), - ToolUseStatus::Pending => container, - ToolUseStatus::NeedsConfirmation => container.child( - results_content_container() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - Label::new("Asking Permission") - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx), - ), - ), - }); - - let gradient_overlay = |color: Hsla| { - div() - .h_full() - .absolute() - .w_12() - .bottom_0() - .map(|element| { - if is_status_finished { - element.right_6() - } else { - element.right(px(44.)) - } - }) - .bg(linear_gradient( - 90., - linear_color_stop(color, 1.), - linear_color_stop(color.opacity(0.2), 0.), - )) - }; - - v_flex().gap_1().mb_2().map(|element| { - if !needs_confirmation_tools { - element.child( - v_flex() - .child( - h_flex() - .group("disclosure-header") - .relative() - .gap_1p5() - .justify_between() - .opacity(0.8) - .hover(|style| style.opacity(1.)) - .when(!is_status_finished, |this| this.pr_2()) - .child( - h_flex() - .id("tool-label-container") - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .child( - Icon::new(tool_use.icon) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - h_flex().pr_8().text_size(rems(0.8125)).children( - rendered_tool_use.map(|rendered| MarkdownElement::new(rendered.label, tool_use_markdown_style(window, cx)).on_url_click({let workspace = self.workspace.clone(); move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - }})) - ), - ), - ) - .child( - h_flex() - .gap_1() - .child( - div().visible_on_hover("disclosure-header").child( - Disclosure::new("tool-use-disclosure", is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let tool_use_id = tool_use.id.clone(); - move |this, _event, _window, _cx| { - let is_open = this - .expanded_tool_uses - .entry(tool_use_id.clone()) - .or_insert(false); - - *is_open = !*is_open; - } - })), - ), - ) - .child(status_icons), - ) - .child(gradient_overlay(cx.theme().colors().panel_background)), - ) - .map(|parent| { - if !is_open { - return parent; - } - - parent.child( - v_flex() - .mt_1() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .rounded_lg() - .child(results_content), - ) - }), - ) - } else { - v_flex() - .mb_2() - .rounded_lg() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .overflow_hidden() - .child( - h_flex() - .group("disclosure-header") - .relative() - .justify_between() - .py_1() - .map(|element| { - if is_status_finished { - element.pl_2().pr_0p5() - } else { - element.px_2() - } - }) - .bg(self.tool_card_header_bg(cx)) - .map(|element| { - if is_open { - element.border_b_1().rounded_t_md() - } else if needs_confirmation { - element.rounded_t_md() - } else { - element.rounded_md() - } - }) - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .id("tool-label-container") - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .child( - Icon::new(tool_use.icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - h_flex().pr_8().text_ui_sm(cx).children( - rendered_tool_use.map(|rendered| MarkdownElement::new(rendered.label, tool_use_markdown_style(window, cx)).on_url_click({let workspace = self.workspace.clone(); move |text, window, cx| { - open_markdown_link(text, workspace.clone(), window, cx); - }})) - ), - ), - ) - .child( - h_flex() - .gap_1() - .child( - div().visible_on_hover("disclosure-header").child( - Disclosure::new("tool-use-disclosure", is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let tool_use_id = tool_use.id.clone(); - move |this, _event, _window, _cx| { - let is_open = this - .expanded_tool_uses - .entry(tool_use_id.clone()) - .or_insert(false); - - *is_open = !*is_open; - } - })), - ), - ) - .child(status_icons), - ) - .child(gradient_overlay(self.tool_card_header_bg(cx))), - ) - .map(|parent| { - if !is_open { - return parent; - } - - parent.child( - v_flex() - .bg(cx.theme().colors().editor_background) - .map(|element| { - if needs_confirmation { - element.rounded_none() - } else { - element.rounded_b_lg() - } - }) - .child(results_content), - ) - }) - .when(needs_confirmation, |this| { - this.child( - h_flex() - .py_1() - .pl_2() - .pr_1() - .gap_1() - .justify_between() - .flex_wrap() - .bg(cx.theme().colors().editor_background) - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .rounded_b_lg() - .child( - div() - .min_w(rems_from_px(145.)) - .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small) - ) - ) - .child( - h_flex() - .gap_0p5() - .child({ - let tool_id = tool_use.id.clone(); - Button::new( - "always-allow-tool-action", - "Always Allow", - ) - .label_size(LabelSize::Small) - .icon(IconName::CheckDouble) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .tooltip(move |window, cx| { - Tooltip::with_meta( - "Never ask for permission", - None, - "Restore the original behavior in your Agent Panel settings", - window, - cx, - ) - }) - .on_click(cx.listener( - move |this, event, window, cx| { - if let Some(fs) = fs.clone() { - update_settings_file::( - fs.clone(), - cx, - |settings, _| { - settings.set_always_allow_tool_actions(true); - }, - ); - } - this.handle_allow_tool( - tool_id.clone(), - event, - window, - cx, - ) - }, - )) - }) - .child({ - let tool_id = tool_use.id.clone(); - Button::new("allow-tool-action", "Allow") - .label_size(LabelSize::Small) - .icon(IconName::Check) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Success) - .on_click(cx.listener( - move |this, event, window, cx| { - this.handle_allow_tool( - tool_id.clone(), - event, - window, - cx, - ) - }, - )) - }) - .child({ - let tool_id = tool_use.id.clone(); - let tool_name: Arc = tool_use.name.into(); - Button::new("deny-tool", "Deny") - .label_size(LabelSize::Small) - .icon(IconName::Close) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .on_click(cx.listener( - move |this, event, window, cx| { - this.handle_deny_tool( - tool_id.clone(), - tool_name.clone(), - event, - window, - cx, - ) - }, - )) - }), - ), - ) - }) - } - }).into_any_element() - } - - fn render_rules_item(&self, cx: &Context) -> AnyElement { - let project_context = self.thread.read(cx).project_context(); - let project_context = project_context.borrow(); - let Some(project_context) = project_context.as_ref() else { - return div().into_any(); - }; - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return div().into_any(); - } - - v_flex() - .pt_2() - .px_2p5() - .gap_1() - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .w_full() - .child( - Icon::new(RULES_ICON) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate() - .buffer_font(cx) - .ml_1p5() - .mr_0p5(), - ) - .child( - IconButton::new("open-prompt-library", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Ignored) - // TODO: Figure out a way to pass focus handle here so we can display the `OpenRulesLibrary` keybinding - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .w_full() - .child( - Icon::new(IconName::File) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx) - .ml_1p5() - .mr_0p5(), - ) - .child( - IconButton::new("open-rule", IconName::ArrowUpRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Ignored) - .on_click(cx.listener(Self::handle_open_rules)) - .tooltip(Tooltip::text("View Rules")), - ), - ) - }) - .into_any() - } - - fn handle_allow_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - _: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(PendingToolUseStatus::NeedsConfirmation(c)) = self - .thread - .read(cx) - .pending_tool(&tool_use_id) - .map(|tool_use| tool_use.status.clone()) - { - self.thread.update(cx, |thread, cx| { - if let Some(configured) = thread.get_or_init_configured_model(cx) { - thread.run_tool( - c.tool_use_id.clone(), - c.ui_text.clone(), - c.input.clone(), - c.request.clone(), - c.tool.clone(), - configured.model, - Some(window.window_handle()), - cx, - ); - } - }); - } - } - - fn handle_deny_tool( - &mut self, - tool_use_id: LanguageModelToolUseId, - tool_name: Arc, - _: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - let window_handle = window.window_handle(); - self.thread.update(cx, |thread, cx| { - thread.deny_tool_use(tool_use_id, tool_name, Some(window_handle), cx); - }); - } - - fn handle_open_rules(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - let project_context = self.thread.read(cx).project_context(); - let project_context = project_context.borrow(); - let Some(project_context) = project_context.as_ref() else { - return; - }; - - let project_entry_ids = project_context - .worktrees - .iter() - .flat_map(|worktree| worktree.rules_file.as_ref()) - .map(|rules_file| ProjectEntryId::from_usize(rules_file.project_entry_id)) - .collect::>(); - - self.workspace - .update(cx, move |workspace, cx| { - // TODO: Open a multibuffer instead? In some cases this doesn't make the set of rules - // files clear. For example, if rules file 1 is already open but rules file 2 is not, - // this would open and focus rules file 2 in a tab that is not next to rules file 1. - let project = workspace.project().read(cx); - let project_paths = project_entry_ids - .into_iter() - .flat_map(|entry_id| project.path_for_entry(entry_id, cx)) - .collect::>(); - for project_path in project_paths { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - } - }) - .ok(); - } - - fn dismiss_notifications(&mut self, cx: &mut Context) { - for window in self.notifications.drain(..) { - window - .update(cx, |_, window, _| { - window.remove_window(); - }) - .ok(); - - self.notification_subscriptions.remove(&window); - } - } - - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("active-thread-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) - } - - pub fn is_codeblock_expanded(&self, message_id: MessageId, ix: usize) -> bool { - self.expanded_code_blocks - .get(&(message_id, ix)) - .copied() - .unwrap_or(true) - } - - pub fn toggle_codeblock_expanded(&mut self, message_id: MessageId, ix: usize) { - let is_expanded = self - .expanded_code_blocks - .entry((message_id, ix)) - .or_insert(true); - *is_expanded = !*is_expanded; - } - - pub fn scroll_to_top(&mut self, cx: &mut Context) { - self.list_state.scroll_to(ListOffset::default()); - cx.notify(); - } - - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - self.list_state.reset(self.messages.len()); - cx.notify(); - } -} - -pub enum ActiveThreadEvent { - EditingMessageTokenCountChanged, -} - -impl EventEmitter for ActiveThread {} - -impl Render for ActiveThread { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .relative() - .bg(cx.theme().colors().panel_background) - .child(list(self.list_state.clone(), cx.processor(Self::render_message)).flex_grow()) - .child(self.render_vertical_scrollbar(cx)) - } -} - -pub(crate) fn open_active_thread_as_markdown( - thread: Entity, - workspace: Entity, - window: &mut Window, - cx: &mut App, -) -> Task> { - let markdown_language_task = workspace - .read(cx) - .app_state() - .languages - .language_for_name("Markdown"); - - window.spawn(cx, async move |cx| { - let markdown_language = markdown_language_task.await?; - - workspace.update_in(cx, |workspace, window, cx| { - let thread = thread.read(cx); - let markdown = thread.to_markdown(cx)?; - let thread_summary = thread.summary().or_default().to_string(); - - let project = workspace.project().clone(); - - if !project.read(cx).is_local() { - anyhow::bail!("failed to open active thread as markdown in remote project"); - } - - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer(&markdown, Some(markdown_language), cx) - }); - let buffer = - cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone())); - - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); - editor.set_breadcrumb_header(thread_summary); - editor - })), - None, - true, - window, - cx, - ); - - anyhow::Ok(()) - })??; - anyhow::Ok(()) - }) -} - -pub(crate) fn open_context( - context: &AgentContextHandle, - workspace: Entity, - window: &mut Window, - cx: &mut App, -) { - match context { - AgentContextHandle::File(file_context) => { - if let Some(project_path) = file_context.project_path(cx) { - workspace.update(cx, |workspace, cx| { - workspace - .open_path(project_path, None, true, window, cx) - .detach_and_log_err(cx); - }); - } - } - - AgentContextHandle::Directory(directory_context) => { - let entry_id = directory_context.entry_id; - workspace.update(cx, |workspace, cx| { - workspace.project().update(cx, |_project, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)); - }) - }) - } - - AgentContextHandle::Symbol(symbol_context) => { - let buffer = symbol_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = symbol_context.range.start.to_point(&snapshot); - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::Selection(selection_context) => { - let buffer = selection_context.buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let snapshot = buffer.snapshot(); - let target_position = selection_context.range.start.to_point(&snapshot); - - open_editor_at_position(project_path, target_position, &workspace, window, cx) - .detach(); - } - } - - AgentContextHandle::FetchedUrl(fetched_url_context) => { - cx.open_url(&fetched_url_context.url); - } - - AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - let thread = thread_context.thread.clone(); - window.defer(cx, move |window, cx| { - panel.update(cx, |panel, cx| { - panel.open_thread(thread, window, cx); - }); - }); - } - }), - - AgentContextHandle::TextThread(text_thread_context) => { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.panel::(cx) { - let context = text_thread_context.context.clone(); - window.defer(cx, move |window, cx| { - panel.update(cx, |panel, cx| { - panel.open_prompt_editor(context, window, cx) - }); - }); - } - }) - } - - AgentContextHandle::Rules(rules_context) => window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: Some(rules_context.prompt_id.0), - }), - cx, - ), - - AgentContextHandle::Image(_) => {} - } -} - -pub(crate) fn attach_pasted_images_as_context( - context_store: &Entity, - cx: &mut App, -) -> bool { - let images = cx - .read_from_clipboard() - .map(|item| { - item.into_entries() - .filter_map(|entry| { - if let ClipboardEntry::Image(image) = entry { - Some(image) - } else { - None - } - }) - .collect::>() - }) - .unwrap_or_default(); - - if images.is_empty() { - return false; - } - cx.stop_propagation(); - - context_store.update(cx, |store, cx| { - for image in images { - store.add_image_instance(Arc::new(image), cx); - } - }); - true -} - -fn open_editor_at_position( - project_path: project::ProjectPath, - target_position: Point, - workspace: &Entity, - window: &mut Window, - cx: &mut App, -) -> Task<()> { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) - }); - window.spawn(cx, async move |cx| { - if let Some(active_editor) = open_task - .await - .log_err() - .and_then(|item| item.downcast::()) - { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(target_position, window, cx); - }) - .log_err(); - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use agent::{MessageSegment, context::ContextLoadResult, thread_store}; - use assistant_tool::{ToolRegistry, ToolWorkingSet}; - use editor::EditorSettings; - use fs::FakeFs; - use gpui::{AppContext, TestAppContext, VisualTestContext}; - use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelRegistry, - fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}, - }; - use project::Project; - use prompt_store::PromptBuilder; - use serde_json::json; - use settings::SettingsStore; - use util::path; - use workspace::CollaboratorId; - - #[gpui::test] - async fn test_agent_is_unfollowed_after_cancelling_completion(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project( - cx, - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let (cx, _active_thread, workspace, thread, model) = - setup_test_environment(cx, project.clone()).await; - - // Insert user message without any context (empty context vector) - thread.update(cx, |thread, cx| { - thread.insert_user_message( - "What is the best way to learn Rust?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - }); - - // Stream response to user message - thread.update(cx, |thread, cx| { - let intent = CompletionIntent::UserPrompt; - let request = thread.to_completion_request(model.clone(), intent, cx); - thread.stream_completion(request, model, intent, cx.active_window(), cx) - }); - // Follow the agent - cx.update(|window, cx| { - workspace.update(cx, |workspace, cx| { - workspace.follow(CollaboratorId::Agent, window, cx); - }) - }); - assert!(cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent))); - - // Cancel the current completion - thread.update(cx, |thread, cx| { - thread.cancel_last_completion(cx.active_window(), cx) - }); - - cx.executor().run_until_parked(); - - // No longer following the agent - assert!(!cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent))); - } - - #[gpui::test] - async fn test_reinserting_creases_for_edited_message(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - - let (cx, active_thread, _, thread, model) = - setup_test_environment(cx, project.clone()).await; - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model( - Some(ConfiguredModel { - provider: Arc::new(FakeLanguageModelProvider::default()), - model, - }), - cx, - ); - }); - }); - - let creases = vec![MessageCrease { - range: 14..22, - icon_path: "icon".into(), - label: "foo.txt".into(), - context: None, - }]; - - let message = thread.update(cx, |thread, cx| { - let message_id = thread.insert_user_message( - "Tell me about @foo.txt", - ContextLoadResult::default(), - None, - creases, - cx, - ); - thread.message(message_id).cloned().unwrap() - }); - - active_thread.update_in(cx, |active_thread, window, cx| { - if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { - active_thread.start_editing_message( - message.id, - message_text, - message.creases.as_slice(), - window, - cx, - ); - } - let editor = active_thread - .editing_message - .as_ref() - .unwrap() - .1 - .editor - .clone(); - editor.update(cx, |editor, cx| editor.edit([(0..13, "modified")], cx)); - active_thread.confirm_editing_message(&Default::default(), window, cx); - }); - cx.run_until_parked(); - - let message = thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap()); - active_thread.update_in(cx, |active_thread, window, cx| { - if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { - active_thread.start_editing_message( - message.id, - message_text, - message.creases.as_slice(), - window, - cx, - ); - } - let editor = active_thread - .editing_message - .as_ref() - .unwrap() - .1 - .editor - .clone(); - let text = editor.update(cx, |editor, cx| editor.text(cx)); - assert_eq!(text, "modified @foo.txt"); - }); - } - - #[gpui::test] - async fn test_editing_message_cancels_previous_completion(cx: &mut TestAppContext) { - init_test_settings(cx); - - let project = create_test_project(cx, json!({})).await; - - let (cx, active_thread, _, thread, model) = - setup_test_environment(cx, project.clone()).await; - - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model( - Some(ConfiguredModel { - provider: Arc::new(FakeLanguageModelProvider::default()), - model: model.clone(), - }), - cx, - ); - }); - }); - - // Track thread events to verify cancellation - let cancellation_events = Arc::new(std::sync::Mutex::new(Vec::new())); - let new_request_events = Arc::new(std::sync::Mutex::new(Vec::new())); - - let _subscription = cx.update(|_, cx| { - let cancellation_events = cancellation_events.clone(); - let new_request_events = new_request_events.clone(); - cx.subscribe( - &thread, - move |_thread, event: &ThreadEvent, _cx| match event { - ThreadEvent::CompletionCanceled => { - cancellation_events.lock().unwrap().push(()); - } - ThreadEvent::NewRequest => { - new_request_events.lock().unwrap().push(()); - } - _ => {} - }, - ) - }); - - // Insert a user message and start streaming a response - let message = thread.update(cx, |thread, cx| { - let message_id = thread.insert_user_message( - "Hello, how are you?", - ContextLoadResult::default(), - None, - vec![], - cx, - ); - thread.advance_prompt_id(); - thread.send_to_model( - model.clone(), - CompletionIntent::UserPrompt, - cx.active_window(), - cx, - ); - thread.message(message_id).cloned().unwrap() - }); - - cx.run_until_parked(); - - // Verify that a completion is in progress - assert!(cx.read(|cx| thread.read(cx).is_generating())); - assert_eq!(new_request_events.lock().unwrap().len(), 1); - - // Edit the message while the completion is still running - active_thread.update_in(cx, |active_thread, window, cx| { - if let Some(message_text) = message.segments.first().and_then(MessageSegment::text) { - active_thread.start_editing_message( - message.id, - message_text, - message.creases.as_slice(), - window, - cx, - ); - } - let editor = active_thread - .editing_message - .as_ref() - .unwrap() - .1 - .editor - .clone(); - editor.update(cx, |editor, cx| { - editor.set_text("What is the weather like?", window, cx); - }); - active_thread.confirm_editing_message(&Default::default(), window, cx); - }); - - cx.run_until_parked(); - - // Verify that the previous completion was canceled - assert_eq!(cancellation_events.lock().unwrap().len(), 1); - - // Verify that a new request was started after cancellation - assert_eq!(new_request_events.lock().unwrap().len(), 2); - - // Verify that the edited message contains the new text - let edited_message = - thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap()); - match &edited_message.segments[0] { - MessageSegment::Text(text) => { - assert_eq!(text, "What is the weather like?"); - } - _ => panic!("Expected text segment"), - } - } - - fn init_test_settings(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - AgentSettings::register(cx); - prompt_store::init(cx); - thread_store::init(cx); - workspace::init_settings(cx); - language_model::init_settings(cx); - ThemeSettings::register(cx); - EditorSettings::register(cx); - ToolRegistry::default_global(cx); - }); - } - - // Helper to create a test project with test files - async fn create_test_project( - cx: &mut TestAppContext, - files: serde_json::Value, - ) -> Entity { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/test"), files).await; - Project::test(fs, [path!("/test").as_ref()], cx).await - } - - async fn setup_test_environment( - cx: &mut TestAppContext, - project: Entity, - ) -> ( - &mut VisualTestContext, - Entity, - Entity, - Entity, - Arc, - ) { - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - - let thread_store = cx - .update(|_, cx| { - ThreadStore::load( - project.clone(), - cx.new(|_| ToolWorkingSet::default()), - None, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) - }) - .await - .unwrap(); - - let text_thread_store = cx - .update(|_, cx| { - TextThreadStore::new( - project.clone(), - Arc::new(PromptBuilder::new(None).unwrap()), - Default::default(), - cx, - ) - }) - .await - .unwrap(); - - let thread = thread_store.update(cx, |store, cx| store.create_thread(cx)); - let context_store = - cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade()))); - - let model = FakeLanguageModel::default(); - let model: Arc = Arc::new(model); - - let language_registry = LanguageRegistry::new(cx.executor()); - let language_registry = Arc::new(language_registry); - - let active_thread = cx.update(|window, cx| { - cx.new(|cx| { - ActiveThread::new( - thread.clone(), - thread_store.clone(), - text_thread_store, - context_store.clone(), - language_registry.clone(), - workspace.downgrade(), - window, - cx, - ) - }) - }); - - (cx, active_thread, workspace, thread, model) - } -} diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index b4ebb8206c78a3866b4d04cc9e8f5aa714c2c37a..ef0d4735d2d7690111ee2549cdee8ab31e32196e 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,21 +1,23 @@ mod add_llm_provider_modal; mod configure_context_server_modal; +mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc}; -use agent_settings::AgentSettings; -use assistant_tool::{ToolSource, ToolWorkingSet}; -use cloud_llm_client::Plan; +use agent::ContextServerRegistry; +use anyhow::Result; +use cloud_llm_client::{Plan, PlanV1, PlanV2}; use collections::HashMap; use context_server::ContextServerId; +use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable, + ScrollHandle, Subscription, Task, WeakEntity, }; use language::LanguageRegistry; use language_model::{ @@ -23,19 +25,20 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ + agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, - project_settings::{ContextServerSettings, ProjectSettings}, }; -use settings::{Settings, update_settings_file}; +use settings::{SettingsStore, update_settings_file}; use ui::{ - Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, - Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, + Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, + Indicator, PopoverMenu, Switch, SwitchColor, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; -use workspace::Workspace; +use workspace::{Workspace, create_and_open_local_file}; use zed_actions::ExtensionCategoryFilter; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; +pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; use crate::{ @@ -46,23 +49,24 @@ use crate::{ pub struct AgentConfiguration { fs: Arc, language_registry: Arc, + agent_server_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, - expanded_context_server_tools: HashMap, expanded_provider_configurations: HashMap, - tools: Entity, + context_server_registry: Entity, _registry_subscription: Subscription, scroll_handle: ScrollHandle, - scrollbar_state: ScrollbarState, + _check_for_gemini: Task<()>, } impl AgentConfiguration { pub fn new( fs: Arc, + agent_server_store: Entity, context_server_store: Entity, - tools: Entity, + context_server_registry: Entity, language_registry: Arc, workspace: WeakEntity, window: &mut Window, @@ -90,30 +94,19 @@ impl AgentConfiguration { cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) .detach(); - let scroll_handle = ScrollHandle::new(); - let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); - - let mut expanded_provider_configurations = HashMap::default(); - if LanguageModelRegistry::read_global(cx) - .provider(&ZED_CLOUD_PROVIDER_ID) - .map_or(false, |cloud_provider| cloud_provider.must_accept_terms(cx)) - { - expanded_provider_configurations.insert(ZED_CLOUD_PROVIDER_ID, true); - } - let mut this = Self { fs, language_registry, workspace, focus_handle, configuration_views_by_provider: HashMap::default(), + agent_server_store, context_server_store, - expanded_context_server_tools: HashMap::default(), - expanded_provider_configurations, - tools, + expanded_provider_configurations: HashMap::default(), + context_server_registry, _registry_subscription: registry_subscription, - scroll_handle, - scrollbar_state, + scroll_handle: ScrollHandle::new(), + _check_for_gemini: Task::ready(()), }; this.build_provider_configuration_views(window, cx); this @@ -137,7 +130,11 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) { - let configuration_view = provider.configuration_view(window, cx); + let configuration_view = provider.configuration_view( + language_model::ConfigurationViewTargetAgent::ZedAgent, + window, + cx, + ); self.configuration_views_by_provider .insert(provider.id(), configuration_view); } @@ -161,8 +158,8 @@ impl AgentConfiguration { provider: &Arc, cx: &mut Context, ) -> impl IntoElement + use<> { - let provider_id = provider.id().0.clone(); - let provider_name = provider.name().0.clone(); + let provider_id = provider.id().0; + let provider_name = provider.name().0; let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}")); let configuration_view = self @@ -188,7 +185,7 @@ impl AgentConfiguration { let is_signed_in = self .workspace .read_with(cx, |workspace, _| { - workspace.client().status().borrow().is_connected() + !workspace.client().status().borrow().is_signed_out() }) .unwrap_or(false); @@ -197,9 +194,8 @@ impl AgentConfiguration { .when(is_expanded, |this| this.mb_2()) .child( div() - .opacity(0.6) .px_2() - .child(Divider::horizontal().color(DividerColor::Border)), + .child(Divider::horizontal().color(DividerColor::BorderFaded)), ) .child( h_flex() @@ -215,7 +211,6 @@ impl AgentConfiguration { .child( h_flex() .id(provider_id_string.clone()) - .cursor_pointer() .px_2() .py_0p5() .w_full() @@ -225,7 +220,7 @@ impl AgentConfiguration { .child( h_flex() .w_full() - .gap_2() + .gap_1p5() .child( Icon::new(provider.icon()) .size(IconSize::Small) @@ -235,10 +230,7 @@ impl AgentConfiguration { h_flex() .w_full() .gap_1() - .child( - Label::new(provider_name.clone()) - .size(LabelSize::Large), - ) + .child(Label::new(provider_name.clone())) .map(|this| { if is_zed_provider && is_signed_in { this.child( @@ -265,7 +257,7 @@ impl AgentConfiguration { .closed_icon(IconName::ChevronDown), ) .on_click(cx.listener({ - let provider_id = provider.id().clone(); + let provider_id = provider.id(); move |this, _event, _window, _cx| { let is_expanded = this .expanded_provider_configurations @@ -275,15 +267,30 @@ impl AgentConfiguration { *is_expanded = !*is_expanded; } })), - ) - .when(provider.is_authenticated(cx), |parent| { + ), + ) + .child( + v_flex() + .w_full() + .px_2() + .gap_1() + .when(is_expanded, |parent| match configuration_view { + Some(configuration_view) => parent.child(configuration_view), + None => parent.child(Label::new(format!( + "No configuration view for {provider_name}", + ))), + }) + .when(is_expanded && provider.is_authenticated(cx), |parent| { parent.child( Button::new( SharedString::from(format!("new-thread-{provider_id}")), "Start New Thread", ) + .full_width() + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) .icon_position(IconPosition::Start) - .icon(IconName::Plus) + .icon(IconName::Thread) .icon_size(IconSize::Small) .icon_color(Color::Muted) .label_size(LabelSize::Small) @@ -298,17 +305,6 @@ impl AgentConfiguration { ) }), ) - .child( - div() - .w_full() - .px_2() - .when(is_expanded, |parent| match configuration_view { - Some(configuration_view) => parent.child(configuration_view), - None => parent.child(Label::new(format!( - "No configuration view for {provider_name}", - ))), - }), - ) } fn render_provider_configuration_section( @@ -333,6 +329,7 @@ impl AgentConfiguration { .gap_0p5() .child( h_flex() + .pr_1() .w_full() .gap_2() .justify_between() @@ -341,6 +338,8 @@ impl AgentConfiguration { PopoverMenu::new("add-provider-popover") .trigger( Button::new("add-provider", "Add Provider") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) .icon_position(IconPosition::Start) .icon(IconName::Plus) .icon_size(IconSize::Small) @@ -382,7 +381,7 @@ impl AgentConfiguration { ), ) .child( - Label::new("Add at least one provider to use AI-powered features.") + Label::new("Add at least one provider to use AI-powered features with Zed's native agent.") .color(Color::Muted), ), ), @@ -400,98 +399,6 @@ impl AgentConfiguration { ) } - fn render_command_permission(&mut self, cx: &mut Context) -> impl IntoElement { - let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions; - let fs = self.fs.clone(); - - SwitchField::new( - "always-allow-tool-actions-switch", - "Allow running commands without asking for confirmation", - Some( - "The agent can perform potentially destructive actions without asking for your confirmation.".into(), - ), - always_allow_tool_actions, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file::(fs.clone(), cx, move |settings, _| { - settings.set_always_allow_tool_actions(allow); - }); - }, - ) - } - - fn render_single_file_review(&mut self, cx: &mut Context) -> impl IntoElement { - let single_file_review = AgentSettings::get_global(cx).single_file_review; - let fs = self.fs.clone(); - - SwitchField::new( - "single-file-review", - "Enable single-file agent reviews", - Some("Agent edits are also displayed in single-file editors for review.".into()), - single_file_review, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file::(fs.clone(), cx, move |settings, _| { - settings.set_single_file_review(allow); - }); - }, - ) - } - - fn render_sound_notification(&mut self, cx: &mut Context) -> impl IntoElement { - let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done; - let fs = self.fs.clone(); - - SwitchField::new( - "sound-notification", - "Play sound when finished generating", - Some( - "Hear a notification sound when the agent is done generating changes or needs your input.".into(), - ), - play_sound_when_agent_done, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file::(fs.clone(), cx, move |settings, _| { - settings.set_play_sound_when_agent_done(allow); - }); - }, - ) - } - - fn render_modifier_to_send(&mut self, cx: &mut Context) -> impl IntoElement { - let use_modifier_to_send = AgentSettings::get_global(cx).use_modifier_to_send; - let fs = self.fs.clone(); - - SwitchField::new( - "modifier-send", - "Use modifier to submit a message", - Some( - "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux or Windows) required to send messages.".into(), - ), - use_modifier_to_send, - move |state, _window, cx| { - let allow = state == &ToggleState::Selected; - update_settings_file::(fs.clone(), cx, move |settings, _| { - settings.set_use_modifier_to_send(allow); - }); - }, - ) - } - - fn render_general_settings_section(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .p(DynamicSpacing::Base16.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .gap_2p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(Headline::new("General Settings")) - .child(self.render_command_permission(cx)) - .child(self.render_single_file_review(cx)) - .child(self.render_sound_notification(cx)) - .child(self.render_modifier_to_send(cx)) - } - fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { if let Some(plan) = plan { let free_chip_bg = cx @@ -509,9 +416,15 @@ impl AgentConfiguration { .blend(cx.theme().colors().text_accent.opacity(0.2)); let (plan_name, label_color, bg_color) = match plan { - Plan::ZedFree => ("Free", Color::Default, free_chip_bg), - Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), - Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree) => { + ("Free", Color::Default, free_chip_bg) + } + Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial) => { + ("Pro Trial", Color::Accent, pro_chip_bg) + } + Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro) => { + ("Pro", Color::Accent, pro_chip_bg) + } }; Chip::new(plan_name.to_string()) @@ -528,7 +441,61 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).configured_server_ids(); + let mut context_server_ids = self + .context_server_store + .read(cx) + .server_ids(cx) + .into_iter() + .collect::>(); + + // Sort context servers: ones without mcp-server- prefix first, then prefixed ones + context_server_ids.sort_by(|a, b| { + const MCP_PREFIX: &str = "mcp-server-"; + match (a.0.strip_prefix(MCP_PREFIX), b.0.strip_prefix(MCP_PREFIX)) { + // If one has mcp-server- prefix and other doesn't, non-mcp comes first + (Some(_), None) => std::cmp::Ordering::Greater, + (None, Some(_)) => std::cmp::Ordering::Less, + // If both have same prefix status, sort by appropriate key + (Some(a), Some(b)) => a.cmp(b), + (None, None) => a.0.cmp(&b.0), + } + }); + + let add_server_popover = PopoverMenu::new("add-server-popover") + .trigger( + Button::new("add-server", "Add Server") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .anchor(gpui::Corner::TopRight) + .menu({ + move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + menu.entry("Add Custom Server", None, { + |window, cx| window.dispatch_action(AddContextServer.boxed_clone(), cx) + }) + .entry("Install from Extensions", None, { + |window, cx| { + window.dispatch_action( + zed_actions::Extensions { + category_filter: Some( + ExtensionCategoryFilter::ContextServers, + ), + id: None, + } + .boxed_clone(), + cx, + ) + } + }) + })) + } + }); v_flex() .p(DynamicSpacing::Base16.rems(cx)) @@ -536,62 +503,56 @@ impl AgentConfiguration { .gap_2() .border_b_1() .border_color(cx.theme().colors().border) - .child( - v_flex() - .gap_0p5() - .child(Headline::new("Model Context Protocol (MCP) Servers")) - .child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)), - ) - .children( - context_server_ids.into_iter().map(|context_server_id| { - self.render_context_server(context_server_id, window, cx) - }), - ) .child( h_flex() + .w_full() + .items_start() .justify_between() - .gap_2() + .gap_1() .child( - h_flex().w_full().child( - Button::new("add-context-server", "Add Custom Server") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .full_width() - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(|_event, window, cx| { - window.dispatch_action(AddContextServer.boxed_clone(), cx) - }), - ), - ) - .child( - h_flex().w_full().child( - Button::new( - "install-context-server-extensions", - "Install MCP Extensions", - ) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .full_width() - .icon(IconName::ToolHammer) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(|_event, window, cx| { - window.dispatch_action( - zed_actions::Extensions { - category_filter: Some( - ExtensionCategoryFilter::ContextServers, - ), - id: None, - } - .boxed_clone(), - cx, + v_flex() + .gap_0p5() + .child(Headline::new("Model Context Protocol (MCP) Servers")) + .child( + Label::new( + "All MCP servers connected directly or via a Zed extension.", ) - }), - ), - ), + .color(Color::Muted), + ), + ) + .child(add_server_popover), ) + .child(v_flex().w_full().gap_1().map(|mut parent| { + if context_server_ids.is_empty() { + parent.child( + h_flex() + .p_4() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().colors().border.opacity(0.6)) + .rounded_sm() + .child( + Label::new("No MCP servers added yet.") + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + } else { + for (index, context_server_id) in context_server_ids.into_iter().enumerate() { + if index > 0 { + parent = parent.child( + Divider::horizontal() + .color(DividerColor::BorderFaded) + .into_any_element(), + ); + } + parent = + parent.child(self.render_context_server(context_server_id, window, cx)); + } + parent + } + })) } fn render_context_server( @@ -600,7 +561,6 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl use<> + IntoElement { - let tools_by_source = self.tools.read(cx).tools_by_source(cx); let server_status = self .context_server_store .read(cx) @@ -629,19 +589,11 @@ impl AgentConfiguration { None }; - let are_tools_expanded = self - .expanded_context_server_tools - .get(&context_server_id) - .copied() - .unwrap_or_default(); - let tools = tools_by_source - .get(&ToolSource::ContextServer { - id: context_server_id.0.clone().into(), - }) - .map_or([].as_slice(), |tools| tools.as_slice()); - let tool_count = tools.len(); - - let border_color = cx.theme().colors().border.opacity(0.6); + let tool_count = self + .context_server_registry + .read(cx) + .tools_for_server(&context_server_id) + .count(); let (source_icon, source_tooltip) = if is_from_extension { ( @@ -660,10 +612,9 @@ impl AgentConfiguration { Icon::new(IconName::LoadCircle) .size(IconSize::XSmall) .color(Color::Accent) - .with_animation( - SharedString::from(format!("{}-starting", context_server_id.0.clone(),)), - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + .with_keyed_rotate_animation( + SharedString::from(format!("{}-starting", context_server_id.0)), + 3, ) .into_any_element(), "Server is starting.", @@ -687,7 +638,7 @@ impl AgentConfiguration { IconButton::new("context-server-config-menu", IconName::Settings) .icon_color(Color::Muted) .icon_size(IconSize::Small), - Tooltip::text("Open MCP server options"), + Tooltip::text("Configure MCP Server"), ) .anchor(Corner::TopRight) .menu({ @@ -696,6 +647,8 @@ impl AgentConfiguration { let language_registry = self.language_registry.clone(); let context_server_store = self.context_server_store.clone(); let workspace = self.workspace.clone(); + let context_server_registry = self.context_server_registry.clone(); + move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Configure Server", None, { @@ -712,7 +665,24 @@ impl AgentConfiguration { ) .detach_and_log_err(cx); } - }) + }).when(tool_count > 0, |this| this.entry("View Tools", None, { + let context_server_id = context_server_id.clone(); + let context_server_registry = context_server_registry.clone(); + let workspace = workspace.clone(); + move |window, cx| { + let context_server_id = context_server_id.clone(); + workspace.update(cx, |workspace, cx| { + ConfigureContextServerToolsModal::toggle( + context_server_id, + context_server_registry.clone(), + workspace, + window, + cx, + ); + }) + .ok(); + } + })) .separator() .entry("Uninstall", None, { let fs = fs.clone(); @@ -758,14 +728,14 @@ impl AgentConfiguration { async move |cx| { uninstall_extension_task.await?; cx.update(|cx| { - update_settings_file::( + update_settings_file( fs.clone(), cx, { let context_server_id = context_server_id.clone(); move |settings, _| { - settings + settings.project .context_servers .remove(&context_server_id.0); } @@ -783,55 +753,30 @@ impl AgentConfiguration { v_flex() .id(item_id.clone()) - .border_1() - .rounded_md() - .border_color(border_color) - .bg(cx.theme().colors().background.opacity(0.2)) - .overflow_hidden() .child( h_flex() - .p_1() .justify_between() - .when( - error.is_some() || are_tools_expanded && tool_count >= 1, - |element| element.border_b_1().border_color(border_color), - ) .child( h_flex() - .child( - Disclosure::new( - "tool-list-disclosure", - are_tools_expanded || error.is_some(), - ) - .disabled(tool_count == 0) - .on_click(cx.listener({ - let context_server_id = context_server_id.clone(); - move |this, _event, _window, _cx| { - let is_open = this - .expanded_context_server_tools - .entry(context_server_id.clone()) - .or_insert(false); - - *is_open = !*is_open; - } - })), - ) + .flex_1() + .min_w_0() .child( h_flex() .id(SharedString::from(format!("tooltip-{}", item_id))) .h_full() .w_3() - .mx_1() + .mr_2() .justify_center() .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).ml_0p5()) + .child(Label::new(item_id).truncate()) .child( div() .id("extension-source") .mt_0p5() .mx_1() + .flex_none() .tooltip(Tooltip::text(source_tooltip)) .child( Icon::new(source_icon) @@ -853,79 +798,65 @@ impl AgentConfiguration { ) .child( h_flex() - .gap_1() + .gap_0p5() + .flex_none() .child(context_server_configuration_menu) .child( - Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) - .on_click({ - let context_server_manager = - self.context_server_store.clone(); - let context_server_id = context_server_id.clone(); - let fs = self.fs.clone(); - - move |state, _window, cx| { - let is_enabled = match state { - ToggleState::Unselected - | ToggleState::Indeterminate => { - context_server_manager.update( - cx, - |this, cx| { - this.stop_server( - &context_server_id, - cx, - ) - .log_err(); - }, - ); - false - } - ToggleState::Selected => { - context_server_manager.update( - cx, - |this, cx| { - if let Some(server) = - this.get_server(&context_server_id) - { - this.start_server(server, cx); - } - }, - ); - true - } - }; - update_settings_file::( - fs.clone(), - cx, - { - let context_server_id = - context_server_id.clone(); - - move |settings, _| { - settings - .context_servers - .entry(context_server_id.0) - .or_insert_with(|| { - ContextServerSettings::Extension { - enabled: is_enabled, - settings: serde_json::json!({}), - } - }) - .set_enabled(is_enabled); + Switch::new("context-server-switch", is_running.into()) + .color(SwitchColor::Accent) + .on_click({ + let context_server_manager = self.context_server_store.clone(); + let fs = self.fs.clone(); + + move |state, _window, cx| { + let is_enabled = match state { + ToggleState::Unselected + | ToggleState::Indeterminate => { + context_server_manager.update(cx, |this, cx| { + this.stop_server(&context_server_id, cx) + .log_err(); + }); + false + } + ToggleState::Selected => { + context_server_manager.update(cx, |this, cx| { + if let Some(server) = + this.get_server(&context_server_id) + { + this.start_server(server, cx); } - }, - ); - } - }), - ), + }); + true + } + }; + update_settings_file(fs.clone(), cx, { + let context_server_id = context_server_id.clone(); + + move |settings, _| { + settings + .project + .context_servers + .entry(context_server_id.0) + .or_insert_with(|| { + settings::ContextServerSettingsContent::Extension { + enabled: is_enabled, + settings: serde_json::json!({}), + } + }) + .set_enabled(is_enabled); + } + }); + } + }), + ), ), ) .map(|parent| { if let Some(error) = error { return parent.child( h_flex() - .p_2() .gap_2() + .pr_4() .items_start() .child( h_flex() @@ -948,34 +879,120 @@ impl AgentConfiguration { ), ); } + parent + }) + } - if !are_tools_expanded || tools.is_empty() { - return parent; - } + fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { + let user_defined_agents = self + .agent_server_store + .read(cx) + .external_agents() + .filter(|name| { + name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME + }) + .cloned() + .collect::>(); + + let user_defined_agents = user_defined_agents + .into_iter() + .map(|name| { + self.render_agent_server(IconName::Ai, name) + .into_any_element() + }) + .collect::>(); - parent.child(v_flex().py_1p5().px_1().gap_1().children( - tools.into_iter().enumerate().map(|(ix, tool)| { - h_flex() - .id(("tool-item", ix)) - .px_1() - .gap_2() - .justify_between() - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .rounded_sm() + v_flex() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + v_flex() + .p(DynamicSpacing::Base16.rems(cx)) + .pr(DynamicSpacing::Base20.rems(cx)) + .gap_2() + .child( + v_flex() + .gap_0p5() .child( - Label::new(tool.name()) - .buffer_font(cx) - .size(LabelSize::Small), + h_flex() + .pr_1() + .w_full() + .gap_2() + .justify_between() + .child(Headline::new("External Agents")) + .child( + Button::new("add-agent", "Add Agent") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click( + move |_, window, cx| { + if let Some(workspace) = window.root().flatten() { + let workspace = workspace.downgrade(); + window + .spawn(cx, async |cx| { + open_new_agent_servers_entry_in_settings_editor( + workspace, + cx, + ).await + }) + .detach_and_log_err(cx); + } + } + ), + ) ) .child( - Icon::new(IconName::Info) - .size(IconSize::Small) - .color(Color::Ignored), - ) - .tooltip(Tooltip::text(tool.description())) - }), - )) - }) + Label::new( + "All agents connected through the Agent Client Protocol.", + ) + .color(Color::Muted), + ), + ) + .child(self.render_agent_server( + IconName::AiClaude, + "Claude Code", + )) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + .child(self.render_agent_server( + IconName::AiOpenAi, + "Codex", + )) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + .child(self.render_agent_server( + IconName::AiGemini, + "Gemini CLI", + )) + .map(|mut parent| { + for agent in user_defined_agents { + parent = parent.child(Divider::horizontal().color(DividerColor::BorderFaded)) + .child(agent); + } + parent + }) + ) + } + + fn render_agent_server( + &self, + icon: IconName, + name: impl Into, + ) -> impl IntoElement { + h_flex().gap_1p5().justify_between().child( + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name.into())) + .child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success), + ), + ) } } @@ -989,41 +1006,20 @@ impl Render for AgentConfiguration { .size_full() .pb_8() .bg(cx.theme().colors().panel_background) - .child( - v_flex() - .id("assistant-configuration-content") - .track_scroll(&self.scroll_handle) - .size_full() - .overflow_y_scroll() - .child(self.render_general_settings_section(cx)) - .child(self.render_context_servers_section(window, cx)) - .child(self.render_provider_configuration_section(cx)), - ) .child( div() - .id("assistant-configuration-scrollbar") - .occlude() - .absolute() - .right(px(3.)) - .top_0() - .bottom_0() - .pb_6() - .w(px(12.)) - .cursor_default() - .on_mouse_move(cx.listener(|_, _, _window, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_scroll_wheel(cx.listener(|_, _, _window, cx| { - cx.notify(); - })) - .children(Scrollbar::vertical(self.scrollbar_state.clone())), + .size_full() + .child( + v_flex() + .id("assistant-configuration-content") + .track_scroll(&self.scroll_handle) + .size_full() + .overflow_y_scroll() + .child(self.render_agent_servers_section(cx)) + .child(self.render_context_servers_section(window, cx)) + .child(self.render_provider_configuration_section(cx)), + ) + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx), ) } } @@ -1071,7 +1067,6 @@ fn show_unable_to_uninstall_extension_with_context_server( cx, move |this, _cx| { let workspace_handle = workspace_handle.clone(); - let context_server_id = context_server_id.clone(); this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) .dismiss_button(true) @@ -1092,15 +1087,12 @@ fn show_unable_to_uninstall_extension_with_context_server( let context_server_id = context_server_id.clone(); async move |_workspace_handle, cx| { cx.update(|cx| { - update_settings_file::( - fs, - cx, - move |settings, _| { - settings - .context_servers - .remove(&context_server_id.0); - }, - ); + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .context_servers + .remove(&context_server_id.0); + }); })?; anyhow::Ok(()) } @@ -1115,3 +1107,117 @@ fn show_unable_to_uninstall_extension_with_context_server( workspace.toggle_status_toast(status_toast, cx); } + +async fn open_new_agent_servers_entry_in_settings_editor( + workspace: WeakEntity, + cx: &mut AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update_in(cx, |_, window, cx| { + create_and_open_local_file(paths::settings_file(), window, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor + .downgrade() + .update_in(cx, |item, window, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + + let mut unique_server_name = None; + let edits = settings.edits_for_update(&text, |settings| { + let server_name: Option = (0..u8::MAX) + .map(|i| { + if i == 0 { + "your_agent".into() + } else { + format!("your_agent_{}", i).into() + } + }) + .find(|name| { + !settings + .agent_servers + .as_ref() + .is_some_and(|agent_servers| agent_servers.custom.contains_key(name)) + }); + if let Some(server_name) = server_name { + unique_server_name = Some(server_name.clone()); + settings + .agent_servers + .get_or_insert_default() + .custom + .insert( + server_name, + settings::CustomAgentServerSettings { + path: "path_to_executable".into(), + args: vec![], + env: Some(HashMap::default()), + default_mode: None, + }, + ); + } + }); + + if edits.is_empty() { + return; + } + + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); + + item.edit(edits, cx); + if let Some((unique_server_name, buffer)) = + unique_server_name.zip(item.buffer().read(cx).as_singleton()) + { + let snapshot = buffer.read(cx).snapshot(); + if let Some(range) = + find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) + { + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + } + } + }) +} + +fn find_text_in_buffer( + text: &str, + start: usize, + snapshot: &language::BufferSnapshot, +) -> Option> { + let chars = text.chars().collect::>(); + + let mut offset = start; + let mut char_offset = 0; + for c in snapshot.chars_at(start) { + if char_offset >= chars.len() { + break; + } + offset += 1; + + if c == chars[char_offset] { + char_offset += 1; + } else { + char_offset = 0; + } + } + + if char_offset == chars.len() { + Some(offset.saturating_sub(chars.len())..offset) + } else { + None + } +} diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 401a6334886e18ef2e53bbd5b68392597d0db1e9..8f4fdeacf303c9869e903bde95326c80fba10126 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -5,13 +5,12 @@ use collections::HashSet; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, Task}; use language_model::LanguageModelRegistry; -use language_models::{ - AllLanguageModelSettings, OpenAiCompatibleSettingsContent, - provider::open_ai_compatible::AvailableModel, +use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; +use settings::{OpenAiCompatibleSettingsContent, update_settings_file}; +use ui::{ + Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, prelude::*, }; -use settings::update_settings_file; -use ui::{Banner, KeyBinding, Modal, ModalFooter, ModalHeader, Section, prelude::*}; -use ui_input::SingleLineInput; +use ui_input::InputField; use workspace::{ModalView, Workspace}; #[derive(Clone, Copy)] @@ -34,9 +33,9 @@ impl LlmCompatibleProvider { } struct AddLlmProviderInput { - provider_name: Entity, - api_url: Entity, - api_key: Entity, + provider_name: Entity, + api_url: Entity, + api_key: Entity, models: Vec, } @@ -69,11 +68,19 @@ impl AddLlmProviderInput { } } +struct ModelCapabilityToggles { + pub supports_tools: ToggleState, + pub supports_images: ToggleState, + pub supports_parallel_tool_calls: ToggleState, + pub supports_prompt_cache_key: ToggleState, +} + struct ModelInput { - name: Entity, - max_completion_tokens: Entity, - max_output_tokens: Entity, - max_tokens: Entity, + name: Entity, + max_completion_tokens: Entity, + max_output_tokens: Entity, + max_tokens: Entity, + capabilities: ModelCapabilityToggles, } impl ModelInput { @@ -100,11 +107,23 @@ impl ModelInput { cx, ); let max_tokens = single_line_input("Max Tokens", "Max Tokens", Some("200000"), window, cx); + let ModelCapabilities { + tools, + images, + parallel_tool_calls, + prompt_cache_key, + } = ModelCapabilities::default(); Self { name: model_name, max_completion_tokens, max_output_tokens, max_tokens, + capabilities: ModelCapabilityToggles { + supports_tools: tools.into(), + supports_images: images.into(), + supports_parallel_tool_calls: parallel_tool_calls.into(), + supports_prompt_cache_key: prompt_cache_key.into(), + }, } } @@ -136,6 +155,12 @@ impl ModelInput { .text(cx) .parse::() .map_err(|_| SharedString::from("Max Tokens must be a number"))?, + capabilities: ModelCapabilities { + tools: self.capabilities.supports_tools.selected(), + images: self.capabilities.supports_images.selected(), + parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), + prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), + }, }) } } @@ -146,9 +171,9 @@ fn single_line_input( text: Option<&str>, window: &mut Window, cx: &mut App, -) -> Entity { +) -> Entity { cx.new(|cx| { - let input = SingleLineInput::new(window, cx, placeholder).label(label); + let input = InputField::new(window, cx, placeholder).label(label); if let Some(text) = text { input .editor() @@ -210,14 +235,19 @@ fn save_provider_to_settings( task.await .map_err(|_| "Failed to write API key to keychain")?; cx.update(|cx| { - update_settings_file::(fs, cx, |settings, _cx| { - settings.openai_compatible.get_or_insert_default().insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models: models, - }, - ); + update_settings_file(fs, cx, |settings, _cx| { + settings + .language_models + .get_or_insert_default() + .openai_compatible + .get_or_insert_default() + .insert( + provider_name, + OpenAiCompatibleSettingsContent { + api_url, + available_models: models, + }, + ); }); }) .ok(); @@ -322,6 +352,55 @@ impl AddLlmProviderModal { .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) + .child( + v_flex() + .gap_1() + .child( + Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) + .label("Supports tools") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_tools = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new(("supports-images", ix), model.capabilities.supports_images) + .label("Supports images") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_images = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new( + ("supports-parallel-tool-calls", ix), + model.capabilities.supports_parallel_tool_calls, + ) + .label("Supports parallel_tool_calls") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_parallel_tool_calls = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-prompt-cache-key", ix), + model.capabilities.supports_prompt_cache_key, + ) + .label("Supports prompt_cache_key") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_prompt_cache_key = + *checked; + cx.notify(); + }, + )), + ), + ) .when(has_more_than_one_model, |this| { this.child( Button::new(("remove-model", ix), "Remove Model") @@ -352,7 +431,7 @@ impl Focusable for AddLlmProviderModal { impl ModalView for AddLlmProviderModal {} impl Render for AddLlmProviderModal { - fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { + fn render(&mut self, _window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); div() @@ -377,7 +456,7 @@ impl Render for AddLlmProviderModal { this.section( Section::new().child( Banner::new() - .severity(ui::Severity::Warning) + .severity(Severity::Warning) .child(div().text_xs().child(error)), ), ) @@ -405,7 +484,6 @@ impl Render for AddLlmProviderModal { KeyBinding::for_action_in( &menu::Cancel, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -420,7 +498,6 @@ impl Render for AddLlmProviderModal { KeyBinding::for_action_in( &menu::Confirm, &focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))), @@ -540,10 +617,10 @@ mod tests { cx.update(|_window, cx| { LanguageModelRegistry::global(cx).update(cx, |registry, cx| { registry.register_provider( - FakeLanguageModelProvider::new( + Arc::new(FakeLanguageModelProvider::new( LanguageModelProviderId::new("someprovider"), LanguageModelProviderName::new("Some Provider"), - ), + )), cx, ); }); @@ -562,6 +639,93 @@ mod tests { ); } + #[gpui::test] + async fn test_model_input_default_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + assert_eq!( + model_input.capabilities.supports_tools, + ToggleState::Selected + ); + assert_eq!( + model_input.capabilities.supports_images, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.supports_parallel_tool_calls, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.supports_prompt_cache_key, + ToggleState::Unselected + ); + + let parsed_model = model_input.parse(cx).unwrap(); + assert!(parsed_model.capabilities.tools); + assert!(!parsed_model.capabilities.images); + assert!(!parsed_model.capabilities.parallel_tool_calls); + assert!(!parsed_model.capabilities.prompt_cache_key); + }); + } + + #[gpui::test] + async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + + model_input.capabilities.supports_tools = ToggleState::Unselected; + model_input.capabilities.supports_images = ToggleState::Unselected; + model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; + model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; + + let parsed_model = model_input.parse(cx).unwrap(); + assert!(!parsed_model.capabilities.tools); + assert!(!parsed_model.capabilities.images); + assert!(!parsed_model.capabilities.parallel_tool_calls); + assert!(!parsed_model.capabilities.prompt_cache_key); + }); + } + + #[gpui::test] + async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + + model_input.capabilities.supports_tools = ToggleState::Selected; + model_input.capabilities.supports_images = ToggleState::Unselected; + model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; + model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; + + let parsed_model = model_input.parse(cx).unwrap(); + assert_eq!(parsed_model.name, "somemodel"); + assert!(parsed_model.capabilities.tools); + assert!(!parsed_model.capabilities.images); + assert!(parsed_model.capabilities.parallel_tool_calls); + assert!(!parsed_model.capabilities.prompt_cache_key); + }); + } + async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { cx.update(|cx| { let store = SettingsStore::test(cx); @@ -591,12 +755,7 @@ mod tests { models: Vec<(&str, &str, &str, &str)>, cx: &mut VisualTestContext, ) -> Option { - fn set_text( - input: &Entity, - text: &str, - window: &mut Window, - cx: &mut App, - ) { + fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) { input.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { editor.set_text(text, window, cx); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 32360dd56ef925d56310ff7e2e5668de1973f472..88896f51086dc5f7d3eddb2fffef2fa3a7039c79 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,16 +1,14 @@ use std::{ path::PathBuf, sync::{Arc, Mutex}, - time::Duration, }; use anyhow::{Context as _, Result}; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, - WeakEntity, percentage, prelude::*, + AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, + TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -24,7 +22,9 @@ use project::{ }; use settings::{Settings as _, update_settings_file}; use theme::ThemeSettings; -use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; +use ui::{ + CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*, +}; use util::ResultExt as _; use workspace::{ModalView, Workspace}; @@ -163,10 +163,10 @@ impl ConfigurationSource { .read(cx) .text(cx); let settings = serde_json_lenient::from_str::(&text)?; - if let Some(settings_validator) = settings_validator { - if let Err(error) = settings_validator.validate(&settings) { - return Err(anyhow::anyhow!(error.to_string())); - } + if let Some(settings_validator) = settings_validator + && let Err(error) = settings_validator.validate(&settings) + { + return Err(anyhow::anyhow!(error.to_string())); } Ok(( id.clone(), @@ -251,6 +251,7 @@ pub struct ConfigureContextServerModal { workspace: WeakEntity, source: ConfigurationSource, state: State, + original_server_id: Option, } impl ConfigureContextServerModal { @@ -261,7 +262,6 @@ impl ConfigureContextServerModal { _cx: &mut Context, ) { workspace.register_action({ - let language_registry = language_registry.clone(); move |_workspace, _: &AddContextServer, window, cx| { let workspace_handle = cx.weak_entity(); let language_registry = language_registry.clone(); @@ -349,6 +349,11 @@ impl ConfigureContextServerModal { context_server_store, workspace: workspace_handle, state: State::Idle, + original_server_id: match &target { + ConfigurationTarget::Existing { id, .. } => Some(id.clone()), + ConfigurationTarget::Extension { id, .. } => Some(id.clone()), + ConfigurationTarget::New => None, + }, source: ConfigurationSource::from_target( target, language_registry, @@ -416,8 +421,17 @@ impl ConfigureContextServerModal { // When we write the settings to the file, the context server will be restarted. workspace.update(cx, |workspace, cx| { let fs = workspace.app_state().fs.clone(); - update_settings_file::(fs.clone(), cx, |project_settings, _| { - project_settings.context_servers.insert(id.0, settings); + let original_server_id = self.original_server_id.clone(); + update_settings_file(fs.clone(), cx, move |current, _| { + if let Some(original_id) = original_server_id { + if original_id != id { + current.project.context_servers.remove(&original_id.0); + } + } + current + .project + .context_servers + .insert(id.0, settings.into()); }); }); } else if let Some(existing_server) = existing_server { @@ -487,7 +501,7 @@ impl ConfigureContextServerModal { } fn render_modal_description(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - const MODAL_DESCRIPTION: &'static str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; + const MODAL_DESCRIPTION: &str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; if let ConfigurationSource::Extension { installation_instructions: Some(installation_instructions), @@ -552,7 +566,7 @@ impl ConfigureContextServerModal { .into_any_element() } - fn render_modal_footer(&self, window: &mut Window, cx: &mut Context) -> ModalFooter { + fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); let is_connecting = matches!(self.state, State::Waiting); @@ -570,12 +584,11 @@ impl ConfigureContextServerModal { .icon_size(IconSize::Small) .tooltip({ let repository_url = repository_url.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta( "Open Repository", None, repository_url.clone(), - window, cx, ) } @@ -602,7 +615,7 @@ impl ConfigureContextServerModal { }, ) .key_binding( - KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx) + KeyBinding::for_action_in(&menu::Cancel, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click( @@ -620,7 +633,7 @@ impl ConfigureContextServerModal { ) .disabled(is_connecting) .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx) + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click( @@ -639,11 +652,7 @@ impl ConfigureContextServerModal { Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) + .with_rotate_animation(2) .into_any_element(), ) .child( @@ -699,7 +708,7 @@ impl Render for ConfigureContextServerModal { State::Error(error) => Self::render_modal_error(error.clone()), }), ) - .footer(self.render_modal_footer(window, cx)), + .footer(self.render_modal_footer(cx)), ) } } @@ -716,24 +725,24 @@ fn wait_for_context_server( project::context_server_store::Event::ServerStatusChanged { server_id, status } => { match status { ContextServerStatus::Running => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Ok(())); - } + if server_id == &context_server_id + && let Some(tx) = tx.lock().unwrap().take() + { + let _ = tx.send(Ok(())); } } ContextServerStatus::Stopped => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Err("Context server stopped running".into())); - } + if server_id == &context_server_id + && let Some(tx) = tx.lock().unwrap().take() + { + let _ = tx.send(Err("Context server stopped running".into())); } } ContextServerStatus::Error(error) => { - if server_id == &context_server_id { - if let Some(tx) = tx.lock().unwrap().take() { - let _ = tx.send(Err(error.clone())); - } + if server_id == &context_server_id + && let Some(tx) = tx.lock().unwrap().take() + { + let _ = tx.send(Err(error.clone())); } } _ => {} diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..3fe0b8d1b1400b4362192261995ed5b6bd1cb662 --- /dev/null +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs @@ -0,0 +1,175 @@ +use agent::ContextServerRegistry; +use collections::HashMap; +use context_server::ContextServerId; +use gpui::{ + DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*, +}; +use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*}; +use workspace::{ModalView, Workspace}; + +pub struct ConfigureContextServerToolsModal { + context_server_id: ContextServerId, + context_server_registry: Entity, + focus_handle: FocusHandle, + expanded_tools: HashMap, + scroll_handle: ScrollHandle, +} + +impl ConfigureContextServerToolsModal { + fn new( + context_server_id: ContextServerId, + context_server_registry: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + context_server_id, + context_server_registry, + focus_handle: cx.focus_handle(), + expanded_tools: HashMap::default(), + scroll_handle: ScrollHandle::new(), + } + } + + pub fn toggle( + context_server_id: ContextServerId, + context_server_registry: Entity, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + workspace.toggle_modal(window, cx, |window, cx| { + Self::new(context_server_id, context_server_registry, window, cx) + }); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent) + } + + fn render_modal_content( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let tools = self + .context_server_registry + .read(cx) + .tools_for_server(&self.context_server_id) + .collect::>(); + + div() + .size_full() + .pb_2() + .child( + v_flex() + .id("modal_content") + .px_2() + .gap_1() + .max_h_128() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .children(tools.iter().enumerate().flat_map(|(index, tool)| { + let tool_name = tool.name(); + let is_expanded = self + .expanded_tools + .get(tool_name.as_ref()) + .copied() + .unwrap_or(false); + + let icon = if is_expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let mut items = vec![ + v_flex() + .child( + h_flex() + .id(SharedString::from(format!("tool-header-{}", index))) + .py_1() + .pl_1() + .pr_2() + .w_full() + .justify_between() + .rounded_sm() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child( + Label::new(tool_name.clone()) + .buffer_font(cx) + .size(LabelSize::Small), + ) + .child( + Icon::new(icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(cx.listener({ + move |this, _event, _window, _cx| { + let current = this + .expanded_tools + .get(tool_name.as_ref()) + .copied() + .unwrap_or(false); + this.expanded_tools + .insert(tool_name.clone(), !current); + _cx.notify(); + } + })), + ) + .when(is_expanded, |this| { + this.child( + Label::new(tool.description()).color(Color::Muted).mx_1(), + ) + }) + .into_any_element(), + ]; + + if index < tools.len() - 1 { + items.push( + h_flex() + .w_full() + .child(Divider::horizontal().color(DividerColor::BorderVariant)) + .into_any_element(), + ); + } + + items + })), + ) + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .into_any_element() + } +} + +impl ModalView for ConfigureContextServerToolsModal {} + +impl Focusable for ConfigureContextServerToolsModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for ConfigureContextServerToolsModal {} + +impl Render for ConfigureContextServerToolsModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .key_context("ContextServerToolsModal") + .occlude() + .elevation_3(cx) + .w(rems(34.)) + .on_action(cx.listener(Self::cancel)) + .track_focus(&self.focus_handle) + .child( + Modal::new("configure-context-server-tools", None::) + .header( + ModalHeader::new() + .headline(format!("Tools from {}", self.context_server_id.0)) + .show_dismiss_button(true), + ) + .child(self.render_modal_content(window, cx)), + ) + } +} diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 09ad013d1ceb56d7c031cfc9eededb429aed2841..e583bb7d5425ec4c6f233ac0eed67c358ccac98d 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -2,11 +2,12 @@ mod profile_modal_header; use std::sync::Arc; -use agent_settings::{AgentProfileId, AgentSettings, builtin_profiles}; -use assistant_tool::ToolWorkingSet; +use agent::ContextServerRegistry; +use agent_settings::{AgentProfile, AgentProfileId, AgentSettings, builtin_profiles}; use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; +use language_model::LanguageModel; use settings::Settings as _; use ui::{ KeyBinding, ListItem, ListItemSpacing, ListSeparator, Navigable, NavigableEntry, prelude::*, @@ -16,9 +17,6 @@ use workspace::{ModalView, Workspace}; use crate::agent_configuration::manage_profiles_modal::profile_modal_header::ProfileModalHeader; use crate::agent_configuration::tool_picker::{ToolPicker, ToolPickerDelegate}; use crate::{AgentPanel, ManageProfiles}; -use agent::agent_profile::AgentProfile; - -use super::tool_picker::ToolPickerMode; enum Mode { ChooseProfile(ChooseProfileMode), @@ -98,7 +96,8 @@ pub struct NewProfileMode { pub struct ManageProfilesModal { fs: Arc, - tools: Entity, + context_server_registry: Entity, + active_model: Option>, focus_handle: FocusHandle, mode: Mode, } @@ -112,10 +111,14 @@ impl ManageProfilesModal { workspace.register_action(|workspace, action: &ManageProfiles, window, cx| { if let Some(panel) = workspace.panel::(cx) { let fs = workspace.app_state().fs.clone(); - let thread_store = panel.read(cx).thread_store(); - let tools = thread_store.read(cx).tools(); + let active_model = panel + .read(cx) + .active_native_agent_thread(cx) + .and_then(|thread| thread.read(cx).model().cloned()); + + let context_server_registry = panel.read(cx).context_server_registry().clone(); workspace.toggle_modal(window, cx, |window, cx| { - let mut this = Self::new(fs, tools, window, cx); + let mut this = Self::new(fs, active_model, context_server_registry, window, cx); if let Some(profile_id) = action.customize_tools.clone() { this.configure_builtin_tools(profile_id, window, cx); @@ -129,7 +132,8 @@ impl ManageProfilesModal { pub fn new( fs: Arc, - tools: Entity, + active_model: Option>, + context_server_registry: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -137,7 +141,8 @@ impl ManageProfilesModal { Self { fs, - tools, + active_model, + context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), } @@ -156,7 +161,7 @@ impl ManageProfilesModal { ) { let name_editor = cx.new(|cx| Editor::single_line(window, cx)); name_editor.update(cx, |editor, cx| { - editor.set_placeholder_text("Profile name", cx); + editor.set_placeholder_text("Profile name", window, cx); }); self.mode = Mode::NewProfile(NewProfileMode { @@ -194,10 +199,9 @@ impl ManageProfilesModal { }; let tool_picker = cx.new(|cx| { - let delegate = ToolPickerDelegate::new( - ToolPickerMode::McpTools, + let delegate = ToolPickerDelegate::mcp_tools( + &self.context_server_registry, self.fs.clone(), - self.tools.clone(), profile_id.clone(), profile, cx, @@ -231,10 +235,14 @@ impl ManageProfilesModal { }; let tool_picker = cx.new(|cx| { - let delegate = ToolPickerDelegate::new( - ToolPickerMode::BuiltinTools, + let delegate = ToolPickerDelegate::builtin_tools( + //todo: This causes the web search tool to show up even it only works when using zed hosted models + agent::supported_built_in_tool_names( + self.active_model.as_ref().map(|model| model.provider_id()), + ) + .map(|s| s.into()) + .collect::>(), self.fs.clone(), - self.tools.clone(), profile_id.clone(), profile, cx, @@ -318,6 +326,8 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) -> impl IntoElement + use<> { + let is_focused = profile.navigation.focus_handle.contains_focused(window, cx); + div() .id(SharedString::from(format!("profile-{}", profile.id))) .track_focus(&profile.navigation.focus_handle) @@ -329,25 +339,26 @@ impl ManageProfilesModal { }) .child( ListItem::new(SharedString::from(format!("profile-{}", profile.id))) - .toggle_state(profile.navigation.focus_handle.contains_focused(window, cx)) + .toggle_state(is_focused) .inset(true) .spacing(ListItemSpacing::Sparse) .child(Label::new(profile.name.clone())) - .end_slot( - h_flex() - .gap_1() - .child( - Label::new("Customize") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .children(KeyBinding::for_action_in( - &menu::Confirm, - &self.focus_handle, - window, - cx, - )), - ) + .when(is_focused, |this| { + this.end_slot( + h_flex() + .gap_1() + .child( + Label::new("Customize") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(KeyBinding::for_action_in( + &menu::Confirm, + &self.focus_handle, + cx, + )), + ) + }) .on_click({ let profile_id = profile.id.clone(); cx.listener(move |this, _, window, cx| { @@ -464,7 +475,7 @@ impl ManageProfilesModal { }, )) .child(ListSeparator) - .child(h_flex().p_2().child(mode.name_editor.clone())) + .child(h_flex().p_2().child(mode.name_editor)) } fn render_view_profile( @@ -637,14 +648,13 @@ impl ManageProfilesModal { ) .child(Label::new("Go Back")) .end_slot( - div().children( + div().child( KeyBinding::for_action_in( &menu::Cancel, &self.focus_handle, - window, cx, ) - .map(|kb| kb.size(rems_from_px(12.))), + .size(rems_from_px(12.)), ), ) .on_click({ @@ -688,14 +698,9 @@ impl Render for ManageProfilesModal { ) .child(Label::new("Go Back")) .end_slot( - div().children( - KeyBinding::for_action_in( - &menu::Cancel, - &self.focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + div().child( + KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle, cx) + .size(rems_from_px(12.)), ), ) .on_click({ diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 8f1e0d71c0bd8ef56a71c1a88db1bf67929b060c..6b84205e1bd6336d70751090d8f0451b1b1925b0 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -1,14 +1,11 @@ use std::{collections::BTreeMap, sync::Arc}; -use agent_settings::{ - AgentProfileContent, AgentProfileId, AgentProfileSettings, AgentSettings, AgentSettingsContent, - ContextServerPresetContent, -}; -use assistant_tool::{ToolSource, ToolWorkingSet}; +use agent::ContextServerRegistry; +use agent_settings::{AgentProfileId, AgentProfileSettings}; use fs::Fs; use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window}; use picker::{Picker, PickerDelegate}; -use settings::update_settings_file; +use settings::{AgentProfileContent, ContextServerPresetContent, update_settings_file}; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt as _; @@ -17,7 +14,7 @@ pub struct ToolPicker { } #[derive(Clone, Copy, Debug, PartialEq)] -pub enum ToolPickerMode { +enum ToolPickerMode { BuiltinTools, McpTools, } @@ -79,60 +76,80 @@ pub struct ToolPickerDelegate { } impl ToolPickerDelegate { - pub fn new( - mode: ToolPickerMode, + pub fn builtin_tools( + tool_names: Vec>, fs: Arc, - tool_set: Entity, profile_id: AgentProfileId, profile_settings: AgentProfileSettings, cx: &mut Context, ) -> Self { - let items = Arc::new(Self::resolve_items(mode, &tool_set, cx)); + Self::new( + Arc::new( + tool_names + .into_iter() + .map(|name| PickerItem::Tool { + name, + server_id: None, + }) + .collect(), + ), + ToolPickerMode::BuiltinTools, + fs, + profile_id, + profile_settings, + cx, + ) + } + + pub fn mcp_tools( + registry: &Entity, + fs: Arc, + profile_id: AgentProfileId, + profile_settings: AgentProfileSettings, + cx: &mut Context, + ) -> Self { + let mut items = Vec::new(); + + for (id, tools) in registry.read(cx).servers() { + let server_id = id.clone().0; + items.push(PickerItem::ContextServer { + server_id: server_id.clone(), + }); + items.extend(tools.keys().map(|tool_name| PickerItem::Tool { + name: tool_name.clone().into(), + server_id: Some(server_id.clone()), + })); + } + + Self::new( + Arc::new(items), + ToolPickerMode::McpTools, + fs, + profile_id, + profile_settings, + cx, + ) + } + fn new( + items: Arc>, + mode: ToolPickerMode, + fs: Arc, + profile_id: AgentProfileId, + profile_settings: AgentProfileSettings, + cx: &mut Context, + ) -> Self { Self { tool_picker: cx.entity().downgrade(), + mode, fs, items, profile_id, profile_settings, filtered_items: Vec::new(), selected_index: 0, - mode, } } - - fn resolve_items( - mode: ToolPickerMode, - tool_set: &Entity, - cx: &mut App, - ) -> Vec { - let mut items = Vec::new(); - for (source, tools) in tool_set.read(cx).tools_by_source(cx) { - match source { - ToolSource::Native => { - if mode == ToolPickerMode::BuiltinTools { - items.extend(tools.into_iter().map(|tool| PickerItem::Tool { - name: tool.name().into(), - server_id: None, - })); - } - } - ToolSource::ContextServer { id } => { - if mode == ToolPickerMode::McpTools && !tools.is_empty() { - let server_id: Arc = id.clone().into(); - items.push(PickerItem::ContextServer { - server_id: server_id.clone(), - }); - items.extend(tools.into_iter().map(|tool| PickerItem::Tool { - name: tool.name().into(), - server_id: Some(server_id.clone()), - })); - } - } - } - } - items - } } impl PickerDelegate for ToolPickerDelegate { @@ -191,10 +208,10 @@ impl PickerDelegate for ToolPickerDelegate { BTreeMap::default(); for item in all_items.iter() { - if let PickerItem::Tool { server_id, name } = item.clone() { - if name.contains(&query) { - tools_by_provider.entry(server_id).or_default().push(name); - } + if let PickerItem::Tool { server_id, name } = item.clone() + && name.contains(&query) + { + tools_by_provider.entry(server_id).or_default().push(name); } } @@ -266,15 +283,19 @@ impl PickerDelegate for ToolPickerDelegate { is_enabled }; - update_settings_file::(self.fs.clone(), cx, { + update_settings_file(self.fs.clone(), cx, { let profile_id = self.profile_id.clone(); let default_profile = self.profile_settings.clone(); let server_id = server_id.clone(); let tool_name = tool_name.clone(); - move |settings: &mut AgentSettingsContent, _cx| { - let profiles = settings.profiles.get_or_insert_default(); + move |settings, _cx| { + let profiles = settings + .agent + .get_or_insert_default() + .profiles + .get_or_insert_default(); let profile = profiles - .entry(profile_id) + .entry(profile_id.0) .or_insert_with(|| AgentProfileContent { name: default_profile.name.into(), tools: default_profile.tools, @@ -318,7 +339,7 @@ impl PickerDelegate for ToolPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let item = &self.filtered_items[ix]; + let item = &self.filtered_items.get(ix)?; match item { PickerItem::ContextServer { server_id, .. } => Some( div() diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index b9e1ea5d0a26262fc24dc58d05d54ed970371ccd..a0f117b0bf30abee9d2182cf8c3fadd10099b1f0 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,7 +1,6 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; use acp_thread::{AcpThread, AcpThreadEvent}; use action_log::ActionLog; -use agent::{Thread, ThreadEvent, ThreadSummary}; use agent_settings::AgentSettings; use anyhow::Result; use buffer_diff::DiffHunkStatus; @@ -10,16 +9,15 @@ use editor::{ Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, SelectionEffects, ToPoint, actions::{GoToHunk, GoToPreviousHunk}, + multibuffer_context_lines, scroll::Autoscroll, }; use gpui::{ - Action, Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Empty, Entity, - EventEmitter, FocusHandle, Focusable, Global, SharedString, Subscription, Task, Transformation, - WeakEntity, Window, percentage, prelude::*, + Action, AnyElement, AnyView, App, AppContext, Empty, Entity, EventEmitter, FocusHandle, + Focusable, Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*, }; use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point}; -use language_model::StopReason; use multi_buffer::PathKey; use project::{Project, ProjectItem, ProjectPath}; use settings::{Settings, SettingsStore}; @@ -28,9 +26,8 @@ use std::{ collections::hash_map::Entry, ops::Range, sync::Arc, - time::Duration, }; -use ui::{IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; +use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; use util::ResultExt; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, @@ -52,34 +49,29 @@ pub struct AgentDiffPane { #[derive(PartialEq, Eq, Clone)] pub enum AgentDiffThread { - Native(Entity), AcpThread(Entity), } impl AgentDiffThread { fn project(&self, cx: &App) -> Entity { match self { - AgentDiffThread::Native(thread) => thread.read(cx).project().clone(), AgentDiffThread::AcpThread(thread) => thread.read(cx).project().clone(), } } fn action_log(&self, cx: &App) -> Entity { match self { - AgentDiffThread::Native(thread) => thread.read(cx).action_log().clone(), AgentDiffThread::AcpThread(thread) => thread.read(cx).action_log().clone(), } } - fn summary(&self, cx: &App) -> ThreadSummary { + fn title(&self, cx: &App) -> SharedString { match self { - AgentDiffThread::Native(thread) => thread.read(cx).summary().clone(), - AgentDiffThread::AcpThread(thread) => ThreadSummary::Ready(thread.read(cx).title()), + AgentDiffThread::AcpThread(thread) => thread.read(cx).title(), } } fn is_generating(&self, cx: &App) -> bool { match self { - AgentDiffThread::Native(thread) => thread.read(cx).is_generating(), AgentDiffThread::AcpThread(thread) => { thread.read(cx).status() == acp_thread::ThreadStatus::Generating } @@ -88,14 +80,12 @@ impl AgentDiffThread { fn has_pending_edit_tool_uses(&self, cx: &App) -> bool { match self { - AgentDiffThread::Native(thread) => thread.read(cx).has_pending_edit_tool_uses(), AgentDiffThread::AcpThread(thread) => thread.read(cx).has_pending_edit_tool_calls(), } } fn downgrade(&self) -> WeakAgentDiffThread { match self { - AgentDiffThread::Native(thread) => WeakAgentDiffThread::Native(thread.downgrade()), AgentDiffThread::AcpThread(thread) => { WeakAgentDiffThread::AcpThread(thread.downgrade()) } @@ -103,12 +93,6 @@ impl AgentDiffThread { } } -impl From> for AgentDiffThread { - fn from(entity: Entity) -> Self { - AgentDiffThread::Native(entity) - } -} - impl From> for AgentDiffThread { fn from(entity: Entity) -> Self { AgentDiffThread::AcpThread(entity) @@ -117,25 +101,17 @@ impl From> for AgentDiffThread { #[derive(PartialEq, Eq, Clone)] pub enum WeakAgentDiffThread { - Native(WeakEntity), AcpThread(WeakEntity), } impl WeakAgentDiffThread { pub fn upgrade(&self) -> Option { match self { - WeakAgentDiffThread::Native(weak) => weak.upgrade().map(AgentDiffThread::Native), WeakAgentDiffThread::AcpThread(weak) => weak.upgrade().map(AgentDiffThread::AcpThread), } } } -impl From> for WeakAgentDiffThread { - fn from(entity: WeakEntity) -> Self { - WeakAgentDiffThread::Native(entity) - } -} - impl From> for WeakAgentDiffThread { fn from(entity: WeakEntity) -> Self { WeakAgentDiffThread::AcpThread(entity) @@ -185,7 +161,7 @@ impl AgentDiffPane { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let project = thread.project(cx).clone(); + let project = thread.project(cx); let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); @@ -196,27 +172,20 @@ impl AgentDiffPane { editor }); - let action_log = thread.action_log(cx).clone(); + let action_log = thread.action_log(cx); let mut this = Self { - _subscriptions: [ - Some( - cx.observe_in(&action_log, window, |this, _action_log, window, cx| { - this.update_excerpts(window, cx) - }), - ), + _subscriptions: vec![ + cx.observe_in(&action_log, window, |this, _action_log, window, cx| { + this.update_excerpts(window, cx) + }), match &thread { - AgentDiffThread::Native(thread) => { - Some(cx.subscribe(&thread, |this, _thread, event, cx| { - this.handle_thread_event(event, cx) - })) - } - AgentDiffThread::AcpThread(_) => None, + AgentDiffThread::AcpThread(thread) => cx + .subscribe(thread, |this, _thread, event, cx| { + this.handle_acp_thread_event(event, cx) + }), }, - ] - .into_iter() - .flatten() - .collect(), + ], title: SharedString::default(), multibuffer, editor, @@ -260,7 +229,7 @@ impl AgentDiffPane { path_key.clone(), buffer.clone(), diff_hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); multibuffer.add_diff(diff_handle, cx); @@ -288,7 +257,7 @@ impl AgentDiffPane { && buffer .read(cx) .file() - .map_or(false, |file| file.disk_state() == DiskState::Deleted) + .is_some_and(|file| file.disk_state() == DiskState::Deleted) { editor.fold_buffer(snapshot.text.remote_id(), cx) } @@ -317,17 +286,16 @@ impl AgentDiffPane { } fn update_title(&mut self, cx: &mut Context) { - let new_title = self.thread.summary(cx).unwrap_or("Agent Changes"); + let new_title = self.thread.title(cx); if new_title != self.title { self.title = new_title; cx.emit(EditorEvent::TitleChanged); } } - fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context) { - match event { - ThreadEvent::SummaryGenerated => self.update_title(cx), - _ => {} + fn handle_acp_thread_event(&mut self, event: &AcpThreadEvent, cx: &mut Context) { + if let AcpThreadEvent::TitleUpdated = event { + self.update_title(cx) } } @@ -398,7 +366,7 @@ fn keep_edits_in_selection( .disjoint_anchor_ranges() .collect::>(); - keep_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx) + keep_edits_in_ranges(editor, buffer_snapshot, thread, ranges, window, cx) } fn reject_edits_in_selection( @@ -412,7 +380,7 @@ fn reject_edits_in_selection( .selections .disjoint_anchor_ranges() .collect::>(); - reject_edits_in_ranges(editor, buffer_snapshot, &thread, ranges, window, cx) + reject_edits_in_ranges(editor, buffer_snapshot, thread, ranges, window, cx) } fn keep_edits_in_ranges( @@ -484,7 +452,10 @@ fn update_editor_selection( window: &mut Window, cx: &mut Context, ) { - let newest_cursor = editor.selections.newest::(cx).head(); + let newest_cursor = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); if !diff_hunks.iter().any(|hunk| { hunk.row_range @@ -503,8 +474,7 @@ fn update_editor_selection( &[last_kept_hunk_end..editor::Anchor::max()], buffer_snapshot, ) - .skip(1) - .next() + .nth(1) }) .or_else(|| { let first_kept_hunk = diff_hunks.first()?; @@ -569,8 +539,8 @@ impl Item for AgentDiffPane { } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { - let summary = self.thread.summary(cx).unwrap_or("Agent Changes"); - Label::new(format!("Review: {}", summary)) + let title = self.thread.title(cx); + Label::new(format!("Review: {}", title)) .color(if params.selected { Color::Default } else { @@ -595,10 +565,6 @@ impl Item for AgentDiffPane { self.editor.for_each_project_item(cx, f) } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn set_nav_history( &mut self, nav_history: ItemNavHistory, @@ -610,16 +576,22 @@ impl Item for AgentDiffPane { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self::new(self.thread.clone(), self.workspace.clone(), window, cx))) + Task::ready(Some(cx.new(|cx| { + Self::new(self.thread.clone(), self.workspace.clone(), window, cx) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -703,7 +675,7 @@ impl Item for AgentDiffPane { } impl Render for AgentDiffPane { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); let focus_handle = &self.focus_handle; @@ -736,7 +708,6 @@ impl Render for AgentDiffPane { .key_binding(KeyBinding::for_action_in( &ToggleFocus, &focus_handle.clone(), - window, cx, )) .on_click(|_event, window, cx| { @@ -753,14 +724,7 @@ fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControl let thread = thread.clone(); Arc::new( - move |row, - status: &DiffHunkStatus, - hunk_range, - is_created_file, - line_height, - editor: &Entity, - window: &mut Window, - cx: &mut App| { + move |row, status, hunk_range, is_created_file, line_height, editor, _, cx| { { render_diff_hunk_controls( row, @@ -770,7 +734,6 @@ fn diff_hunk_controls(thread: &AgentDiffThread) -> editor::RenderDiffHunkControl line_height, &thread, editor, - window, cx, ) } @@ -786,7 +749,6 @@ fn render_diff_hunk_controls( line_height: Pixels, thread: &AgentDiffThread, editor: &Entity, - window: &mut Window, cx: &mut App, ) -> AnyElement { let editor = editor.clone(); @@ -809,13 +771,8 @@ fn render_diff_hunk_controls( Button::new(("reject", row as u64), "Reject") .disabled(is_created_file) .key_binding( - KeyBinding::for_action_in( - &Reject, - &editor.read(cx).focus_handle(cx), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + KeyBinding::for_action_in(&Reject, &editor.read(cx).focus_handle(cx), cx) + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click({ let editor = editor.clone(); @@ -836,7 +793,7 @@ fn render_diff_hunk_controls( }), Button::new(("keep", row as u64), "Keep") .key_binding( - KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), window, cx) + KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click({ @@ -867,14 +824,8 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) } }) .on_click({ @@ -883,7 +834,7 @@ fn render_diff_hunk_controls( editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let position = - hunk_range.end.to_point(&snapshot.buffer_snapshot); + hunk_range.end.to_point(&snapshot.buffer_snapshot()); editor.go_to_hunk_before_or_after_position( &snapshot, position, @@ -903,12 +854,11 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Previous Hunk", &GoToPreviousHunk, &focus_handle, - window, cx, ) } @@ -919,7 +869,7 @@ fn render_diff_hunk_controls( editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let point = - hunk_range.start.to_point(&snapshot.buffer_snapshot); + hunk_range.start.to_point(&snapshot.buffer_snapshot()); editor.go_to_hunk_before_or_after_position( &snapshot, point, @@ -1001,7 +951,7 @@ impl AgentDiffToolbar { return; }; - *state = agent_diff.read(cx).editor_state(&editor); + *state = agent_diff.read(cx).editor_state(editor); self.update_location(cx); cx.notify(); } @@ -1044,23 +994,23 @@ impl ToolbarItemView for AgentDiffToolbar { return self.location(cx); } - if let Some(editor) = item.act_as::(cx) { - if editor.read(cx).mode().is_full() { - let agent_diff = AgentDiff::global(cx); + if let Some(editor) = item.act_as::(cx) + && editor.read(cx).mode().is_full() + { + let agent_diff = AgentDiff::global(cx); - self.active_item = Some(AgentDiffToolbarItem::Editor { - editor: editor.downgrade(), - state: agent_diff.read(cx).editor_state(&editor.downgrade()), - _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify), - }); + self.active_item = Some(AgentDiffToolbarItem::Editor { + editor: editor.downgrade(), + state: agent_diff.read(cx).editor_state(&editor.downgrade()), + _diff_subscription: cx.observe(&agent_diff, Self::handle_diff_notify), + }); - return self.location(cx); - } + return self.location(cx); } } self.active_item = None; - return self.location(cx); + self.location(cx) } fn pane_focus_update( @@ -1073,7 +1023,7 @@ impl ToolbarItemView for AgentDiffToolbar { } impl Render for AgentDiffToolbar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let spinner_icon = div() .px_0p5() .id("generating") @@ -1082,11 +1032,7 @@ impl Render for AgentDiffToolbar { Icon::new(IconName::LoadCircle) .size(IconSize::Small) .color(Color::Accent) - .with_animation( - "load_circle", - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ), + .with_rotate_animation(3), ) .into_any(); @@ -1152,7 +1098,6 @@ impl Render for AgentDiffToolbar { KeyBinding::for_action_in( &RejectAll, &editor_focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))) @@ -1167,7 +1112,6 @@ impl Render for AgentDiffToolbar { KeyBinding::for_action_in( &KeepAll, &editor_focus_handle, - window, cx, ) .map(|kb| kb.size(rems_from_px(12.))) @@ -1244,13 +1188,8 @@ impl Render for AgentDiffToolbar { .child( Button::new("reject-all", "Reject All") .key_binding({ - KeyBinding::for_action_in( - &RejectAll, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))) + KeyBinding::for_action_in(&RejectAll, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))) }) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&RejectAll, window, cx) @@ -1259,13 +1198,8 @@ impl Render for AgentDiffToolbar { .child( Button::new("keep-all", "Keep All") .key_binding({ - KeyBinding::for_action_in( - &KeepAll, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))) + KeyBinding::for_action_in(&KeepAll, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))) }) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&KeepAll, window, cx) @@ -1311,7 +1245,7 @@ impl AgentDiff { let entity = cx.new(|_cx| Self::default()); let global = AgentDiffGlobal(entity.clone()); cx.set_global(global); - entity.clone() + entity }) } @@ -1333,7 +1267,7 @@ impl AgentDiff { window: &mut Window, cx: &mut Context, ) { - let action_log = thread.action_log(cx).clone(); + let action_log = thread.action_log(cx); let action_log_subscription = cx.observe_in(&action_log, window, { let workspace = workspace.clone(); @@ -1343,13 +1277,7 @@ impl AgentDiff { }); let thread_subscription = match &thread { - AgentDiffThread::Native(thread) => cx.subscribe_in(&thread, window, { - let workspace = workspace.clone(); - move |this, _thread, event, window, cx| { - this.handle_native_thread_event(&workspace, event, window, cx) - } - }), - AgentDiffThread::AcpThread(thread) => cx.subscribe_in(&thread, window, { + AgentDiffThread::AcpThread(thread) => cx.subscribe_in(thread, window, { let workspace = workspace.clone(); move |this, thread, event, window, cx| { this.handle_acp_thread_event(&workspace, thread, event, window, cx) @@ -1357,11 +1285,11 @@ impl AgentDiff { }), }; - if let Some(workspace_thread) = self.workspace_threads.get_mut(&workspace) { + if let Some(workspace_thread) = self.workspace_threads.get_mut(workspace) { // replace thread and action log subscription, but keep editors workspace_thread.thread = thread.downgrade(); workspace_thread._thread_subscriptions = (action_log_subscription, thread_subscription); - self.update_reviewing_editors(&workspace, window, cx); + self.update_reviewing_editors(workspace, window, cx); return; } @@ -1451,47 +1379,6 @@ impl AgentDiff { }); } - fn handle_native_thread_event( - &mut self, - workspace: &WeakEntity, - event: &ThreadEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ThreadEvent::NewRequest - | ThreadEvent::Stopped(Ok(StopReason::EndTurn)) - | ThreadEvent::Stopped(Ok(StopReason::MaxTokens)) - | ThreadEvent::Stopped(Ok(StopReason::Refusal)) - | ThreadEvent::Stopped(Err(_)) - | ThreadEvent::ShowError(_) - | ThreadEvent::CompletionCanceled => { - self.update_reviewing_editors(workspace, window, cx); - } - // intentionally being exhaustive in case we add a variant we should handle - ThreadEvent::Stopped(Ok(StopReason::ToolUse)) - | ThreadEvent::StreamedCompletion - | ThreadEvent::ReceivedTextChunk - | ThreadEvent::StreamedAssistantText(_, _) - | ThreadEvent::StreamedAssistantThinking(_, _) - | ThreadEvent::StreamedToolUse { .. } - | ThreadEvent::InvalidToolInput { .. } - | ThreadEvent::MissingToolUse { .. } - | ThreadEvent::MessageAdded(_) - | ThreadEvent::MessageEdited(_) - | ThreadEvent::MessageDeleted(_) - | ThreadEvent::SummaryGenerated - | ThreadEvent::SummaryChanged - | ThreadEvent::UsePendingTools { .. } - | ThreadEvent::ToolFinished { .. } - | ThreadEvent::CheckpointChanged - | ThreadEvent::ToolConfirmationNeeded - | ThreadEvent::ToolUseLimitReached - | ThreadEvent::CancelEditing - | ThreadEvent::ProfileChanged => {} - } - } - fn handle_acp_thread_event( &mut self, workspace: &WeakEntity, @@ -1506,7 +1393,7 @@ impl AgentDiff { .read(cx) .entries() .last() - .map_or(false, |entry| entry.diffs().next().is_some()) + .is_some_and(|entry| entry.diffs().next().is_some()) { self.update_reviewing_editors(workspace, window, cx); } @@ -1516,16 +1403,25 @@ impl AgentDiff { .read(cx) .entries() .get(*ix) - .map_or(false, |entry| entry.diffs().next().is_some()) + .is_some_and(|entry| entry.diffs().next().is_some()) { self.update_reviewing_editors(workspace, window, cx); } } - AcpThreadEvent::EntriesRemoved(_) - | AcpThreadEvent::Stopped - | AcpThreadEvent::ToolAuthorizationRequired + AcpThreadEvent::Stopped | AcpThreadEvent::Error - | AcpThreadEvent::ServerExited(_) => {} + | AcpThreadEvent::LoadError(_) + | AcpThreadEvent::Refusal => { + self.update_reviewing_editors(workspace, window, cx); + } + AcpThreadEvent::TitleUpdated + | AcpThreadEvent::TokenUsageUpdated + | AcpThreadEvent::EntriesRemoved(_) + | AcpThreadEvent::ToolAuthorizationRequired + | AcpThreadEvent::PromptCapabilitiesUpdated + | AcpThreadEvent::AvailableCommandsUpdated(_) + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::ModeUpdated(_) => {} } } @@ -1536,21 +1432,11 @@ impl AgentDiff { window: &mut Window, cx: &mut Context, ) { - match event { - workspace::Event::ItemAdded { item } => { - if let Some(editor) = item.downcast::() { - if let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) { - self.register_editor( - workspace.downgrade(), - buffer.clone(), - editor, - window, - cx, - ); - } - } - } - _ => {} + if let workspace::Event::ItemAdded { item } = event + && let Some(editor) = item.downcast::() + && let Some(buffer) = Self::full_editor_buffer(editor.read(cx), cx) + { + self.register_editor(workspace.downgrade(), buffer, editor, window, cx); } } @@ -1649,7 +1535,7 @@ impl AgentDiff { continue; }; - for (weak_editor, _) in buffer_editors { + for weak_editor in buffer_editors.keys() { let Some(editor) = weak_editor.upgrade() else { continue; }; @@ -1677,7 +1563,7 @@ impl AgentDiff { editor.register_addon(EditorAgentDiffAddon); }); } else { - unaffected.remove(&weak_editor); + unaffected.remove(weak_editor); } if new_state == EditorState::Reviewing && previous_state != Some(new_state) { @@ -1710,7 +1596,7 @@ impl AgentDiff { .read_with(cx, |editor, _cx| editor.workspace()) .ok() .flatten() - .map_or(false, |editor_workspace| { + .is_some_and(|editor_workspace| { editor_workspace.entity_id() == workspace.entity_id() }); @@ -1730,7 +1616,7 @@ impl AgentDiff { fn editor_state(&self, editor: &WeakEntity) -> EditorState { self.reviewing_editors - .get(&editor) + .get(editor) .cloned() .unwrap_or(EditorState::Idle) } @@ -1850,26 +1736,26 @@ impl AgentDiff { let thread = thread.upgrade()?; - if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx) { - if let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton() { - let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx); - - let mut keys = changed_buffers.keys().cycle(); - keys.find(|k| *k == &curr_buffer); - let next_project_path = keys - .next() - .filter(|k| *k != &curr_buffer) - .and_then(|after| after.read(cx).project_path(cx)); - - if let Some(path) = next_project_path { - let task = workspace.open_path(path, None, true, window, cx); - let task = cx.spawn(async move |_, _cx| task.await.map(|_| ())); - return Some(task); - } + if let PostReviewState::AllReviewed = review(&editor, &thread, window, cx) + && let Some(curr_buffer) = editor.read(cx).buffer().read(cx).as_singleton() + { + let changed_buffers = thread.action_log(cx).read(cx).changed_buffers(cx); + + let mut keys = changed_buffers.keys().cycle(); + keys.find(|k| *k == &curr_buffer); + let next_project_path = keys + .next() + .filter(|k| *k != &curr_buffer) + .and_then(|after| after.read(cx).project_path(cx)); + + if let Some(path) = next_project_path { + let task = workspace.open_path(path, None, true, window, cx); + let task = cx.spawn(async move |_, _cx| task.await.map(|_| ())); + return Some(task); } } - return Some(Task::ready(Ok(()))); + Some(Task::ready(Ok(()))) } } @@ -1895,17 +1781,14 @@ impl editor::Addon for EditorAgentDiffAddon { mod tests { use super::*; use crate::Keep; - use agent::thread_store::{self, ThreadStore}; + use acp_thread::AgentConnection as _; use agent_settings::AgentSettings; - use assistant_tool::ToolWorkingSet; use editor::EditorSettings; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, Project}; - use prompt_store::PromptBuilder; use serde_json::json; use settings::{Settings, SettingsStore}; - use std::sync::Arc; - use theme::ThemeSettings; + use std::{path::Path, rc::Rc}; use util::path; #[gpui::test] @@ -1917,9 +1800,8 @@ mod tests { Project::init_settings(cx); AgentSettings::register(cx); prompt_store::init(cx); - thread_store::init(cx); workspace::init_settings(cx); - ThemeSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); EditorSettings::register(cx); language_model::init_settings(cx); }); @@ -1937,21 +1819,17 @@ mod tests { }) .unwrap(); - let prompt_store = None; - let thread_store = cx + let connection = Rc::new(acp_thread::StubAgentConnection::new()); + let thread = cx .update(|cx| { - ThreadStore::load( - project.clone(), - cx.new(|_| ToolWorkingSet::default()), - prompt_store, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) + connection + .clone() + .new_thread(project.clone(), Path::new(path!("/test")), cx) }) .await .unwrap(); - let thread = - AgentDiffThread::Native(thread_store.update(cx, |store, cx| store.create_thread(cx))); + + let thread = AgentDiffThread::AcpThread(thread); let action_log = cx.read(|cx| thread.action_log(cx)); let (workspace, cx) = @@ -1992,7 +1870,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(1, 0)..Point::new(1, 0) ); @@ -2006,7 +1886,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2027,7 +1909,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2059,7 +1943,9 @@ mod tests { ); assert_eq!( editor - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2074,9 +1960,8 @@ mod tests { Project::init_settings(cx); AgentSettings::register(cx); prompt_store::init(cx); - thread_store::init(cx); workspace::init_settings(cx); - ThemeSettings::register(cx); + theme::init(theme::LoadThemes::JustBase, cx); EditorSettings::register(cx); language_model::init_settings(cx); workspace::register_project_item::(cx); @@ -2103,22 +1988,6 @@ mod tests { }) .unwrap(); - let prompt_store = None; - let thread_store = cx - .update(|cx| { - ThreadStore::load( - project.clone(), - cx.new(|_| ToolWorkingSet::default()), - prompt_store, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) - }) - .await - .unwrap(); - let thread = thread_store.update(cx, |store, cx| store.create_thread(cx)); - let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); @@ -2137,8 +2006,19 @@ mod tests { } }); + let connection = Rc::new(acp_thread::StubAgentConnection::new()); + let thread = cx + .update(|_, cx| { + connection + .clone() + .new_thread(project.clone(), Path::new(path!("/test")), cx) + }) + .await + .unwrap(); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + // Set the active thread - let thread = AgentDiffThread::Native(thread); + let thread = AgentDiffThread::AcpThread(thread); cx.update(|window, cx| { AgentDiff::set_active_thread(&workspace.downgrade(), thread.clone(), window, cx) }); @@ -2222,7 +2102,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(1, 0)..Point::new(1, 0) ); @@ -2263,7 +2145,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2284,7 +2168,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2310,7 +2196,9 @@ mod tests { ); assert_eq!( editor1 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(3, 0)..Point::new(3, 0) ); @@ -2343,7 +2231,9 @@ mod tests { ); assert_eq!( editor2 - .update(cx, |editor, cx| editor.selections.newest::(cx)) + .update(cx, |editor, cx| editor + .selections + .newest::(&editor.display_snapshot(cx))) .range(), Point::new(0, 0)..Point::new(0, 0) ); diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index b989e7bf1e9147c7f6beb90b5054120cef7b818f..df7d166064da20aa4bc958ebd6a9df806164eb7a 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -2,14 +2,12 @@ use crate::{ ModelUsageContext, language_model_selector::{LanguageModelSelector, language_model_selector}, }; -use agent_settings::AgentSettings; use fs::Fs; use gpui::{Entity, FocusHandle, SharedString}; -use language_model::{ConfiguredModel, LanguageModelRegistry}; use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; -use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*}; +use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; use zed_actions::agent::ToggleModelSelector; pub struct AgentModelSelector { @@ -39,39 +37,13 @@ impl AgentModelSelector { let provider = model.provider_id().0.to_string(); let model_id = model.id().0.to_string(); match &model_usage_context { - ModelUsageContext::Thread(thread) => { - thread.update(cx, |thread, cx| { - let registry = LanguageModelRegistry::read_global(cx); - if let Some(provider) = registry.provider(&model.provider_id()) - { - thread.set_configured_model( - Some(ConfiguredModel { - provider, - model: model.clone(), - }), - cx, - ); - } - }); - update_settings_file::( - fs.clone(), - cx, - move |settings, _cx| { - settings.set_model(model.clone()); - }, - ); - } ModelUsageContext::InlineAssistant => { - update_settings_file::( - fs.clone(), - cx, - move |settings, _cx| { - settings.set_inline_assistant_model( - provider.clone(), - model_id.clone(), - ); - }, - ); + update_settings_file(fs.clone(), cx, move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_inline_assistant_model(provider.clone(), model_id); + }); } } }, @@ -98,6 +70,11 @@ impl Render for AgentModelSelector { .unwrap_or_else(|| SharedString::from("Select a Model")); let provider_icon = model.as_ref().map(|model| model.provider.icon()); + let color = if self.menu_handle.is_deployed() { + Color::Accent + } else { + Color::Muted + }; let focus_handle = self.focus_handle.clone(); @@ -105,32 +82,31 @@ impl Render for AgentModelSelector { self.selector.clone(), ButtonLike::new("active-model") .when_some(provider_icon, |this, icon| { - this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)) + this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) }) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( Label::new(model_name) - .color(Color::Muted) + .color(color) .size(LabelSize::Small) .ml_0p5(), ) .child( Icon::new(IconName::ChevronDown) - .color(Color::Muted) + .color(color) .size(IconSize::XSmall), ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, - gpui::Corner::BottomRight, + gpui::Corner::TopRight, cx, ) .with_handle(self.menu_handle.clone()) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) .render(window, cx) } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index b01bf39728f672434ea1d875b6426649edde62a4..997a2bec09aa2a0ae39909c909c7de80771c5055 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,72 +1,69 @@ -use std::ops::{Not, Range}; +use std::ops::Range; use std::path::Path; use std::rc::Rc; use std::sync::Arc; -use std::time::Duration; -use agent_servers::AgentServer; +use acp_thread::AcpThread; +use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use project::{ + ExternalAgentServerName, + agent_server_store::{ + AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, + }, +}; use serde::{Deserialize, Serialize}; +use settings::{ + DefaultAgentView as DefaultView, LanguageModelProviderSetting, LanguageModelSelection, +}; +use zed_actions::OpenBrowser; +use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; -use crate::NewExternalAgentThread; -use crate::agent_diff::AgentDiffThread; +use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; +use crate::context_store::ContextStore; +use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ - AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, - DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, - NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, - ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, - ToggleNewThreadMenu, ToggleOptionsMenu, + AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant, + NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, + ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu, + ToggleOptionsMenu, acp::AcpThreadView, - active_thread::{self, ActiveThread, ActiveThreadEvent}, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, - agent_diff::AgentDiff, - message_editor::{MessageEditor, MessageEditorEvent}, slash_command::SlashCommandCompletionProvider, - text_thread_editor::{ - AgentPanelDelegate, TextThreadEditor, humanize_token_count, make_lsp_adapter_delegate, - render_remaining_tokens, - }, - thread_history::{HistoryEntryElement, ThreadHistory}, + text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate}, ui::{AgentOnboardingModal, EndTrialUpsell}, }; -use agent::{ - Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio, - context_store::ContextStore, - history_store::{HistoryEntryId, HistoryStore}, - thread_store::{TextThreadStore, ThreadStore}, +use crate::{ + ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command, }; -use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView}; +use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; -use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; -use assistant_tool::ToolWorkingSet; +use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::{UserStore, zed_urls}; -use cloud_llm_client::{CompletionIntent, Plan, UsageLimit}; +use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; -use feature_flags::{self, FeatureFlagAppExt}; +use extension::ExtensionEvents; +use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, - Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla, - KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, - pulsating_between, + Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter, + ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, Task, UpdateGlobal, + WeakEntity, prelude::*, }; use language::LanguageRegistry; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry, -}; -use project::{DisableAiSettings, Project, ProjectPath, Worktree}; +use language_model::{ConfigurationError, LanguageModelRegistry}; +use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; -use settings::{Settings, update_settings_file}; +use settings::{Settings, SettingsStore, update_settings_file}; use theme::ThemeSettings; -use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, Callout, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, KeyBinding, - PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*, + Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, + ProgressBar, Tab, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{ @@ -75,13 +72,15 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector}, + agent::{ + OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetAgentZoom, ResetOnboarding, + }, assistant::{OpenRulesLibrary, ToggleFocus}, }; const AGENT_PANEL_KEY: &str = "agent_panel"; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { width: Option, selected_agent: Option, @@ -97,6 +96,16 @@ pub fn init(cx: &mut App) { workspace.focus_panel::(window, cx); } }) + .register_action( + |workspace, action: &NewNativeAgentThreadFromSummary, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_native_agent_thread_from_summary(action, window, cx) + }); + workspace.focus_panel::(window, cx); + } + }, + ) .register_action(|workspace, _: &OpenHistory, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -112,14 +121,14 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &NewTextThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); + panel.update(cx, |panel, cx| panel.new_text_thread(window, cx)); } }) .register_action(|workspace, action: &NewExternalAgentThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); panel.update(cx, |panel, cx| { - panel.new_external_thread(action.agent, window, cx) + panel.external_thread(action.agent.clone(), None, None, window, cx) }); } }) @@ -131,36 +140,18 @@ pub fn init(cx: &mut App) { }); } }) - .register_action(|workspace, _: &OpenAgentDiff, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - match &panel.read(cx).active_view { - ActiveView::Thread { thread, .. } => { - let thread = thread.read(cx).thread().clone(); - AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); - } - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} - } - } - }) .register_action(|workspace, _: &Follow, window, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) - .register_action(|workspace, _: &ExpandMessageEditor, window, cx| { - let Some(panel) = workspace.panel::(cx) else { - return; - }; - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - if let Some(message_editor) = panel.active_message_editor() { - message_editor.update(cx, |editor, cx| { - editor.expand_message_editor(&ExpandMessageEditor, window, cx); - }); - } - }); + .register_action(|workspace, _: &OpenAgentDiff, window, cx| { + let thread = workspace + .panel::(cx) + .and_then(|panel| panel.read(cx).active_thread_view().cloned()) + .and_then(|thread_view| thread_view.read(cx).thread().cloned()); + + if let Some(thread) = thread { + AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); + } }) .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| { if let Some(panel) = workspace.panel::(cx) { @@ -189,6 +180,12 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenOnboardingModal, window, cx| { AgentOnboardingModal::toggle(workspace, window, cx) }) + .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { + AcpOnboardingModal::toggle(workspace, window, cx) + }) + .register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| { + ClaudeCodeOnboardingModal::toggle(workspace, window, cx) + }) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); @@ -198,6 +195,13 @@ pub fn init(cx: &mut App) { }) .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { TrialEndUpsell::set_dismissed(false, cx); + }) + .register_action(|workspace, _: &ResetAgentZoom, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.reset_agent_zoom(window, cx); + }); + } }); }, ) @@ -205,17 +209,11 @@ pub fn init(cx: &mut App) { } enum ActiveView { - Thread { - thread: Entity, - change_title_editor: Entity, - message_editor: Entity, - _subscriptions: Vec, - }, ExternalAgentThread { thread_view: Entity, }, TextThread { - context_editor: Entity, + text_thread_editor: Entity, title_editor: Entity, buffer_search_bar: Entity, _subscriptions: Vec, @@ -230,32 +228,51 @@ enum WhichFontSize { None, } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +// TODO unify this with ExternalAgent +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentType { #[default] - Zed, + NativeAgent, TextThread, Gemini, ClaudeCode, - NativeAgent, + Codex, + Custom { + name: SharedString, + command: AgentServerCommand, + }, } impl AgentType { - fn label(self) -> impl Into { + fn label(&self) -> SharedString { match self { - Self::Zed | Self::TextThread => "Zed Agent", - Self::NativeAgent => "Agent 2", - Self::Gemini => "Google Gemini", - Self::ClaudeCode => "Claude Code", + Self::NativeAgent | Self::TextThread => "Zed Agent".into(), + Self::Gemini => "Gemini CLI".into(), + Self::ClaudeCode => "Claude Code".into(), + Self::Codex => "Codex".into(), + Self::Custom { name, .. } => name.into(), } } - fn icon(self) -> IconName { + fn icon(&self) -> Option { match self { - Self::Zed | Self::TextThread => IconName::AiZed, - Self::NativeAgent => IconName::ZedAssistant, - Self::Gemini => IconName::AiGemini, - Self::ClaudeCode => IconName::AiClaude, + Self::NativeAgent | Self::TextThread => None, + Self::Gemini => Some(IconName::AiGemini), + Self::ClaudeCode => Some(IconName::AiClaude), + Self::Codex => Some(IconName::AiOpenAi), + Self::Custom { .. } => Some(IconName::Terminal), + } + } +} + +impl From for AgentType { + fn from(value: ExternalAgent) -> Self { + match value { + ExternalAgent::Gemini => Self::Gemini, + ExternalAgent::ClaudeCode => Self::ClaudeCode, + ExternalAgent::Codex => Self::Codex, + ExternalAgent::Custom { name, command } => Self::Custom { name, command }, + ExternalAgent::NativeAgent => Self::NativeAgent, } } } @@ -263,110 +280,48 @@ impl AgentType { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } - | ActiveView::ExternalAgentThread { .. } - | ActiveView::History => WhichFontSize::AgentFont, + ActiveView::ExternalAgentThread { .. } | ActiveView::History => { + WhichFontSize::AgentFont + } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::Configuration => WhichFontSize::None, } } - pub fn thread( - active_thread: Entity, - message_editor: Entity, + pub fn native_agent( + fs: Arc, + prompt_store: Option>, + history_store: Entity, + project: Entity, + workspace: WeakEntity, window: &mut Window, - cx: &mut Context, + cx: &mut App, ) -> Self { - let summary = active_thread.read(cx).summary(cx).or_default(); - - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(summary.clone(), window, cx); - editor + let thread_view = cx.new(|cx| { + crate::acp::AcpThreadView::new( + ExternalAgent::NativeAgent.server(fs, history_store.clone()), + None, + None, + workspace, + project, + history_store, + prompt_store, + window, + cx, + ) }); - let subscriptions = vec![ - cx.subscribe(&message_editor, |this, _, event, cx| match event { - MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => { - cx.notify(); - } - MessageEditorEvent::ScrollThreadToBottom => match &this.active_view { - ActiveView::Thread { thread, .. } => { - thread.update(cx, |thread, cx| { - thread.scroll_to_bottom(cx); - }); - } - ActiveView::ExternalAgentThread { .. } => {} - ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} - }, - }), - window.subscribe(&editor, cx, { - { - let thread = active_thread.clone(); - move |editor, event, window, cx| match event { - EditorEvent::BufferEdited => { - let new_summary = editor.read(cx).text(cx); - - thread.update(cx, |thread, cx| { - thread.thread().update(cx, |thread, cx| { - thread.set_summary(new_summary, cx); - }); - }) - } - EditorEvent::Blurred => { - if editor.read(cx).text(cx).is_empty() { - let summary = thread.read(cx).summary(cx).or_default(); - - editor.update(cx, |editor, cx| { - editor.set_text(summary, window, cx); - }); - } - } - _ => {} - } - } - }), - cx.subscribe(&active_thread, |_, _, event, cx| match &event { - ActiveThreadEvent::EditingMessageTokenCountChanged => { - cx.notify(); - } - }), - cx.subscribe_in(&active_thread.read(cx).thread().clone(), window, { - let editor = editor.clone(); - move |_, thread, event, window, cx| match event { - ThreadEvent::SummaryGenerated => { - let summary = thread.read(cx).summary().or_default(); - - editor.update(cx, |editor, cx| { - editor.set_text(summary, window, cx); - }) - } - ThreadEvent::MessageAdded(_) => { - cx.notify(); - } - _ => {} - } - }), - ]; - - Self::Thread { - change_title_editor: editor, - thread: active_thread, - message_editor: message_editor, - _subscriptions: subscriptions, - } + Self::ExternalAgentThread { thread_view } } - pub fn prompt_editor( - context_editor: Entity, - history_store: Entity, + pub fn text_thread( + text_thread_editor: Entity, + acp_history_store: Entity, language_registry: Arc, window: &mut Window, cx: &mut App, ) -> Self { - let title = context_editor.read(cx).title(cx).to_string(); + let title = text_thread_editor.read(cx).title(cx).to_string(); let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -382,7 +337,7 @@ impl ActiveView { let subscriptions = vec![ window.subscribe(&editor, cx, { { - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); move |editor, event, window, cx| match event { EditorEvent::BufferEdited => { if suppress_first_edit { @@ -391,19 +346,19 @@ impl ActiveView { } let new_summary = editor.read(cx).text(cx); - context_editor.update(cx, |context_editor, cx| { - context_editor - .context() - .update(cx, |assistant_context, cx| { - assistant_context.set_custom_summary(new_summary, cx); + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor + .text_thread() + .update(cx, |text_thread, cx| { + text_thread.set_custom_summary(new_summary, cx); }) }) } EditorEvent::Blurred => { if editor.read(cx).text(cx).is_empty() { - let summary = context_editor + let summary = text_thread_editor .read(cx) - .context() + .text_thread() .read(cx) .summary() .or_default(); @@ -417,24 +372,24 @@ impl ActiveView { } } }), - window.subscribe(&context_editor.read(cx).context().clone(), cx, { + window.subscribe(&text_thread_editor.read(cx).text_thread().clone(), cx, { let editor = editor.clone(); - move |assistant_context, event, window, cx| match event { - ContextEvent::SummaryGenerated => { - let summary = assistant_context.read(cx).summary().or_default(); + move |text_thread, event, window, cx| match event { + TextThreadEvent::SummaryGenerated => { + let summary = text_thread.read(cx).summary().or_default(); editor.update(cx, |editor, cx| { editor.set_text(summary, window, cx); }) } - ContextEvent::PathChanged { old_path, new_path } => { - history_store.update(cx, |history_store, cx| { + TextThreadEvent::PathChanged { old_path, new_path } => { + acp_history_store.update(cx, |history_store, cx| { if let Some(old_path) = old_path { history_store .replace_recently_opened_text_thread(old_path, new_path, cx); } else { history_store.push_recently_opened_entry( - HistoryEntryId::Context(new_path.clone()), + agent::HistoryEntryId::TextThread(new_path.clone()), cx, ); } @@ -448,11 +403,11 @@ impl ActiveView { let buffer_search_bar = cx.new(|cx| BufferSearchBar::new(Some(language_registry), window, cx)); buffer_search_bar.update(cx, |buffer_search_bar, cx| { - buffer_search_bar.set_active_pane_item(Some(&context_editor), window, cx) + buffer_search_bar.set_active_pane_item(Some(&text_thread_editor), window, cx) }); Self::TextThread { - context_editor, + text_thread_editor, title_editor: editor, buffer_search_bar, _subscriptions: subscriptions, @@ -462,27 +417,26 @@ impl ActiveView { pub struct AgentPanel { workspace: WeakEntity, + loading: bool, user_store: Entity, project: Entity, fs: Arc, language_registry: Arc, - thread_store: Entity, - _default_model_subscription: Subscription, - context_store: Entity, + acp_history: Entity, + history_store: Entity, + text_thread_store: Entity, prompt_store: Option>, + context_server_registry: Entity, inline_assist_context_store: Entity, configuration: Option>, configuration_subscription: Option, - local_timezone: UtcOffset, active_view: ActiveView, previous_view: Option, - history_store: Entity, - history: Entity, - hovered_recent_history_item: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, - assistant_navigation_menu_handle: PopoverMenuHandle, - assistant_navigation_menu: Option>, + agent_navigation_menu_handle: PopoverMenuHandle, + agent_navigation_menu: Option>, + _extension_subscription: Option, width: Option, height: Option, zoomed: bool, @@ -494,7 +448,7 @@ pub struct AgentPanel { impl AgentPanel { fn serialize(&mut self, cx: &mut Context) { let width = self.width; - let selected_agent = self.selected_agent; + let selected_agent = self.selected_agent.clone(); self.pending_serialization = Some(cx.background_spawn(async move { KEY_VALUE_STORE .write_kvp( @@ -508,6 +462,7 @@ impl AgentPanel { anyhow::Ok(()) })); } + pub fn load( workspace: WeakEntity, prompt_builder: Arc, @@ -519,64 +474,50 @@ impl AgentPanel { Ok(prompt_store) => prompt_store.await.ok(), Err(_) => None, }; - let tools = cx.new(|_| ToolWorkingSet::default())?; - let thread_store = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - ThreadStore::load( - project, - tools.clone(), - prompt_store.clone(), - prompt_builder.clone(), - cx, - ) - })? - .await?; - - let slash_commands = Arc::new(SlashCommandWorkingSet::default()); - let context_store = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - assistant_context::ContextStore::new( - project, - prompt_builder.clone(), - slash_commands, - cx, - ) - })? - .await?; - let serialized_panel = if let Some(panel) = cx .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) }) .await .log_err() .flatten() { - Some(serde_json::from_str::(&panel)?) + serde_json::from_str::(&panel).log_err() } else { None }; - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| { - Self::new( - workspace, - thread_store, - context_store, - prompt_store, - window, + let slash_commands = Arc::new(SlashCommandWorkingSet::default()); + let text_thread_store = workspace + .update(cx, |workspace, cx| { + let project = workspace.project().clone(); + assistant_text_thread::TextThreadStore::new( + project, + prompt_builder, + slash_commands, cx, ) - }); + })? + .await?; + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = + cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); + + panel.as_mut(cx).loading = true; if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent { - panel.selected_agent = selected_agent; + panel.selected_agent = selected_agent.clone(); + panel.new_agent_thread(selected_agent, window, cx); } cx.notify(); }); + } else { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::NativeAgent, window, cx); + }); } + panel.as_mut(cx).loading = false; panel })?; @@ -586,76 +527,61 @@ impl AgentPanel { fn new( workspace: &Workspace, - thread_store: Entity, - context_store: Entity, + text_thread_store: Entity, prompt_store: Option>, window: &mut Window, cx: &mut Context, ) -> Self { - let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); let fs = workspace.app_state().fs.clone(); let user_store = workspace.app_state().user_store.clone(); let project = workspace.project(); let language_registry = project.read(cx).languages().clone(); let client = workspace.client().clone(); let workspace = workspace.weak_handle(); - let weak_self = cx.entity().downgrade(); - let message_editor_context_store = - cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade()))); - let inline_assist_context_store = - cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade()))); + let inline_assist_context_store = cx.new(|_cx| ContextStore::new(project.downgrade())); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let thread_id = thread.read(cx).id().clone(); - - let history_store = cx.new(|cx| { - HistoryStore::new( - thread_store.clone(), - context_store.clone(), - [HistoryEntryId::Thread(thread_id)], - cx, - ) - }); + let history_store = cx.new(|cx| agent::HistoryStore::new(text_thread_store.clone(), cx)); + let acp_history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + cx.subscribe_in( + &acp_history, + window, + |this, _, event, window, cx| match event { + ThreadHistoryEvent::Open(HistoryEntry::AcpThread(thread)) => { + this.external_thread( + Some(crate::ExternalAgent::NativeAgent), + Some(thread.clone()), + None, + window, + cx, + ); + } + ThreadHistoryEvent::Open(HistoryEntry::TextThread(thread)) => { + this.open_saved_text_thread(thread.path.clone(), window, cx) + .detach_and_log_err(cx); + } + }, + ) + .detach(); - let message_editor = cx.new(|cx| { - MessageEditor::new( + let panel_type = AgentSettings::get_global(cx).default_view; + let active_view = match panel_type { + DefaultView::Thread => ActiveView::native_agent( fs.clone(), - workspace.clone(), - message_editor_context_store.clone(), prompt_store.clone(), - thread_store.downgrade(), - context_store.downgrade(), - Some(history_store.downgrade()), - thread.clone(), - window, - cx, - ) - }); - - cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); - - let active_thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - thread_store.clone(), - context_store.clone(), - message_editor_context_store.clone(), - language_registry.clone(), + history_store.clone(), + project.clone(), workspace.clone(), window, cx, - ) - }); - - let panel_type = AgentSettings::get_global(cx).default_view; - let active_view = match panel_type { - DefaultView::Thread => ActiveView::thread(active_thread, message_editor, window, cx), + ), DefaultView::TextThread => { - let context = - context_store.update(cx, |context_store, cx| context_store.create(cx)); + let context = text_thread_store.update(cx, |store, cx| store.create(cx)); let lsp_adapter_delegate = make_lsp_adapter_delegate(&project.clone(), cx).unwrap(); - let context_editor = cx.new(|cx| { - let mut editor = TextThreadEditor::for_context( + let text_thread_editor = cx.new(|cx| { + let mut editor = TextThreadEditor::for_text_thread( context, fs.clone(), workspace.clone(), @@ -667,8 +593,8 @@ impl AgentPanel { editor.insert_default_prompt(window, cx); editor }); - ActiveView::prompt_editor( - context_editor, + ActiveView::text_thread( + text_thread_editor, history_store.clone(), language_registry.clone(), window, @@ -677,13 +603,11 @@ impl AgentPanel { } }; - AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); - - let weak_panel = weak_self.clone(); + let weak_panel = cx.entity().downgrade(); window.defer(cx, move |window, cx| { let panel = weak_panel.clone(); - let assistant_navigation_menu = + let agent_navigation_menu = ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { if let Some(panel) = panel.upgrade() { menu = Self::populate_recently_opened_menu_section(menu, panel, cx); @@ -697,7 +621,7 @@ impl AgentPanel { weak_panel .update(cx, |panel, cx| { cx.subscribe_in( - &assistant_navigation_menu, + &agent_navigation_menu, window, |_, menu, _: &DismissEvent, window, cx| { menu.update(cx, |menu, _| { @@ -707,31 +631,11 @@ impl AgentPanel { }, ) .detach(); - panel.assistant_navigation_menu = Some(assistant_navigation_menu); + panel.agent_navigation_menu = Some(agent_navigation_menu); }) .ok(); }); - let _default_model_subscription = cx.subscribe( - &LanguageModelRegistry::global(cx), - |this, _, event: &language_model::Event, cx| match event { - language_model::Event::DefaultModelChanged => match &this.active_view { - ActiveView::Thread { thread, .. } => { - thread - .read(cx) - .thread() - .clone() - .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); - } - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} - }, - _ => {} - }, - ); - let onboarding = cx.new(|cx| { AgentPanelOnboarding::new( user_store.clone(), @@ -743,39 +647,56 @@ impl AgentPanel { ) }); - Self { + // Subscribe to extension events to sync agent servers when extensions change + let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx) + { + Some( + cx.subscribe(&extension_events, |this, _source, event, cx| match event { + extension::Event::ExtensionInstalled(_) + | extension::Event::ExtensionUninstalled(_) + | extension::Event::ExtensionsInstalledChanged => { + this.sync_agent_servers_from_extensions(cx); + } + _ => {} + }), + ) + } else { + None + }; + + let mut panel = Self { active_view, workspace, user_store, project: project.clone(), fs: fs.clone(), language_registry, - thread_store: thread_store.clone(), - _default_model_subscription, - context_store, + text_thread_store, prompt_store, configuration: None, configuration_subscription: None, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), + context_server_registry, inline_assist_context_store, previous_view: None, - history_store: history_store.clone(), - history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), - hovered_recent_history_item: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), - assistant_navigation_menu_handle: PopoverMenuHandle::default(), - assistant_navigation_menu: None, + agent_navigation_menu_handle: PopoverMenuHandle::default(), + agent_navigation_menu: None, + _extension_subscription: extension_subscription, width: None, height: None, zoomed: false, pending_serialization: None, onboarding, + acp_history, + history_store, selected_agent: AgentType::default(), - } + loading: false, + }; + + // Initial sync of agent servers from extensions + panel.sync_agent_servers_from_extensions(cx); + panel } pub fn toggle_focus( @@ -787,16 +708,11 @@ impl AgentPanel { if workspace .panel::(cx) .is_some_and(|panel| panel.read(cx).enabled(cx)) - && !DisableAiSettings::get_global(cx).disable_ai { workspace.toggle_panel_focus::(window, cx); } } - pub(crate) fn local_timezone(&self) -> UtcOffset { - self.local_timezone - } - pub(crate) fn prompt_store(&self) -> &Option> { &self.prompt_store } @@ -805,125 +721,60 @@ impl AgentPanel { &self.inline_assist_context_store } - pub(crate) fn thread_store(&self) -> &Entity { - &self.thread_store + pub(crate) fn thread_store(&self) -> &Entity { + &self.history_store } - pub(crate) fn text_thread_store(&self) -> &Entity { - &self.context_store + pub(crate) fn context_server_registry(&self) -> &Entity { + &self.context_server_registry } - fn cancel(&mut self, _: &editor::actions::Cancel, window: &mut Window, cx: &mut Context) { + fn active_thread_view(&self) -> Option<&Entity> { match &self.active_view { - ActiveView::Thread { thread, .. } => { - thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); - } - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} + ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view), + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, } } - fn active_message_editor(&self) -> Option<&Entity> { - match &self.active_view { - ActiveView::Thread { message_editor, .. } => Some(message_editor), - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => None, - } + fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { + self.new_agent_thread(AgentType::NativeAgent, window, cx); } - fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context) { - // Preserve chat box text when using creating new thread - let preserved_text = self - .active_message_editor() - .map(|editor| editor.read(cx).get_text(cx).trim().to_string()); - - let thread = self - .thread_store - .update(cx, |this, cx| this.create_thread(cx)); - - let context_store = cx.new(|_cx| { - ContextStore::new( - self.project.downgrade(), - Some(self.thread_store.downgrade()), - ) - }); - - if let Some(other_thread_id) = action.from_thread_id.clone() { - let other_thread_task = self.thread_store.update(cx, |this, cx| { - this.open_thread(&other_thread_id, window, cx) - }); - - cx.spawn({ - let context_store = context_store.clone(); - - async move |_panel, cx| { - let other_thread = other_thread_task.await?; - - context_store.update(cx, |this, cx| { - this.add_thread(other_thread, false, cx); - })?; - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - } - - let active_thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - self.thread_store.clone(), - self.context_store.clone(), - context_store.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - }); - - let message_editor = cx.new(|cx| { - MessageEditor::new( - self.fs.clone(), - self.workspace.clone(), - context_store.clone(), - self.prompt_store.clone(), - self.thread_store.downgrade(), - self.context_store.downgrade(), - Some(self.history_store.downgrade()), - thread.clone(), - window, - cx, - ) - }); - - if let Some(text) = preserved_text { - message_editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - }); - } - - message_editor.focus_handle(cx).focus(window); - - let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx); - self.set_active_view(thread_view, window, cx); + fn new_native_agent_thread_from_summary( + &mut self, + action: &NewNativeAgentThreadFromSummary, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread) = self + .history_store + .read(cx) + .thread_from_session_id(&action.from_session_id) + else { + return; + }; - AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); + self.external_thread( + Some(ExternalAgent::NativeAgent), + None, + Some(thread.clone()), + window, + cx, + ); } - fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { + fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context) { + telemetry::event!("Agent Thread Started", agent = "zed-text"); + let context = self - .context_store + .text_thread_store .update(cx, |context_store, cx| context_store.create(cx)); let lsp_adapter_delegate = make_lsp_adapter_delegate(&self.project, cx) .log_err() .flatten(); - let context_editor = cx.new(|cx| { - let mut editor = TextThreadEditor::for_context( + let text_thread_editor = cx.new(|cx| { + let mut editor = TextThreadEditor::for_text_thread( context, self.fs.clone(), self.workspace.clone(), @@ -936,9 +787,14 @@ impl AgentPanel { editor }); + if self.selected_agent != AgentType::TextThread { + self.selected_agent = AgentType::TextThread; + self.serialize(cx); + } + self.set_active_view( - ActiveView::prompt_editor( - context_editor.clone(), + ActiveView::text_thread( + text_thread_editor.clone(), self.history_store.clone(), self.language_registry.clone(), window, @@ -947,69 +803,93 @@ impl AgentPanel { window, cx, ); - context_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window); } - fn new_external_thread( + fn external_thread( &mut self, agent_choice: Option, + resume_thread: Option, + summarize_thread: Option, window: &mut Window, cx: &mut Context, ) { let workspace = self.workspace.clone(); let project = self.project.clone(); let fs = self.fs.clone(); + let is_via_collab = self.project.read(cx).is_via_collab(); const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; - #[derive(Default, Serialize, Deserialize)] + #[derive(Serialize, Deserialize)] struct LastUsedExternalAgent { agent: crate::ExternalAgent, } - let thread_store = self.thread_store.clone(); - let text_thread_store = self.context_store.clone(); + let loading = self.loading; + let history = self.history_store.clone(); cx.spawn_in(window, async move |this, cx| { - let server: Rc = match agent_choice { + let ext_agent = match agent_choice { Some(agent) => { - cx.background_spawn(async move { - if let Some(serialized) = - serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() - { - KEY_VALUE_STORE - .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) - .await - .log_err(); + cx.background_spawn({ + let agent = agent.clone(); + async move { + if let Some(serialized) = + serde_json::to_string(&LastUsedExternalAgent { agent }).log_err() + { + KEY_VALUE_STORE + .write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized) + .await + .log_err(); + } } }) .detach(); - agent.server(fs) + agent + } + None => { + if is_via_collab { + ExternalAgent::NativeAgent + } else { + cx.background_spawn(async move { + KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) + }) + .await + .log_err() + .flatten() + .and_then(|value| { + serde_json::from_str::(&value).log_err() + }) + .map(|agent| agent.agent) + .unwrap_or(ExternalAgent::NativeAgent) + } } - None => cx - .background_spawn(async move { - KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) - }) - .await - .log_err() - .flatten() - .and_then(|value| { - serde_json::from_str::(&value).log_err() - }) - .unwrap_or_default() - .agent - .server(fs), }; + let server = ext_agent.server(fs, history); + + if !loading { + telemetry::event!("Agent Thread Started", agent = server.telemetry_id()); + } + this.update_in(cx, |this, window, cx| { + let selected_agent = ext_agent.into(); + if this.selected_agent != selected_agent { + this.selected_agent = selected_agent; + this.serialize(cx); + } + let thread_view = cx.new(|cx| { crate::acp::AcpThreadView::new( server, + resume_thread, + summarize_thread, workspace.clone(), project, - thread_store.clone(), - text_thread_store.clone(), + this.history_store.clone(), + this.prompt_store.clone(), window, cx, ) @@ -1051,34 +931,31 @@ impl AgentPanel { self.set_active_view(previous_view, window, cx); } } else { - self.thread_store - .update(cx, |thread_store, cx| thread_store.reload(cx)) - .detach_and_log_err(cx); self.set_active_view(ActiveView::History, window, cx); } cx.notify(); } - pub(crate) fn open_saved_prompt_editor( + pub(crate) fn open_saved_text_thread( &mut self, path: Arc, window: &mut Window, cx: &mut Context, ) -> Task> { - let context = self - .context_store - .update(cx, |store, cx| store.open_local_context(path, cx)); + let text_thread_task = self + .history_store + .update(cx, |store, cx| store.load_text_thread(path, cx)); cx.spawn_in(window, async move |this, cx| { - let context = context.await?; + let text_thread = text_thread_task.await?; this.update_in(cx, |this, window, cx| { - this.open_prompt_editor(context, window, cx); + this.open_text_thread(text_thread, window, cx); }) }) } - pub(crate) fn open_prompt_editor( + pub(crate) fn open_text_thread( &mut self, - context: Entity, + text_thread: Entity, window: &mut Window, cx: &mut Context, ) { @@ -1086,8 +963,8 @@ impl AgentPanel { .log_err() .flatten(); let editor = cx.new(|cx| { - TextThreadEditor::for_context( - context, + TextThreadEditor::for_text_thread( + text_thread, self.fs.clone(), self.workspace.clone(), self.project.clone(), @@ -1096,9 +973,15 @@ impl AgentPanel { cx, ) }); + + if self.selected_agent != AgentType::TextThread { + self.selected_agent = AgentType::TextThread; + self.serialize(cx); + } + self.set_active_view( - ActiveView::prompt_editor( - editor.clone(), + ActiveView::text_thread( + editor, self.history_store.clone(), self.language_registry.clone(), window, @@ -1109,72 +992,6 @@ impl AgentPanel { ); } - pub(crate) fn open_thread_by_id( - &mut self, - thread_id: &ThreadId, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let open_thread_task = self - .thread_store - .update(cx, |this, cx| this.open_thread(thread_id, window, cx)); - cx.spawn_in(window, async move |this, cx| { - let thread = open_thread_task.await?; - this.update_in(cx, |this, window, cx| { - this.open_thread(thread, window, cx); - anyhow::Ok(()) - })??; - Ok(()) - }) - } - - pub(crate) fn open_thread( - &mut self, - thread: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let context_store = cx.new(|_cx| { - ContextStore::new( - self.project.downgrade(), - Some(self.thread_store.downgrade()), - ) - }); - - let active_thread = cx.new(|cx| { - ActiveThread::new( - thread.clone(), - self.thread_store.clone(), - self.context_store.clone(), - context_store.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - }); - - let message_editor = cx.new(|cx| { - MessageEditor::new( - self.fs.clone(), - self.workspace.clone(), - context_store, - self.prompt_store.clone(), - self.thread_store.downgrade(), - self.context_store.downgrade(), - Some(self.history_store.downgrade()), - thread.clone(), - window, - cx, - ) - }); - message_editor.focus_handle(cx).focus(window); - - let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx); - self.set_active_view(thread_view, window, cx); - AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); - } - pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { match self.active_view { ActiveView::Configuration | ActiveView::History => { @@ -1182,14 +999,13 @@ impl AgentPanel { self.active_view = previous_view; match &self.active_view { - ActiveView::Thread { message_editor, .. } => { - message_editor.focus_handle(cx).focus(window); - } ActiveView::ExternalAgentThread { thread_view } => { thread_view.focus_handle(cx).focus(window); } - ActiveView::TextThread { context_editor, .. } => { - context_editor.focus_handle(cx).focus(window); + ActiveView::TextThread { + text_thread_editor, .. + } => { + text_thread_editor.focus_handle(cx).focus(window); } ActiveView::History | ActiveView::Configuration => {} } @@ -1206,7 +1022,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.assistant_navigation_menu_handle.toggle(window, cx); + self.agent_navigation_menu_handle.toggle(window, cx); } pub fn toggle_options_menu( @@ -1249,19 +1065,24 @@ impl AgentPanel { match self.active_view.which_font_size_used() { WhichFontSize::AgentFont => { if persist { - update_settings_file::( - self.fs.clone(), - cx, - move |settings, cx| { - let agent_font_size = - ThemeSettings::get_global(cx).agent_font_size(cx) + delta; - let _ = settings - .agent_font_size - .insert(Some(theme::clamp_font_size(agent_font_size).into())); - }, - ); + update_settings_file(self.fs.clone(), cx, move |settings, cx| { + let agent_ui_font_size = + ThemeSettings::get_global(cx).agent_ui_font_size(cx) + delta; + let agent_buffer_font_size = + ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta; + + let _ = settings + .theme + .agent_ui_font_size + .insert(theme::clamp_font_size(agent_ui_font_size).into()); + let _ = settings + .theme + .agent_buffer_font_size + .insert(theme::clamp_font_size(agent_buffer_font_size).into()); + }); } else { - theme::adjust_agent_font_size(cx, |size| size + delta); + theme::adjust_agent_ui_font_size(cx, |size| size + delta); + theme::adjust_agent_buffer_font_size(cx, |size| size + delta); } } WhichFontSize::BufferFont => { @@ -1280,14 +1101,21 @@ impl AgentPanel { cx: &mut Context, ) { if action.persist { - update_settings_file::(self.fs.clone(), cx, move |settings, _| { - settings.agent_font_size = None; + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.theme.agent_ui_font_size = None; + settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_agent_font_size(cx); + theme::reset_agent_ui_font_size(cx); + theme::reset_agent_buffer_font_size(cx); } } + pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context) { + theme::reset_agent_ui_font_size(cx); + theme::reset_agent_buffer_font_size(cx); + } + pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context) { if self.zoomed { cx.emit(PanelEvent::ZoomOut); @@ -1299,44 +1127,18 @@ impl AgentPanel { } } - pub fn open_agent_diff( - &mut self, - _: &OpenAgentDiff, - window: &mut Window, - cx: &mut Context, - ) { - match &self.active_view { - ActiveView::Thread { thread, .. } => { - let thread = thread.read(cx).thread().clone(); - self.workspace - .update(cx, |workspace, cx| { - AgentDiffPane::deploy_in_workspace( - AgentDiffThread::Native(thread), - workspace, - window, - cx, - ) - }) - .log_err(); - } - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} - } - } - pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { + let agent_server_store = self.project.read(cx).agent_server_store().clone(); let context_server_store = self.project.read(cx).context_server_store(); - let tools = self.thread_store.read(cx).tools(); let fs = self.fs.clone(); self.set_active_view(ActiveView::Configuration, window, cx); self.configuration = Some(cx.new(|cx| { AgentConfiguration::new( fs, + agent_server_store, context_server_store, - tools, + self.context_server_registry.clone(), self.language_registry.clone(), self.workspace.clone(), window, @@ -1366,15 +1168,6 @@ impl AgentPanel { }; match &self.active_view { - ActiveView::Thread { thread, .. } => { - active_thread::open_active_thread_as_markdown( - thread.read(cx).thread().clone(), - workspace, - window, - cx, - ) - .detach_and_log_err(cx); - } ActiveView::ExternalAgentThread { thread_view } => { thread_view .update(cx, |thread_view, cx| { @@ -1397,118 +1190,62 @@ impl AgentPanel { AssistantConfigurationEvent::NewThread(provider) => { if LanguageModelRegistry::read_global(cx) .default_model() - .map_or(true, |model| model.provider.id() != provider.id()) + .is_none_or(|model| model.provider.id() != provider.id()) + && let Some(model) = provider.default_model(cx) { - if let Some(model) = provider.default_model(cx) { - update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| settings.set_model(model), - ); - } + update_settings_file(self.fs.clone(), cx, move |settings, _| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings + .agent + .get_or_insert_default() + .set_model(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider), + model, + }) + }); } - self.new_thread(&NewThread::default(), window, cx); - if let Some((thread, model)) = - self.active_thread(cx).zip(provider.default_model(cx)) + self.new_thread(&NewThread, window, cx); + if let Some((thread, model)) = self + .active_native_agent_thread(cx) + .zip(provider.default_model(cx)) { thread.update(cx, |thread, cx| { - thread.set_configured_model( - Some(ConfiguredModel { - provider: provider.clone(), - model, - }), - cx, - ); + thread.set_model(model, cx); }); } } } } - pub(crate) fn active_thread(&self, cx: &App) -> Option> { + pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { - ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), + ActiveView::ExternalAgentThread { thread_view, .. } => { + thread_view.read(cx).thread().cloned() + } _ => None, } } - pub(crate) fn delete_thread( - &mut self, - thread_id: &ThreadId, - cx: &mut Context, - ) -> Task> { - self.thread_store - .update(cx, |this, cx| this.delete_thread(thread_id, cx)) - } - - fn continue_conversation(&mut self, window: &mut Window, cx: &mut Context) { - let ActiveView::Thread { thread, .. } = &self.active_view else { - return; - }; - - let thread_state = thread.read(cx).thread().read(cx); - if !thread_state.tool_use_limit_reached() { - return; - } - - let model = thread_state.configured_model().map(|cm| cm.model.clone()); - if let Some(model) = model { - thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, cx| { - thread.insert_invisible_continue_message(cx); - thread.advance_prompt_id(); - thread.send_to_model( - model, - CompletionIntent::UserPrompt, - Some(window.window_handle()), - cx, - ); - }); - }); - } else { - log::warn!("No configured model available for continuation"); + pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { + match &self.active_view { + ActiveView::ExternalAgentThread { thread_view, .. } => { + thread_view.read(cx).as_native_thread(cx) + } + _ => None, } } - fn toggle_burn_mode( - &mut self, - _: &ToggleBurnMode, - _window: &mut Window, - cx: &mut Context, - ) { - let ActiveView::Thread { thread, .. } = &self.active_view else { - return; - }; - - thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, _cx| { - let current_mode = thread.completion_mode(); - - thread.set_completion_mode(match current_mode { - CompletionMode::Burn => CompletionMode::Normal, - CompletionMode::Normal => CompletionMode::Burn, - }); - }); - }); - } - - pub(crate) fn active_context_editor(&self) -> Option> { + pub(crate) fn active_text_thread_editor(&self) -> Option> { match &self.active_view { - ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()), + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), _ => None, } } - pub(crate) fn delete_context( - &mut self, - path: Arc, - cx: &mut Context, - ) -> Task> { - self.context_store - .update(cx, |this, cx| this.delete_local_context(path, cx)) - } - fn set_active_view( &mut self, new_view: ActiveView, @@ -1524,31 +1261,17 @@ impl AgentPanel { let current_is_special = current_is_history || current_is_config; let new_is_special = new_is_history || new_is_config; - match &self.active_view { - ActiveView::Thread { thread, .. } => { - let thread = thread.read(cx); - if thread.is_empty() { - let id = thread.thread().read(cx).id().clone(); - self.history_store.update(cx, |store, cx| { - store.remove_recently_opened_thread(id, cx); - }); - } - } - _ => {} - } - match &new_view { - ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| { - let id = thread.read(cx).thread().read(cx).id().clone(); - store.push_recently_opened_entry(HistoryEntryId::Thread(id), cx); + ActiveView::TextThread { + text_thread_editor, .. + } => self.history_store.update(cx, |store, cx| { + if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() { + store.push_recently_opened_entry( + agent::HistoryEntryId::TextThread(path.clone()), + cx, + ) + } }), - ActiveView::TextThread { context_editor, .. } => { - self.history_store.update(cx, |store, cx| { - if let Some(path) = context_editor.read(cx).context().read(cx).path() { - store.push_recently_opened_entry(HistoryEntryId::Context(path.clone()), cx) - } - }) - } ActiveView::ExternalAgentThread { .. } => {} ActiveView::History | ActiveView::Configuration => {} } @@ -1586,23 +1309,26 @@ impl AgentPanel { for entry in entries { let title = entry.title().clone(); - let id = entry.id(); menu = menu.entry_with_end_slot_on_hover( title, None, { let panel = panel.downgrade(); - let id = id.clone(); + let entry = entry.clone(); move |window, cx| { - let id = id.clone(); + let entry = entry.clone(); panel - .update(cx, move |this, cx| match id { - HistoryEntryId::Thread(id) => this - .open_thread_by_id(&id, window, cx) - .detach_and_log_err(cx), - HistoryEntryId::Context(path) => this - .open_saved_prompt_editor(path.clone(), window, cx) + .update(cx, move |this, cx| match &entry { + agent::HistoryEntry::AcpThread(entry) => this.external_thread( + Some(ExternalAgent::NativeAgent), + Some(entry.clone()), + None, + window, + cx, + ), + agent::HistoryEntry::TextThread(entry) => this + .open_saved_text_thread(entry.path.clone(), window, cx) .detach_and_log_err(cx), }) .ok(); @@ -1612,7 +1338,7 @@ impl AgentPanel { "Close Entry".into(), { let panel = panel.downgrade(); - let id = id.clone(); + let id = entry.id(); move |_window, cx| { panel .update(cx, |this, cx| { @@ -1631,25 +1357,105 @@ impl AgentPanel { menu } - pub fn set_selected_agent(&mut self, agent: AgentType, cx: &mut Context) { - if self.selected_agent != agent { - self.selected_agent = agent; - self.serialize(cx); + pub fn selected_agent(&self) -> AgentType { + self.selected_agent.clone() + } + + fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context) { + if let Some(extension_store) = ExtensionStore::try_global(cx) { + let (manifests, extensions_dir) = { + let store = extension_store.read(cx); + let installed = store.installed_extensions(); + let manifests: Vec<_> = installed + .iter() + .map(|(id, entry)| (id.clone(), entry.manifest.clone())) + .collect(); + let extensions_dir = paths::extensions_dir().join("installed"); + (manifests, extensions_dir) + }; + + self.project.update(cx, |project, cx| { + project.agent_server_store().update(cx, |store, cx| { + let manifest_refs: Vec<_> = manifests + .iter() + .map(|(id, manifest)| (id.as_ref(), manifest.as_ref())) + .collect(); + store.sync_extension_agents(manifest_refs, extensions_dir, cx); + }); + }); } } - pub fn selected_agent(&self) -> AgentType { - self.selected_agent + pub fn new_agent_thread( + &mut self, + agent: AgentType, + window: &mut Window, + cx: &mut Context, + ) { + match agent { + AgentType::TextThread => { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + } + AgentType::NativeAgent => self.external_thread( + Some(crate::ExternalAgent::NativeAgent), + None, + None, + window, + cx, + ), + AgentType::Gemini => { + self.external_thread(Some(crate::ExternalAgent::Gemini), None, None, window, cx) + } + AgentType::ClaudeCode => { + self.selected_agent = AgentType::ClaudeCode; + self.serialize(cx); + self.external_thread( + Some(crate::ExternalAgent::ClaudeCode), + None, + None, + window, + cx, + ) + } + AgentType::Codex => { + self.selected_agent = AgentType::Codex; + self.serialize(cx); + self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx) + } + AgentType::Custom { name, command } => self.external_thread( + Some(crate::ExternalAgent::Custom { name, command }), + None, + None, + window, + cx, + ), + } + } + + pub fn load_agent_thread( + &mut self, + thread: DbThreadMetadata, + window: &mut Window, + cx: &mut Context, + ) { + self.external_thread( + Some(ExternalAgent::NativeAgent), + Some(thread), + None, + window, + cx, + ); } } impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { - ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx), ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx), - ActiveView::History => self.history.focus_handle(cx), - ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), + ActiveView::History => self.acp_history.focus_handle(cx), + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor.focus_handle(cx), ActiveView::Configuration => { if let Some(configuration) = self.configuration.as_ref() { configuration.focus_handle(cx) @@ -1662,11 +1468,7 @@ impl Focusable for AgentPanel { } fn agent_panel_dock_position(cx: &App) -> DockPosition { - match AgentSettings::get_global(cx).dock { - AgentDockPosition::Left => DockPosition::Left, - AgentDockPosition::Bottom => DockPosition::Bottom, - AgentDockPosition::Right => DockPosition::Right, - } + AgentSettings::get_global(cx).dock.into() } impl EventEmitter for AgentPanel {} @@ -1676,6 +1478,10 @@ impl Panel for AgentPanel { "AgentPanel" } + fn panel_key() -> &'static str { + AGENT_PANEL_KEY + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { agent_panel_dock_position(cx) } @@ -1685,13 +1491,11 @@ impl Panel for AgentPanel { } fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file::(self.fs.clone(), cx, move |settings, _| { - let dock = match position { - DockPosition::Left => AgentDockPosition::Left, - DockPosition::Bottom => AgentDockPosition::Bottom, - DockPosition::Right => AgentDockPosition::Right, - }; - settings.set_dock(dock); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_dock(position.into()); }); } @@ -1737,7 +1541,7 @@ impl Panel for AgentPanel { } fn enabled(&self, cx: &App) -> bool { - DisableAiSettings::get_global(cx).disable_ai.not() && AgentSettings::get_global(cx).enabled + AgentSettings::get_global(cx).enabled(cx) } fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { @@ -1755,73 +1559,48 @@ impl AgentPanel { const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; let content = match &self.active_view { - ActiveView::Thread { - thread: active_thread, - change_title_editor, - .. - } => { - let state = { - let active_thread = active_thread.read(cx); - if active_thread.is_empty() { - &ThreadSummary::Pending - } else { - active_thread.summary(cx) - } - }; - - match state { - ThreadSummary::Pending => Label::new(ThreadSummary::DEFAULT.clone()) - .truncate() - .into_any_element(), - ThreadSummary::Generating => Label::new(LOADING_SUMMARY_PLACEHOLDER) - .truncate() - .into_any_element(), - ThreadSummary::Ready(_) => div() - .w_full() - .child(change_title_editor.clone()) - .into_any_element(), - ThreadSummary::Error => h_flex() + ActiveView::ExternalAgentThread { thread_view } => { + if let Some(title_editor) = thread_view.read(cx).title_editor() { + div() .w_full() - .child(change_title_editor.clone()) - .child( - IconButton::new("retry-summary-generation", IconName::RotateCcw) - .icon_size(IconSize::Small) - .on_click({ - let active_thread = active_thread.clone(); - move |_, _window, cx| { - active_thread.update(cx, |thread, cx| { - thread.regenerate_summary(cx); - }); - } - }) - .tooltip(move |_window, cx| { - cx.new(|_| { - Tooltip::new("Failed to generate title") - .meta("Click to try again") - }) - .into() - }), - ) - .into_any_element(), + .on_action({ + let thread_view = thread_view.downgrade(); + move |_: &menu::Confirm, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window); + } + } + }) + .on_action({ + let thread_view = thread_view.downgrade(); + move |_: &editor::actions::Cancel, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window); + } + } + }) + .child(title_editor) + .into_any_element() + } else { + Label::new(thread_view.read(cx).title(cx)) + .color(Color::Muted) + .truncate() + .into_any_element() } } - ActiveView::ExternalAgentThread { thread_view } => { - Label::new(thread_view.read(cx).title(cx)) - .truncate() - .into_any_element() - } ActiveView::TextThread { title_editor, - context_editor, + text_thread_editor, .. } => { - let summary = context_editor.read(cx).context().read(cx).summary(); + let summary = text_thread_editor.read(cx).text_thread().read(cx).summary(); match summary { - ContextSummary::Pending => Label::new(ContextSummary::DEFAULT) + TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT) + .color(Color::Muted) .truncate() .into_any_element(), - ContextSummary::Content(summary) => { + TextThreadSummary::Content(summary) => { if summary.done { div() .w_full() @@ -1830,20 +1609,21 @@ impl AgentPanel { } else { Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() + .color(Color::Muted) .into_any_element() } } - ContextSummary::Error => h_flex() + TextThreadSummary::Error => h_flex() .w_full() .child(title_editor.clone()) .child( IconButton::new("retry-summary-generation", IconName::RotateCcw) .icon_size(IconSize::Small) .on_click({ - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); move |_, _window, cx| { - context_editor.update(cx, |context_editor, cx| { - context_editor.regenerate_summary(cx); + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.regenerate_summary(cx); }); } }) @@ -1890,18 +1670,19 @@ impl AgentPanel { "Enable Full Screen" }; + let selected_agent = self.selected_agent.clone(); + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) .icon_size(IconSize::Small), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Agent Menu", &ToggleOptionsMenu, &focus_handle, - window, cx, ) } @@ -1910,7 +1691,6 @@ impl AgentPanel { .anchor(Corner::TopRight) .with_handle(self.agent_panel_menu_handle.clone()) .menu({ - let focus_handle = focus_handle.clone(); move |window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _| { menu = menu.context(focus_handle.clone()); @@ -1966,40 +1746,50 @@ impl AgentPanel { .separator(); menu = menu - .action("Rules…", Box::new(OpenRulesLibrary::default())) + .action("Rules", Box::new(OpenRulesLibrary::default())) .action("Settings", Box::new(OpenSettings)) .separator() .action(full_screen_label, Box::new(ToggleZoom)); + + if selected_agent == AgentType::Gemini { + menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent)) + } + menu })) } }) } - fn render_recent_entries_menu(&self, cx: &mut Context) -> impl IntoElement { + fn render_recent_entries_menu( + &self, + icon: IconName, + corner: Corner, + cx: &mut Context, + ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); PopoverMenu::new("agent-nav-menu") .trigger_with_tooltip( - IconButton::new("agent-nav-menu", IconName::MenuAlt).icon_size(IconSize::Small), + IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small), { - let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Recent Threads", &ToggleNavigationMenu, &focus_handle, - window, cx, ) } }, ) - .anchor(Corner::TopLeft) - .with_handle(self.assistant_navigation_menu_handle.clone()) + .anchor(corner) + .with_handle(self.agent_navigation_menu_handle.clone()) .menu({ - let menu = self.assistant_navigation_menu.clone(); + let menu = self.agent_navigation_menu.clone(); move |window, cx| { + telemetry::event!("View Thread History Clicked"); + if let Some(menu) = menu.as_ref() { menu.update(cx, |_, cx| { cx.defer_in(window, |menu, window, cx| { @@ -2021,52 +1811,73 @@ impl AgentPanel { this.go_back(&workspace::GoBack, window, cx); })) .tooltip({ - let focus_handle = focus_handle.clone(); - - move |window, cx| { - Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, window, cx) + move |_window, cx| { + Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx) } }) } - fn render_toolbar_old(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + // Get custom icon path for selected agent before building menu (to avoid borrow issues) + let selected_agent_custom_icon = + if let AgentType::Custom { name, .. } = &self.selected_agent { + agent_server_store + .read(cx) + .agent_icon(&ExternalAgentServerName(name.clone())) + } else { + None + }; + let active_thread = match &self.active_view { - ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => None, + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.read(cx).as_native_thread(cx) + } + ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, }; let new_thread_menu = PopoverMenu::new("new_thread_menu") .trigger_with_tooltip( IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), - Tooltip::text("New Thread…"), + { + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in("New…", &ToggleNewThreadMenu, &focus_handle, cx) + } + }, ) .anchor(Corner::TopRight) .with_handle(self.new_thread_menu_handle.clone()) .menu({ - let focus_handle = focus_handle.clone(); + let workspace = self.workspace.clone(); + let is_via_collab = workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).is_via_collab() + }) + .unwrap_or_default(); + move |window, cx| { + telemetry::event!("New Thread Clicked"); + let active_thread = active_thread.clone(); - Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { - menu = menu - .context(focus_handle.clone()) + Some(ContextMenu::build(window, cx, |menu, _window, cx| { + menu.context(focus_handle.clone()) + .header("Zed Agent") .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); if !thread.is_empty() { - let thread_id = thread.id().clone(); + let session_id = thread.id().clone(); this.item( ContextMenuEntry::new("New From Summary") .icon(IconName::ThreadFromSummary) .icon_color(Color::Muted) .handler(move |window, cx| { window.dispatch_action( - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), + Box::new(NewNativeAgentThreadFromSummary { + from_session_id: session_id.clone(), }), cx, ); @@ -2078,138 +1889,9 @@ impl AgentPanel { }) .item( ContextMenuEntry::new("New Thread") + .action(NewThread.boxed_clone()) .icon(IconName::Thread) .icon_color(Color::Muted) - .action(NewThread::default().boxed_clone()) - .handler(move |window, cx| { - window.dispatch_action( - NewThread::default().boxed_clone(), - cx, - ); - }), - ) - .item( - ContextMenuEntry::new("New Text Thread") - .icon(IconName::TextThread) - .icon_color(Color::Muted) - .action(NewTextThread.boxed_clone()) - .handler(move |window, cx| { - window.dispatch_action(NewTextThread.boxed_clone(), cx); - }), - ); - menu - })) - } - }); - - h_flex() - .id("assistant-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .size_full() - .pl_1() - .gap_1() - .child(match &self.active_view { - ActiveView::History | ActiveView::Configuration => div() - .pl(DynamicSpacing::Base04.rems(cx)) - .child(self.render_toolbar_back_button(cx)) - .into_any_element(), - _ => self.render_recent_entries_menu(cx).into_any_element(), - }) - .child(self.render_title_view(window, cx)), - ) - .child( - h_flex() - .h_full() - .gap_2() - .children(self.render_token_count(cx)) - .child( - h_flex() - .h_full() - .gap(DynamicSpacing::Base02.rems(cx)) - .px(DynamicSpacing::Base08.rems(cx)) - .border_l_1() - .border_color(cx.theme().colors().border) - .child(new_thread_menu) - .child(self.render_panel_options_menu(window, cx)), - ), - ) - } - - fn render_toolbar_new(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - let active_thread = match &self.active_view { - ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::ExternalAgentThread { .. } - | ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => None, - }; - - let new_thread_menu = PopoverMenu::new("new_thread_menu") - .trigger_with_tooltip( - IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small), - { - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "New…", - &ToggleNewThreadMenu, - &focus_handle, - window, - cx, - ) - } - }, - ) - .anchor(Corner::TopLeft) - .with_handle(self.new_thread_menu_handle.clone()) - .menu({ - let focus_handle = focus_handle.clone(); - let workspace = self.workspace.clone(); - - move |window, cx| { - let active_thread = active_thread.clone(); - Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { - menu = menu - .context(focus_handle.clone()) - .header("Zed Agent") - .when_some(active_thread, |this, active_thread| { - let thread = active_thread.read(cx); - - if !thread.is_empty() { - let thread_id = thread.id().clone(); - this.item( - ContextMenuEntry::new("New From Summary") - .icon(IconName::ThreadFromSummary) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - cx, - ); - }), - ) - } else { - this - } - }) - .item( - ContextMenuEntry::new("New Thread") - .icon(IconName::Thread) - .icon_color(Color::Muted) - .action(NewThread::default().boxed_clone()) .handler({ let workspace = workspace.clone(); move |window, cx| { @@ -2219,18 +1901,15 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( - AgentType::Zed, + panel.new_agent_thread( + AgentType::NativeAgent, + window, cx, ); }); } }); } - window.dispatch_action( - NewThread::default().boxed_clone(), - cx, - ); } }), ) @@ -2248,21 +1927,24 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::TextThread, + window, cx, ); }); } }); } - window.dispatch_action(NewTextThread.boxed_clone(), cx); } }), ) + .separator() + .header("External Agents") .item( - ContextMenuEntry::new("New Native Agent Thread") - .icon(IconName::ZedAssistant) + ContextMenuEntry::new("New Claude Code Thread") + .icon(IconName::AiClaude) + .disabled(is_via_collab) .icon_color(Color::Muted) .handler({ let workspace = workspace.clone(); @@ -2273,29 +1955,22 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( - AgentType::NativeAgent, + panel.new_agent_thread( + AgentType::ClaudeCode, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::NativeAgent), - } - .boxed_clone(), - cx, - ); } }), ) - .separator() - .header("External Agents") .item( - ContextMenuEntry::new("New Gemini Thread") - .icon(IconName::AiGemini) + ContextMenuEntry::new("New Codex Thread") + .icon(IconName::AiOpenAi) + .disabled(is_via_collab) .icon_color(Color::Muted) .handler({ let workspace = workspace.clone(); @@ -2306,28 +1981,23 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( - AgentType::Gemini, + panel.new_agent_thread( + AgentType::Codex, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), - cx, - ); } }), ) .item( - ContextMenuEntry::new("New Claude Code Thread") - .icon(IconName::AiClaude) + ContextMenuEntry::new("New Gemini CLI Thread") + .icon(IconName::AiGemini) .icon_color(Color::Muted) + .disabled(is_via_collab) .handler({ let workspace = workspace.clone(); move |window, cx| { @@ -2337,42 +2007,122 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( - AgentType::ClaudeCode, + panel.new_agent_thread( + AgentType::Gemini, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), - cx, - ); } }), - ); - menu + ) + .map(|mut menu| { + let agent_server_store_read = agent_server_store.read(cx); + let agent_names = agent_server_store_read + .external_agents() + .filter(|name| { + name.0 != GEMINI_NAME + && name.0 != CLAUDE_CODE_NAME + && name.0 != CODEX_NAME + }) + .cloned() + .collect::>(); + let custom_settings = cx + .global::() + .get::(None) + .custom + .clone(); + for agent_name in agent_names { + let icon_path = agent_server_store_read.agent_icon(&agent_name); + let mut entry = + ContextMenuEntry::new(format!("New {} Thread", agent_name)); + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_path(icon_path); + } else { + entry = entry.icon(IconName::Terminal); + } + entry = entry + .icon_color(Color::Muted) + .disabled(is_via_collab) + .handler({ + let workspace = workspace.clone(); + let agent_name = agent_name.clone(); + let custom_settings = custom_settings.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::Custom { + name: agent_name + .clone() + .into(), + command: custom_settings + .get(&agent_name.0) + .map(|settings| { + settings + .command + .clone() + }) + .unwrap_or( + placeholder_command( + ), + ), + }, + window, + cx, + ); + }); + } + }); + } + } + }); + menu = menu.item(entry); + } + + menu + }) + .separator() + .link( + "Add Other Agents", + OpenBrowser { + url: zed_urls::external_agents_docs(cx), + } + .boxed_clone(), + ) })) } }); - let selected_agent_label = self.selected_agent.label().into(); + let selected_agent_label = self.selected_agent.label(); + + let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent = div() .id("selected_agent_icon") - .px(DynamicSpacing::Base02.rems(cx)) - .child(Icon::new(self.selected_agent.icon()).color(Color::Muted)) - .tooltip(move |window, cx| { - Tooltip::with_meta( - selected_agent_label.clone(), - None, - "Selected Agent", - window, - cx, - ) + .when_some(selected_agent_custom_icon, |this, icon_path| { + let label = selected_agent_label.clone(); + this.px(DynamicSpacing::Base02.rems(cx)) + .child(Icon::from_path(icon_path).color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) + }) + }) + .when(!has_custom_icon, |this| { + this.when_some(self.selected_agent.icon(), |this, icon| { + let label = selected_agent_label.clone(); + this.px(DynamicSpacing::Base02.rems(cx)) + .child(Icon::new(icon).color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) + }) + }) }) .into_any_element(); @@ -2395,190 +2145,37 @@ impl AgentPanel { ActiveView::History | ActiveView::Configuration => { self.render_toolbar_back_button(cx).into_any_element() } - _ => h_flex() - .gap_1() - .child(self.render_recent_entries_menu(cx)) - .child(Divider::vertical()) - .child(selected_agent) - .into_any_element(), + _ => selected_agent.into_any_element(), }) .child(self.render_title_view(window, cx)), ) .child( h_flex() - .h_full() - .gap_2() - .children(self.render_token_count(cx)) - .child( - h_flex() - .h_full() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .border_l_1() - .border_color(cx.theme().colors().border) - .child(new_thread_menu) - .child(self.render_panel_options_menu(window, cx)), - ), + .flex_none() + .gap(DynamicSpacing::Base02.rems(cx)) + .pl(DynamicSpacing::Base04.rems(cx)) + .pr(DynamicSpacing::Base06.rems(cx)) + .child(new_thread_menu) + .child(self.render_recent_entries_menu( + IconName::MenuAltTemp, + Corner::TopRight, + cx, + )) + .child(self.render_panel_options_menu(window, cx)), ) } - fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - if cx.has_flag::() { - self.render_toolbar_new(window, cx).into_any_element() - } else { - self.render_toolbar_old(window, cx).into_any_element() - } - } - - fn render_token_count(&self, cx: &App) -> Option { - match &self.active_view { - ActiveView::Thread { - thread, - message_editor, - .. - } => { - let active_thread = thread.read(cx); - let message_editor = message_editor.read(cx); - - let editor_empty = message_editor.is_editor_fully_empty(cx); - - if active_thread.is_empty() && editor_empty { - return None; - } - - let thread = active_thread.thread().read(cx); - let is_generating = thread.is_generating(); - let conversation_token_usage = thread.total_token_usage()?; - - let (total_token_usage, is_estimating) = - if let Some((editing_message_id, unsent_tokens)) = - active_thread.editing_message_id() - { - let combined = thread - .token_usage_up_to_message(editing_message_id) - .add(unsent_tokens); - - (combined, unsent_tokens > 0) - } else { - let unsent_tokens = - message_editor.last_estimated_token_count().unwrap_or(0); - let combined = conversation_token_usage.add(unsent_tokens); - - (combined, unsent_tokens > 0) - }; - - let is_waiting_to_update_token_count = - message_editor.is_waiting_to_update_token_count(); - - if total_token_usage.total == 0 { - return None; - } - - let token_color = match total_token_usage.ratio() { - TokenUsageRatio::Normal if is_estimating => Color::Default, - TokenUsageRatio::Normal => Color::Muted, - TokenUsageRatio::Warning => Color::Warning, - TokenUsageRatio::Exceeded => Color::Error, - }; - - let token_count = h_flex() - .id("token-count") - .flex_shrink_0() - .gap_0p5() - .when(!is_generating && is_estimating, |parent| { - parent - .child( - h_flex() - .mr_1() - .size_2p5() - .justify_center() - .rounded_full() - .bg(cx.theme().colors().text.opacity(0.1)) - .child( - div().size_1().rounded_full().bg(cx.theme().colors().text), - ), - ) - .tooltip(move |window, cx| { - Tooltip::with_meta( - "Estimated New Token Count", - None, - format!( - "Current Conversation Tokens: {}", - humanize_token_count(conversation_token_usage.total) - ), - window, - cx, - ) - }) - }) - .child( - Label::new(humanize_token_count(total_token_usage.total)) - .size(LabelSize::Small) - .color(token_color) - .map(|label| { - if is_generating || is_waiting_to_update_token_count { - label - .with_animation( - "used-tokens-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } - }), - ) - .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) - .child( - Label::new(humanize_token_count(total_token_usage.max)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any(); - - Some(token_count) - } - ActiveView::TextThread { context_editor, .. } => { - let element = render_remaining_tokens(context_editor, cx)?; - - Some(element.into_any_element()) - } - ActiveView::ExternalAgentThread { .. } - | ActiveView::History - | ActiveView::Configuration => { - return None; - } - } - } - fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed() { return false; } match &self.active_view { - ActiveView::Thread { thread, .. } => { - if thread - .read(cx) - .thread() - .read(cx) - .configured_model() - .map_or(false, |model| { - model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID - }) - { - return false; - } - } ActiveView::TextThread { .. } => { if LanguageModelRegistry::global(cx) .read(cx) .default_model() - .map_or(false, |model| { + .is_some_and(|model| { model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID }) { @@ -2593,7 +2190,10 @@ impl AgentPanel { let plan = self.user_store.read(cx).plan(); let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some(); - matches!(plan, Some(Plan::ZedFree)) && has_previous_trial + matches!( + plan, + Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree)) + ) && has_previous_trial } fn should_render_onboarding(&self, cx: &mut Context) -> bool { @@ -2601,11 +2201,29 @@ impl AgentPanel { return false; } + let user_store = self.user_store.read(cx); + + if user_store + .plan() + .is_some_and(|plan| matches!(plan, Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro))) + && user_store + .subscription_period() + .and_then(|period| period.0.checked_add_days(chrono::Days::new(1))) + .is_some_and(|date| date < chrono::Utc::now()) + { + OnboardingUpsell::set_dismissed(true, cx); + return false; + } + match &self.active_view { - ActiveView::Thread { .. } | ActiveView::TextThread { .. } => { - let history_is_empty = self - .history_store - .update(cx, |store, cx| store.recent_entries(1, cx).is_empty()); + ActiveView::History | ActiveView::Configuration => false, + ActiveView::ExternalAgentThread { thread_view, .. } + if thread_view.read(cx).as_native_thread(cx).is_none() => + { + false + } + _ => { + let history_is_empty = self.history_store.read(cx).is_empty(cx); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) .providers() @@ -2617,9 +2235,6 @@ impl AgentPanel { history_is_empty || !has_configured_non_zed_providers } - ActiveView::ExternalAgentThread { .. } - | ActiveView::History - | ActiveView::Configuration => false, } } @@ -2632,14 +2247,10 @@ impl AgentPanel { return None; } - let thread_view = matches!(&self.active_view, ActiveView::Thread { .. }); let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. }); Some( div() - .when(thread_view, |this| { - this.size_full().bg(cx.theme().colors().panel_background) - }) .when(text_thread_view, |this| { this.bg(cx.theme().colors().editor_background) }) @@ -2647,16 +2258,6 @@ impl AgentPanel { ) } - fn render_backdrop(&self, cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().panel_background) - .opacity(0.8) - .block_mouse_except_scroll() - } - fn render_trial_end_upsell( &self, _window: &mut Window, @@ -2666,6 +2267,8 @@ impl AgentPanel { return None; } + let plan = self.user_store.read(cx).plan()?; + Some( v_flex() .absolute() @@ -2674,567 +2277,93 @@ impl AgentPanel { .bg(cx.theme().colors().panel_background) .opacity(0.85) .block_mouse_except_scroll() - .child(EndTrialUpsell::new(Arc::new({ - let this = cx.entity(); - move |_, cx| { - this.update(cx, |_this, cx| { - TrialEndUpsell::set_dismissed(true, cx); - cx.notify(); - }); - } - }))), + .child(EndTrialUpsell::new( + plan, + Arc::new({ + let this = cx.entity(); + move |_, cx| { + this.update(cx, |_this, cx| { + TrialEndUpsell::set_dismissed(true, cx); + cx.notify(); + }); + } + }), + )), ) } - fn render_empty_state_section_header( - &self, - label: impl Into, - action_slot: Option, - cx: &mut Context, - ) -> impl IntoElement { - h_flex() - .mt_2() - .pl_1p5() - .pb_1() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(label.into()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .children(action_slot) - } - - fn render_thread_empty_state( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let recent_history = self - .history_store - .update(cx, |this, cx| this.recent_entries(6, cx)); - - let model_registry = LanguageModelRegistry::read_global(cx); - - let configuration_error = - model_registry.configuration_error(model_registry.default_model(), cx); - - let no_error = configuration_error.is_none(); - let focus_handle = self.focus_handle(cx); - - v_flex() - .size_full() - .bg(cx.theme().colors().panel_background) - .when(recent_history.is_empty(), |this| { - this.child( - v_flex() - .size_full() - .mx_auto() - .justify_center() - .items_center() - .gap_1() - .child(h_flex().child(Headline::new("Welcome to the Agent Panel"))) - .when(no_error, |parent| { - parent - .child(h_flex().child( - Label::new("Ask and build anything.").color(Color::Muted), - )) - .child( - v_flex() - .mt_2() - .gap_1() - .max_w_48() - .child( - Button::new("context", "Add Context") - .label_size(LabelSize::Small) - .icon(IconName::FileCode) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &ToggleContextPicker, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - ToggleContextPicker.boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("mode", "Switch Model") - .label_size(LabelSize::Small) - .icon(IconName::DatabaseZap) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &ToggleModelSelector, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - ToggleModelSelector.boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("settings", "View Settings") - .label_size(LabelSize::Small) - .icon(IconName::Settings) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .full_width() - .key_binding(KeyBinding::for_action_in( - &OpenSettings, - &focus_handle, - window, - cx, - )) - .on_click(|_event, window, cx| { - window.dispatch_action( - OpenSettings.boxed_clone(), - cx, - ) - }), - ), - ) - }) - .when_some(configuration_error.as_ref(), |this, err| { - this.child(self.render_configuration_error( - err, - &focus_handle, - window, - cx, - )) - }), - ) - }) - .when(!recent_history.is_empty(), |parent| { - let focus_handle = focus_handle.clone(); - parent - .overflow_hidden() - .p_1p5() - .justify_end() - .gap_1() - .child( - self.render_empty_state_section_header( - "Recent", - Some( - Button::new("view-history", "View All") - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenHistory, - &self.focus_handle(cx), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(move |_event, window, cx| { - window.dispatch_action(OpenHistory.boxed_clone(), cx); - }) - .into_any_element(), - ), - cx, - ), - ) - .child( - v_flex() - .gap_1() - .children(recent_history.into_iter().enumerate().map( - |(index, entry)| { - // TODO: Add keyboard navigation. - let is_hovered = - self.hovered_recent_history_item == Some(index); - HistoryEntryElement::new(entry.clone(), cx.entity().downgrade()) - .hovered(is_hovered) - .on_hover(cx.listener( - move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_recent_history_item = Some(index); - } else if this.hovered_recent_history_item - == Some(index) - { - this.hovered_recent_history_item = None; - } - cx.notify(); - }, - )) - .into_any_element() - }, - )), - ) - .when_some(configuration_error.as_ref(), |this, err| { - this.child(self.render_configuration_error(err, &focus_handle, window, cx)) - }) - }) - } - fn render_configuration_error( &self, + border_bottom: bool, configuration_error: &ConfigurationError, focus_handle: &FocusHandle, - window: &mut Window, cx: &mut App, ) -> impl IntoElement { - match configuration_error { - ConfigurationError::ModelNotFound - | ConfigurationError::ProviderNotAuthenticated(_) - | ConfigurationError::NoProvider => Banner::new() - .severity(ui::Severity::Warning) - .child(Label::new(configuration_error.to_string())) - .action_slot( - Button::new("settings", "Configure Provider") + let zed_provider_configured = AgentSettings::get_global(cx) + .default_model + .as_ref() + .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev"); + + let callout = if zed_provider_configured { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .when(border_bottom, |this| { + this.border_position(ui::BorderPosition::Bottom) + }) + .title("Sign in to continue using Zed as your LLM provider.") + .actions_slot( + Button::new("sign_in", "Sign In") + .style(ButtonStyle::Tinted(ui::TintColor::Warning)) + .label_size(LabelSize::Small) + .on_click({ + let workspace = self.workspace.clone(); + move |_, _, cx| { + let Ok(client) = + workspace.update(cx, |workspace, _| workspace.client().clone()) + else { + return; + }; + + cx.spawn(async move |cx| { + client.sign_in_with_optional_connect(true, cx).await + }) + .detach_and_log_err(cx); + } + }), + ) + } else { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .when(border_bottom, |this| { + this.border_position(ui::BorderPosition::Bottom) + }) + .title(configuration_error.to_string()) + .actions_slot( + Button::new("settings", "Configure") .style(ButtonStyle::Tinted(ui::TintColor::Warning)) .label_size(LabelSize::Small) .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, window, cx) + KeyBinding::for_action_in(&OpenSettings, focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_event, window, cx| { window.dispatch_action(OpenSettings.boxed_clone(), cx) }), - ), - ConfigurationError::ProviderPendingTermsAcceptance(provider) => { - Banner::new().severity(ui::Severity::Warning).child( - h_flex().w_full().children( - provider.render_accept_terms( - LanguageModelProviderTosView::ThreadEmptyState, - cx, - ), - ), ) - } - } - } - - fn render_tool_use_limit_reached( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Option { - let active_thread = match &self.active_view { - ActiveView::Thread { thread, .. } => thread, - ActiveView::ExternalAgentThread { .. } => { - return None; - } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { - return None; - } }; - let thread = active_thread.read(cx).thread().read(cx); - - let tool_use_limit_reached = thread.tool_use_limit_reached(); - if !tool_use_limit_reached { - return None; - } - - let model = thread.configured_model()?.model; - - let focus_handle = self.focus_handle(cx); - - let banner = Banner::new() - .severity(ui::Severity::Info) - .child(Label::new("Consecutive tool use limit reached.").size(LabelSize::Small)) - .action_slot( - h_flex() - .gap_1() - .child( - Button::new("continue-conversation", "Continue") - .layer(ElevationIndex::ModalSurface) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &ContinueThread, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.continue_conversation(window, cx); - })), - ) - .when(model.supports_burn_mode(), |this| { - this.child( - Button::new("continue-burn-mode", "Continue with Burn Mode") - .style(ButtonStyle::Filled) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .layer(ElevationIndex::ModalSurface) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &ContinueWithBurnMode, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .tooltip(Tooltip::text("Enable Burn Mode for unlimited tool use.")) - .on_click({ - let active_thread = active_thread.clone(); - cx.listener(move |this, _, window, cx| { - active_thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, _cx| { - thread.set_completion_mode(CompletionMode::Burn); - }); - }); - this.continue_conversation(window, cx); - }) - }), - ) - }), - ); - - Some(div().px_2().pb_2().child(banner).into_any_element()) - } - - fn create_copy_button(&self, message: impl Into) -> impl IntoElement { - let message = message.into(); - - IconButton::new("copy", IconName::Copy) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Copy Error Message")) - .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(message.clone())) - }) - } - - fn dismiss_error_button( - &self, - thread: &Entity, - cx: &mut Context, - ) -> impl IntoElement { - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Error")) - .on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - - cx.notify(); - } - })) - } - - fn upgrade_button( - &self, - thread: &Entity, - cx: &mut Context, - ) -> impl IntoElement { - Button::new("upgrade", "Upgrade") - .label_size(LabelSize::Small) - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(cx.listener({ - let thread = thread.clone(); - move |_, _, _, cx| { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - - cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)); - cx.notify(); - } - })) - } - - fn error_callout_bg(&self, cx: &Context) -> Hsla { - cx.theme().status().error.opacity(0.08) - } - - fn render_payment_required_error( - &self, - thread: &Entity, - cx: &mut Context, - ) -> AnyElement { - const ERROR_MESSAGE: &str = - "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; - - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Callout::new() - .icon(icon) - .title("Free Usage Exceeded") - .description(ERROR_MESSAGE) - .tertiary_action(self.upgrade_button(thread, cx)) - .secondary_action(self.create_copy_button(ERROR_MESSAGE)) - .primary_action(self.dismiss_error_button(thread, cx)) - .bg_color(self.error_callout_bg(cx)), - ) - .into_any_element() - } - - fn render_model_request_limit_reached_error( - &self, - plan: Plan, - thread: &Entity, - cx: &mut Context, - ) -> AnyElement { - let error_message = match plan { - Plan::ZedPro => "Upgrade to usage-based billing for more prompts.", - Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.", - }; - - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Callout::new() - .icon(icon) - .title("Model Prompt Limit Reached") - .description(error_message) - .tertiary_action(self.upgrade_button(thread, cx)) - .secondary_action(self.create_copy_button(error_message)) - .primary_action(self.dismiss_error_button(thread, cx)) - .bg_color(self.error_callout_bg(cx)), - ) - .into_any_element() - } - - fn render_error_message( - &self, - header: SharedString, - message: SharedString, - thread: &Entity, - cx: &mut Context, - ) -> AnyElement { - let message_with_header = format!("{}\n{}", header, message); - - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - - let retry_button = Button::new("retry", "Retry") - .icon(IconName::RotateCw) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .on_click({ - let thread = thread.clone(); - move |_, window, cx| { - thread.update(cx, |thread, cx| { - thread.clear_last_error(); - thread.thread().update(cx, |thread, cx| { - thread.retry_last_completion(Some(window.window_handle()), cx); - }); - }); - } - }); - - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Callout::new() - .icon(icon) - .title(header) - .description(message.clone()) - .primary_action(retry_button) - .secondary_action(self.dismiss_error_button(thread, cx)) - .tertiary_action(self.create_copy_button(message_with_header)) - .bg_color(self.error_callout_bg(cx)), - ) - .into_any_element() - } - - fn render_retryable_error( - &self, - message: SharedString, - can_enable_burn_mode: bool, - thread: &Entity, - cx: &mut Context, - ) -> AnyElement { - let icon = Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error); - - let retry_button = Button::new("retry", "Retry") - .icon(IconName::RotateCw) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .on_click({ - let thread = thread.clone(); - move |_, window, cx| { - thread.update(cx, |thread, cx| { - thread.clear_last_error(); - thread.thread().update(cx, |thread, cx| { - thread.retry_last_completion(Some(window.window_handle()), cx); - }); - }); - } - }); - - let mut callout = Callout::new() - .icon(icon) - .title("Error") - .description(message.clone()) - .bg_color(self.error_callout_bg(cx)) - .primary_action(retry_button); - - if can_enable_burn_mode { - let burn_mode_button = Button::new("enable_burn_retry", "Enable Burn Mode and Retry") - .icon(IconName::ZedBurnMode) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .label_size(LabelSize::Small) - .on_click({ - let thread = thread.clone(); - move |_, window, cx| { - thread.update(cx, |thread, cx| { - thread.clear_last_error(); - thread.thread().update(cx, |thread, cx| { - thread.enable_burn_mode_and_retry(Some(window.window_handle()), cx); - }); - }); - } - }); - callout = callout.secondary_action(burn_mode_button); + match configuration_error { + ConfigurationError::ModelNotFound + | ConfigurationError::ProviderNotAuthenticated(_) + | ConfigurationError::NoProvider => callout.into_any_element(), } - - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child(callout) - .into_any_element() } - fn render_prompt_editor( + fn render_text_thread( &self, - context_editor: &Entity, + text_thread_editor: &Entity, buffer_search_bar: &Entity, window: &mut Window, cx: &mut Context, @@ -3268,7 +2397,7 @@ impl AgentPanel { ) }) }) - .child(context_editor.clone()) + .child(text_thread_editor.clone()) .child(self.render_drag_target(cx)) } @@ -3307,9 +2436,9 @@ impl AgentPanel { .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| { let tasks = paths .paths() - .into_iter() + .iter() .map(|path| { - Workspace::project_path_for_path(this.project.clone(), &path, false, cx) + Workspace::project_path_for_path(this.project.clone(), path, false, cx) }) .collect::>(); cx.spawn_in(window, async move |this, cx| { @@ -3339,35 +2468,17 @@ impl AgentPanel { cx: &mut Context, ) { match &self.active_view { - ActiveView::Thread { thread, .. } => { - let context_store = thread.read(cx).context_store().clone(); - context_store.update(cx, move |context_store, cx| { - let mut tasks = Vec::new(); - for project_path in &paths { - tasks.push(context_store.add_file_from_path( - project_path.clone(), - false, - cx, - )); - } - cx.background_spawn(async move { - futures::future::join_all(tasks).await; - // Need to hold onto the worktrees until they have already been used when - // opening the buffers. - drop(added_worktrees); - }) - .detach(); - }); - } ActiveView::ExternalAgentThread { thread_view } => { thread_view.update(cx, |thread_view, cx| { thread_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - ActiveView::TextThread { context_editor, .. } => { - context_editor.update(cx, |context_editor, cx| { + ActiveView::TextThread { + text_thread_editor, .. + } => { + text_thread_editor.update(cx, |text_thread_editor, cx| { TextThreadEditor::insert_dragged_files( - context_editor, + text_thread_editor, paths, added_worktrees, window, @@ -3383,9 +2494,9 @@ impl AgentPanel { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); match &self.active_view { - ActiveView::ExternalAgentThread { .. } => key_context.add("external_agent_thread"), - ActiveView::TextThread { .. } => key_context.add("prompt_editor"), - ActiveView::Thread { .. } | ActiveView::History | ActiveView::Configuration => {} + ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"), + ActiveView::TextThread { .. } => key_context.add("text_thread"), + ActiveView::History | ActiveView::Configuration => {} } key_context } @@ -3407,7 +2518,6 @@ impl Render for AgentPanel { .size_full() .justify_between() .key_context(self.key_context()) - .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(|this, action: &NewThread, window, cx| { this.new_thread(action, window, cx); })) @@ -3419,7 +2529,6 @@ impl Render for AgentPanel { })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) .on_action(cx.listener(Self::deploy_rules_library)) - .on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) @@ -3427,79 +2536,20 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) - .on_action(cx.listener(|this, _: &ContinueThread, window, cx| { - this.continue_conversation(window, cx); - })) - .on_action(cx.listener(|this, _: &ContinueWithBurnMode, window, cx| { - match &this.active_view { - ActiveView::Thread { thread, .. } => { - thread.update(cx, |active_thread, cx| { - active_thread.thread().update(cx, |thread, _cx| { - thread.set_completion_mode(CompletionMode::Burn); - }); - }); - this.continue_conversation(window, cx); - } - ActiveView::ExternalAgentThread { .. } => {} - ActiveView::TextThread { .. } - | ActiveView::History - | ActiveView::Configuration => {} + .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { + if let Some(thread_view) = this.active_thread_view() { + thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) } })) - .on_action(cx.listener(Self::toggle_burn_mode)) .child(self.render_toolbar(window, cx)) .children(self.render_onboarding(window, cx)) .map(|parent| match &self.active_view { - ActiveView::Thread { - thread, - message_editor, - .. - } => parent - .child( - if thread.read(cx).is_empty() && !self.should_render_onboarding(cx) { - self.render_thread_empty_state(window, cx) - .into_any_element() - } else { - thread.clone().into_any_element() - }, - ) - .children(self.render_tool_use_limit_reached(window, cx)) - .when_some(thread.read(cx).last_error(), |this, last_error| { - this.child( - div() - .child(match last_error { - ThreadError::PaymentRequired => { - self.render_payment_required_error(thread, cx) - } - ThreadError::ModelRequestLimitReached { plan } => self - .render_model_request_limit_reached_error(plan, thread, cx), - ThreadError::Message { header, message } => { - self.render_error_message(header, message, thread, cx) - } - ThreadError::RetryableError { - message, - can_enable_burn_mode, - } => self.render_retryable_error( - message, - can_enable_burn_mode, - thread, - cx, - ), - }) - .into_any(), - ) - }) - .child(h_flex().relative().child(message_editor.clone()).when( - !LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx), - |this| this.child(self.render_backdrop(cx)), - )) - .child(self.render_drag_target(cx)), ActiveView::ExternalAgentThread { thread_view, .. } => parent .child(thread_view.clone()) .child(self.render_drag_target(cx)), - ActiveView::History => parent.child(self.history.clone()), + ActiveView::History => parent.child(self.acp_history.clone()), ActiveView::TextThread { - context_editor, + text_thread_editor, buffer_search_bar, .. } => { @@ -3511,22 +2561,18 @@ impl Render for AgentPanel { if !self.should_render_onboarding(cx) && let Some(err) = configuration_error.as_ref() { - this.child( - div().bg(cx.theme().colors().editor_background).p_2().child( - self.render_configuration_error( - err, - &self.focus_handle(cx), - window, - cx, - ), - ), - ) + this.child(self.render_configuration_error( + true, + err, + &self.focus_handle(cx), + cx, + )) } else { this } }) - .child(self.render_prompt_editor( - context_editor, + .child(self.render_text_thread( + text_thread_editor, buffer_search_bar, window, cx, @@ -3538,7 +2584,7 @@ impl Render for AgentPanel { match self.active_view.which_font_size_used() { WhichFontSize::AgentFont => { - WithRemSize::new(ThemeSettings::get_global(cx).agent_font_size(cx)) + WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .size_full() .child(content) .into_any() @@ -3576,16 +2622,14 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { }; let prompt_store = None; let thread_store = None; - let text_thread_store = None; - let context_store = cx.new(|_| ContextStore::new(project.clone(), None)); + let context_store = cx.new(|_| ContextStore::new(project.clone())); assistant.assist( - &prompt_editor, + prompt_editor, self.workspace.clone(), context_store, project, prompt_store, thread_store, - text_thread_store, initial_prompt, window, cx, @@ -3606,17 +2650,17 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { pub struct ConcreteAssistantPanelDelegate; impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { - fn active_context_editor( + fn active_text_thread_editor( &self, workspace: &mut Workspace, _window: &mut Window, cx: &mut Context, ) -> Option> { let panel = workspace.panel::(cx)?; - panel.read(cx).active_context_editor() + panel.read(cx).active_text_thread_editor() } - fn open_saved_context( + fn open_local_text_thread( &self, workspace: &mut Workspace, path: Arc, @@ -3628,14 +2672,14 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { }; panel.update(cx, |panel, cx| { - panel.open_saved_prompt_editor(path, window, cx) + panel.open_saved_text_thread(path, window, cx) }) } - fn open_remote_context( + fn open_remote_text_thread( &self, _workspace: &mut Workspace, - _context_id: assistant_context::ContextId, + _text_thread_id: assistant_text_thread::TextThreadId, _window: &mut Window, _cx: &mut Context, ) -> Task>> { @@ -3662,38 +2706,19 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(message_editor) = panel.active_message_editor() { - message_editor.update(cx, |message_editor, cx| { - message_editor.context_store().update(cx, |store, cx| { - let buffer = buffer.read(cx); - let selection_ranges = selection_ranges - .into_iter() - .flat_map(|range| { - let (start_buffer, start) = - buffer.text_anchor_for_position(range.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(range.end, cx)?; - if start_buffer != end_buffer { - return None; - } - Some((start_buffer, start..end)) - }) - .collect::>(); - - for (buffer, range) in selection_ranges { - store.add_selection(buffer, range, cx); - } - }) - }) - } else if let Some(context_editor) = panel.active_context_editor() { + if let Some(thread_view) = panel.active_thread_view() { + thread_view.update(cx, |thread_view, cx| { + thread_view.insert_selections(window, cx); + }); + } else if let Some(text_thread_editor) = panel.active_text_thread_editor() { let snapshot = buffer.read(cx).snapshot(cx); let selection_ranges = selection_ranges .into_iter() .map(|range| range.to_point(&snapshot)) .collect::>(); - context_editor.update(cx, |context_editor, cx| { - context_editor.quote_ranges(selection_ranges, snapshot, window, cx) + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.quote_ranges(selection_ranges, snapshot, window, cx) }); } }); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index f25b576886d83db32ea48dbffac400a8a096a695..7869aa4e0191f393a05ff1b2c0307bccaef41dc8 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,14 +1,14 @@ mod acp; -mod active_thread; mod agent_configuration; mod agent_diff; mod agent_model_selector; mod agent_panel; mod buffer_codegen; +mod context; mod context_picker; mod context_server_configuration; +mod context_store; mod context_strip; -mod debug; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; @@ -16,44 +16,38 @@ mod message_editor; mod profile_selector; mod slash_command; mod slash_command_picker; -mod slash_command_settings; mod terminal_codegen; mod terminal_inline_assistant; mod text_thread_editor; -mod thread_history; -mod tool_compatibility; mod ui; use std::rc::Rc; use std::sync::Arc; -use agent::{Thread, ThreadId}; -use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; +use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; -use gpui::{Action, App, Entity, actions}; +use gpui::{Action, App, Entity, SharedString, actions}; use language::LanguageRegistry; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::DisableAiSettings; +use project::agent_server_store::AgentServerCommand; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{Settings as _, SettingsStore}; +use settings::{LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; -pub use crate::active_thread::ActiveThread; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; pub use crate::inline_assistant::InlineAssistant; -use crate::slash_command_settings::SlashCommandSettings; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; -pub use ui::preview::{all_agent_previews, get_agent_preview}; use zed_actions; actions!( @@ -71,8 +65,10 @@ actions!( ToggleOptionsMenu, /// Deletes the recently opened thread from history. DeleteRecentlyOpenThread, - /// Toggles the profile selector for switching between agent profiles. + /// Toggles the profile or mode selector for switching between agent profiles. ToggleProfileSelector, + /// Cycles through available session modes. + CycleModeSelector, /// Removes all added context from the current conversation. RemoveAllContext, /// Expands the message editor to full size. @@ -113,6 +109,12 @@ actions!( RejectAll, /// Keeps all suggestions or changes. KeepAll, + /// Allow this operation only this time. + AllowOnce, + /// Allow this operation and remember the choice. + AllowAlways, + /// Reject this operation only this time. + RejectOnce, /// Follows the agent's suggestions. Follow, /// Resets the trial upsell notification. @@ -132,10 +134,7 @@ actions!( #[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] #[serde(deny_unknown_fields)] -pub struct NewThread { - #[serde(default)] - from_thread_id: Option, -} +pub struct NewThread; /// Creates a new external agent conversation thread. #[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -146,21 +145,59 @@ pub struct NewExternalAgentThread { agent: Option, } -#[derive(Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = agent)] +#[serde(deny_unknown_fields)] +pub struct NewNativeAgentThreadFromSummary { + from_session_id: agent_client_protocol::SessionId, +} + +// TODO unify this with AgentType +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] -enum ExternalAgent { - #[default] +pub enum ExternalAgent { Gemini, ClaudeCode, + Codex, NativeAgent, + Custom { + name: SharedString, + command: AgentServerCommand, + }, +} + +fn placeholder_command() -> AgentServerCommand { + AgentServerCommand { + path: "/placeholder".into(), + args: vec![], + env: None, + } } impl ExternalAgent { - pub fn server(&self, fs: Arc) -> Rc { + pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option { + match server.telemetry_id() { + "gemini-cli" => Some(Self::Gemini), + "claude-code" => Some(Self::ClaudeCode), + "codex" => Some(Self::Codex), + "zed" => Some(Self::NativeAgent), + _ => None, + } + } + + pub fn server( + &self, + fs: Arc, + history: Entity, + ) -> Rc { match self { - ExternalAgent::Gemini => Rc::new(agent_servers::Gemini), - ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode), - ExternalAgent::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs)), + Self::Gemini => Rc::new(agent_servers::Gemini), + Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode), + Self::Codex => Rc::new(agent_servers::Codex), + Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, history)), + Self::Custom { name, command: _ } => { + Rc::new(agent_servers::CustomAgentServer::new(name.clone())) + } } } } @@ -184,14 +221,12 @@ impl ManageProfiles { #[derive(Clone)] pub(crate) enum ModelUsageContext { - Thread(Entity), InlineAssistant, } impl ModelUsageContext { pub fn configured_model(&self, cx: &App) -> Option { match self { - Self::Thread(thread) => thread.read(cx).configured_model(), Self::InlineAssistant => { LanguageModelRegistry::read_global(cx).inline_assistant_model() } @@ -214,9 +249,8 @@ pub fn init( cx: &mut App, ) { AgentSettings::register(cx); - SlashCommandSettings::register(cx); - assistant_context::init(client.clone(), cx); + assistant_text_thread::init(client.clone(), cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -224,7 +258,6 @@ pub fn init( init_language_model_settings(cx); } assistant_slash_command::init(cx); - agent::init(cx); agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); TextThreadEditor::init(cx); @@ -236,12 +269,7 @@ pub fn init( client.telemetry().clone(), cx, ); - terminal_inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); + terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) @@ -306,8 +334,7 @@ fn update_command_palette_filter(cx: &mut App) { ]; filter.show_action_types(edit_prediction_actions.iter()); - filter - .show_action_types([TypeId::of::()].iter()); + filter.show_action_types(&[TypeId::of::()]); } }); } @@ -320,7 +347,7 @@ fn init_language_model_settings(cx: &mut App) { cx.subscribe( &LanguageModelRegistry::global(cx), |_, event: &language_model::Event, cx| match event { - language_model::Event::ProviderStateChanged + language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { update_active_language_model_from_settings(cx); @@ -376,8 +403,6 @@ fn register_slash_commands(cx: &mut App) { slash_command_registry.register_command(assistant_slash_commands::DeltaSlashCommand, true); slash_command_registry.register_command(assistant_slash_commands::OutlineSlashCommand, true); slash_command_registry.register_command(assistant_slash_commands::TabSlashCommand, true); - slash_command_registry - .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true); slash_command_registry.register_command(assistant_slash_commands::PromptSlashCommand, true); slash_command_registry.register_command(assistant_slash_commands::SelectionCommand, true); slash_command_registry.register_command(assistant_slash_commands::DefaultSlashCommand, false); @@ -387,7 +412,6 @@ fn register_slash_commands(cx: &mut App) { slash_command_registry.register_command(assistant_slash_commands::FetchSlashCommand, true); cx.observe_flag::({ - let slash_command_registry = slash_command_registry.clone(); move |is_enabled, _cx| { if is_enabled { slash_command_registry.register_command( @@ -398,21 +422,4 @@ fn register_slash_commands(cx: &mut App) { } }) .detach(); - - update_slash_commands_from_settings(cx); - cx.observe_global::(update_slash_commands_from_settings) - .detach(); -} - -fn update_slash_commands_from_settings(cx: &mut App) { - let slash_command_registry = SlashCommandRegistry::global(cx); - let settings = SlashCommandSettings::get_global(cx); - - if settings.cargo_workspace.enabled { - slash_command_registry - .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true); - } else { - slash_command_registry - .unregister_command(assistant_slash_commands::CargoWorkspaceSlashCommand); - } } diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 615142b73dfd6eed59f635af780310290e3f6f25..215e2a74d7be9cbcb18442dcefa1581d08eec7b2 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,7 +1,5 @@ -use crate::inline_prompt_editor::CodegenStatus; -use agent::{ - ContextStore, - context::{ContextLoadResult, load_context}, +use crate::{ + context::load_context, context_store::ContextStore, inline_prompt_editor::CodegenStatus, }; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; @@ -352,12 +350,12 @@ impl CodegenAlternative { event: &multi_buffer::Event, cx: &mut Context, ) { - if let multi_buffer::Event::TransactionUndone { transaction_id } = event { - if self.transformation_transaction_id == Some(*transaction_id) { - self.transformation_transaction_id = None; - self.generation = Task::ready(()); - cx.emit(CodegenEvent::Undone); - } + if let multi_buffer::Event::TransactionUndone { transaction_id } = event + && self.transformation_transaction_id == Some(*transaction_id) + { + self.transformation_transaction_id = None; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Undone); } } @@ -388,7 +386,7 @@ impl CodegenAlternative { } else { let request = self.build_request(&model, user_prompt, cx)?; cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, &cx).await?) + Ok(model.stream_completion_text(request.await, cx).await?) }) .boxed_local() }; @@ -434,20 +432,20 @@ impl CodegenAlternative { .generate_inline_transformation_prompt(user_prompt, language_name, buffer, range) .context("generating content prompt")?; - let context_task = self.context_store.as_ref().map(|context_store| { + let context_task = self.context_store.as_ref().and_then(|context_store| { if let Some(project) = self.project.upgrade() { let context = context_store .read(cx) .context() .cloned() .collect::>(); - load_context(context, &project, &self.prompt_store, cx) + Some(load_context(context, &project, &self.prompt_store, cx)) } else { - Task::ready(ContextLoadResult::default()) + None } }); - let temperature = AgentSettings::temperature_for_model(&model, cx); + let temperature = AgentSettings::temperature_for_model(model, cx); Ok(cx.spawn(async move |_cx| { let mut request_message = LanguageModelRequestMessage { @@ -459,7 +457,6 @@ impl CodegenAlternative { if let Some(context_task) = context_task { context_task .await - .loaded_context .add_to_request_message(&mut request_message); } @@ -576,38 +573,34 @@ impl CodegenAlternative { let mut lines = chunk.split('\n').peekable(); while let Some(line) = lines.next() { new_text.push_str(line); - if line_indent.is_none() { - if let Some(non_whitespace_ch_ix) = + if line_indent.is_none() + && let Some(non_whitespace_ch_ix) = new_text.find(|ch: char| !ch.is_whitespace()) - { - line_indent = Some(non_whitespace_ch_ix); - base_indent = base_indent.or(line_indent); - - let line_indent = line_indent.unwrap(); - let base_indent = base_indent.unwrap(); - let indent_delta = - line_indent as i32 - base_indent as i32; - let mut corrected_indent_len = cmp::max( - 0, - suggested_line_indent.len as i32 + indent_delta, - ) - as usize; - if first_line { - corrected_indent_len = corrected_indent_len - .saturating_sub( - selection_start.column as usize, - ); - } - - let indent_char = suggested_line_indent.char(); - let mut indent_buffer = [0; 4]; - let indent_str = - indent_char.encode_utf8(&mut indent_buffer); - new_text.replace_range( - ..line_indent, - &indent_str.repeat(corrected_indent_len), - ); + { + line_indent = Some(non_whitespace_ch_ix); + base_indent = base_indent.or(line_indent); + + let line_indent = line_indent.unwrap(); + let base_indent = base_indent.unwrap(); + let indent_delta = line_indent as i32 - base_indent as i32; + let mut corrected_indent_len = cmp::max( + 0, + suggested_line_indent.len as i32 + indent_delta, + ) + as usize; + if first_line { + corrected_indent_len = corrected_indent_len + .saturating_sub(selection_start.column as usize); } + + let indent_char = suggested_line_indent.char(); + let mut indent_buffer = [0; 4]; + let indent_str = + indent_char.encode_utf8(&mut indent_buffer); + new_text.replace_range( + ..line_indent, + &indent_str.repeat(corrected_indent_len), + ); } if line_indent.is_some() { @@ -1028,7 +1021,7 @@ where chunk.push('\n'); } - chunk.push_str(&line); + chunk.push_str(line); } consumed += line.len(); @@ -1133,7 +1126,7 @@ mod tests { ) }); - let chunks_tx = simulate_response_stream(codegen.clone(), cx); + let chunks_tx = simulate_response_stream(&codegen, cx); let mut new_text = concat!( " let mut x = 0;\n", @@ -1143,7 +1136,7 @@ mod tests { ); while !new_text.is_empty() { let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); + let len = rng.random_range(1..=max_len); let (chunk, suffix) = new_text.split_at(len); chunks_tx.unbounded_send(chunk.to_string()).unwrap(); new_text = suffix; @@ -1200,7 +1193,7 @@ mod tests { ) }); - let chunks_tx = simulate_response_stream(codegen.clone(), cx); + let chunks_tx = simulate_response_stream(&codegen, cx); cx.background_executor.run_until_parked(); @@ -1212,7 +1205,7 @@ mod tests { ); while !new_text.is_empty() { let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); + let len = rng.random_range(1..=max_len); let (chunk, suffix) = new_text.split_at(len); chunks_tx.unbounded_send(chunk.to_string()).unwrap(); new_text = suffix; @@ -1269,7 +1262,7 @@ mod tests { ) }); - let chunks_tx = simulate_response_stream(codegen.clone(), cx); + let chunks_tx = simulate_response_stream(&codegen, cx); cx.background_executor.run_until_parked(); @@ -1281,7 +1274,7 @@ mod tests { ); while !new_text.is_empty() { let max_len = cmp::min(new_text.len(), 10); - let len = rng.gen_range(1..=max_len); + let len = rng.random_range(1..=max_len); let (chunk, suffix) = new_text.split_at(len); chunks_tx.unbounded_send(chunk.to_string()).unwrap(); new_text = suffix; @@ -1338,7 +1331,7 @@ mod tests { ) }); - let chunks_tx = simulate_response_stream(codegen.clone(), cx); + let chunks_tx = simulate_response_stream(&codegen, cx); let new_text = concat!( "func main() {\n", "\tx := 0\n", @@ -1395,7 +1388,7 @@ mod tests { ) }); - let chunks_tx = simulate_response_stream(codegen.clone(), cx); + let chunks_tx = simulate_response_stream(&codegen, cx); chunks_tx .unbounded_send("let mut x = 0;\nx += 1;".to_string()) .unwrap(); @@ -1477,7 +1470,7 @@ mod tests { } fn simulate_response_stream( - codegen: Entity, + codegen: &Entity, cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); diff --git a/crates/agent/src/context.rs b/crates/agent_ui/src/context.rs similarity index 82% rename from crates/agent/src/context.rs rename to crates/agent_ui/src/context.rs index 8cdb87ef8d9f3363e68c14053c01f34ece64b3b9..2a1ff4a1d9d3e0bb6c8b128cf7f944e9ed3ff657 100644 --- a/crates/agent/src/context.rs +++ b/crates/agent_ui/src/context.rs @@ -1,12 +1,9 @@ -use crate::thread::Thread; -use assistant_context::AssistantContext; -use assistant_tool::outline; -use collections::HashSet; +use agent::outline; +use assistant_text_thread::TextThread; use futures::future; use futures::{FutureExt, future::Shared}; use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task}; -use icons::IconName; -use language::{Buffer, ParseStatus}; +use language::Buffer; use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent}; use project::{Project, ProjectEntryId, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; @@ -17,7 +14,9 @@ use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::{ops::Range, path::Path, sync::Arc}; use text::{Anchor, OffsetRangeExt as _}; +use ui::IconName; use util::markdown::MarkdownCodeBlock; +use util::rel_path::RelPath; use util::{ResultExt as _, post_inc}; pub const RULES_ICON: IconName = IconName::Reader; @@ -158,7 +157,7 @@ pub struct FileContextHandle { #[derive(Debug, Clone)] pub struct FileContext { pub handle: FileContextHandle, - pub full_path: Arc, + pub full_path: String, pub text: SharedString, pub is_outline: bool, } @@ -180,59 +179,32 @@ impl FileContextHandle { }) } - fn load(self, cx: &App) -> Task>)>> { + fn load(self, cx: &App) -> Task> { let buffer_ref = self.buffer.read(cx); let Some(file) = buffer_ref.file() else { log::error!("file context missing path"); return Task::ready(None); }; - let full_path: Arc = file.full_path(cx).into(); + let full_path = file.full_path(cx).to_string_lossy().into_owned(); let rope = buffer_ref.as_rope().clone(); let buffer = self.buffer.clone(); cx.spawn(async move |cx| { - // For large files, use outline instead of full content - if rope.len() > outline::AUTO_OUTLINE_SIZE { - // Wait until the buffer has been fully parsed, so we can read its outline - if let Ok(mut parse_status) = - buffer.read_with(cx, |buffer, _| buffer.parse_status()) - { - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await.log_err(); - } - - if let Ok(snapshot) = buffer.read_with(cx, |buffer, _| buffer.snapshot()) { - if let Some(outline) = snapshot.outline(None) { - let items = outline - .items - .into_iter() - .map(|item| item.to_point(&snapshot)); - - if let Ok(outline_text) = - outline::render_outline(items, None, 0, usize::MAX).await - { - let context = AgentContext::File(FileContext { - handle: self, - full_path, - text: outline_text.into(), - is_outline: true, - }); - return Some((context, vec![buffer])); - } - } - } - } - } + let buffer_content = + outline::get_buffer_content_or_outline(buffer.clone(), Some(&full_path), &cx) + .await + .unwrap_or_else(|_| outline::BufferContent { + text: rope.to_string(), + is_outline: false, + }); - // Fallback to full content if we couldn't build an outline - // (or didn't need to because the file was small enough) let context = AgentContext::File(FileContext { handle: self, full_path, - text: rope.to_string().into(), - is_outline: false, + text: buffer_content.text.into(), + is_outline: buffer_content.is_outline, }); - Some((context, vec![buffer])) + Some(context) }) } } @@ -262,14 +234,14 @@ pub struct DirectoryContextHandle { #[derive(Debug, Clone)] pub struct DirectoryContext { pub handle: DirectoryContextHandle, - pub full_path: Arc, + pub full_path: String, pub descendants: Vec, } #[derive(Debug, Clone)] pub struct DirectoryContextDescendant { /// Path within the directory. - pub rel_path: Arc, + pub rel_path: Arc, pub fenced_codeblock: SharedString, } @@ -282,11 +254,7 @@ impl DirectoryContextHandle { self.entry_id.hash(state) } - fn load( - self, - project: Entity, - cx: &mut App, - ) -> Task>)>> { + fn load(self, project: Entity, cx: &mut App) -> Task> { let Some(worktree) = project.read(cx).worktree_for_entry(self.entry_id, cx) else { return Task::ready(None); }; @@ -300,13 +268,16 @@ impl DirectoryContextHandle { } let directory_path = entry.path.clone(); - let directory_full_path = worktree_ref.full_path(&directory_path).into(); + let directory_full_path = worktree_ref + .full_path(&directory_path) + .to_string_lossy() + .to_string(); let file_paths = collect_files_in_path(worktree_ref, &directory_path); let descendants_future = future::join_all(file_paths.into_iter().map(|path| { let worktree_ref = worktree.read(cx); let worktree_id = worktree_ref.id(); - let full_path = worktree_ref.full_path(&path); + let full_path = worktree_ref.full_path(&path).to_string_lossy().into_owned(); let rel_path = path .strip_prefix(&directory_path) @@ -330,7 +301,7 @@ impl DirectoryContextHandle { }); cx.background_spawn(async move { - let (rope, buffer) = rope_task.await?; + let (rope, _buffer) = rope_task.await?; let fenced_codeblock = MarkdownCodeBlock { tag: &codeblock_tag(&full_path, None), text: &rope.to_string(), @@ -341,18 +312,22 @@ impl DirectoryContextHandle { rel_path, fenced_codeblock, }; - Some((descendant, buffer)) + Some(descendant) }) })); cx.background_spawn(async move { - let (descendants, buffers) = descendants_future.await.into_iter().flatten().unzip(); + let descendants = descendants_future + .await + .into_iter() + .flatten() + .collect::>(); let context = AgentContext::Directory(DirectoryContext { handle: self, full_path: directory_full_path, descendants, }); - Some((context, buffers)) + Some(context) }) } } @@ -362,7 +337,7 @@ impl Display for DirectoryContext { let mut is_first = true; for descendant in &self.descendants { if !is_first { - write!(f, "\n")?; + writeln!(f)?; } else { is_first = false; } @@ -387,7 +362,7 @@ pub struct SymbolContextHandle { #[derive(Debug, Clone)] pub struct SymbolContext { pub handle: SymbolContextHandle, - pub full_path: Arc, + pub full_path: String, pub line_range: Range, pub text: SharedString, } @@ -420,23 +395,22 @@ impl SymbolContextHandle { .into() } - fn load(self, cx: &App) -> Task>)>> { + fn load(self, cx: &App) -> Task> { let buffer_ref = self.buffer.read(cx); let Some(file) = buffer_ref.file() else { log::error!("symbol context's file has no path"); return Task::ready(None); }; - let full_path = file.full_path(cx).into(); + let full_path = file.full_path(cx).to_string_lossy().into_owned(); let line_range = self.enclosing_range.to_point(&buffer_ref.snapshot()); let text = self.text(cx); - let buffer = self.buffer.clone(); let context = AgentContext::Symbol(SymbolContext { handle: self, full_path, line_range, text, }); - Task::ready(Some((context, vec![buffer]))) + Task::ready(Some(context)) } } @@ -460,7 +434,7 @@ pub struct SelectionContextHandle { #[derive(Debug, Clone)] pub struct SelectionContext { pub handle: SelectionContextHandle, - pub full_path: Arc, + pub full_path: String, pub line_range: Range, pub text: SharedString, } @@ -491,21 +465,20 @@ impl SelectionContextHandle { .into() } - fn load(self, cx: &App) -> Task>)>> { + fn load(self, cx: &App) -> Task> { let Some(full_path) = self.full_path(cx) else { log::error!("selection context's file has no path"); return Task::ready(None); }; let text = self.text(cx); - let buffer = self.buffer.clone(); let context = AgentContext::Selection(SelectionContext { - full_path: full_path.into(), + full_path: full_path.to_string_lossy().into_owned(), line_range: self.line_range(cx), text, handle: self, }); - Task::ready(Some((context, vec![buffer]))) + Task::ready(Some(context)) } } @@ -546,8 +519,8 @@ impl FetchedUrlContext { })) } - pub fn load(self) -> Task>)>> { - Task::ready(Some((AgentContext::FetchedUrl(self), vec![]))) + pub fn load(self) -> Task> { + Task::ready(Some(AgentContext::FetchedUrl(self))) } } @@ -560,7 +533,7 @@ impl Display for FetchedUrlContext { #[derive(Debug, Clone)] pub struct ThreadContextHandle { - pub thread: Entity, + pub thread: Entity, pub context_id: ContextId, } @@ -581,22 +554,20 @@ impl ThreadContextHandle { } pub fn title(&self, cx: &App) -> SharedString { - self.thread.read(cx).summary().or_default() + self.thread.read(cx).title() } - fn load(self, cx: &App) -> Task>)>> { - cx.spawn(async move |cx| { - let text = Thread::wait_for_detailed_summary_or_text(&self.thread, cx).await?; - let title = self - .thread - .read_with(cx, |thread, _cx| thread.summary().or_default()) - .ok()?; + fn load(self, cx: &mut App) -> Task> { + let task = self.thread.update(cx, |thread, cx| thread.summary(cx)); + let title = self.title(cx); + cx.background_spawn(async move { + let text = task.await?; let context = AgentContext::Thread(ThreadContext { title, text, handle: self, }); - Some((context, vec![])) + Some(context) }) } } @@ -610,7 +581,7 @@ impl Display for ThreadContext { #[derive(Debug, Clone)] pub struct TextThreadContextHandle { - pub context: Entity, + pub text_thread: Entity, pub context_id: ContextId, } @@ -624,33 +595,33 @@ pub struct TextThreadContext { impl TextThreadContextHandle { // pub fn lookup_key() -> pub fn eq_for_key(&self, other: &Self) -> bool { - self.context == other.context + self.text_thread == other.text_thread } pub fn hash_for_key(&self, state: &mut H) { - self.context.hash(state) + self.text_thread.hash(state) } pub fn title(&self, cx: &App) -> SharedString { - self.context.read(cx).summary().or_default() + self.text_thread.read(cx).summary().or_default() } - fn load(self, cx: &App) -> Task>)>> { + fn load(self, cx: &App) -> Task> { let title = self.title(cx); - let text = self.context.read(cx).to_xml(cx); + let text = self.text_thread.read(cx).to_xml(cx); let context = AgentContext::TextThread(TextThreadContext { title, text: text.into(), handle: self, }); - Task::ready(Some((context, vec![]))) + Task::ready(Some(context)) } } impl Display for TextThreadContext { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { // TODO: escape title? - write!(f, "\n", self.title)?; + writeln!(f, "", self.title)?; write!(f, "{}", self.text.trim())?; write!(f, "\n") } @@ -689,7 +660,7 @@ impl RulesContextHandle { self, prompt_store: &Option>, cx: &App, - ) -> Task>)>> { + ) -> Task> { let Some(prompt_store) = prompt_store.as_ref() else { return Task::ready(None); }; @@ -708,7 +679,7 @@ impl RulesContextHandle { title, text, }); - Some((context, vec![])) + Some(context) }) } } @@ -716,7 +687,7 @@ impl RulesContextHandle { impl Display for RulesContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(title) = &self.title { - write!(f, "Rules title: {}\n", title)?; + writeln!(f, "Rules title: {}", title)?; } let code_block = MarkdownCodeBlock { tag: "", @@ -729,7 +700,7 @@ impl Display for RulesContext { #[derive(Debug, Clone)] pub struct ImageContext { pub project_path: Option, - pub full_path: Option>, + pub full_path: Option, pub original_image: Arc, // TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml // needed due to a false positive of `clippy::mutable_key_type`. @@ -771,32 +742,21 @@ impl ImageContext { } } - pub fn load(self, cx: &App) -> Task>)>> { + pub fn load(self, cx: &App) -> Task> { cx.background_spawn(async move { self.image_task.clone().await; - Some((AgentContext::Image(self), vec![])) + Some(AgentContext::Image(self)) }) } } -#[derive(Debug, Clone, Default)] -pub struct ContextLoadResult { - pub loaded_context: LoadedContext, - pub referenced_buffers: HashSet>, -} - #[derive(Debug, Clone, Default)] pub struct LoadedContext { - pub contexts: Vec, pub text: String, pub images: Vec, } impl LoadedContext { - pub fn is_empty(&self) -> bool { - self.text.is_empty() && self.images.is_empty() - } - pub fn add_to_request_message(&self, request_message: &mut LanguageModelRequestMessage) { if !self.text.is_empty() { request_message @@ -827,7 +787,7 @@ pub fn load_context( project: &Entity, prompt_store: &Option>, cx: &mut App, -) -> Task { +) -> Task { let load_tasks: Vec<_> = contexts .into_iter() .map(|context| match context { @@ -846,16 +806,7 @@ pub fn load_context( cx.background_spawn(async move { let load_results = future::join_all(load_tasks).await; - let mut contexts = Vec::new(); let mut text = String::new(); - let mut referenced_buffers = HashSet::default(); - for context in load_results { - let Some((context, buffers)) = context else { - continue; - }; - contexts.push(context); - referenced_buffers.extend(buffers); - } let mut file_context = Vec::new(); let mut directory_context = Vec::new(); @@ -866,7 +817,7 @@ pub fn load_context( let mut text_thread_context = Vec::new(); let mut rules_context = Vec::new(); let mut images = Vec::new(); - for context in &contexts { + for context in load_results.into_iter().flatten() { match context { AgentContext::File(context) => file_context.push(context), AgentContext::Directory(context) => directory_context.push(context), @@ -891,14 +842,7 @@ pub fn load_context( && text_thread_context.is_empty() && rules_context.is_empty() { - return ContextLoadResult { - loaded_context: LoadedContext { - contexts, - text, - images, - }, - referenced_buffers, - }; + return LoadedContext { text, images }; } text.push_str( @@ -984,18 +928,11 @@ pub fn load_context( text.push_str("\n"); - ContextLoadResult { - loaded_context: LoadedContext { - contexts, - text, - images, - }, - referenced_buffers, - } + LoadedContext { text, images } }) } -fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec> { +fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec> { let mut files = Vec::new(); for entry in worktree.child_entries(path) { @@ -1009,14 +946,17 @@ fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec> { files } -fn codeblock_tag(full_path: &Path, line_range: Option>) -> String { +fn codeblock_tag(full_path: &str, line_range: Option>) -> String { let mut result = String::new(); - if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) { + if let Some(extension) = Path::new(full_path) + .extension() + .and_then(|ext| ext.to_str()) + { let _ = write!(result, "{} ", extension); } - let _ = write!(result, "{}", full_path.display()); + let _ = write!(result, "{}", full_path); if let Some(range) = line_range { if range.start.row == range.end.row { @@ -1151,11 +1091,13 @@ mod tests { assert!(content_len > outline::AUTO_OUTLINE_SIZE); - let file_context = file_context_for(large_content, cx).await; + let file_context = load_context_for("file.txt", large_content, cx).await; assert!( - file_context.is_outline, - "Large file should use outline format" + file_context + .text + .contains(&format!("# File outline for {}", path!("test/file.txt"))), + "Large files should not get an outline" ); assert!( @@ -1173,29 +1115,38 @@ mod tests { assert!(content_len < outline::AUTO_OUTLINE_SIZE); - let file_context = file_context_for(small_content.to_string(), cx).await; + let file_context = load_context_for("file.txt", small_content.to_string(), cx).await; assert!( - !file_context.is_outline, + !file_context + .text + .contains(&format!("# File outline for {}", path!("test/file.txt"))), "Small files should not get an outline" ); - assert_eq!(file_context.text, small_content); + assert!( + file_context.text.contains(small_content), + "Small files should use full content" + ); } - async fn file_context_for(content: String, cx: &mut TestAppContext) -> FileContext { + async fn load_context_for( + filename: &str, + content: String, + cx: &mut TestAppContext, + ) -> LoadedContext { // Create a test project with the file let project = create_test_project( cx, json!({ - "file.txt": content, + filename: content, }), ) .await; // Open the buffer let buffer_path = project - .read_with(cx, |project, cx| project.find_project_path("file.txt", cx)) + .read_with(cx, |project, cx| project.find_project_path(filename, cx)) .unwrap(); let buffer = project @@ -1210,16 +1161,5 @@ mod tests { cx.update(|cx| load_context(vec![context_handle], &project, &None, cx)) .await - .loaded_context - .contexts - .into_iter() - .find_map(|ctx| { - if let AgentContext::File(file_ctx) = ctx { - Some(file_ctx) - } else { - None - } - }) - .expect("Should have found a file context") } } diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 131023d249852e54e508cac3165cb482860d005b..0a6e811673aa47339087e538003e87b1940d0039 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -6,9 +6,11 @@ pub(crate) mod symbol_context_picker; pub(crate) mod thread_context_picker; use std::ops::Range; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use collections::HashSet; pub use completion_provider::ContextPickerCompletionProvider; @@ -23,27 +25,20 @@ use gpui::{ }; use language::Buffer; use multi_buffer::MultiBufferRow; -use paths::contexts_dir; -use project::{Entry, ProjectPath}; -use prompt_store::{PromptStore, UserPromptId}; +use project::ProjectPath; +use prompt_store::PromptStore; use rules_context_picker::{RulesContextEntry, RulesContextPicker}; use symbol_context_picker::SymbolContextPicker; -use thread_context_picker::{ - ThreadContextEntry, ThreadContextPicker, render_thread_context_entry, unordered_thread_entries, -}; +use thread_context_picker::render_thread_context_entry; use ui::{ ButtonLike, ContextMenu, ContextMenuEntry, ContextMenuItem, Disclosure, TintColor, prelude::*, }; -use uuid::Uuid; +use util::paths::PathStyle; +use util::rel_path::RelPath; use workspace::{Workspace, notifications::NotifyResultExt}; -use crate::AgentPanel; -use agent::{ - ThreadId, - context::RULES_ICON, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; +use crate::context_picker::thread_context_picker::ThreadContextPicker; +use crate::{context::RULES_ICON, context_store::ContextStore}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ContextPickerEntry { @@ -169,17 +164,16 @@ pub(super) struct ContextPicker { mode: ContextPickerState, workspace: WeakEntity, context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, - prompt_store: Option>, + thread_store: Option>, + prompt_store: Option>, _subscriptions: Vec, } impl ContextPicker { pub fn new( workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, context_store: WeakEntity, window: &mut Window, cx: &mut Context, @@ -200,13 +194,6 @@ impl ContextPicker { ) .collect::>(); - let prompt_store = thread_store.as_ref().and_then(|thread_store| { - thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten() - }); - ContextPicker { mode: ContextPickerState::Default(ContextMenu::build( window, @@ -216,7 +203,6 @@ impl ContextPicker { workspace, context_store, thread_store, - text_thread_store, prompt_store, _subscriptions: subscriptions, } @@ -231,12 +217,19 @@ impl ContextPicker { let context_picker = cx.entity(); let menu = ContextMenu::build(window, cx, move |menu, _window, cx| { + let Some(workspace) = self.workspace.upgrade() else { + return menu; + }; + let path_style = workspace.read(cx).path_style(cx); let recent = self.recent_entries(cx); let has_recent = !recent.is_empty(); let recent_entries = recent .into_iter() .enumerate() - .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); + .map(|(ix, entry)| { + self.recent_menu_item(context_picker.clone(), ix, entry, path_style) + }) + .collect::>(); let entries = self .workspace @@ -349,17 +342,13 @@ impl ContextPicker { })); } ContextPickerMode::Thread => { - if let Some((thread_store, text_thread_store)) = self - .thread_store - .as_ref() - .zip(self.text_thread_store.as_ref()) - { + if let Some(thread_store) = self.thread_store.clone() { self.mode = ContextPickerState::Thread(cx.new(|cx| { ThreadContextPicker::new( - thread_store.clone(), - text_thread_store.clone(), + thread_store, context_picker.clone(), self.context_store.clone(), + self.workspace.clone(), window, cx, ) @@ -385,12 +374,11 @@ impl ContextPicker { } pub fn select_first(&mut self, window: &mut Window, cx: &mut Context) { - match &self.mode { - ContextPickerState::Default(entity) => entity.update(cx, |entity, cx| { + // Other variants already select their first entry on open automatically + if let ContextPickerState::Default(entity) = &self.mode { + entity.update(cx, |entity, cx| { entity.select_first(&Default::default(), window, cx) - }), - // Other variants already select their first entry on open automatically - _ => {} + }) } } @@ -399,6 +387,7 @@ impl ContextPicker { context_picker: Entity, ix: usize, entry: RecentEntry, + path_style: PathStyle, ) -> ContextMenuItem { match entry { RecentEntry::File { @@ -417,6 +406,7 @@ impl ContextPicker { &path, &path_prefix, false, + path_style, context_store.clone(), cx, ) @@ -473,16 +463,23 @@ impl ContextPicker { fn add_recent_thread( &self, - entry: ThreadContextEntry, - window: &mut Window, + entry: HistoryEntry, + _window: &mut Window, cx: &mut Context, ) -> Task> { let Some(context_store) = self.context_store.upgrade() else { return Task::ready(Err(anyhow!("context store not available"))); }; + let Some(project) = self + .workspace + .upgrade() + .map(|workspace| workspace.read(cx).project().clone()) + else { + return Task::ready(Err(anyhow!("project not available"))); + }; match entry { - ThreadContextEntry::Thread { id, .. } => { + HistoryEntry::AcpThread(thread) => { let Some(thread_store) = self .thread_store .as_ref() @@ -490,28 +487,28 @@ impl ContextPicker { else { return Task::ready(Err(anyhow!("thread store not available"))); }; - - let open_thread_task = - thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx)); + let load_thread_task = + agent::load_agent_thread(thread.id, thread_store, project, cx); cx.spawn(async move |this, cx| { - let thread = open_thread_task.await?; + let thread = load_thread_task.await?; context_store.update(cx, |context_store, cx| { context_store.add_thread(thread, true, cx); })?; this.update(cx, |_this, cx| cx.notify()) }) } - ThreadContextEntry::Context { path, .. } => { - let Some(text_thread_store) = self - .text_thread_store + HistoryEntry::TextThread(thread) => { + let Some(thread_store) = self + .thread_store .as_ref() .and_then(|thread_store| thread_store.upgrade()) else { return Task::ready(Err(anyhow!("text thread store not available"))); }; - let task = text_thread_store - .update(cx, |this, cx| this.open_local_context(path.clone(), cx)); + let task = thread_store.update(cx, |this, cx| { + this.load_text_thread(thread.path.clone(), cx) + }); cx.spawn(async move |this, cx| { let thread = task.await?; context_store.update(cx, |context_store, cx| { @@ -535,7 +532,6 @@ impl ContextPicker { recent_context_picker_entries_with_store( context_store, self.thread_store.clone(), - self.text_thread_store.clone(), workspace, None, cx, @@ -590,14 +586,14 @@ impl Render for ContextPicker { pub(crate) enum RecentEntry { File { project_path: ProjectPath, - path_prefix: Arc, + path_prefix: Arc, }, - Thread(ThreadContextEntry), + Thread(HistoryEntry), } pub(crate) fn available_context_picker_entries( - prompt_store: &Option>, - thread_store: &Option>, + prompt_store: &Option>, + thread_store: &Option>, workspace: &Entity, cx: &mut App, ) -> Vec { @@ -610,8 +606,10 @@ pub(crate) fn available_context_picker_entries( .read(cx) .active_item(cx) .and_then(|item| item.downcast::()) - .map_or(false, |editor| { - editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx)) + .is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) }); if has_selection { entries.push(ContextPickerEntry::Action( @@ -634,8 +632,7 @@ pub(crate) fn available_context_picker_entries( fn recent_context_picker_entries_with_store( context_store: Entity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, workspace: Entity, exclude_path: Option, cx: &App, @@ -652,27 +649,20 @@ fn recent_context_picker_entries_with_store( let exclude_threads = context_store.read(cx).thread_ids(); - recent_context_picker_entries( - thread_store, - text_thread_store, - workspace, - &exclude_paths, - exclude_threads, - cx, - ) + recent_context_picker_entries(thread_store, workspace, &exclude_paths, exclude_threads, cx) } pub(crate) fn recent_context_picker_entries( - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, workspace: Entity, exclude_paths: &HashSet, - exclude_threads: &HashSet, + exclude_threads: &HashSet, cx: &App, ) -> Vec { let mut recent = Vec::with_capacity(6); let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let include_root_name = workspace.visible_worktrees(cx).count() > 1; recent.extend( workspace @@ -680,49 +670,41 @@ pub(crate) fn recent_context_picker_entries( .filter(|(_, abs_path)| { abs_path .as_ref() - .map_or(true, |path| !exclude_paths.contains(path.as_path())) + .is_none_or(|path| !exclude_paths.contains(path.as_path())) }) .take(4) .filter_map(|(project_path, _)| { project .worktree_for_id(project_path.worktree_id, cx) - .map(|worktree| RecentEntry::File { - project_path, - path_prefix: worktree.read(cx).root_name().into(), + .map(|worktree| { + let path_prefix = if include_root_name { + worktree.read(cx).root_name().into() + } else { + RelPath::empty().into() + }; + RecentEntry::File { + project_path, + path_prefix, + } }) }), ); - let active_thread_id = workspace - .panel::(cx) - .and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id())); - - if let Some((thread_store, text_thread_store)) = thread_store - .and_then(|store| store.upgrade()) - .zip(text_thread_store.and_then(|store| store.upgrade())) - { - let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx) - .filter(|(_, thread)| match thread { - ThreadContextEntry::Thread { id, .. } => { - Some(id) != active_thread_id && !exclude_threads.contains(id) - } - ThreadContextEntry::Context { .. } => true, - }) - .collect::>(); - - const RECENT_COUNT: usize = 2; - if threads.len() > RECENT_COUNT { - threads.select_nth_unstable_by_key(RECENT_COUNT - 1, |(updated_at, _)| { - std::cmp::Reverse(*updated_at) - }); - threads.truncate(RECENT_COUNT); - } - threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at)); - + if let Some(thread_store) = thread_store.and_then(|store| store.upgrade()) { + const RECENT_THREADS_COUNT: usize = 2; recent.extend( - threads - .into_iter() - .map(|(_, thread)| RecentEntry::Thread(thread)), + thread_store + .read(cx) + .recently_opened_entries(cx) + .iter() + .filter(|e| match e.id() { + HistoryEntryId::AcpThread(session_id) => !exclude_threads.contains(&session_id), + HistoryEntryId::TextThread(path) => { + !exclude_paths.contains(&path.to_path_buf()) + } + }) + .take(RECENT_THREADS_COUNT) + .map(|thread| RecentEntry::Thread(thread.clone())), ); } @@ -755,7 +737,7 @@ pub(crate) fn selection_ranges( }; editor.update(cx, |editor, cx| { - let selections = editor.selections.all_adjusted(cx); + let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); let buffer = editor.buffer().clone().read(cx); let snapshot = buffer.snapshot(cx); @@ -821,13 +803,8 @@ pub fn crease_for_mention( let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any(); - Crease::inline( - range, - placeholder.clone(), - fold_toggle("mention"), - render_trailer, - ) - .with_metadata(CreaseMetadata { icon_path, label }) + Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer) + .with_metadata(CreaseMetadata { icon_path, label }) } fn render_fold_icon_button( @@ -882,15 +859,7 @@ fn fold_toggle( } } -pub enum MentionLink { - File(ProjectPath, Entry), - Symbol(ProjectPath, String), - Selection(ProjectPath, Range), - Fetch(String), - Thread(ThreadId), - TextThread(Arc), - Rule(UserPromptId), -} +pub struct MentionLink; impl MentionLink { const FILE: &str = "@file"; @@ -902,17 +871,6 @@ impl MentionLink { const TEXT_THREAD_URL_PREFIX: &str = "text-thread://"; - const SEPARATOR: &str = ":"; - - pub fn is_valid(url: &str) -> bool { - url.starts_with(Self::FILE) - || url.starts_with(Self::SYMBOL) - || url.starts_with(Self::FETCH) - || url.starts_with(Self::SELECTION) - || url.starts_with(Self::THREAD) - || url.starts_with(Self::RULE) - } - pub fn for_file(file_name: &str, full_path: &str) -> String { format!("[@{}]({}:{})", file_name, Self::FILE, full_path) } @@ -940,17 +898,21 @@ impl MentionLink { ) } - pub fn for_thread(thread: &ThreadContextEntry) -> String { + pub fn for_thread(thread: &HistoryEntry) -> String { match thread { - ThreadContextEntry::Thread { id, title } => { - format!("[@{}]({}:{})", title, Self::THREAD, id) + HistoryEntry::AcpThread(thread) => { + format!("[@{}]({}:{})", thread.title, Self::THREAD, thread.id) } - ThreadContextEntry::Context { path, title } => { - let filename = path.file_name().unwrap_or_default().to_string_lossy(); + HistoryEntry::TextThread(thread) => { + let filename = thread + .path + .file_name() + .unwrap_or_default() + .to_string_lossy(); let escaped_filename = urlencoding::encode(&filename); format!( "[@{}]({}:{}{})", - title, + thread.title, Self::THREAD, Self::TEXT_THREAD_URL_PREFIX, escaped_filename @@ -966,74 +928,4 @@ impl MentionLink { pub fn for_rule(rule: &RulesContextEntry) -> String { format!("[@{}]({}:{})", rule.title, Self::RULE, rule.prompt_id.0) } - - pub fn try_parse(link: &str, workspace: &Entity, cx: &App) -> Option { - fn extract_project_path_from_link( - path: &str, - workspace: &Entity, - cx: &App, - ) -> Option { - let path = PathBuf::from(path); - let worktree_name = path.iter().next()?; - let path: PathBuf = path.iter().skip(1).collect(); - let worktree_id = workspace - .read(cx) - .visible_worktrees(cx) - .find(|worktree| worktree.read(cx).root_name() == worktree_name) - .map(|worktree| worktree.read(cx).id())?; - Some(ProjectPath { - worktree_id, - path: path.into(), - }) - } - - let (prefix, argument) = link.split_once(Self::SEPARATOR)?; - match prefix { - Self::FILE => { - let project_path = extract_project_path_from_link(argument, workspace, cx)?; - let entry = workspace - .read(cx) - .project() - .read(cx) - .entry_for_path(&project_path, cx)?; - Some(MentionLink::File(project_path, entry)) - } - Self::SYMBOL => { - let (path, symbol) = argument.split_once(Self::SEPARATOR)?; - let project_path = extract_project_path_from_link(path, workspace, cx)?; - Some(MentionLink::Symbol(project_path, symbol.to_string())) - } - Self::SELECTION => { - let (path, line_args) = argument.split_once(Self::SEPARATOR)?; - let project_path = extract_project_path_from_link(path, workspace, cx)?; - - let line_range = { - let (start, end) = line_args - .trim_start_matches('(') - .trim_end_matches(')') - .split_once('-')?; - start.parse::().ok()?..end.parse::().ok()? - }; - - Some(MentionLink::Selection(project_path, line_range)) - } - Self::THREAD => { - if let Some(encoded_filename) = argument.strip_prefix(Self::TEXT_THREAD_URL_PREFIX) - { - let filename = urlencoding::decode(encoded_filename).ok()?; - let path = contexts_dir().join(filename.as_ref()).into(); - Some(MentionLink::TextThread(path)) - } else { - let thread_id = ThreadId::from(argument); - Some(MentionLink::Thread(thread_id)) - } - } - Self::FETCH => Some(MentionLink::Fetch(argument.to_string())), - Self::RULE => { - let prompt_id = UserPromptId(Uuid::try_parse(argument).ok()?); - Some(MentionLink::Rule(prompt_id)) - } - _ => None, - } - } } diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index 962c0df03db99ba8739df2c0eb8713d0e25f7f75..3a3ea45c800e3031dc8939c1801ca989a220bf0c 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use agent::context_store::ContextStore; +use agent::{HistoryEntry, HistoryStore}; use anyhow::Result; use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; use file_icons::FileIcons; @@ -11,20 +11,25 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use http_client::HttpClientWithUrl; use itertools::Itertools; -use language::{Buffer, CodeLabel, HighlightId}; +use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; -use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId}; +use project::lsp_store::SymbolLocation; +use project::{ + Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, Project, + ProjectPath, Symbol, WorktreeId, +}; use prompt_store::PromptStore; use rope::Point; use text::{Anchor, OffsetRangeExt, ToPoint}; use ui::prelude::*; use util::ResultExt as _; +use util::paths::PathStyle; +use util::rel_path::RelPath; use workspace::Workspace; -use agent::{ - Thread, +use crate::{ context::{AgentContextHandle, AgentContextKey, RULES_ICON}, - thread_store::{TextThreadStore, ThreadStore}, + context_store::ContextStore, }; use super::fetch_context_picker::fetch_url_content; @@ -32,7 +37,7 @@ use super::file_context_picker::{FileMatch, search_files}; use super::rules_context_picker::{RulesContextEntry, search_rules}; use super::symbol_context_picker::SymbolMatch; use super::symbol_context_picker::search_symbols; -use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads}; +use super::thread_context_picker::search_threads; use super::{ ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry, available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges, @@ -42,7 +47,8 @@ use crate::message_editor::ContextCreasesAddon; pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), - Thread(ThreadMatch), + Thread(HistoryEntry), + RecentThread(HistoryEntry), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), @@ -59,6 +65,7 @@ impl Match { Match::File(file) => file.mat.score, Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.), Match::Thread(_) => 1., + Match::RecentThread(_) => 1., Match::Symbol(_) => 1., Match::Fetch(_) => 1., Match::Rules(_) => 1., @@ -71,16 +78,14 @@ fn search( query: String, cancellation_flag: Arc, recent_entries: Vec, - prompt_store: Option>, - thread_store: Option>, - text_thread_context_store: Option>, + prompt_store: Option>, + thread_store: Option>, workspace: Entity, cx: &mut App, ) -> Task> { match mode { Some(ContextPickerMode::File) => { - let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); + let search_files_task = search_files(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_files_task .await @@ -91,8 +96,7 @@ fn search( } Some(ContextPickerMode::Symbol) => { - let search_symbols_task = - search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx); + let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx); cx.background_spawn(async move { search_symbols_task .await @@ -103,18 +107,9 @@ fn search( } Some(ContextPickerMode::Thread) => { - if let Some((thread_store, context_store)) = thread_store - .as_ref() - .and_then(|t| t.upgrade()) - .zip(text_thread_context_store.as_ref().and_then(|t| t.upgrade())) - { - let search_threads_task = search_threads( - query.clone(), - cancellation_flag.clone(), - thread_store, - context_store, - cx, - ); + if let Some(thread_store) = thread_store.as_ref().and_then(|t| t.upgrade()) { + let search_threads_task = + search_threads(query, cancellation_flag, &thread_store, cx); cx.background_spawn(async move { search_threads_task .await @@ -136,9 +131,8 @@ fn search( } Some(ContextPickerMode::Rules) => { - if let Some(prompt_store) = prompt_store.as_ref() { - let search_rules_task = - search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx); + if let Some(prompt_store) = prompt_store.as_ref().and_then(|p| p.upgrade()) { + let search_rules_task = search_rules(query, cancellation_flag, &prompt_store, cx); cx.background_spawn(async move { search_rules_task .await @@ -171,12 +165,7 @@ fn search( }, is_recent: true, }), - super::RecentEntry::Thread(thread_context_entry) => { - Match::Thread(ThreadMatch { - thread: thread_context_entry, - is_recent: true, - }) - } + super::RecentEntry::Thread(entry) => Match::RecentThread(entry), }) .collect::>(); @@ -196,7 +185,7 @@ fn search( let executor = cx.background_executor().clone(); let search_files_task = - search_files(query.clone(), cancellation_flag.clone(), &workspace, cx); + search_files(query.clone(), cancellation_flag, &workspace, cx); let entries = available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx); @@ -247,8 +236,8 @@ fn search( pub struct ContextPickerCompletionProvider { workspace: WeakEntity, context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, editor: WeakEntity, excluded_buffer: Option>, } @@ -257,8 +246,8 @@ impl ContextPickerCompletionProvider { pub fn new( workspace: WeakEntity, context_store: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, editor: WeakEntity, exclude_buffer: Option>, ) -> Self { @@ -266,7 +255,7 @@ impl ContextPickerCompletionProvider { workspace, context_store, thread_store, - text_thread_store, + prompt_store, editor, excluded_buffer: exclude_buffer, } @@ -283,7 +272,7 @@ impl ContextPickerCompletionProvider { ) -> Option { match entry { ContextPickerEntry::Mode(mode) => Some(Completion { - replace_range: source_range.clone(), + replace_range: source_range, new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), icon_path: Some(mode.icon().path().into()), @@ -330,9 +319,6 @@ impl ContextPickerCompletionProvider { ); let callback = Arc::new({ - let context_store = context_store.clone(); - let selections = selections.clone(); - let selection_infos = selection_infos.clone(); move |_, window: &mut Window, cx: &mut App| { context_store.update(cx, |context_store, cx| { for (buffer, range) in &selections { @@ -411,14 +397,14 @@ impl ContextPickerCompletionProvider { } fn completion_for_thread( - thread_entry: ThreadContextEntry, + thread_entry: HistoryEntry, excerpt_id: ExcerptId, source_range: Range, recent: bool, editor: Entity, context_store: Entity, - thread_store: Entity, - text_thread_store: Entity, + thread_store: Entity, + project: Entity, ) -> Completion { let icon_for_completion = if recent { IconName::HistoryRerun @@ -441,21 +427,19 @@ impl ContextPickerCompletionProvider { excerpt_id, source_range.start, new_text_len - 1, - editor.clone(), + editor, context_store.clone(), move |window, cx| match &thread_entry { - ThreadContextEntry::Thread { id, .. } => { - let thread_id = id.clone(); + HistoryEntry::AcpThread(thread) => { let context_store = context_store.clone(); - let thread_store = thread_store.clone(); + let load_thread_task = agent::load_agent_thread( + thread.id.clone(), + thread_store.clone(), + project.clone(), + cx, + ); window.spawn::<_, Option<_>>(cx, async move |cx| { - let thread: Entity = thread_store - .update_in(cx, |thread_store, window, cx| { - thread_store.open_thread(&thread_id, window, cx) - }) - .ok()? - .await - .log_err()?; + let thread = load_thread_task.await.log_err()?; let context = context_store .update(cx, |context_store, cx| { context_store.add_thread(thread, false, cx) @@ -464,13 +448,13 @@ impl ContextPickerCompletionProvider { Some(context) }) } - ThreadContextEntry::Context { path, .. } => { - let path = path.clone(); + HistoryEntry::TextThread(thread) => { + let path = thread.path.clone(); let context_store = context_store.clone(); - let text_thread_store = text_thread_store.clone(); + let thread_store = thread_store.clone(); cx.spawn::<_, Option<_>>(async move |cx| { - let thread = text_thread_store - .update(cx, |store, cx| store.open_local_context(path, cx)) + let thread = thread_store + .update(cx, |store, cx| store.load_text_thread(path, cx)) .ok()? .await .log_err()?; @@ -510,7 +494,7 @@ impl ContextPickerCompletionProvider { excerpt_id, source_range.start, new_text_len - 1, - editor.clone(), + editor, context_store.clone(), move |_, cx| { let user_prompt_id = rules.prompt_id; @@ -547,7 +531,7 @@ impl ContextPickerCompletionProvider { excerpt_id, source_range.start, new_text_len - 1, - editor.clone(), + editor, context_store.clone(), move |_, cx| { let context_store = context_store.clone(); @@ -582,11 +566,12 @@ impl ContextPickerCompletionProvider { fn completion_for_path( project_path: ProjectPath, - path_prefix: &str, + path_prefix: &RelPath, is_recent: bool, is_directory: bool, excerpt_id: ExcerptId, source_range: Range, + path_style: PathStyle, editor: Entity, context_store: Entity, cx: &App, @@ -594,6 +579,7 @@ impl ContextPickerCompletionProvider { let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( &project_path.path, path_prefix, + path_style, ); let label = @@ -604,11 +590,12 @@ impl ContextPickerCompletionProvider { file_name.to_string() }; + let path = Path::new(&full_path); let crease_icon_path = if is_directory { - FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) + FileIcons::get_folder_icon(false, path, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) } else { - FileIcons::get_icon(Path::new(&full_path), cx) - .unwrap_or_else(|| IconName::File.path().into()) + FileIcons::get_icon(path, cx).unwrap_or_else(|| IconName::File.path().into()) }; let completion_icon_path = if is_recent { IconName::HistoryRerun.path().into() @@ -664,17 +651,21 @@ impl ContextPickerCompletionProvider { workspace: Entity, cx: &mut App, ) -> Option { - let path_prefix = workspace + let path_style = workspace.read(cx).path_style(cx); + let SymbolLocation::InProject(symbol_path) = &symbol.path else { + return None; + }; + let _path_prefix = workspace .read(cx) .project() .read(cx) - .worktree_for_id(symbol.path.worktree_id, cx)? - .read(cx) - .root_name(); + .worktree_for_id(symbol_path.worktree_id, cx)?; + let path_prefix = RelPath::empty(); let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory( - &symbol.path.path, + &symbol_path.path, path_prefix, + path_style, ); let full_path = if let Some(directory) = directory { format!("{}{}", directory, file_name) @@ -683,7 +674,8 @@ impl ContextPickerCompletionProvider { }; let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); - let mut label = CodeLabel::plain(symbol.name.clone(), None); + let mut label = CodeLabelBuilder::default(); + label.push_str(&symbol.name, None); label.push_str(" ", None); label.push_str(&file_name, comment_id); label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id); @@ -693,7 +685,7 @@ impl ContextPickerCompletionProvider { Some(Completion { replace_range: source_range.clone(), new_text, - label, + label: label.build(), documentation: None, source: project::CompletionSource::Custom, icon_path: Some(IconName::Code.path().into()), @@ -704,16 +696,16 @@ impl ContextPickerCompletionProvider { excerpt_id, source_range.start, new_text_len - 1, - editor.clone(), + editor, context_store.clone(), move |_, cx| { let symbol = symbol.clone(); let context_store = context_store.clone(); let workspace = workspace.clone(); let result = super::symbol_context_picker::add_symbol( - symbol.clone(), + symbol, false, - workspace.clone(), + workspace, context_store.downgrade(), cx, ); @@ -726,18 +718,16 @@ impl ContextPickerCompletionProvider { fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); - label.push_str(&file_name, None); + label.push_str(file_name, None); label.push_str(" ", None); if let Some(directory) = directory { - label.push_str(&directory, comment_id); + label.push_str(directory, comment_id); } - label.filter_range = 0..label.text().len(); - - label + label.build() } impl CompletionProvider for ContextPickerCompletionProvider { @@ -750,15 +740,15 @@ impl CompletionProvider for ContextPickerCompletionProvider { _window: &mut Window, cx: &mut Context, ) -> Task>> { - let state = buffer.update(cx, |buffer, _cx| { - let position = buffer_position.to_point(buffer); - let line_start = Point::new(position.row, 0); - let offset_to_line = buffer.point_to_offset(line_start); - let mut lines = buffer.text_for_range(line_start..position).lines(); - let line = lines.next()?; - MentionCompletion::try_parse(line, offset_to_line) - }); - let Some(state) = state else { + let snapshot = buffer.read(cx).snapshot(); + let position = buffer_position.to_point(&snapshot); + let line_start = Point::new(position.row, 0); + let offset_to_line = snapshot.point_to_offset(line_start); + let mut lines = snapshot.text_for_range(line_start..position).lines(); + let Some(line) = lines.next() else { + return Task::ready(Ok(Vec::new())); + }; + let Some(state) = MentionCompletion::try_parse(line, offset_to_line) else { return Task::ready(Ok(Vec::new())); }; @@ -768,14 +758,14 @@ impl CompletionProvider for ContextPickerCompletionProvider { return Task::ready(Ok(Vec::new())); }; - let snapshot = buffer.read(cx).snapshot(); let source_range = snapshot.anchor_before(state.source_range.start) ..snapshot.anchor_after(state.source_range.end); let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); + let prompt_store = self.prompt_store.clone(); let editor = self.editor.clone(); let http_client = workspace.read(cx).client().http_client(); + let path_style = workspace.read(cx).path_style(cx); let MentionCompletion { mode, argument, .. } = state; let query = argument.unwrap_or_else(|| "".to_string()); @@ -790,19 +780,11 @@ impl CompletionProvider for ContextPickerCompletionProvider { let recent_entries = recent_context_picker_entries_with_store( context_store.clone(), thread_store.clone(), - text_thread_store.clone(), workspace.clone(), excluded_path.clone(), cx, ); - let prompt_store = thread_store.as_ref().and_then(|thread_store| { - thread_store - .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone()) - .ok() - .flatten() - }); - let search_task = search( mode, query, @@ -810,14 +792,14 @@ impl CompletionProvider for ContextPickerCompletionProvider { recent_entries, prompt_store, thread_store.clone(), - text_thread_store.clone(), workspace.clone(), cx, ); + let project = workspace.read(cx).project().downgrade(); cx.spawn(async move |_, cx| { let matches = search_task.await; - let Some(editor) = editor.upgrade() else { + let Some((editor, project)) = editor.upgrade().zip(project.upgrade()) else { return Ok(Vec::new()); }; @@ -835,13 +817,26 @@ impl CompletionProvider for ContextPickerCompletionProvider { return None; } + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + let path_prefix = if mat.path.is_empty() { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| mat.path_prefix.clone()) + } else { + mat.path_prefix.clone() + }; + Some(Self::completion_for_path( project_path, - &mat.path_prefix, + &path_prefix, is_recent, mat.is_dir, excerpt_id, source_range.clone(), + path_style, editor.clone(), context_store.clone(), cx, @@ -857,25 +852,32 @@ impl CompletionProvider for ContextPickerCompletionProvider { workspace.clone(), cx, ), - - Match::Thread(ThreadMatch { - thread, is_recent, .. - }) => { + Match::Thread(thread) => { let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?; - let text_thread_store = - text_thread_store.as_ref().and_then(|t| t.upgrade())?; Some(Self::completion_for_thread( thread, excerpt_id, source_range.clone(), - is_recent, + false, editor.clone(), context_store.clone(), thread_store, - text_thread_store, + project.clone(), + )) + } + Match::RecentThread(thread) => { + let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?; + Some(Self::completion_for_thread( + thread, + excerpt_id, + source_range.clone(), + true, + editor.clone(), + context_store.clone(), + thread_store, + project.clone(), )) } - Match::Rules(user_rules) => Some(Self::completion_for_rules( user_rules, excerpt_id, @@ -908,6 +910,7 @@ impl CompletionProvider for ContextPickerCompletionProvider { Ok(vec![CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), // Since this does its own filtering (see `filter_completions()` returns false), // there is no benefit to computing whether this set of completions is incomplete. is_incomplete: true, @@ -1020,7 +1023,7 @@ impl MentionCompletion { && line .chars() .nth(last_mention_start - 1) - .map_or(false, |c| !c.is_whitespace()) + .is_some_and(|c| !c.is_whitespace()) { return None; } @@ -1071,7 +1074,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use std::{ops::Deref, rc::Rc}; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{AppState, Item}; #[test] @@ -1162,7 +1165,7 @@ mod tests { impl Focusable for AtMentionEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.0.read(cx).focus_handle(cx).clone() + self.0.read(cx).focus_handle(cx) } } @@ -1222,16 +1225,18 @@ mod tests { let mut cx = VisualTestContext::from_window(*window.deref(), cx); let paths = vec![ - path!("a/one.txt"), - path!("a/two.txt"), - path!("a/three.txt"), - path!("a/four.txt"), - path!("b/five.txt"), - path!("b/six.txt"), - path!("b/seven.txt"), - path!("b/eight.txt"), + rel_path("a/one.txt"), + rel_path("a/two.txt"), + rel_path("a/three.txt"), + rel_path("a/four.txt"), + rel_path("b/five.txt"), + rel_path("b/six.txt"), + rel_path("b/seven.txt"), + rel_path("b/eight.txt"), ]; + let slash = PathStyle::local().separator(); + let mut opened_editors = Vec::new(); for path in paths { let buffer = workspace @@ -1239,7 +1244,7 @@ mod tests { workspace.open_path( ProjectPath { worktree_id, - path: Path::new(path).into(), + path: path.into(), }, None, false, @@ -1275,7 +1280,7 @@ mod tests { editor }); - let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None)); + let context_store = cx.new(|_| ContextStore::new(project.downgrade())); let editor_entity = editor.downgrade(); editor.update_in(&mut cx, |editor, window, cx| { @@ -1315,13 +1320,13 @@ mod tests { assert_eq!( current_completion_labels(editor), &[ - "seven.txt dir/b/", - "six.txt dir/b/", - "five.txt dir/b/", - "four.txt dir/a/", - "Files & Directories", - "Symbols", - "Fetch" + format!("seven.txt b{slash}"), + format!("six.txt b{slash}"), + format!("five.txt b{slash}"), + format!("four.txt a{slash}"), + "Files & Directories".into(), + "Symbols".into(), + "Fetch".into() ] ); }); @@ -1348,7 +1353,10 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!(editor.text(cx), "Lorem @file one"); assert!(editor.has_visible_completions_menu()); - assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]); + assert_eq!( + current_completion_labels(editor), + vec![format!("one.txt a{slash}")] + ); }); editor.update_in(&mut cx, |editor, window, cx| { @@ -1357,22 +1365,28 @@ mod tests { }); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) "); + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt](@file:a{slash}one.txt) ") + ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) "); + assert_eq!( + editor.text(cx), + format!("Lorem [@one.txt](@file:a{slash}one.txt) ") + ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1381,12 +1395,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ", + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum "), ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1395,12 +1409,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ", + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum @file "), ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 37)] + vec![Point::new(0, 6)..Point::new(0, 33)] ); }); @@ -1413,14 +1427,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) " + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72) ] ); }); @@ -1430,14 +1444,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n@" + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n@") ); assert!(editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72) ] ); }); @@ -1451,20 +1465,203 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n[@six.txt](@file:dir/b/six.txt) " + format!("Lorem [@one.txt](@file:a{slash}one.txt) Ipsum [@seven.txt](@file:b{slash}seven.txt) \n[@six.txt](@file:b{slash}six.txt) ") ); assert!(!editor.has_visible_completions_menu()); assert_eq!( fold_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 37), - Point::new(0, 45)..Point::new(0, 80), - Point::new(1, 0)..Point::new(1, 31) + Point::new(0, 6)..Point::new(0, 33), + Point::new(0, 41)..Point::new(0, 72), + Point::new(1, 0)..Point::new(1, 27) ] ); }); } + #[gpui::test] + async fn test_context_completion_provider_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project1"), + json!({ + "a": { + "one.txt": "", + "two.txt": "", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project2"), + json!({ + "b": { + "three.txt": "", + "four.txt": "", + } + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/project1").as_ref(), path!("/project2").as_ref()], + cx, + ) + .await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let worktrees = project.update(cx, |project, cx| { + let worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 2); + worktrees + }); + + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let slash = PathStyle::local().separator(); + + for (worktree_idx, paths) in [ + vec![rel_path("a/one.txt"), rel_path("a/two.txt")], + vec![rel_path("b/three.txt"), rel_path("b/four.txt")], + ] + .iter() + .enumerate() + { + let worktree_id = worktrees[worktree_idx].read_with(&cx, |wt, _| wt.id()); + for path in paths { + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: (*path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + } + } + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + Editor::new( + editor::EditorMode::full(), + multi_buffer::MultiBuffer::build_simple("", cx), + None, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| AtMentionEditor(editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + editor + }); + + let context_store = cx.new(|_| ContextStore::new(project.downgrade())); + + let editor_entity = editor.downgrade(); + editor.update_in(&mut cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + workspace.downgrade(), + context_store.downgrade(), + None, + None, + editor_entity, + None, + )))); + }); + + cx.simulate_input("@"); + + // With multiple worktrees, we should see the project name as prefix + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@"); + assert!(editor.has_visible_completions_menu()); + let labels = current_completion_labels(editor); + + assert!( + labels.contains(&format!("four.txt project2{slash}b{slash}")), + "Expected 'four.txt project2{slash}b{slash}' in labels: {:?}", + labels + ); + assert!( + labels.contains(&format!("three.txt project2{slash}b{slash}")), + "Expected 'three.txt project2{slash}b{slash}' in labels: {:?}", + labels + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@file "); + assert!(editor.has_visible_completions_menu()); + }); + + cx.simulate_input("one"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "@file one"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + vec![format!("one.txt project1{slash}a{slash}")] + ); + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("[@one.txt](@file:project1{slash}a{slash}one.txt) ") + ); + assert!(!editor.has_visible_completions_menu()); + }); + } + fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec> { let snapshot = editor.buffer().read(cx).snapshot(cx); editor.display_map.update(cx, |display_map, cx| { @@ -1480,7 +1677,7 @@ mod tests { let completions = editor.current_completions().expect("Missing completions"); completions .into_iter() - .map(|completion| completion.label.text.to_string()) + .map(|completion| completion.label.text) .collect::>() } diff --git a/crates/agent_ui/src/context_picker/fetch_context_picker.rs b/crates/agent_ui/src/context_picker/fetch_context_picker.rs index 8ff68a8365ee01ac79d707abf00197bf5175e43a..31fc45aca3ccbf561793769939169d214aaa2d99 100644 --- a/crates/agent_ui/src/context_picker/fetch_context_picker.rs +++ b/crates/agent_ui/src/context_picker/fetch_context_picker.rs @@ -2,7 +2,6 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; -use agent::context_store::ContextStore; use anyhow::{Context as _, Result, bail}; use futures::AsyncReadExt as _; use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; @@ -12,7 +11,7 @@ use picker::{Picker, PickerDelegate}; use ui::{Context, ListItem, Window, prelude::*}; use workspace::Workspace; -use crate::context_picker::ContextPicker; +use crate::{context_picker::ContextPicker, context_store::ContextStore}; pub struct FetchContextPicker { picker: Entity>, @@ -226,9 +225,10 @@ impl PickerDelegate for FetchContextPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let added = self.context_store.upgrade().map_or(false, |context_store| { - context_store.read(cx).includes_url(&self.url) - }); + let added = self + .context_store + .upgrade() + .is_some_and(|context_store| context_store.read(cx).includes_url(&self.url)); Some( ListItem::new(ix) diff --git a/crates/agent_ui/src/context_picker/file_context_picker.rs b/crates/agent_ui/src/context_picker/file_context_picker.rs index eaf9ed16d6fc7a09854d9f0160d87e23f3c5ffd8..ded24caa922d27d8821e46e5c58b5ed22ab754ff 100644 --- a/crates/agent_ui/src/context_picker/file_context_picker.rs +++ b/crates/agent_ui/src/context_picker/file_context_picker.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -10,11 +9,13 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; use ui::{ListItem, Tooltip, prelude::*}; -use util::ResultExt as _; +use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; use workspace::Workspace; -use crate::context_picker::ContextPicker; -use agent::context_store::{ContextStore, FileInclusion}; +use crate::{ + context_picker::ContextPicker, + context_store::{ContextStore, FileInclusion}, +}; pub struct FileContextPicker { picker: Entity>, @@ -160,7 +161,9 @@ impl PickerDelegate for FileContextPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let FileMatch { mat, .. } = &self.matches[ix]; + let FileMatch { mat, .. } = &self.matches.get(ix)?; + let workspace = self.workspace.upgrade()?; + let path_style = workspace.read(cx).path_style(cx); Some( ListItem::new(ix) @@ -172,6 +175,7 @@ impl PickerDelegate for FileContextPickerDelegate { &mat.path, &mat.path_prefix, mat.is_dir, + path_style, self.context_store.clone(), cx, )), @@ -193,28 +197,43 @@ pub(crate) fn search_files( if query.is_empty() { let workspace = workspace.read(cx); let project = workspace.project().read(cx); + let visible_worktrees = workspace.visible_worktrees(cx).collect::>(); + let include_root_name = visible_worktrees.len() > 1; + let recent_matches = workspace .recent_navigation_history(Some(10), cx) .into_iter() - .filter_map(|(project_path, _)| { - let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; - Some(FileMatch { + .map(|(project_path, _)| { + let path_prefix = if include_root_name { + project + .worktree_for_id(project_path.worktree_id, cx) + .map(|wt| wt.read(cx).root_name().into()) + .unwrap_or_else(|| RelPath::empty().into()) + } else { + RelPath::empty().into() + }; + + FileMatch { mat: PathMatch { score: 0., positions: Vec::new(), worktree_id: project_path.worktree_id.to_usize(), path: project_path.path, - path_prefix: worktree.read(cx).root_name().into(), + path_prefix, distance_to_relative_ancestor: 0, is_dir: false, }, is_recent: true, - }) + } }); - let file_matches = project.worktrees(cx).flat_map(|worktree| { + let file_matches = visible_worktrees.into_iter().flat_map(|worktree| { let worktree = worktree.read(cx); - let path_prefix: Arc = worktree.root_name().into(); + let path_prefix: Arc = if include_root_name { + worktree.root_name().into() + } else { + RelPath::empty().into() + }; worktree.entries(false, 0).map(move |entry| FileMatch { mat: PathMatch { score: 0., @@ -232,6 +251,7 @@ pub(crate) fn search_files( Task::ready(recent_matches.chain(file_matches).collect()) } else { let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); + let include_root_name = worktrees.len() > 1; let candidate_sets = worktrees .into_iter() .map(|worktree| { @@ -239,10 +259,8 @@ pub(crate) fn search_files( PathMatchCandidateSet { snapshot: worktree.snapshot(), - include_ignored: worktree - .root_entry() - .map_or(false, |entry| entry.is_ignored), - include_root_name: true, + include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored), + include_root_name, candidates: project::Candidates::Entries, } }) @@ -253,7 +271,7 @@ pub(crate) fn search_files( fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), - None, + &None, false, 100, &cancellation_flag, @@ -271,51 +289,37 @@ pub(crate) fn search_files( } pub fn extract_file_name_and_directory( - path: &Path, - path_prefix: &str, + path: &RelPath, + path_prefix: &RelPath, + path_style: PathStyle, ) -> (SharedString, Option) { - if path == Path::new("") { - ( - SharedString::from( - path_prefix - .trim_end_matches(std::path::MAIN_SEPARATOR) - .to_string(), - ), - None, - ) - } else { - let file_name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - - let mut directory = path_prefix - .trim_end_matches(std::path::MAIN_SEPARATOR) - .to_string(); - if !directory.ends_with('/') { - directory.push('/'); - } - if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) { - directory.push_str(&parent.to_string_lossy()); - directory.push('/'); - } - - (file_name, Some(directory.into())) + // If path is empty, this means we're matching with the root directory itself + // so we use the path_prefix as the name + if path.is_empty() && !path_prefix.is_empty() { + return (path_prefix.display(path_style).to_string().into(), None); } + + let full_path = path_prefix.join(path); + let file_name = full_path.file_name().unwrap_or_default(); + let display_path = full_path.display(path_style); + let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len()); + ( + file_name.to_string().into(), + Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()), + ) } pub fn render_file_context_entry( id: ElementId, worktree_id: WorktreeId, - path: &Arc, - path_prefix: &Arc, + path: &Arc, + path_prefix: &Arc, is_directory: bool, + path_style: PathStyle, context_store: WeakEntity, cx: &App, ) -> Stateful
{ - let (file_name, directory) = extract_file_name_and_directory(&path, path_prefix); + let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style); let added = context_store.upgrade().and_then(|context_store| { let project_path = ProjectPath { @@ -332,9 +336,9 @@ pub fn render_file_context_entry( }); let file_icon = if is_directory { - FileIcons::get_folder_icon(false, cx) + FileIcons::get_folder_icon(false, path.as_std_path(), cx) } else { - FileIcons::get_icon(&path, cx) + FileIcons::get_icon(path.as_std_path(), cx) } .map(Icon::from_path) .unwrap_or_else(|| Icon::new(IconName::File)); diff --git a/crates/agent_ui/src/context_picker/rules_context_picker.rs b/crates/agent_ui/src/context_picker/rules_context_picker.rs index 8ce821cfaaab0a49f4af70fca13c1ed202de20a1..68f4917a4fd5689aab1a418dd78d2c8a322cd717 100644 --- a/crates/agent_ui/src/context_picker/rules_context_picker.rs +++ b/crates/agent_ui/src/context_picker/rules_context_picker.rs @@ -7,9 +7,11 @@ use prompt_store::{PromptId, PromptStore, UserPromptId}; use ui::{ListItem, prelude::*}; use util::ResultExt as _; -use crate::context_picker::ContextPicker; -use agent::context::RULES_ICON; -use agent::context_store::{self, ContextStore}; +use crate::{ + context::RULES_ICON, + context_picker::ContextPicker, + context_store::{self, ContextStore}, +}; pub struct RulesContextPicker { picker: Entity>, @@ -17,7 +19,7 @@ pub struct RulesContextPicker { impl RulesContextPicker { pub fn new( - prompt_store: Entity, + prompt_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, window: &mut Window, @@ -49,7 +51,7 @@ pub struct RulesContextEntry { } pub struct RulesContextPickerDelegate { - prompt_store: Entity, + prompt_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, matches: Vec, @@ -58,7 +60,7 @@ pub struct RulesContextPickerDelegate { impl RulesContextPickerDelegate { pub fn new( - prompt_store: Entity, + prompt_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, ) -> Self { @@ -102,12 +104,10 @@ impl PickerDelegate for RulesContextPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let search_task = search_rules( - query, - Arc::new(AtomicBool::default()), - &self.prompt_store, - cx, - ); + let Some(prompt_store) = self.prompt_store.upgrade() else { + return Task::ready(()); + }; + let search_task = search_rules(query, Arc::new(AtomicBool::default()), &prompt_store, cx); cx.spawn_in(window, async move |this, cx| { let matches = search_task.await; this.update(cx, |this, cx| { @@ -146,7 +146,7 @@ impl PickerDelegate for RulesContextPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let thread = &self.matches[ix]; + let thread = &self.matches.get(ix)?; Some(ListItem::new(ix).inset(true).toggle_state(selected).child( render_thread_context_entry(thread, self.context_store.clone(), cx), @@ -159,7 +159,7 @@ pub fn render_thread_context_entry( context_store: WeakEntity, cx: &mut App, ) -> Div { - let added = context_store.upgrade().map_or(false, |context_store| { + let added = context_store.upgrade().is_some_and(|context_store| { context_store .read(cx) .includes_user_rules(user_rules.prompt_id) diff --git a/crates/agent_ui/src/context_picker/symbol_context_picker.rs b/crates/agent_ui/src/context_picker/symbol_context_picker.rs index 05e77deece6117d250d6efedbd9d24c6716b757e..fbce71d94efd84b1acc6e0b5d4ea11cb2b9243d5 100644 --- a/crates/agent_ui/src/context_picker/symbol_context_picker.rs +++ b/crates/agent_ui/src/context_picker/symbol_context_picker.rs @@ -2,21 +2,22 @@ use std::cmp::Reverse; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use anyhow::Result; +use anyhow::{Result, anyhow}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; +use project::lsp_store::SymbolLocation; use project::{DocumentSymbol, Symbol}; use ui::{ListItem, prelude::*}; use util::ResultExt as _; use workspace::Workspace; -use crate::context_picker::ContextPicker; -use agent::context::AgentContextHandle; -use agent::context_store::ContextStore; +use crate::{ + context::AgentContextHandle, context_picker::ContextPicker, context_store::ContextStore, +}; pub struct SymbolContextPicker { picker: Entity>, @@ -169,7 +170,7 @@ impl PickerDelegate for SymbolContextPickerDelegate { _window: &mut Window, _: &mut Context>, ) -> Option { - let mat = &self.matches[ix]; + let mat = &self.matches.get(ix)?; Some(ListItem::new(ix).inset(true).toggle_state(selected).child( render_symbol_context_entry(ElementId::named_usize("symbol-ctx-picker", ix), mat), @@ -191,7 +192,10 @@ pub(crate) fn add_symbol( ) -> Task, bool)>> { let project = workspace.read(cx).project().clone(); let open_buffer_task = project.update(cx, |project, cx| { - project.open_buffer(symbol.path.clone(), cx) + let SymbolLocation::InProject(symbol_path) = &symbol.path else { + return Task::ready(Err(anyhow!("can't add symbol from outside of project"))); + }; + project.open_buffer(symbol_path.clone(), cx) }); cx.spawn(async move |cx| { let buffer = open_buffer_task.await?; @@ -289,12 +293,13 @@ pub(crate) fn search_symbols( .iter() .enumerate() .map(|(id, symbol)| { - StringMatchCandidate::new(id, &symbol.label.filter_text()) + StringMatchCandidate::new(id, symbol.label.filter_text()) }) - .partition(|candidate| { - project - .entry_for_path(&symbols[candidate.id].path, cx) - .map_or(false, |e| !e.is_ignored) + .partition(|candidate| match &symbols[candidate.id].path { + SymbolLocation::InProject(project_path) => project + .entry_for_path(project_path, cx) + .is_some_and(|e| !e.is_ignored), + SymbolLocation::OutsideProject { .. } => false, }) }) .log_err() @@ -360,13 +365,18 @@ fn compute_symbol_entries( } pub fn render_symbol_context_entry(id: ElementId, entry: &SymbolEntry) -> Stateful
{ - let path = entry - .symbol - .path - .path - .file_name() - .map(|s| s.to_string_lossy()) - .unwrap_or_default(); + let path = match &entry.symbol.path { + SymbolLocation::InProject(project_path) => { + project_path.path.file_name().unwrap_or_default().into() + } + SymbolLocation::OutsideProject { + abs_path, + signature: _, + } => abs_path + .file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_default(), + }; let symbol_location = format!("{} L{}", path, entry.symbol.range.start.0.row + 1); h_flex() diff --git a/crates/agent_ui/src/context_picker/thread_context_picker.rs b/crates/agent_ui/src/context_picker/thread_context_picker.rs index 15cc731f8f2b7c82885c566273bc1cda9f3c156a..d6a3a270742fe28c483d2d7d39894eb9e3c021ea 100644 --- a/crates/agent_ui/src/context_picker/thread_context_picker.rs +++ b/crates/agent_ui/src/context_picker/thread_context_picker.rs @@ -1,19 +1,16 @@ -use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use chrono::{DateTime, Utc}; +use crate::{ + context_picker::ContextPicker, + context_store::{self, ContextStore}, +}; +use agent::{HistoryEntry, HistoryStore}; use fuzzy::StringMatchCandidate; use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity}; use picker::{Picker, PickerDelegate}; use ui::{ListItem, prelude::*}; - -use crate::context_picker::ContextPicker; -use agent::{ - ThreadId, - context_store::{self, ContextStore}, - thread_store::{TextThreadStore, ThreadStore}, -}; +use workspace::Workspace; pub struct ThreadContextPicker { picker: Entity>, @@ -21,18 +18,18 @@ pub struct ThreadContextPicker { impl ThreadContextPicker { pub fn new( - thread_store: WeakEntity, - text_thread_context_store: WeakEntity, + thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, + workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { let delegate = ThreadContextPickerDelegate::new( thread_store, - text_thread_context_store, context_picker, context_store, + workspace, ); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); @@ -52,48 +49,27 @@ impl Render for ThreadContextPicker { } } -#[derive(Debug, Clone)] -pub enum ThreadContextEntry { - Thread { - id: ThreadId, - title: SharedString, - }, - Context { - path: Arc, - title: SharedString, - }, -} - -impl ThreadContextEntry { - pub fn title(&self) -> &SharedString { - match self { - Self::Thread { title, .. } => title, - Self::Context { title, .. } => title, - } - } -} - pub struct ThreadContextPickerDelegate { - thread_store: WeakEntity, - text_thread_store: WeakEntity, + thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, - matches: Vec, + workspace: WeakEntity, + matches: Vec, selected_index: usize, } impl ThreadContextPickerDelegate { pub fn new( - thread_store: WeakEntity, - text_thread_store: WeakEntity, + thread_store: WeakEntity, context_picker: WeakEntity, context_store: WeakEntity, + workspace: WeakEntity, ) -> Self { ThreadContextPickerDelegate { thread_store, context_picker, context_store, - text_thread_store, + workspace, matches: Vec::new(), selected_index: 0, } @@ -130,25 +106,15 @@ impl PickerDelegate for ThreadContextPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let Some((thread_store, text_thread_context_store)) = self - .thread_store - .upgrade() - .zip(self.text_thread_store.upgrade()) - else { + let Some(thread_store) = self.thread_store.upgrade() else { return Task::ready(()); }; - let search_task = search_threads( - query, - Arc::new(AtomicBool::default()), - thread_store, - text_thread_context_store, - cx, - ); + let search_task = search_threads(query, Arc::new(AtomicBool::default()), &thread_store, cx); cx.spawn_in(window, async move |this, cx| { let matches = search_task.await; this.update(cx, |this, cx| { - this.delegate.matches = matches.into_iter().map(|mat| mat.thread).collect(); + this.delegate.matches = matches; this.delegate.selected_index = 0; cx.notify(); }) @@ -156,21 +122,29 @@ impl PickerDelegate for ThreadContextPickerDelegate { }) } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index) else { + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { + let Some(project) = self + .workspace + .upgrade() + .map(|w| w.read(cx).project().clone()) + else { + return; + }; + let Some((entry, thread_store)) = self + .matches + .get(self.selected_index) + .zip(self.thread_store.upgrade()) + else { return; }; match entry { - ThreadContextEntry::Thread { id, .. } => { - let Some(thread_store) = self.thread_store.upgrade() else { - return; - }; - let open_thread_task = - thread_store.update(cx, |this, cx| this.open_thread(&id, window, cx)); + HistoryEntry::AcpThread(thread) => { + let load_thread_task = + agent::load_agent_thread(thread.id.clone(), thread_store, project, cx); cx.spawn(async move |this, cx| { - let thread = open_thread_task.await?; + let thread = load_thread_task.await?; this.update(cx, |this, cx| { this.delegate .context_store @@ -182,12 +156,10 @@ impl PickerDelegate for ThreadContextPickerDelegate { }) .detach_and_log_err(cx); } - ThreadContextEntry::Context { path, .. } => { - let Some(text_thread_store) = self.text_thread_store.upgrade() else { - return; - }; - let task = text_thread_store - .update(cx, |this, cx| this.open_local_context(path.clone(), cx)); + HistoryEntry::TextThread(thread) => { + let task = thread_store.update(cx, |this, cx| { + this.load_text_thread(thread.path.clone(), cx) + }); cx.spawn(async move |this, cx| { let thread = task.await?; @@ -220,7 +192,7 @@ impl PickerDelegate for ThreadContextPickerDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let thread = &self.matches[ix]; + let thread = &self.matches.get(ix)?; Some(ListItem::new(ix).inset(true).toggle_state(selected).child( render_thread_context_entry(thread, self.context_store.clone(), cx), @@ -229,19 +201,17 @@ impl PickerDelegate for ThreadContextPickerDelegate { } pub fn render_thread_context_entry( - entry: &ThreadContextEntry, + entry: &HistoryEntry, context_store: WeakEntity, cx: &mut App, ) -> Div { let is_added = match entry { - ThreadContextEntry::Thread { id, .. } => context_store + HistoryEntry::AcpThread(thread) => context_store .upgrade() - .map_or(false, |ctx_store| ctx_store.read(cx).includes_thread(&id)), - ThreadContextEntry::Context { path, .. } => { - context_store.upgrade().map_or(false, |ctx_store| { - ctx_store.read(cx).includes_text_thread(path) - }) - } + .is_some_and(|ctx_store| ctx_store.read(cx).includes_thread(&thread.id)), + HistoryEntry::TextThread(thread) => context_store + .upgrade() + .is_some_and(|ctx_store| ctx_store.read(cx).includes_text_thread(&thread.path)), }; h_flex() @@ -273,91 +243,38 @@ pub fn render_thread_context_entry( }) } -#[derive(Clone)] -pub struct ThreadMatch { - pub thread: ThreadContextEntry, - pub is_recent: bool, -} - -pub fn unordered_thread_entries( - thread_store: Entity, - text_thread_store: Entity, - cx: &App, -) -> impl Iterator, ThreadContextEntry)> { - let threads = thread_store - .read(cx) - .reverse_chronological_threads() - .map(|thread| { - ( - thread.updated_at, - ThreadContextEntry::Thread { - id: thread.id.clone(), - title: thread.summary.clone(), - }, - ) - }); - - let text_threads = text_thread_store - .read(cx) - .unordered_contexts() - .map(|context| { - ( - context.mtime.to_utc(), - ThreadContextEntry::Context { - path: context.path.clone(), - title: context.title.clone(), - }, - ) - }); - - threads.chain(text_threads) -} - pub(crate) fn search_threads( query: String, cancellation_flag: Arc, - thread_store: Entity, - text_thread_store: Entity, + thread_store: &Entity, cx: &mut App, -) -> Task> { - let mut threads = - unordered_thread_entries(thread_store, text_thread_store, cx).collect::>(); - threads.sort_unstable_by_key(|(updated_at, _)| std::cmp::Reverse(*updated_at)); +) -> Task> { + let threads = thread_store.read(cx).entries().collect(); + if query.is_empty() { + return Task::ready(threads); + } let executor = cx.background_executor().clone(); cx.background_spawn(async move { - if query.is_empty() { - threads - .into_iter() - .map(|(_, thread)| ThreadMatch { - thread, - is_recent: false, - }) - .collect() - } else { - let candidates = threads - .iter() - .enumerate() - .map(|(id, (_, thread))| StringMatchCandidate::new(id, &thread.title())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; + let candidates = threads + .iter() + .enumerate() + .map(|(id, thread)| StringMatchCandidate::new(id, thread.title())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &cancellation_flag, + executor, + ) + .await; - matches - .into_iter() - .map(|mat| ThreadMatch { - thread: threads[mat.candidate_id].1.clone(), - is_recent: false, - }) - .collect() - } + matches + .into_iter() + .map(|mat| threads[mat.candidate_id].clone()) + .collect() }) } diff --git a/crates/agent_ui/src/context_server_configuration.rs b/crates/agent_ui/src/context_server_configuration.rs index fe15e8606d4026054ef0d04f1aab08375a440cf7..3a1a8695172fcb68e52b6269b88b7b1576c2a5cb 100644 --- a/crates/agent_ui/src/context_server_configuration.rs +++ b/crates/agent_ui/src/context_server_configuration.rs @@ -5,7 +5,6 @@ use extension::ExtensionManifest; use fs::Fs; use gpui::WeakEntity; use language::LanguageRegistry; -use project::project_settings::ProjectSettings; use settings::update_settings_file; use ui::prelude::*; use util::ResultExt; @@ -69,8 +68,9 @@ fn remove_context_server_settings( fs: Arc, cx: &mut App, ) { - update_settings_file::(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, _| { settings + .project .context_servers .retain(|server_id, _| !context_server_ids.contains(server_id)); }); diff --git a/crates/agent/src/context_store.rs b/crates/agent_ui/src/context_store.rs similarity index 83% rename from crates/agent/src/context_store.rs rename to crates/agent_ui/src/context_store.rs index 60ba5527dcca22d81b7da62657c6abc00aa51607..18aa59c8f716d59e4a0d717904b09472494c4dbc 100644 --- a/crates/agent/src/context_store.rs +++ b/crates/agent_ui/src/context_store.rs @@ -1,20 +1,20 @@ -use crate::{ - context::{ - AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle, - FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, - SelectionContextHandle, SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, - }, - thread::{MessageId, Thread, ThreadId}, - thread_store::ThreadStore, +use crate::context::{ + AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle, + FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle, + SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, }; +use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; -use assistant_context::AssistantContext; +use assistant_text_thread::TextThread; use collections::{HashSet, IndexSet}; use futures::{self, FutureExt}; use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity}; use language::{Buffer, File as _}; use language_model::LanguageModelImage; -use project::{Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file}; +use project::{ + Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file, + lsp_store::SymbolLocation, +}; use prompt_store::UserPromptId; use ref_cast::RefCast as _; use std::{ @@ -26,10 +26,9 @@ use text::{Anchor, OffsetRangeExt}; pub struct ContextStore { project: WeakEntity, - thread_store: Option>, next_context_id: ContextId, context_set: IndexSet, - context_thread_ids: HashSet, + context_thread_ids: HashSet, context_text_thread_paths: HashSet>, } @@ -40,13 +39,9 @@ pub enum ContextStoreEvent { impl EventEmitter for ContextStore {} impl ContextStore { - pub fn new( - project: WeakEntity, - thread_store: Option>, - ) -> Self { + pub fn new(project: WeakEntity) -> Self { Self { project, - thread_store, next_context_id: ContextId::zero(), context_set: IndexSet::default(), context_thread_ids: HashSet::default(), @@ -64,29 +59,6 @@ impl ContextStore { cx.notify(); } - pub fn new_context_for_thread( - &self, - thread: &Thread, - exclude_messages_from_id: Option, - ) -> Vec { - let existing_context = thread - .messages() - .take_while(|message| exclude_messages_from_id.is_none_or(|id| message.id != id)) - .flat_map(|message| { - message - .loaded_context - .contexts - .iter() - .map(|context| AgentContextKey(context.handle())) - }) - .collect::>(); - self.context_set - .iter() - .filter(|context| !existing_context.contains(context)) - .map(|entry| entry.0.clone()) - .collect::>() - } - pub fn add_file_from_path( &mut self, project_path: ProjectPath, @@ -206,7 +178,7 @@ impl ContextStore { pub fn add_thread( &mut self, - thread: Entity, + thread: Entity, remove_if_exists: bool, cx: &mut Context, ) -> Option { @@ -228,13 +200,13 @@ impl ContextStore { pub fn add_text_thread( &mut self, - context: Entity, + text_thread: Entity, remove_if_exists: bool, cx: &mut Context, ) -> Option { let context_id = self.next_context_id.post_inc(); let context = AgentContextHandle::TextThread(TextThreadContextHandle { - context, + text_thread, context_id, }); @@ -309,7 +281,7 @@ impl ContextStore { let item = image_item.read(cx); this.insert_image( Some(item.project_path(cx)), - Some(item.file.full_path(cx).into()), + Some(item.file.full_path(cx).to_string_lossy().into_owned()), item.image.clone(), remove_if_exists, cx, @@ -325,7 +297,7 @@ impl ContextStore { fn insert_image( &mut self, project_path: Option, - full_path: Option>, + full_path: Option, image: Arc, remove_if_exists: bool, cx: &mut Context, @@ -338,11 +310,9 @@ impl ContextStore { image_task, context_id: self.next_context_id.post_inc(), }); - if self.has_context(&context) { - if remove_if_exists { - self.remove_context(&context, cx); - return None; - } + if self.has_context(&context) && remove_if_exists { + self.remove_context(&context, cx); + return None; } self.insert_context(context.clone(), cx); @@ -383,21 +353,15 @@ impl ContextStore { ); }; } - SuggestedContext::Thread { thread, name: _ } => { - if let Some(thread) = thread.upgrade() { - let context_id = self.next_context_id.post_inc(); - self.insert_context( - AgentContextHandle::Thread(ThreadContextHandle { thread, context_id }), - cx, - ); - } - } - SuggestedContext::TextThread { context, name: _ } => { - if let Some(context) = context.upgrade() { + SuggestedContext::TextThread { + text_thread, + name: _, + } => { + if let Some(text_thread) = text_thread.upgrade() { let context_id = self.next_context_id.post_inc(); self.insert_context( AgentContextHandle::TextThread(TextThreadContextHandle { - context, + text_thread, context_id, }), cx, @@ -409,20 +373,20 @@ impl ContextStore { fn insert_context(&mut self, context: AgentContextHandle, cx: &mut Context) -> bool { match &context { - AgentContextHandle::Thread(thread_context) => { - if let Some(thread_store) = self.thread_store.clone() { - thread_context.thread.update(cx, |thread, cx| { - thread.start_generating_detailed_summary_if_needed(thread_store, cx); - }); - self.context_thread_ids - .insert(thread_context.thread.read(cx).id().clone()); - } else { - return false; - } - } + // AgentContextHandle::Thread(thread_context) => { + // if let Some(thread_store) = self.thread_store.clone() { + // thread_context.thread.update(cx, |thread, cx| { + // thread.start_generating_detailed_summary_if_needed(thread_store, cx); + // }); + // self.context_thread_ids + // .insert(thread_context.thread.read(cx).id().clone()); + // } else { + // return false; + // } + // } AgentContextHandle::TextThread(text_thread_context) => { self.context_text_thread_paths - .extend(text_thread_context.context.read(cx).path().cloned()); + .extend(text_thread_context.text_thread.read(cx).path().cloned()); } _ => {} } @@ -444,7 +408,7 @@ impl ContextStore { .remove(thread_context.thread.read(cx).id()); } AgentContextHandle::TextThread(text_thread_context) => { - if let Some(path) = text_thread_context.context.read(cx).path() { + if let Some(path) = text_thread_context.text_thread.read(cx).path() { self.context_text_thread_paths.remove(path); } } @@ -502,7 +466,7 @@ impl ContextStore { let Some(context_path) = buffer.project_path(cx) else { return false; }; - if context_path != symbol.path { + if symbol.path != SymbolLocation::InProject(context_path) { return false; } let context_range = context.range.to_point_utf16(&buffer.snapshot()); @@ -513,7 +477,7 @@ impl ContextStore { }) } - pub fn includes_thread(&self, thread_id: &ThreadId) -> bool { + pub fn includes_thread(&self, thread_id: &acp::SessionId) -> bool { self.context_thread_ids.contains(thread_id) } @@ -546,9 +510,9 @@ impl ContextStore { } AgentContextHandle::Directory(_) | AgentContextHandle::Symbol(_) + | AgentContextHandle::Thread(_) | AgentContextHandle::Selection(_) | AgentContextHandle::FetchedUrl(_) - | AgentContextHandle::Thread(_) | AgentContextHandle::TextThread(_) | AgentContextHandle::Rules(_) | AgentContextHandle::Image(_) => None, @@ -556,7 +520,7 @@ impl ContextStore { .collect() } - pub fn thread_ids(&self) -> &HashSet { + pub fn thread_ids(&self) -> &HashSet { &self.context_thread_ids } } @@ -568,13 +532,9 @@ pub enum SuggestedContext { icon_path: Option, buffer: WeakEntity, }, - Thread { - name: SharedString, - thread: WeakEntity, - }, TextThread { name: SharedString, - context: WeakEntity, + text_thread: WeakEntity, }, } @@ -582,7 +542,6 @@ impl SuggestedContext { pub fn name(&self) -> &SharedString { match self { Self::File { name, .. } => name, - Self::Thread { name, .. } => name, Self::TextThread { name, .. } => name, } } @@ -590,7 +549,6 @@ impl SuggestedContext { pub fn icon_path(&self) -> Option { match self { Self::File { icon_path, .. } => icon_path.clone(), - Self::Thread { .. } => None, Self::TextThread { .. } => None, } } @@ -598,7 +556,6 @@ impl SuggestedContext { pub fn kind(&self) -> ContextKind { match self { Self::File { .. } => ContextKind::File, - Self::Thread { .. } => ContextKind::Thread, Self::TextThread { .. } => ContextKind::TextThread, } } diff --git a/crates/agent_ui/src/context_strip.rs b/crates/agent_ui/src/context_strip.rs index 369964f165dc4d4460fd446c949538ec820fb82e..d2393ac4f612cebc6cf97d10a38894e7022e53b9 100644 --- a/crates/agent_ui/src/context_strip.rs +++ b/crates/agent_ui/src/context_strip.rs @@ -4,24 +4,27 @@ use crate::{ context_picker::ContextPicker, ui::{AddedContext, ContextPill}, }; -use agent::context_store::SuggestedContext; -use agent::{ +use crate::{ context::AgentContextHandle, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, + context_store::{ContextStore, SuggestedContext}, }; +use agent::HistoryStore; use collections::HashSet; use editor::Editor; -use file_icons::FileIcons; use gpui::{ App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, WeakEntity, + Subscription, Task, WeakEntity, }; use itertools::Itertools; use project::ProjectItem; -use std::{path::Path, rc::Rc}; +use prompt_store::PromptStore; +use rope::Point; +use std::rc::Rc; +use text::ToPoint as _; use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; +use util::ResultExt as _; use workspace::Workspace; +use zed_actions::assistant::OpenRulesLibrary; pub struct ContextStrip { context_store: Entity, @@ -30,7 +33,7 @@ pub struct ContextStrip { focus_handle: FocusHandle, suggest_context_kind: SuggestContextKind, workspace: WeakEntity, - thread_store: Option>, + prompt_store: Option>, _subscriptions: Vec, focused_index: Option, children_bounds: Option>>, @@ -41,8 +44,8 @@ impl ContextStrip { pub fn new( context_store: Entity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, context_picker_menu_handle: PopoverMenuHandle, suggest_context_kind: SuggestContextKind, model_usage_context: ModelUsageContext, @@ -53,7 +56,7 @@ impl ContextStrip { ContextPicker::new( workspace.clone(), thread_store.clone(), - text_thread_store, + prompt_store.clone(), context_store.downgrade(), window, cx, @@ -76,7 +79,7 @@ impl ContextStrip { focus_handle, suggest_context_kind, workspace, - thread_store, + prompt_store, _subscriptions: subscriptions, focused_index: None, children_bounds: None, @@ -93,11 +96,7 @@ impl ContextStrip { fn added_contexts(&self, cx: &App) -> Vec { if let Some(workspace) = self.workspace.upgrade() { let project = workspace.read(cx).project().read(cx); - let prompt_store = self - .thread_store - .as_ref() - .and_then(|thread_store| thread_store.upgrade()) - .and_then(|thread_store| thread_store.read(cx).prompt_store().as_ref()); + let prompt_store = self.prompt_store.as_ref().and_then(|p| p.upgrade()); let current_model = self.model_usage_context.language_model(cx); @@ -107,7 +106,7 @@ impl ContextStrip { .flat_map(|context| { AddedContext::new_pending( context.clone(), - prompt_store, + prompt_store.as_ref(), project, current_model.as_ref(), cx, @@ -121,38 +120,10 @@ impl ContextStrip { fn suggested_context(&self, cx: &App) -> Option { match self.suggest_context_kind { - SuggestContextKind::File => self.suggested_file(cx), SuggestContextKind::Thread => self.suggested_thread(cx), } } - fn suggested_file(&self, cx: &App) -> Option { - let workspace = self.workspace.upgrade()?; - let active_item = workspace.read(cx).active_item(cx)?; - - let editor = active_item.to_any().downcast::().ok()?.read(cx); - let active_buffer_entity = editor.buffer().read(cx).as_singleton()?; - let active_buffer = active_buffer_entity.read(cx); - let project_path = active_buffer.project_path(cx)?; - - if self - .context_store - .read(cx) - .file_path_included(&project_path, cx) - .is_some() - { - return None; - } - - let file_name = active_buffer.file()?.file_name(cx); - let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx); - Some(SuggestedContext::File { - name: file_name.to_string_lossy().into_owned().into(), - buffer: active_buffer_entity.downgrade(), - icon_path, - }) - } - fn suggested_thread(&self, cx: &App) -> Option { if !self.context_picker.read(cx).allow_threads() { return None; @@ -161,36 +132,19 @@ impl ContextStrip { let workspace = self.workspace.upgrade()?; let panel = workspace.read(cx).panel::(cx)?.read(cx); - if let Some(active_thread) = panel.active_thread(cx) { - let weak_active_thread = active_thread.downgrade(); - - let active_thread = active_thread.read(cx); - - if self - .context_store - .read(cx) - .includes_thread(active_thread.id()) - { - return None; - } - - Some(SuggestedContext::Thread { - name: active_thread.summary().or_default(), - thread: weak_active_thread, - }) - } else if let Some(active_context_editor) = panel.active_context_editor() { - let context = active_context_editor.read(cx).context(); - let weak_context = context.downgrade(); - let context = context.read(cx); - let path = context.path()?; + if let Some(active_text_thread_editor) = panel.active_text_thread_editor() { + let text_thread = active_text_thread_editor.read(cx).text_thread(); + let weak_text_thread = text_thread.downgrade(); + let text_thread = text_thread.read(cx); + let path = text_thread.path()?; if self.context_store.read(cx).includes_text_thread(path) { return None; } Some(SuggestedContext::TextThread { - name: context.summary().or_default(), - context: weak_context, + name: text_thread.summary().or_default(), + text_thread: weak_text_thread, }) } else { None @@ -328,7 +282,75 @@ impl ContextStrip { return; }; - crate::active_thread::open_context(context, workspace, window, cx); + match context { + AgentContextHandle::File(file_context) => { + if let Some(project_path) = file_context.project_path(cx) { + workspace.update(cx, |workspace, cx| { + workspace + .open_path(project_path, None, true, window, cx) + .detach_and_log_err(cx); + }); + } + } + + AgentContextHandle::Directory(directory_context) => { + let entry_id = directory_context.entry_id; + workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |_project, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)); + }) + }) + } + + AgentContextHandle::Symbol(symbol_context) => { + let buffer = symbol_context.buffer.read(cx); + if let Some(project_path) = buffer.project_path(cx) { + let snapshot = buffer.snapshot(); + let target_position = symbol_context.range.start.to_point(&snapshot); + open_editor_at_position(project_path, target_position, &workspace, window, cx) + .detach(); + } + } + + AgentContextHandle::Selection(selection_context) => { + let buffer = selection_context.buffer.read(cx); + if let Some(project_path) = buffer.project_path(cx) { + let snapshot = buffer.snapshot(); + let target_position = selection_context.range.start.to_point(&snapshot); + + open_editor_at_position(project_path, target_position, &workspace, window, cx) + .detach(); + } + } + + AgentContextHandle::FetchedUrl(fetched_url_context) => { + cx.open_url(&fetched_url_context.url); + } + + AgentContextHandle::Thread(_thread_context) => {} + + AgentContextHandle::TextThread(text_thread_context) => { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + let context = text_thread_context.text_thread.clone(); + window.defer(cx, move |window, cx| { + panel.update(cx, |panel, cx| { + panel.open_text_thread(context, window, cx) + }); + }); + } + }) + } + + AgentContextHandle::Rules(rules_context) => window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: Some(rules_context.prompt_id.0), + }), + cx, + ), + + AgentContextHandle::Image(_) => {} + } } fn remove_focused_context( @@ -368,16 +390,16 @@ impl ContextStrip { _window: &mut Window, cx: &mut Context, ) { - if let Some(suggested) = self.suggested_context(cx) { - if self.is_suggested_focused(&self.added_contexts(cx)) { - self.add_suggested_context(&suggested, cx); - } + if let Some(suggested) = self.suggested_context(cx) + && self.is_suggested_focused(&self.added_contexts(cx)) + { + self.add_suggested_context(&suggested, cx); } } fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut Context) { self.context_store.update(cx, |context_store, cx| { - context_store.add_suggested_context(&suggested, cx) + context_store.add_suggested_context(suggested, cx) }); cx.notify(); } @@ -461,12 +483,11 @@ impl Render for ContextStrip { .style(ui::ButtonStyle::Filled), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Add Context", &ToggleContextPicker, &focus_handle, - window, cx, ) } @@ -536,12 +557,11 @@ impl Render for ContextStrip { .icon_size(IconSize::Small) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Remove All Context", &RemoveAllContext, &focus_handle, - window, cx, ) } @@ -569,6 +589,31 @@ pub enum ContextStripEvent { impl EventEmitter for ContextStrip {} pub enum SuggestContextKind { - File, Thread, } + +fn open_editor_at_position( + project_path: project::ProjectPath, + target_position: Point, + workspace: &Entity, + window: &mut Window, + cx: &mut App, +) -> Task<()> { + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) + }); + window.spawn(cx, async move |cx| { + if let Some(active_editor) = open_task + .await + .log_err() + .and_then(|item| item.downcast::()) + { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(target_position, window, cx); + }) + .log_err(); + } + }) +} diff --git a/crates/agent_ui/src/debug.rs b/crates/agent_ui/src/debug.rs deleted file mode 100644 index bd34659210e933ad99357e7e1ceeedb6b53c5ee0..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/debug.rs +++ /dev/null @@ -1,124 +0,0 @@ -#![allow(unused, dead_code)] - -use client::{ModelRequestUsage, RequestUsage}; -use cloud_llm_client::{Plan, UsageLimit}; -use gpui::Global; -use std::ops::{Deref, DerefMut}; -use ui::prelude::*; - -/// Debug only: Used for testing various account states -/// -/// Use this by initializing it with -/// `cx.set_global(DebugAccountState::default());` somewhere -/// -/// Then call `cx.debug_account()` to get access -#[derive(Clone, Debug)] -pub struct DebugAccountState { - pub enabled: bool, - pub trial_expired: bool, - pub plan: Plan, - pub custom_prompt_usage: ModelRequestUsage, - pub usage_based_billing_enabled: bool, - pub monthly_spending_cap: i32, - pub custom_edit_prediction_usage: UsageLimit, -} - -impl DebugAccountState { - pub fn enabled(&self) -> bool { - self.enabled - } - - pub fn set_enabled(&mut self, enabled: bool) -> &mut Self { - self.enabled = enabled; - self - } - - pub fn set_trial_expired(&mut self, trial_expired: bool) -> &mut Self { - self.trial_expired = trial_expired; - self - } - - pub fn set_plan(&mut self, plan: Plan) -> &mut Self { - self.plan = plan; - self - } - - pub fn set_custom_prompt_usage(&mut self, custom_prompt_usage: ModelRequestUsage) -> &mut Self { - self.custom_prompt_usage = custom_prompt_usage; - self - } - - pub fn set_usage_based_billing_enabled( - &mut self, - usage_based_billing_enabled: bool, - ) -> &mut Self { - self.usage_based_billing_enabled = usage_based_billing_enabled; - self - } - - pub fn set_monthly_spending_cap(&mut self, monthly_spending_cap: i32) -> &mut Self { - self.monthly_spending_cap = monthly_spending_cap; - self - } - - pub fn set_custom_edit_prediction_usage( - &mut self, - custom_edit_prediction_usage: UsageLimit, - ) -> &mut Self { - self.custom_edit_prediction_usage = custom_edit_prediction_usage; - self - } -} - -impl Default for DebugAccountState { - fn default() -> Self { - Self { - enabled: false, - trial_expired: false, - plan: Plan::ZedFree, - custom_prompt_usage: ModelRequestUsage(RequestUsage { - limit: UsageLimit::Unlimited, - amount: 0, - }), - usage_based_billing_enabled: false, - // $50.00 - monthly_spending_cap: 5000, - custom_edit_prediction_usage: UsageLimit::Unlimited, - } - } -} - -impl DebugAccountState { - pub fn get_global(cx: &App) -> &Self { - &cx.global::().0 - } -} - -#[derive(Clone, Debug)] -pub struct GlobalDebugAccountState(pub DebugAccountState); - -impl Global for GlobalDebugAccountState {} - -impl Deref for GlobalDebugAccountState { - type Target = DebugAccountState; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for GlobalDebugAccountState { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -pub trait DebugAccount { - fn debug_account(&self) -> &DebugAccountState; -} - -impl DebugAccount for App { - fn debug_account(&self) -> &DebugAccountState { - &self.global::().0 - } -} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index bbd35958059346750cb899746823d5e12b2de1b8..b05dba59e6b19fa5091903882748de853cd9cb93 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -7,18 +7,18 @@ use std::sync::Arc; use crate::{ AgentPanel, buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent}, + context_store::ContextStore, inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent}, terminal_inline_assistant::TerminalInlineAssistant, }; -use agent::{ - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; +use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; +use editor::RowExt; use editor::SelectionEffects; +use editor::scroll::ScrollOffset; use editor::{ Anchor, AnchorRangeExt, CodeActionProvider, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint, @@ -144,7 +144,7 @@ impl InlineAssistant { let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { return; }; - let enabled = AgentSettings::get_global(cx).enabled; + let enabled = AgentSettings::get_global(cx).enabled(cx); terminal_panel.update(cx, |terminal_panel, cx| { terminal_panel.set_assistant_enabled(enabled, cx) }); @@ -182,13 +182,13 @@ impl InlineAssistant { match event { workspace::Event::UserSavedItem { item, .. } => { // When the user manually saves an editor, automatically accepts all finished transformations. - if let Some(editor) = item.upgrade().and_then(|item| item.act_as::(cx)) { - if let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) { - for assist_id in editor_assists.assist_ids.clone() { - let assist = &self.assists[&assist_id]; - if let CodegenStatus::Done = assist.codegen.read(cx).status(cx) { - self.finish_assist(assist_id, false, window, cx) - } + if let Some(editor) = item.upgrade().and_then(|item| item.act_as::(cx)) + && let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) + { + for assist_id in editor_assists.assist_ids.clone() { + let assist = &self.assists[&assist_id]; + if let CodegenStatus::Done = assist.codegen.read(cx).status(cx) { + self.finish_assist(assist_id, false, window, cx) } } } @@ -207,24 +207,21 @@ impl InlineAssistant { window: &mut Window, cx: &mut App, ) { - let is_assistant2_enabled = !DisableAiSettings::get_global(cx).disable_ai; + let is_ai_enabled = !DisableAiSettings::get_global(cx).disable_ai; if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - if is_assistant2_enabled { + if is_ai_enabled { let panel = workspace.read(cx).panel::(cx); let thread_store = panel .as_ref() .map(|agent_panel| agent_panel.read(cx).thread_store().downgrade()); - let text_thread_store = panel - .map(|agent_panel| agent_panel.read(cx).text_thread_store().downgrade()); editor.add_code_action_provider( Rc::new(AssistantCodeActionProvider { editor: cx.entity().downgrade(), workspace: workspace.downgrade(), thread_store, - text_thread_store, }), window, cx, @@ -256,8 +253,7 @@ impl InlineAssistant { window: &mut Window, cx: &mut Context, ) { - let settings = AgentSettings::get_global(cx); - if !settings.enabled || DisableAiSettings::get_global(cx).disable_ai { + if !AgentSettings::get_global(cx).enabled(cx) { return; } @@ -282,7 +278,6 @@ impl InlineAssistant { let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = Some(agent_panel.thread_store().downgrade()); - let text_thread_store = Some(agent_panel.text_thread_store().downgrade()); let context_store = agent_panel.inline_assist_context_store().clone(); let handle_assist = @@ -296,7 +291,6 @@ impl InlineAssistant { workspace.project().downgrade(), prompt_store, thread_store, - text_thread_store, action.prompt.clone(), window, cx, @@ -311,7 +305,6 @@ impl InlineAssistant { workspace.project().downgrade(), prompt_store, thread_store, - text_thread_store, action.prompt.clone(), window, cx, @@ -342,13 +335,11 @@ impl InlineAssistant { ) .await .ok(); - if let Some(answer) = answer { - if answer == 0 { - cx.update(|window, cx| { - window.dispatch_action(Box::new(OpenSettings), cx) - }) + if let Some(answer) = answer + && answer == 0 + { + cx.update(|window, cx| window.dispatch_action(Box::new(OpenSettings), cx)) .ok(); - } } anyhow::Ok(()) }) @@ -366,16 +357,18 @@ impl InlineAssistant { context_store: Entity, project: WeakEntity, prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, ) { let (snapshot, initial_selections, newest_selection) = editor.update(cx, |editor, cx| { - let selections = editor.selections.all::(cx); - let newest_selection = editor.selections.newest::(cx); - (editor.snapshot(window, cx), selections, newest_selection) + let snapshot = editor.snapshot(window, cx); + let selections = editor.selections.all::(&snapshot.display_snapshot); + let newest_selection = editor + .selections + .newest::(&snapshot.display_snapshot); + (snapshot, selections, newest_selection) }); // Check if there is already an inline assistant that contains the @@ -383,7 +376,7 @@ impl InlineAssistant { if let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) { for assist_id in &editor_assists.assist_ids { let assist = &self.assists[assist_id]; - let range = assist.range.to_point(&snapshot.buffer_snapshot); + let range = assist.range.to_point(&snapshot.buffer_snapshot()); if range.start.row <= newest_selection.start.row && newest_selection.end.row <= range.end.row { @@ -403,16 +396,16 @@ impl InlineAssistant { selection.end.row -= 1; } selection.end.column = snapshot - .buffer_snapshot + .buffer_snapshot() .line_len(MultiBufferRow(selection.end.row)); } else if let Some(fold) = snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) { selection.start = fold.range().start; selection.end = fold.range().end; - if MultiBufferRow(selection.end.row) < snapshot.buffer_snapshot.max_row() { + if MultiBufferRow(selection.end.row) < snapshot.buffer_snapshot().max_row() { let chars = snapshot - .buffer_snapshot + .buffer_snapshot() .chars_at(Point::new(selection.end.row + 1, 0)); for c in chars { @@ -428,18 +421,18 @@ impl InlineAssistant { { selection.end.row += 1; selection.end.column = snapshot - .buffer_snapshot + .buffer_snapshot() .line_len(MultiBufferRow(selection.end.row)); } } } } - if let Some(prev_selection) = selections.last_mut() { - if selection.start <= prev_selection.end { - prev_selection.end = selection.end; - continue; - } + if let Some(prev_selection) = selections.last_mut() + && selection.start <= prev_selection.end + { + prev_selection.end = selection.end; + continue; } let latest_selection = newest_selection.get_or_insert_with(|| selection.clone()); @@ -448,7 +441,7 @@ impl InlineAssistant { } selections.push(selection); } - let snapshot = &snapshot.buffer_snapshot; + let snapshot = &snapshot.buffer_snapshot(); let newest_selection = newest_selection.unwrap(); let mut codegen_ranges = Vec::new(); @@ -518,7 +511,7 @@ impl InlineAssistant { context_store.clone(), workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.as_ref().map(|s| s.downgrade()), window, cx, ) @@ -526,9 +519,9 @@ impl InlineAssistant { if assist_to_focus.is_none() { let focus_assist = if newest_selection.reversed { - range.start.to_point(&snapshot) == newest_selection.start + range.start.to_point(snapshot) == newest_selection.start } else { - range.end.to_point(&snapshot) == newest_selection.end + range.end.to_point(snapshot) == newest_selection.end }; if focus_assist { assist_to_focus = Some(assist_id); @@ -550,7 +543,7 @@ impl InlineAssistant { let editor_assists = self .assists_by_editor .entry(editor.downgrade()) - .or_insert_with(|| EditorInlineAssists::new(&editor, window, cx)); + .or_insert_with(|| EditorInlineAssists::new(editor, window, cx)); let mut assist_group = InlineAssistGroup::new(); for (assist_id, range, prompt_editor, prompt_block_id, end_block_id) in assists { let codegen = prompt_editor.read(cx).codegen().clone(); @@ -590,8 +583,7 @@ impl InlineAssistant { focus: bool, workspace: Entity, prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, window: &mut Window, cx: &mut App, ) -> InlineAssistId { @@ -609,7 +601,7 @@ impl InlineAssistant { } let project = workspace.read(cx).project().downgrade(); - let context_store = cx.new(|_cx| ContextStore::new(project.clone(), thread_store.clone())); + let context_store = cx.new(|_cx| ContextStore::new(project.clone())); let codegen = cx.new(|cx| { BufferCodegen::new( @@ -618,7 +610,7 @@ impl InlineAssistant { initial_transaction_id, context_store.clone(), project, - prompt_store, + prompt_store.clone(), self.telemetry.clone(), self.prompt_builder.clone(), cx, @@ -637,7 +629,7 @@ impl InlineAssistant { context_store, workspace.downgrade(), thread_store, - text_thread_store, + prompt_store.map(|s| s.downgrade()), window, cx, ) @@ -649,7 +641,7 @@ impl InlineAssistant { let editor_assists = self .assists_by_editor .entry(editor.downgrade()) - .or_insert_with(|| EditorInlineAssists::new(&editor, window, cx)); + .or_insert_with(|| EditorInlineAssists::new(editor, window, cx)); let mut assist_group = InlineAssistGroup::new(); self.assists.insert( @@ -745,19 +737,14 @@ impl InlineAssistant { .update(cx, |editor, cx| { let scroll_top = editor.scroll_position(cx).y; let scroll_bottom = scroll_top + editor.visible_line_count().unwrap_or(0.); - let prompt_row = editor + editor_assists.scroll_lock = editor .row_for_block(decorations.prompt_block_id, cx) - .unwrap() - .0 as f32; - - if (scroll_top..scroll_bottom).contains(&prompt_row) { - editor_assists.scroll_lock = Some(InlineAssistScrollLock { + .map(|row| row.as_f64()) + .filter(|prompt_row| (scroll_top..scroll_bottom).contains(&prompt_row)) + .map(|prompt_row| InlineAssistScrollLock { assist_id, distance_from_top: prompt_row - scroll_top, }); - } else { - editor_assists.scroll_lock = None; - } }) .ok(); } @@ -814,7 +801,9 @@ impl InlineAssistant { if editor.read(cx).selections.count() == 1 { let (selection, buffer) = editor.update(cx, |editor, cx| { ( - editor.selections.newest::(cx), + editor + .selections + .newest::(&editor.display_snapshot(cx)), editor.buffer().read(cx).snapshot(cx), ) }); @@ -845,7 +834,9 @@ impl InlineAssistant { if editor.read(cx).selections.count() == 1 { let (selection, buffer) = editor.update(cx, |editor, cx| { ( - editor.selections.newest::(cx), + editor + .selections + .newest::(&editor.display_snapshot(cx)), editor.buffer().read(cx).snapshot(cx), ) }); @@ -919,13 +910,13 @@ impl InlineAssistant { editor.update(cx, |editor, cx| { let scroll_position = editor.scroll_position(cx); let target_scroll_top = editor - .row_for_block(decorations.prompt_block_id, cx) - .unwrap() - .0 as f32 + .row_for_block(decorations.prompt_block_id, cx)? + .as_f64() - scroll_lock.distance_from_top; if target_scroll_top != scroll_position.y { editor.set_scroll_position(point(scroll_position.x, target_scroll_top), window, cx); } + Some(()) }); } @@ -970,13 +961,14 @@ impl InlineAssistant { let distance_from_top = editor.update(cx, |editor, cx| { let scroll_top = editor.scroll_position(cx).y; let prompt_row = editor - .row_for_block(decorations.prompt_block_id, cx) - .unwrap() - .0 as f32; - prompt_row - scroll_top + .row_for_block(decorations.prompt_block_id, cx)? + .0 as ScrollOffset; + Some(prompt_row - scroll_top) }); - if distance_from_top != scroll_lock.distance_from_top { + if distance_from_top.is_none_or(|distance_from_top| { + distance_from_top != scroll_lock.distance_from_top + }) { editor_assists.scroll_lock = None; } } @@ -985,14 +977,13 @@ impl InlineAssistant { EditorEvent::SelectionsChanged { .. } => { for assist_id in editor_assists.assist_ids.clone() { let assist = &self.assists[&assist_id]; - if let Some(decorations) = assist.decorations.as_ref() { - if decorations + if let Some(decorations) = assist.decorations.as_ref() + && decorations .prompt_editor .focus_handle(cx) .is_focused(window) - { - return; - } + { + return; } } @@ -1123,7 +1114,7 @@ impl InlineAssistant { if editor_assists .scroll_lock .as_ref() - .map_or(false, |lock| lock.assist_id == assist_id) + .is_some_and(|lock| lock.assist_id == assist_id) { editor_assists.scroll_lock = None; } @@ -1203,8 +1194,8 @@ impl InlineAssistant { let mut scroll_target_range = None; if let Some(decorations) = assist.decorations.as_ref() { scroll_target_range = maybe!({ - let top = editor.row_for_block(decorations.prompt_block_id, cx)?.0 as f32; - let bottom = editor.row_for_block(decorations.end_block_id, cx)?.0 as f32; + let top = editor.row_for_block(decorations.prompt_block_id, cx)?.0 as f64; + let bottom = editor.row_for_block(decorations.end_block_id, cx)?.0 as f64; Some((top, bottom)) }); if scroll_target_range.is_none() { @@ -1218,15 +1209,15 @@ impl InlineAssistant { .start .to_display_point(&snapshot.display_snapshot) .row(); - let top = start_row.0 as f32; + let top = start_row.0 as ScrollOffset; let bottom = top + 1.0; (top, bottom) }); let mut scroll_target_top = scroll_target_range.0; let mut scroll_target_bottom = scroll_target_range.1; - scroll_target_top -= editor.vertical_scroll_margin() as f32; - scroll_target_bottom += editor.vertical_scroll_margin() as f32; + scroll_target_top -= editor.vertical_scroll_margin() as ScrollOffset; + scroll_target_bottom += editor.vertical_scroll_margin() as ScrollOffset; let height_in_lines = editor.visible_line_count().unwrap_or(0.); let scroll_top = editor.scroll_position(cx).y; @@ -1503,24 +1494,22 @@ impl InlineAssistant { window: &mut Window, cx: &mut App, ) -> Option { - if let Some(terminal_panel) = workspace.panel::(cx) { - if terminal_panel + if let Some(terminal_panel) = workspace.panel::(cx) + && terminal_panel .read(cx) .focus_handle(cx) .contains_focused(window, cx) - { - if let Some(terminal_view) = terminal_panel.read(cx).pane().and_then(|pane| { - pane.read(cx) - .active_item() - .and_then(|t| t.downcast::()) - }) { - return Some(InlineAssistTarget::Terminal(terminal_view)); - } - } + && let Some(terminal_view) = terminal_panel.read(cx).pane().and_then(|pane| { + pane.read(cx) + .active_item() + .and_then(|t| t.downcast::()) + }) + { + return Some(InlineAssistTarget::Terminal(terminal_view)); } - let context_editor = agent_panel - .and_then(|panel| panel.read(cx).active_context_editor()) + let text_thread_editor = agent_panel + .and_then(|panel| panel.read(cx).active_text_thread_editor()) .and_then(|editor| { let editor = &editor.read(cx).editor().clone(); if editor.read(cx).is_focused(window) { @@ -1530,20 +1519,18 @@ impl InlineAssistant { } }); - if let Some(context_editor) = context_editor { - Some(InlineAssistTarget::Editor(context_editor)) + if let Some(text_thread_editor) = text_thread_editor { + Some(InlineAssistTarget::Editor(text_thread_editor)) } else if let Some(workspace_editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) { Some(InlineAssistTarget::Editor(workspace_editor)) - } else if let Some(terminal_view) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - { - Some(InlineAssistTarget::Terminal(terminal_view)) } else { - None + workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .map(InlineAssistTarget::Terminal) } } } @@ -1558,7 +1545,7 @@ struct EditorInlineAssists { struct InlineAssistScrollLock { assist_id: InlineAssistId, - distance_from_top: f32, + distance_from_top: ScrollOffset, } impl EditorInlineAssists { @@ -1698,7 +1685,7 @@ impl InlineAssist { }), range, codegen: codegen.clone(), - workspace: workspace.clone(), + workspace, _subscriptions: vec![ window.on_focus_in(&prompt_editor_focus_handle, cx, move |_, cx| { InlineAssistant::update_global(cx, |this, cx| { @@ -1741,22 +1728,20 @@ impl InlineAssist { return; }; - if let CodegenStatus::Error(error) = codegen.read(cx).status(cx) { - if assist.decorations.is_none() { - if let Some(workspace) = assist.workspace.upgrade() { - let error = format!("Inline assistant error: {}", error); - workspace.update(cx, |workspace, cx| { - struct InlineAssistantError; - - let id = - NotificationId::composite::( - assist_id.0, - ); - - workspace.show_toast(Toast::new(id, error), cx); - }) - } - } + if let CodegenStatus::Error(error) = codegen.read(cx).status(cx) + && assist.decorations.is_none() + && let Some(workspace) = assist.workspace.upgrade() + { + let error = format!("Inline assistant error: {}", error); + workspace.update(cx, |workspace, cx| { + struct InlineAssistantError; + + let id = NotificationId::composite::( + assist_id.0, + ); + + workspace.show_toast(Toast::new(id, error), cx); + }) } if assist.decorations.is_none() { @@ -1785,8 +1770,7 @@ struct InlineAssistDecorations { struct AssistantCodeActionProvider { editor: WeakEntity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, } const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2"; @@ -1803,7 +1787,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { _: &mut Window, cx: &mut App, ) -> Task>> { - if !AgentSettings::get_global(cx).enabled { + if !AgentSettings::get_global(cx).enabled(cx) { return Task::ready(Ok(Vec::new())); } @@ -1821,18 +1805,15 @@ impl CodeActionProvider for AssistantCodeActionProvider { has_diagnostics = true; } if has_diagnostics { - if let Some(symbols_containing_start) = snapshot.symbols_containing(range.start, None) { - if let Some(symbol) = symbols_containing_start.last() { - range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot)); - range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot)); - } + let symbols_containing_start = snapshot.symbols_containing(range.start, None); + if let Some(symbol) = symbols_containing_start.last() { + range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot)); + range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot)); } - - if let Some(symbols_containing_end) = snapshot.symbols_containing(range.end, None) { - if let Some(symbol) = symbols_containing_end.last() { - range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot)); - range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot)); - } + let symbols_containing_end = snapshot.symbols_containing(range.end, None); + if let Some(symbol) = symbols_containing_end.last() { + range.start = cmp::min(range.start, symbol.range.start.to_point(&snapshot)); + range.end = cmp::max(range.end, symbol.range.end.to_point(&snapshot)); } Task::ready(Ok(vec![CodeAction { @@ -1861,7 +1842,6 @@ impl CodeActionProvider for AssistantCodeActionProvider { let editor = self.editor.clone(); let workspace = self.workspace.clone(); let thread_store = self.thread_store.clone(); - let text_thread_store = self.text_thread_store.clone(); let prompt_store = PromptStore::global(cx); window.spawn(cx, async move |cx| { let workspace = workspace.upgrade().context("workspace was released")?; @@ -1893,12 +1873,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { } let multibuffer_snapshot = multibuffer.read(cx); - Some( - multibuffer_snapshot - .anchor_in_excerpt(excerpt_id, action.range.start)? - ..multibuffer_snapshot - .anchor_in_excerpt(excerpt_id, action.range.end)?, - ) + multibuffer_snapshot.anchor_range_in_excerpt(excerpt_id, action.range) }) })? .context("invalid range")?; @@ -1914,7 +1889,6 @@ impl CodeActionProvider for AssistantCodeActionProvider { workspace, prompt_store, thread_store, - text_thread_store, window, cx, ); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index e6fca1698496b064917a6b1b8257388e81a00df7..89bfd50e37e8ea681e70fadd78cbbd047f7258cb 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -1,44 +1,39 @@ -use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; -use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; -use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases}; -use crate::terminal_codegen::TerminalCodegen; -use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; -use crate::{RemoveAllContext, ToggleContextPicker}; -use agent::{ - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, -}; -use client::ErrorExt; +use crate::context_store::ContextStore; +use agent::HistoryStore; use collections::VecDeque; -use db::kvp::Dismissable; use editor::actions::Paste; use editor::display_map::EditorMargins; use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; -use feature_flags::{FeatureFlagAppExt as _, ZedProFeatureFlag}; use fs::Fs; use gpui::{ - AnyElement, App, ClickEvent, Context, CursorStyle, Entity, EventEmitter, FocusHandle, - Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window, anchored, deferred, point, + AnyElement, App, ClipboardEntry, Context, CursorStyle, Entity, EventEmitter, FocusHandle, + Focusable, Subscription, TextStyle, WeakEntity, Window, }; use language_model::{LanguageModel, LanguageModelRegistry}; use parking_lot::Mutex; +use prompt_store::PromptStore; use settings::Settings; use std::cmp; use std::rc::Rc; use std::sync::Arc; use theme::ThemeSettings; use ui::utils::WithRemSize; -use ui::{ - CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*, -}; +use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use workspace::Workspace; use zed_actions::agent::ToggleModelSelector; +use crate::agent_model_selector::AgentModelSelector; +use crate::buffer_codegen::BufferCodegen; +use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider}; +use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; +use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases}; +use crate::terminal_codegen::TerminalCodegen; +use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; +use crate::{RemoveAllContext, ToggleContextPicker}; + pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, @@ -75,7 +70,7 @@ impl Render for PromptEditor { let codegen = codegen.read(cx); if codegen.alternative_count(cx) > 1 { - buttons.push(self.render_cycle_controls(&codegen, cx)); + buttons.push(self.render_cycle_controls(codegen, cx)); } let editor_margins = editor_margins.lock(); @@ -93,8 +88,8 @@ impl Render for PromptEditor { }; let bottom_padding = match &self.mode { - PromptEditorMode::Buffer { .. } => Pixels::from(0.), - PromptEditorMode::Terminal { .. } => Pixels::from(8.0), + PromptEditorMode::Buffer { .. } => rems_from_px(2.0), + PromptEditorMode::Terminal { .. } => rems_from_px(8.0), }; buttons.extend(self.render_buttons(window, cx)); @@ -144,47 +139,16 @@ impl Render for PromptEditor { }; let error_message = SharedString::from(error.to_string()); - if error.error_code() == proto::ErrorCode::RateLimitExceeded - && cx.has_flag::() - { - el.child( - v_flex() - .child( - IconButton::new( - "rate-limit-error", - IconName::XCircle, - ) - .toggle_state(self.show_rate_limit_notice) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .on_click( - cx.listener(Self::toggle_rate_limit_notice), - ), - ) - .children(self.show_rate_limit_notice.then(|| { - deferred( - anchored() - .position_mode( - gpui::AnchoredPositionMode::Local, - ) - .position(point(px(0.), px(24.))) - .anchor(gpui::Corner::TopLeft) - .child(self.render_rate_limit_notice(cx)), - ) - })), - ) - } else { - el.child( - div() - .id("error") - .tooltip(Tooltip::text(error_message)) - .child( - Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error), - ), - ) - } + el.child( + div() + .id("error") + .tooltip(Tooltip::text(error_message)) + .child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ), + ) }), ) .child( @@ -264,7 +228,7 @@ impl PromptEditor { self.editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - editor.set_placeholder_text("Add a prompt…", cx); + editor.set_placeholder_text("Add a prompt…", window, cx); editor.set_text(prompt, window, cx); insert_message_creases( &mut editor, @@ -307,20 +271,31 @@ impl PromptEditor { } fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context) { - crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx); - } + let images = cx + .read_from_clipboard() + .map(|item| { + item.into_entries() + .filter_map(|entry| { + if let ClipboardEntry::Image(image) = entry { + Some(image) + } else { + None + } + }) + .collect::>() + }) + .unwrap_or_default(); - fn toggle_rate_limit_notice( - &mut self, - _: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - self.show_rate_limit_notice = !self.show_rate_limit_notice; - if self.show_rate_limit_notice { - window.focus(&self.editor.focus_handle(cx)); + if images.is_empty() { + return; } - cx.notify(); + cx.stop_propagation(); + + self.context_store.update(cx, |store, cx| { + for image in images { + store.add_image_instance(Arc::new(image), cx); + } + }); } fn handle_prompt_editor_events( @@ -334,7 +309,7 @@ impl PromptEditor { EditorEvent::Edited { .. } => { if let Some(workspace) = window.root::().flatten() { workspace.update(cx, |workspace, cx| { - let is_via_ssh = workspace.project().read(cx).is_via_ssh(); + let is_via_ssh = workspace.project().read(cx).is_via_remote_server(); workspace .client() @@ -345,7 +320,7 @@ impl PromptEditor { let prompt = self.editor.read(cx).text(cx); if self .prompt_history_ix - .map_or(true, |ix| self.prompt_history[ix] != prompt) + .is_none_or(|ix| self.prompt_history[ix] != prompt) { self.prompt_history_ix.take(); self.pending_prompt = prompt; @@ -493,12 +468,11 @@ impl PromptEditor { IconButton::new("stop", IconName::Stop) .icon_color(Color::Error) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( mode.tooltip_interrupt(), Some(&menu::Cancel), "Changes won't be discarded", - window, cx, ) }) @@ -512,12 +486,11 @@ impl PromptEditor { IconButton::new("restart", IconName::RotateCw) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( mode.tooltip_restart(), Some(&menu::Confirm), "Changes will be discarded", - window, cx, ) }) @@ -530,8 +503,8 @@ impl PromptEditor { let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .tooltip(move |window, cx| { - Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action(mode.tooltip_accept(), &menu::Confirm, cx) }) .on_click(cx.listener(|_, _, _, cx| { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); @@ -544,11 +517,10 @@ impl PromptEditor { IconButton::new("confirm", IconName::PlayFilled) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::for_action( "Execute Generated Command", &menu::SecondaryConfirm, - window, cx, ) }) @@ -640,13 +612,12 @@ impl PromptEditor { .shape(IconButtonShape::Square) .tooltip({ let focus_handle = self.editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { cx.new(|cx| { let mut tooltip = Tooltip::new("Previous Alternative").key_binding( KeyBinding::for_action_in( &CyclePreviousInlineAssist, &focus_handle, - window, cx, ), ); @@ -682,13 +653,12 @@ impl PromptEditor { .shape(IconButtonShape::Square) .tooltip({ let focus_handle = self.editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { cx.new(|cx| { let mut tooltip = Tooltip::new("Next Alternative").key_binding( KeyBinding::for_action_in( &CycleNextInlineAssist, &focus_handle, - window, cx, ), ); @@ -707,75 +677,22 @@ impl PromptEditor { .into_any_element() } - fn render_rate_limit_notice(&self, cx: &mut Context) -> impl IntoElement { - Popover::new().child( - v_flex() - .occlude() - .p_2() - .child( - Label::new("Out of Tokens") - .size(LabelSize::Small) - .weight(FontWeight::BOLD), - ) - .child(Label::new( - "Try Zed Pro for higher limits, a wider range of models, and more.", - )) - .child( - h_flex() - .justify_between() - .child(CheckboxWithLabel::new( - "dont-show-again", - Label::new("Don't show again"), - if RateLimitNotice::dismissed() { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |selection, _, cx| { - let is_dismissed = match selection { - ui::ToggleState::Unselected => false, - ui::ToggleState::Indeterminate => return, - ui::ToggleState::Selected => true, - }; - - RateLimitNotice::set_dismissed(is_dismissed, cx); - }, - )) - .child( - h_flex() - .gap_2() - .child( - Button::new("dismiss", "Dismiss") - .style(ButtonStyle::Transparent) - .on_click(cx.listener(Self::toggle_rate_limit_notice)), - ) - .child(Button::new("more-info", "More Info").on_click( - |_event, window, cx| { - window.dispatch_action( - Box::new(zed_actions::OpenAccountSettings), - cx, - ) - }, - )), - ), - ), - ) - } - - fn render_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let font_size = TextSize::Default.rems(cx); - let line_height = font_size.to_pixels(window.rem_size()) * 1.3; + fn render_editor(&mut self, _window: &mut Window, cx: &mut Context) -> AnyElement { + let colors = cx.theme().colors(); div() .key_context("InlineAssistEditor") .size_full() .p_2() .pl_1() - .bg(cx.theme().colors().editor_background) + .bg(colors.editor_background) .child({ let settings = ThemeSettings::get_global(cx); + let font_size = settings.buffer_font_size(cx); + let line_height = font_size * 1.2; + let text_style = TextStyle { - color: cx.theme().colors().editor_foreground, + color: colors.editor_foreground, font_family: settings.buffer_font.family.clone(), font_features: settings.buffer_font.features.clone(), font_size: font_size.into(), @@ -786,7 +703,7 @@ impl PromptEditor { EditorElement::new( &self.editor, EditorStyle { - background: cx.theme().colors().editor_background, + background: colors.editor_background, local_player: cx.theme().players().local(), text: text_style, ..Default::default() @@ -854,8 +771,8 @@ impl PromptEditor { fs: Arc, context_store: Entity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, window: &mut Window, cx: &mut Context>, ) -> PromptEditor { @@ -883,7 +800,7 @@ impl PromptEditor { // always show the cursor (even when it isn't focused) because // typing in one will make what you typed appear in all of them. editor.set_show_cursor_when_unfocused(true, cx); - editor.set_placeholder_text(Self::placeholder_text(&mode, window, cx), cx); + editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx); editor.register_addon(ContextCreasesAddon::new()); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, @@ -900,7 +817,7 @@ impl PromptEditor { workspace.clone(), context_store.downgrade(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.clone(), prompt_editor_entity, codegen_buffer.as_ref().map(Entity::downgrade), )))); @@ -914,7 +831,7 @@ impl PromptEditor { context_store.clone(), workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store, context_picker_menu_handle.clone(), SuggestContextKind::Thread, ModelUsageContext::InlineAssistant, @@ -976,15 +893,7 @@ impl PromptEditor { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } - CodegenStatus::Error(error) => { - if cx.has_flag::() - && error.error_code() == proto::ErrorCode::RateLimitExceeded - && !RateLimitNotice::dismissed() - { - self.show_rate_limit_notice = true; - cx.notify(); - } - + CodegenStatus::Error(_error) => { self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -1034,8 +943,8 @@ impl PromptEditor { fs: Arc, context_store: Entity, workspace: WeakEntity, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, + prompt_store: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -1058,7 +967,7 @@ impl PromptEditor { cx, ); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - editor.set_placeholder_text(Self::placeholder_text(&mode, window, cx), cx); + editor.set_placeholder_text(&Self::placeholder_text(&mode, window, cx), window, cx); editor.set_context_menu_options(ContextMenuOptions { min_entries_visible: 12, max_entries_visible: 12, @@ -1073,7 +982,7 @@ impl PromptEditor { workspace.clone(), context_store.downgrade(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.clone(), prompt_editor_entity, None, )))); @@ -1087,7 +996,7 @@ impl PromptEditor { context_store.clone(), workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.clone(), context_picker_menu_handle.clone(), SuggestContextKind::Thread, ModelUsageContext::InlineAssistant, @@ -1187,12 +1096,6 @@ impl PromptEditor { } } -struct RateLimitNotice; - -impl Dismissable for RateLimitNotice { - const KEY: &'static str = "dismissed-rate-limit-notice"; -} - pub enum CodegenStatus { Idle, Pending, @@ -1229,27 +1132,27 @@ pub enum GenerationMode { impl GenerationMode { fn start_label(self) -> &'static str { match self { - GenerationMode::Generate { .. } => "Generate", + GenerationMode::Generate => "Generate", GenerationMode::Transform => "Transform", } } fn tooltip_interrupt(self) -> &'static str { match self { - GenerationMode::Generate { .. } => "Interrupt Generation", + GenerationMode::Generate => "Interrupt Generation", GenerationMode::Transform => "Interrupt Transform", } } fn tooltip_restart(self) -> &'static str { match self { - GenerationMode::Generate { .. } => "Restart Generation", + GenerationMode::Generate => "Restart Generation", GenerationMode::Transform => "Restart Transform", } } fn tooltip_accept(self) -> &'static str { match self { - GenerationMode::Generate { .. } => "Accept Generation", + GenerationMode::Generate => "Accept Generation", GenerationMode::Transform => "Accept Transform", } } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index bb8514a224dd1af3c4668be87d8a02e1d3a0e9be..eb5a734b4ca57c2b79ac0dd004e42fc59c195fed 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,8 +1,6 @@ use std::{cmp::Reverse, sync::Arc}; -use cloud_llm_client::Plan; use collections::{HashSet, IndexMap}; -use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ @@ -13,8 +11,6 @@ use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use ui::{ListItem, ListItemSpacing, prelude::*}; -const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro"; - type OnModelChanged = Arc, &mut App) + 'static>; type GetActiveModel = Arc Option + 'static>; @@ -93,7 +89,7 @@ impl LanguageModelPickerDelegate { let entries = models.entries(); Self { - on_model_changed: on_model_changed.clone(), + on_model_changed, all_models: Arc::new(models), selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, @@ -104,7 +100,7 @@ impl LanguageModelPickerDelegate { window, |picker, _, event, window, cx| { match event { - language_model::Event::ProviderStateChanged + language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { let query = picker.query(cx); @@ -296,7 +292,7 @@ impl ModelMatcher { pub fn fuzzy_search(&self, query: &str) -> Vec { let mut matches = self.bg_executor.block(match_strings( &self.candidates, - &query, + query, false, true, 100, @@ -514,7 +510,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { .pl_0p5() .gap_1p5() .w(px(240.)) - .child(Label::new(model_info.model.name().0.clone()).truncate()), + .child(Label::new(model_info.model.name().0).truncate()), ) .end_slot(div().pr_3().when(is_selected, |this| { this.child( @@ -531,13 +527,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { fn render_footer( &self, - _: &mut Window, + _window: &mut Window, cx: &mut Context>, ) -> Option { - use feature_flags::FeatureFlagAppExt; - - let plan = Plan::ZedPro; - Some( h_flex() .w_full() @@ -546,28 +538,6 @@ impl PickerDelegate for LanguageModelPickerDelegate { .p_1() .gap_4() .justify_between() - .when(cx.has_flag::(), |this| { - this.child(match plan { - Plan::ZedPro => Button::new("zed-pro", "Zed Pro") - .icon(IconName::ZedAssistant) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(|_, window, cx| { - window - .dispatch_action(Box::new(zed_actions::OpenAccountSettings), cx) - }), - Plan::ZedFree | Plan::ZedProTrial => Button::new( - "try-pro", - if plan == Plan::ZedProTrial { - "Upgrade to Pro" - } else { - "Try Pro" - }, - ) - .on_click(|_, _, cx| cx.open_url(TRY_ZED_PRO_URL)), - }) - }) .child( Button::new("configure", "Configure") .icon(IconName::Settings) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index d6c9a778a60a73a57db6aaf4a38fd907f5366615..42607833e4b5734424988d1edaa32d10bec06506 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1,1505 +1,25 @@ -use std::collections::BTreeMap; -use std::rc::Rc; -use std::sync::Arc; +use std::ops::Range; -use crate::agent_diff::AgentDiffThread; -use crate::agent_model_selector::AgentModelSelector; -use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; -use crate::ui::{ - BurnModeTooltip, - preview::{AgentPreview, UsageCallout}, -}; -use agent::history_store::HistoryStore; -use agent::{ - context::{AgentContextKey, ContextLoadResult, load_context}, - context_store::ContextStoreEvent, -}; -use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; -use ai_onboarding::ApiKeysWithProviders; -use buffer_diff::BufferDiff; -use cloud_llm_client::CompletionIntent; -use collections::{HashMap, HashSet}; -use editor::actions::{MoveUp, Paste}; +use collections::HashMap; use editor::display_map::CreaseId; -use editor::{ - Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, - EditorEvent, EditorMode, EditorStyle, MultiBuffer, -}; -use file_icons::FileIcons; -use fs::Fs; -use futures::future::Shared; -use futures::{FutureExt as _, future}; -use gpui::{ - Animation, AnimationExt, App, Entity, EventEmitter, Focusable, IntoElement, KeyContext, - Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, - pulsating_between, -}; -use language::{Buffer, Language, Point}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent, - ZED_CLOUD_PROVIDER_ID, -}; -use multi_buffer; -use project::Project; -use prompt_store::PromptStore; -use settings::Settings; -use std::time::Duration; -use theme::ThemeSettings; -use ui::{ - Callout, Disclosure, Divider, DividerColor, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*, -}; -use util::ResultExt as _; -use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::Chat; -use zed_actions::agent::ToggleModelSelector; +use editor::{Addon, AnchorRangeExt, Editor}; +use gpui::{Entity, Subscription}; +use ui::prelude::*; -use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; -use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; -use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::{ - ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, - ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, - ToggleContextPicker, ToggleProfileSelector, register_agent_preview, -}; -use agent::{ - MessageCrease, Thread, TokenUsageRatio, - context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, + context::{AgentContextHandle, AgentContextKey}, + context_picker::crease_for_mention, + context_store::{ContextStore, ContextStoreEvent}, }; -pub const MIN_EDITOR_LINES: usize = 4; -pub const MAX_EDITOR_LINES: usize = 8; - -#[derive(RegisterComponent)] -pub struct MessageEditor { - thread: Entity, - incompatible_tools_state: Entity, - editor: Entity, - workspace: WeakEntity, - project: Entity, - context_store: Entity, - prompt_store: Option>, - history_store: Option>, - context_strip: Entity, - context_picker_menu_handle: PopoverMenuHandle, - model_selector: Entity, - last_loaded_context: Option, - load_context_task: Option>>, - profile_selector: Entity, - edits_expanded: bool, - editor_is_expanded: bool, - last_estimated_token_count: Option, - update_token_count_task: Option>, - _subscriptions: Vec, -} - -pub(crate) fn create_editor( - workspace: WeakEntity, - context_store: WeakEntity, - thread_store: WeakEntity, - text_thread_store: WeakEntity, - min_lines: usize, - max_lines: Option, - window: &mut Window, - cx: &mut App, -) -> Entity { - let language = Language::new( - language::LanguageConfig { - completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), - ..Default::default() - }, - None, - ); - - let editor = cx.new(|cx| { - let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - let mut editor = Editor::new( - editor::EditorMode::AutoHeight { - min_lines, - max_lines: max_lines, - }, - buffer, - None, - window, - cx, - ); - editor.set_placeholder_text("Message the agent – @ to include context", cx); - editor.set_show_indent_guides(false, cx); - editor.set_soft_wrap(); - editor.set_use_modal_editing(true); - editor.set_context_menu_options(ContextMenuOptions { - min_entries_visible: 12, - max_entries_visible: 12, - placement: Some(ContextMenuPlacement::Above), - }); - editor.register_addon(ContextCreasesAddon::new()); - editor.register_addon(MessageEditorAddon::new()); - editor - }); - - let editor_entity = editor.downgrade(); - editor.update(cx, |editor, _| { - editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( - workspace, - context_store, - Some(thread_store), - Some(text_thread_store), - editor_entity, - None, - )))); - }); - editor -} - -impl ProfileProvider for Entity { - fn profiles_supported(&self, cx: &App) -> bool { - self.read(cx) - .configured_model() - .map_or(false, |model| model.model.supports_tools()) - } - - fn profile_id(&self, cx: &App) -> AgentProfileId { - self.read(cx).profile().id().clone() - } - - fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) { - self.update(cx, |this, cx| { - this.set_profile(profile_id, cx); - }); - } -} - -impl MessageEditor { - pub fn new( - fs: Arc, - workspace: WeakEntity, - context_store: Entity, - prompt_store: Option>, - thread_store: WeakEntity, - text_thread_store: WeakEntity, - history_store: Option>, - thread: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let context_picker_menu_handle = PopoverMenuHandle::default(); - let model_selector_menu_handle = PopoverMenuHandle::default(); - - let editor = create_editor( - workspace.clone(), - context_store.downgrade(), - thread_store.clone(), - text_thread_store.clone(), - MIN_EDITOR_LINES, - Some(MAX_EDITOR_LINES), - window, - cx, - ); - - let context_strip = cx.new(|cx| { - ContextStrip::new( - context_store.clone(), - workspace.clone(), - Some(thread_store.clone()), - Some(text_thread_store.clone()), - context_picker_menu_handle.clone(), - SuggestContextKind::File, - ModelUsageContext::Thread(thread.clone()), - window, - cx, - ) - }); - - let incompatible_tools = cx.new(|cx| IncompatibleToolsState::new(thread.clone(), cx)); - - let subscriptions = vec![ - cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event), - cx.subscribe(&editor, |this, _, event, cx| match event { - EditorEvent::BufferEdited => this.handle_message_changed(cx), - _ => {} - }), - cx.observe(&context_store, |this, _, cx| { - // When context changes, reload it for token counting. - let _ = this.reload_context(cx); - }), - cx.observe(&thread.read(cx).action_log().clone(), |_, _, cx| { - cx.notify() - }), - ]; - - let model_selector = cx.new(|cx| { - AgentModelSelector::new( - fs.clone(), - model_selector_menu_handle, - editor.focus_handle(cx), - ModelUsageContext::Thread(thread.clone()), - window, - cx, - ) - }); - - let profile_selector = cx.new(|cx| { - ProfileSelector::new(fs, Arc::new(thread.clone()), editor.focus_handle(cx), cx) - }); - - Self { - editor: editor.clone(), - project: thread.read(cx).project().clone(), - thread, - incompatible_tools_state: incompatible_tools.clone(), - workspace, - context_store, - prompt_store, - history_store, - context_strip, - context_picker_menu_handle, - load_context_task: None, - last_loaded_context: None, - model_selector, - edits_expanded: false, - editor_is_expanded: false, - profile_selector, - last_estimated_token_count: None, - update_token_count_task: None, - _subscriptions: subscriptions, - } - } - - pub fn context_store(&self) -> &Entity { - &self.context_store - } - - pub fn get_text(&self, cx: &App) -> String { - self.editor.read(cx).text(cx) - } - - pub fn set_text( - &mut self, - text: impl Into>, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - editor.set_text(text, window, cx); - }); - } - - pub fn expand_message_editor( - &mut self, - _: &ExpandMessageEditor, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_editor_is_expanded(!self.editor_is_expanded, cx); - } - - fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) { - self.editor_is_expanded = is_expanded; - self.editor.update(cx, |editor, _| { - if self.editor_is_expanded { - editor.set_mode(EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sized_by_content: false, - }) - } else { - editor.set_mode(EditorMode::AutoHeight { - min_lines: MIN_EDITOR_LINES, - max_lines: Some(MAX_EDITOR_LINES), - }) - } - }); - cx.notify(); - } - - fn toggle_context_picker( - &mut self, - _: &ToggleContextPicker, - window: &mut Window, - cx: &mut Context, - ) { - self.context_picker_menu_handle.toggle(window, cx); - } - - pub fn remove_all_context( - &mut self, - _: &RemoveAllContext, - _window: &mut Window, - cx: &mut Context, - ) { - self.context_store.update(cx, |store, cx| store.clear(cx)); - cx.notify(); - } - - fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { - if self.is_editor_empty(cx) { - return; - } - - self.thread.update(cx, |thread, cx| { - thread.cancel_editing(cx); - }); - - if self.thread.read(cx).is_generating() { - self.stop_current_and_send_new_message(window, cx); - return; - } - - self.set_editor_is_expanded(false, cx); - self.send_to_model(window, cx); - - cx.emit(MessageEditorEvent::ScrollThreadToBottom); - cx.notify(); - } - - fn chat_with_follow( - &mut self, - _: &ChatWithFollow, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |this, cx| { - this.follow(CollaboratorId::Agent, window, cx) - }) - .log_err(); - - self.chat(&Chat, window, cx); - } - - fn is_editor_empty(&self, cx: &App) -> bool { - self.editor.read(cx).text(cx).trim().is_empty() - } - - pub fn is_editor_fully_empty(&self, cx: &App) -> bool { - self.editor.read(cx).is_empty(cx) - } - - fn send_to_model(&mut self, window: &mut Window, cx: &mut Context) { - let Some(ConfiguredModel { model, provider }) = self - .thread - .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)) - else { - return; - }; - - if provider.must_accept_terms(cx) { - cx.notify(); - return; - } - - let (user_message, user_message_creases) = self.editor.update(cx, |editor, cx| { - let creases = extract_message_creases(editor, cx); - let text = editor.text(cx); - editor.clear(window, cx); - (text, creases) - }); - - self.last_estimated_token_count.take(); - cx.emit(MessageEditorEvent::EstimatedTokenCount); - - let thread = self.thread.clone(); - let git_store = self.project.read(cx).git_store().clone(); - let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx)); - let context_task = self.reload_context(cx); - let window_handle = window.window_handle(); - - cx.spawn(async move |_this, cx| { - let (checkpoint, loaded_context) = future::join(checkpoint, context_task).await; - let loaded_context = loaded_context.unwrap_or_default(); - - thread - .update(cx, |thread, cx| { - thread.insert_user_message( - user_message, - loaded_context, - checkpoint.ok(), - user_message_creases, - cx, - ); - }) - .log_err(); - - thread - .update(cx, |thread, cx| { - thread.advance_prompt_id(); - thread.send_to_model( - model, - CompletionIntent::UserPrompt, - Some(window_handle), - cx, - ); - }) - .log_err(); - }) - .detach(); - } - - fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context) { - self.thread.update(cx, |thread, cx| { - thread.cancel_editing(cx); - }); - - let canceled = self.thread.update(cx, |thread, cx| { - thread.cancel_last_completion(Some(window.window_handle()), cx) - }); - - if canceled { - self.set_editor_is_expanded(false, cx); - self.send_to_model(window, cx); - } - } - - fn handle_context_strip_event( - &mut self, - _context_strip: &Entity, - event: &ContextStripEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ContextStripEvent::PickerDismissed - | ContextStripEvent::BlurredEmpty - | ContextStripEvent::BlurredDown => { - let editor_focus_handle = self.editor.focus_handle(cx); - window.focus(&editor_focus_handle); - } - ContextStripEvent::BlurredUp => {} - } - } - - fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.context_picker_menu_handle.is_deployed() { - cx.propagate(); - } else if self.context_strip.read(cx).has_context_items(cx) { - self.context_strip.focus_handle(cx).focus(window); - } - } - - fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { - crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx); - } - - fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context) { - self.edits_expanded = true; - AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err(); - cx.notify(); - } - - fn handle_edit_bar_expand(&mut self, cx: &mut Context) { - self.edits_expanded = !self.edits_expanded; - cx.notify(); - } - - fn handle_file_click( - &self, - buffer: Entity, - window: &mut Window, - cx: &mut Context, - ) { - if let Ok(diff) = AgentDiffPane::deploy( - AgentDiffThread::Native(self.thread.clone()), - self.workspace.clone(), - window, - cx, - ) { - let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx); - diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx)); - } - } - - pub fn toggle_burn_mode( - &mut self, - _: &ToggleBurnMode, - _window: &mut Window, - cx: &mut Context, - ) { - self.thread.update(cx, |thread, _cx| { - let active_completion_mode = thread.completion_mode(); - - thread.set_completion_mode(match active_completion_mode { - CompletionMode::Burn => CompletionMode::Normal, - CompletionMode::Normal => CompletionMode::Burn, - }); - }); - } - - fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context) { - if self.thread.read(cx).has_pending_edit_tool_uses() { - return; - } - - self.thread.update(cx, |thread, cx| { - thread.keep_all_edits(cx); - }); - cx.notify(); - } - - fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context) { - if self.thread.read(cx).has_pending_edit_tool_uses() { - return; - } - - // Since there's no reject_all_edits method in the thread API, - // we need to iterate through all buffers and reject their edits - let action_log = self.thread.read(cx).action_log().clone(); - let changed_buffers = action_log.read(cx).changed_buffers(cx); - - for (buffer, _) in changed_buffers { - self.thread.update(cx, |thread, cx| { - let buffer_snapshot = buffer.read(cx); - let start = buffer_snapshot.anchor_before(Point::new(0, 0)); - let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point()); - thread - .reject_edits_in_ranges(buffer, vec![start..end], cx) - .detach(); - }); - } - cx.notify(); - } - - fn handle_reject_file_changes( - &mut self, - buffer: Entity, - _window: &mut Window, - cx: &mut Context, - ) { - if self.thread.read(cx).has_pending_edit_tool_uses() { - return; - } - - self.thread.update(cx, |thread, cx| { - let buffer_snapshot = buffer.read(cx); - let start = buffer_snapshot.anchor_before(Point::new(0, 0)); - let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point()); - thread - .reject_edits_in_ranges(buffer, vec![start..end], cx) - .detach(); - }); - cx.notify(); - } - - fn handle_accept_file_changes( - &mut self, - buffer: Entity, - _window: &mut Window, - cx: &mut Context, - ) { - if self.thread.read(cx).has_pending_edit_tool_uses() { - return; - } - - self.thread.update(cx, |thread, cx| { - let buffer_snapshot = buffer.read(cx); - let start = buffer_snapshot.anchor_before(Point::new(0, 0)); - let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point()); - thread.keep_edits_in_range(buffer, start..end, cx); - }); - cx.notify(); - } - - fn render_burn_mode_toggle(&self, cx: &mut Context) -> Option { - let thread = self.thread.read(cx); - let model = thread.configured_model(); - if !model?.model.supports_burn_mode() { - return None; - } - - let active_completion_mode = thread.completion_mode(); - let burn_mode_enabled = active_completion_mode == CompletionMode::Burn; - let icon = if burn_mode_enabled { - IconName::ZedBurnModeOn - } else { - IconName::ZedBurnMode - }; - - Some( - IconButton::new("burn-mode", icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(burn_mode_enabled) - .selected_icon_color(Color::Error) - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_burn_mode(&ToggleBurnMode, window, cx); - })) - .tooltip(move |_window, cx| { - cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled)) - .into() - }) - .into_any_element(), - ) - } - - fn render_follow_toggle( - &self, - is_model_selected: bool, - cx: &mut Context, - ) -> impl IntoElement { - let following = self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) - }) - .unwrap_or(false); - - IconButton::new("follow-agent", IconName::Crosshair) - .disabled(!is_model_selected) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(following) - .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) - .tooltip(move |window, cx| { - if following { - Tooltip::for_action("Stop Following Agent", &Follow, window, cx) - } else { - Tooltip::with_meta( - "Follow Agent", - Some(&Follow), - "Track the agent's location as it reads and edits files.", - window, - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); - })) - } - - fn render_editor(&self, window: &mut Window, cx: &mut Context) -> Div { - let thread = self.thread.read(cx); - let model = thread.configured_model(); - - let editor_bg_color = cx.theme().colors().editor_background; - let is_generating = thread.is_generating(); - let focus_handle = self.editor.focus_handle(cx); - - let is_model_selected = model.is_some(); - let is_editor_empty = self.is_editor_empty(cx); - - let incompatible_tools = model - .as_ref() - .map(|model| { - self.incompatible_tools_state.update(cx, |state, cx| { - state - .incompatible_tools(&model.model, cx) - .iter() - .cloned() - .collect::>() - }) - }) - .unwrap_or_default(); - - let is_editor_expanded = self.editor_is_expanded; - let expand_icon = if is_editor_expanded { - IconName::Minimize - } else { - IconName::Maximize - }; - - v_flex() - .key_context("MessageEditor") - .on_action(cx.listener(Self::chat)) - .on_action(cx.listener(Self::chat_with_follow)) - .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { - this.profile_selector - .read(cx) - .menu_handle() - .toggle(window, cx); - })) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - this.model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - })) - .on_action(cx.listener(Self::toggle_context_picker)) - .on_action(cx.listener(Self::remove_all_context)) - .on_action(cx.listener(Self::move_up)) - .on_action(cx.listener(Self::expand_message_editor)) - .on_action(cx.listener(Self::toggle_burn_mode)) - .on_action( - cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)), - ) - .on_action( - cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)), - ) - .capture_action(cx.listener(Self::paste)) - .p_2() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(editor_bg_color) - .child( - h_flex() - .justify_between() - .child(self.context_strip.clone()) - .when(focus_handle.is_focused(window), |this| { - this.child( - IconButton::new("toggle-height", expand_icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - let expand_label = if is_editor_expanded { - "Minimize Message Editor".to_string() - } else { - "Expand Message Editor".to_string() - }; - - Tooltip::for_action_in( - expand_label, - &ExpandMessageEditor, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(ExpandMessageEditor), cx); - })), - ) - }), - ) - .child( - v_flex() - .size_full() - .gap_1() - .when(is_editor_expanded, |this| { - this.h(vh(0.8, window)).justify_between() - }) - .child({ - let settings = ThemeSettings::get_global(cx); - let font_size = TextSize::Small - .rems(cx) - .to_pixels(settings.agent_font_size(cx)); - let line_height = settings.buffer_line_height.value() * font_size; - - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.buffer_font.family.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_features: settings.buffer_font.features.clone(), - font_size: font_size.into(), - line_height: line_height.into(), - ..Default::default() - }; - - EditorElement::new( - &self.editor, - EditorStyle { - background: editor_bg_color, - local_player: cx.theme().players().local(), - text: text_style, - syntax: cx.theme().syntax().clone(), - ..Default::default() - }, - ) - .into_any() - }) - .child( - h_flex() - .flex_none() - .flex_wrap() - .justify_between() - .child( - h_flex() - .child(self.render_follow_toggle(is_model_selected, cx)) - .children(self.render_burn_mode_toggle(cx)), - ) - .child( - h_flex() - .gap_1() - .flex_wrap() - .when(!incompatible_tools.is_empty(), |this| { - this.child( - IconButton::new( - "tools-incompatible-warning", - IconName::Warning, - ) - .icon_color(Color::Warning) - .icon_size(IconSize::Small) - .tooltip({ - move |_, cx| { - cx.new(|_| IncompatibleToolsTooltip { - incompatible_tools: incompatible_tools - .clone(), - }) - .into() - } - }), - ) - }) - .child(self.profile_selector.clone()) - .child(self.model_selector.clone()) - .map({ - let focus_handle = focus_handle.clone(); - move |parent| { - if is_generating { - parent - .when(is_editor_empty, |parent| { - parent.child( - IconButton::new( - "stop-generation", - IconName::Stop, - ) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted( - ui::TintColor::Error, - )) - .tooltip(move |window, cx| { - Tooltip::for_action( - "Stop Generation", - &editor::actions::Cancel, - window, - cx, - ) - }) - .on_click({ - let focus_handle = - focus_handle.clone(); - move |_event, window, cx| { - focus_handle.dispatch_action( - &editor::actions::Cancel, - window, - cx, - ); - } - }) - .with_animation( - "pulsating-label", - Animation::new( - Duration::from_secs(2), - ) - .repeat() - .with_easing(pulsating_between( - 0.4, 1.0, - )), - |icon_button, delta| { - icon_button.alpha(delta) - }, - ), - ) - }) - .when(!is_editor_empty, |parent| { - parent.child( - IconButton::new( - "send-message", - IconName::Send, - ) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .disabled(!is_model_selected) - .on_click({ - let focus_handle = - focus_handle.clone(); - move |_event, window, cx| { - focus_handle.dispatch_action( - &Chat, window, cx, - ); - } - }) - .tooltip(move |window, cx| { - Tooltip::for_action( - "Stop and Send New Message", - &Chat, - window, - cx, - ) - }), - ) - }) - } else { - parent.child( - IconButton::new("send-message", IconName::Send) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .disabled( - is_editor_empty || !is_model_selected, - ) - .on_click({ - let focus_handle = focus_handle.clone(); - move |_event, window, cx| { - telemetry::event!( - "Agent Message Sent", - agent = "zed", - ); - focus_handle.dispatch_action( - &Chat, window, cx, - ); - } - }) - .when( - !is_editor_empty && is_model_selected, - |button| { - button.tooltip(move |window, cx| { - Tooltip::for_action( - "Send", &Chat, window, cx, - ) - }) - }, - ) - .when(is_editor_empty, |button| { - button.tooltip(Tooltip::text( - "Type a message to submit", - )) - }) - .when(!is_model_selected, |button| { - button.tooltip(Tooltip::text( - "Select a model to continue", - )) - }), - ) - } - } - }), - ), - ), - ) - } - - fn render_edits_bar( - &self, - changed_buffers: &BTreeMap, Entity>, - window: &mut Window, - cx: &mut Context, - ) -> Div { - let focus_handle = self.editor.focus_handle(cx); - - let editor_bg_color = cx.theme().colors().editor_background; - let border_color = cx.theme().colors().border; - let active_color = cx.theme().colors().element_selected; - let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); - - let is_edit_changes_expanded = self.edits_expanded; - let thread = self.thread.read(cx); - let pending_edits = thread.has_pending_edit_tool_uses(); - - const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; - - v_flex() - .mt_1() - .mx_2() - .bg(bg_edit_files_disclosure) - .border_1() - .border_b_0() - .border_color(border_color) - .rounded_t_md() - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), - offset: point(px(1.), px(-1.)), - blur_radius: px(3.), - spread_radius: px(0.), - }]) - .child( - h_flex() - .p_1() - .justify_between() - .when(is_edit_changes_expanded, |this| { - this.border_b_1().border_color(border_color) - }) - .child( - h_flex() - .id("edits-container") - .cursor_pointer() - .w_full() - .gap_1() - .child( - Disclosure::new("edits-disclosure", is_edit_changes_expanded) - .on_click(cx.listener(|this, _, _, cx| { - this.handle_edit_bar_expand(cx) - })), - ) - .map(|this| { - if pending_edits { - this.child( - Label::new(format!( - "Editing {} {}…", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .color(Color::Muted) - .size(LabelSize::Small) - .with_animation( - "edit-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.7)), - |label, delta| label.alpha(delta), - ), - ) - } else { - this.child( - Label::new("Edits") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("•").size(LabelSize::XSmall).color(Color::Muted), - ) - .child( - Label::new(format!( - "{} {}", - changed_buffers.len(), - if changed_buffers.len() == 1 { - "file" - } else { - "files" - } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - } - }) - .on_click( - cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)), - ), - ) - .child( - h_flex() - .gap_1() - .child( - IconButton::new("review-changes", IconName::ListTodo) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Review Changes", - &OpenAgentDiff, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_review_click(window, cx) - })), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .child( - Button::new("reject-all-changes", "Reject All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in( - &RejectAll, - &focus_handle.clone(), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_reject_all(window, cx) - })), - ) - .child( - Button::new("accept-all-changes", "Accept All") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .when(pending_edits, |this| { - this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) - }) - .key_binding( - KeyBinding::for_action_in( - &KeepAll, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_accept_all(window, cx) - })), - ), - ), - ) - .when(is_edit_changes_expanded, |parent| { - parent.child( - v_flex().children(changed_buffers.into_iter().enumerate().flat_map( - |(index, (buffer, _diff))| { - let file = buffer.read(cx).file()?; - let path = file.path(); - - let file_path = path.parent().and_then(|parent| { - let parent_str = parent.to_string_lossy(); - - if parent_str.is_empty() { - None - } else { - Some( - Label::new(format!( - "/{}{}", - parent_str, - std::path::MAIN_SEPARATOR_STR - )) - .color(Color::Muted) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) - } - }); - - let file_name = path.file_name().map(|name| { - Label::new(name.to_string_lossy().to_string()) - .size(LabelSize::XSmall) - .buffer_font(cx) - }); - - let file_icon = FileIcons::get_icon(&path, cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - Icon::new(IconName::File) - .color(Color::Muted) - .size(IconSize::Small) - }); - - let overlay_gradient = linear_gradient( - 90., - linear_color_stop(editor_bg_color, 1.), - linear_color_stop(editor_bg_color.opacity(0.2), 0.), - ); - - let element = h_flex() - .group("edited-code") - .id(("file-container", index)) - .relative() - .py_1() - .pl_2() - .pr_1() - .gap_2() - .justify_between() - .bg(editor_bg_color) - .when(index < changed_buffers.len() - 1, |parent| { - parent.border_color(border_color).border_b_1() - }) - .child( - h_flex() - .id(("file-name", index)) - .pr_8() - .gap_1p5() - .max_w_full() - .overflow_x_scroll() - .child(file_icon) - .child( - h_flex() - .gap_0p5() - .children(file_name) - .children(file_path), - ) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_file_click(buffer.clone(), window, cx); - }) - }), // TODO: Implement line diff - // .child(Label::new("+").color(Color::Created)) - // .child(Label::new("-").color(Color::Deleted)), - // - ) - .child( - h_flex() - .gap_1() - .visible_on_hover("edited-code") - .child( - Button::new("review", "Review") - .label_size(LabelSize::Small) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_file_click( - buffer.clone(), - window, - cx, - ); - }) - }), - ) - .child( - Divider::vertical().color(DividerColor::BorderVariant), - ) - .child( - Button::new("reject-file", "Reject") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_reject_file_changes( - buffer.clone(), - window, - cx, - ); - }) - }), - ) - .child( - Button::new("accept-file", "Accept") - .label_size(LabelSize::Small) - .disabled(pending_edits) - .on_click({ - let buffer = buffer.clone(); - cx.listener(move |this, _, window, cx| { - this.handle_accept_file_changes( - buffer.clone(), - window, - cx, - ); - }) - }), - ), - ) - .child( - div() - .id("gradient-overlay") - .absolute() - .h_full() - .w_12() - .top_0() - .bottom_0() - .right(px(152.)) - .bg(overlay_gradient), - ); - - Some(element) - }, - )), - ) - }) - } - - fn is_using_zed_provider(&self, cx: &App) -> bool { - self.thread - .read(cx) - .configured_model() - .map_or(false, |model| model.provider.id() == ZED_CLOUD_PROVIDER_ID) - } - - fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context) -> Option
{ - if !self.is_using_zed_provider(cx) { - return None; - } - - let user_store = self.project.read(cx).user_store().read(cx); - if user_store.is_usage_based_billing_enabled() { - return None; - } - - let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree); - - let usage = user_store.model_request_usage()?; - - Some( - div() - .child(UsageCallout::new(plan, usage)) - .line_height(line_height), - ) - } - - fn render_token_limit_callout( - &self, - line_height: Pixels, - token_usage_ratio: TokenUsageRatio, - cx: &mut Context, - ) -> Option
{ - let icon = if token_usage_ratio == TokenUsageRatio::Exceeded { - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::XSmall) - } else { - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall) - }; - - let title = if token_usage_ratio == TokenUsageRatio::Exceeded { - "Thread reached the token limit" - } else { - "Thread reaching the token limit soon" - }; - - let description = if self.is_using_zed_provider(cx) { - "To continue, start a new thread from a summary or turn burn mode on." - } else { - "To continue, start a new thread from a summary." - }; - - let mut callout = Callout::new() - .line_height(line_height) - .icon(icon) - .title(title) - .description(description) - .primary_action( - Button::new("start-new-thread", "Start New Thread") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - let from_thread_id = Some(this.thread.read(cx).id().clone()); - window.dispatch_action(Box::new(NewThread { from_thread_id }), cx); - })), - ); - - if self.is_using_zed_provider(cx) { - callout = callout.secondary_action( - IconButton::new("burn-mode-callout", IconName::ZedBurnMode) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_burn_mode(&ToggleBurnMode, window, cx); - })), - ); - } - - Some( - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child(callout), - ) - } - - pub fn last_estimated_token_count(&self) -> Option { - self.last_estimated_token_count - } - - pub fn is_waiting_to_update_token_count(&self) -> bool { - self.update_token_count_task.is_some() - } - - fn reload_context(&mut self, cx: &mut Context) -> Task> { - let load_task = cx.spawn(async move |this, cx| { - let Ok(load_task) = this.update(cx, |this, cx| { - let new_context = this - .context_store - .read(cx) - .new_context_for_thread(this.thread.read(cx), None); - load_context(new_context, &this.project, &this.prompt_store, cx) - }) else { - return; - }; - let result = load_task.await; - this.update(cx, |this, cx| { - this.last_loaded_context = Some(result); - this.load_context_task = None; - this.message_or_context_changed(false, cx); - }) - .ok(); - }); - // Replace existing load task, if any, causing it to be canceled. - let load_task = load_task.shared(); - self.load_context_task = Some(load_task.clone()); - cx.spawn(async move |this, cx| { - load_task.await; - this.read_with(cx, |this, _cx| this.last_loaded_context.clone()) - .ok() - .flatten() - }) - } - - fn handle_message_changed(&mut self, cx: &mut Context) { - self.message_or_context_changed(true, cx); - } - - fn message_or_context_changed(&mut self, debounce: bool, cx: &mut Context) { - cx.emit(MessageEditorEvent::Changed); - self.update_token_count_task.take(); - - let Some(model) = self.thread.read(cx).configured_model() else { - self.last_estimated_token_count.take(); - return; - }; - - let editor = self.editor.clone(); - - self.update_token_count_task = Some(cx.spawn(async move |this, cx| { - if debounce { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - } - - let token_count = if let Some(task) = this - .update(cx, |this, cx| { - let loaded_context = this - .last_loaded_context - .as_ref() - .map(|context_load_result| &context_load_result.loaded_context); - let message_text = editor.read(cx).text(cx); - - if message_text.is_empty() - && loaded_context.map_or(true, |loaded_context| loaded_context.is_empty()) - { - return None; - } - - let mut request_message = LanguageModelRequestMessage { - role: language_model::Role::User, - content: Vec::new(), - cache: false, - }; - - if let Some(loaded_context) = loaded_context { - loaded_context.add_to_request_message(&mut request_message); - } - - if !message_text.is_empty() { - request_message - .content - .push(MessageContent::Text(message_text)); - } - - let request = language_model::LanguageModelRequest { - thread_id: None, - prompt_id: None, - intent: None, - mode: None, - messages: vec![request_message], - tools: vec![], - tool_choice: None, - stop: vec![], - temperature: AgentSettings::temperature_for_model(&model.model, cx), - thinking_allowed: true, - }; - - Some(model.model.count_tokens(request, cx)) - }) - .ok() - .flatten() - { - task.await.log_err() - } else { - Some(0) - }; - - this.update(cx, |this, cx| { - if let Some(token_count) = token_count { - this.last_estimated_token_count = Some(token_count); - cx.emit(MessageEditorEvent::EstimatedTokenCount); - } - this.update_token_count_task.take(); - }) - .ok(); - })); - } +/// Stored information that can be used to resurrect a context crease when creating an editor for a past message. +#[derive(Clone, Debug)] +pub struct MessageCrease { + pub range: Range, + pub icon_path: SharedString, + pub label: SharedString, + /// None for a deserialized message, Some otherwise. + pub context: Option, } #[derive(Default)] @@ -1508,31 +28,6 @@ pub struct ContextCreasesAddon { _subscription: Option, } -pub struct MessageEditorAddon {} - -impl MessageEditorAddon { - pub fn new() -> Self { - Self {} - } -} - -impl Addon for MessageEditorAddon { - fn to_any(&self) -> &dyn std::any::Any { - self - } - - fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { - Some(self) - } - - fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) { - let settings = agent_settings::AgentSettings::get_global(cx); - if settings.use_modifier_to_send { - key_context.add("use_modifier_to_send"); - } - } -} - impl Addon for ContextCreasesAddon { fn to_any(&self) -> &dyn std::any::Any { self @@ -1559,9 +54,8 @@ impl ContextCreasesAddon { cx: &mut Context, ) { self.creases.entry(key).or_default().extend(creases); - self._subscription = Some(cx.subscribe( - &context_store, - |editor, _, event, cx| match event { + self._subscription = Some( + cx.subscribe(context_store, |editor, _, event, cx| match event { ContextStoreEvent::ContextRemoved(key) => { let Some(this) = editor.addon_mut::() else { return; @@ -1581,8 +75,8 @@ impl ContextCreasesAddon { editor.edit(ranges.into_iter().zip(replacement_texts), cx); cx.notify(); } - }, - )) + }), + ) } pub fn into_inner(self) -> HashMap> { @@ -1610,7 +104,8 @@ pub fn extract_message_creases( .collect::>(); // Filter the addon's list of creases based on what the editor reports, // since the addon might have removed creases in it. - let creases = editor.display_map.update(cx, |display_map, cx| { + + editor.display_map.update(cx, |display_map, cx| { display_map .snapshot(cx) .crease_snapshot @@ -1634,88 +129,7 @@ pub fn extract_message_creases( } }) .collect() - }); - creases -} - -impl EventEmitter for MessageEditor {} - -pub enum MessageEditorEvent { - EstimatedTokenCount, - Changed, - ScrollThreadToBottom, -} - -impl Focusable for MessageEditor { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl Render for MessageEditor { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let thread = self.thread.read(cx); - let token_usage_ratio = thread - .total_token_usage() - .map_or(TokenUsageRatio::Normal, |total_token_usage| { - total_token_usage.ratio() - }); - - let burn_mode_enabled = thread.completion_mode() == CompletionMode::Burn; - - let action_log = self.thread.read(cx).action_log(); - let changed_buffers = action_log.read(cx).changed_buffers(cx); - - let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; - - let has_configured_providers = LanguageModelRegistry::read_global(cx) - .providers() - .iter() - .filter(|provider| { - provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID - }) - .count() - > 0; - - let is_signed_out = self - .workspace - .read_with(cx, |workspace, _| { - workspace.client().status().borrow().is_signed_out() - }) - .unwrap_or(true); - - let has_history = self - .history_store - .as_ref() - .and_then(|hs| hs.update(cx, |hs, cx| hs.entries(cx).len() > 0).ok()) - .unwrap_or(false) - || self - .thread - .read_with(cx, |thread, _| thread.messages().len() > 0); - - v_flex() - .size_full() - .bg(cx.theme().colors().panel_background) - .when( - !has_history && is_signed_out && has_configured_providers, - |this| this.child(cx.new(ApiKeysWithProviders::new)), - ) - .when(changed_buffers.len() > 0, |parent| { - parent.child(self.render_edits_bar(&changed_buffers, window, cx)) - }) - .child(self.render_editor(window, cx)) - .children({ - let usage_callout = self.render_usage_callout(line_height, cx); - - if usage_callout.is_some() { - usage_callout - } else if token_usage_ratio != TokenUsageRatio::Normal && !burn_mode_enabled { - self.render_token_limit_callout(line_height, token_usage_ratio, cx) - } else { - None - } - }) - } + }) } pub fn insert_message_creases( @@ -1750,70 +164,3 @@ pub fn insert_message_creases( } } } -impl Component for MessageEditor { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn description() -> Option<&'static str> { - Some( - "The composer experience of the Agent Panel. This interface handles context, composing messages, switching profiles, models and more.", - ) - } -} - -impl AgentPreview for MessageEditor { - fn agent_preview( - workspace: WeakEntity, - active_thread: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - if let Some(workspace) = workspace.upgrade() { - let fs = workspace.read(cx).app_state().fs.clone(); - let project = workspace.read(cx).project().clone(); - let weak_project = project.downgrade(); - let context_store = cx.new(|_cx| ContextStore::new(weak_project, None)); - let active_thread = active_thread.read(cx); - let thread = active_thread.thread().clone(); - let thread_store = active_thread.thread_store().clone(); - let text_thread_store = active_thread.text_thread_store().clone(); - - let default_message_editor = cx.new(|cx| { - MessageEditor::new( - fs, - workspace.downgrade(), - context_store, - None, - thread_store.downgrade(), - text_thread_store.downgrade(), - None, - thread, - window, - cx, - ) - }); - - Some( - v_flex() - .gap_4() - .children(vec![single_example( - "Default Message Editor", - div() - .w(px(540.)) - .pt_12() - .bg(cx.theme().colors().panel_background) - .border_1() - .border_color(cx.theme().colors().border) - .child(default_message_editor.clone()) - .into_any_element(), - )]) - .into_any_element(), - ) - } else { - None - } - } -} - -register_agent_preview!(MessageEditor); diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index ce25f531e254750fce36628f7d0138b728c9205a..2f9fe19eb33667d6ca6bb2f5502fbd1c9f094e9c 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,13 +1,22 @@ use crate::{ManageProfiles, ToggleProfileSelector}; -use agent::agent_profile::{AgentProfile, AvailableProfiles}; -use agent_settings::{AgentDockPosition, AgentProfileId, AgentSettings, builtin_profiles}; +use agent_settings::{ + AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, +}; use fs::Fs; -use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*}; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle, + Focusable, SharedString, Subscription, Task, Window, +}; +use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; use settings::{Settings as _, SettingsStore, update_settings_file}; -use std::sync::Arc; +use std::{ + sync::atomic::Ordering, + sync::{Arc, atomic::AtomicBool}, +}; use ui::{ - ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, Tooltip, - prelude::*, + DocumentationAside, DocumentationEdge, DocumentationSide, HighlightedLabel, LabelSize, + ListItem, ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; /// Trait for types that can provide and manage agent profiles @@ -24,9 +33,11 @@ pub trait ProfileProvider { pub struct ProfileSelector { profiles: AvailableProfiles, + pending_refresh: bool, fs: Arc, provider: Arc, - menu_handle: PopoverMenuHandle, + picker: Option>>, + picker_handle: PopoverMenuHandle>, focus_handle: FocusHandle, _subscriptions: Vec, } @@ -39,180 +50,691 @@ impl ProfileSelector { cx: &mut Context, ) -> Self { let settings_subscription = cx.observe_global::(move |this, cx| { - this.refresh_profiles(cx); + this.pending_refresh = true; + cx.notify(); }); Self { profiles: AgentProfile::available_profiles(cx), + pending_refresh: false, fs, provider, - menu_handle: PopoverMenuHandle::default(), + picker: None, + picker_handle: PopoverMenuHandle::default(), focus_handle, _subscriptions: vec![settings_subscription], } } - pub fn menu_handle(&self) -> PopoverMenuHandle { - self.menu_handle.clone() + pub fn menu_handle(&self) -> PopoverMenuHandle> { + self.picker_handle.clone() } - fn refresh_profiles(&mut self, cx: &mut Context) { - self.profiles = AgentProfile::available_profiles(cx); - } - - fn build_context_menu( - &self, + fn ensure_picker( + &mut self, window: &mut Window, cx: &mut Context, - ) -> Entity { - ContextMenu::build(window, cx, |mut menu, _window, cx| { - let settings = AgentSettings::get_global(cx); - - let mut found_non_builtin = false; - for (profile_id, profile_name) in self.profiles.iter() { - if !builtin_profiles::is_builtin(profile_id) { - found_non_builtin = true; - continue; - } - menu = menu.item(self.menu_entry_for_profile( - profile_id.clone(), - profile_name, - settings, - cx, - )); - } + ) -> Entity> { + if self.picker.is_none() { + let delegate = ProfilePickerDelegate::new( + self.fs.clone(), + self.provider.clone(), + self.profiles.clone(), + cx.background_executor().clone(), + cx, + ); - if found_non_builtin { - menu = menu.separator().header("Custom Profiles"); - for (profile_id, profile_name) in self.profiles.iter() { - if builtin_profiles::is_builtin(profile_id) { - continue; - } - menu = menu.item(self.menu_entry_for_profile( - profile_id.clone(), - profile_name, - settings, - cx, - )); - } + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .width(rems(18.)) + .max_height(Some(rems(20.).into())) + }); + + self.picker = Some(picker); + } + + if self.pending_refresh { + if let Some(picker) = &self.picker { + let profiles = AgentProfile::available_profiles(cx); + self.profiles = profiles.clone(); + picker.update(cx, |picker, cx| { + let query = picker.query(cx); + picker + .delegate + .refresh_profiles(profiles.clone(), query, cx); + }); } + self.pending_refresh = false; + } - menu = menu.separator(); - menu = menu.item(ContextMenuEntry::new("Configure Profiles…").handler( - move |window, cx| { - window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); - }, - )); + self.picker.as_ref().unwrap().clone() + } +} - menu - }) +impl Focusable for ProfileSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + if let Some(picker) = &self.picker { + picker.focus_handle(cx) + } else { + self.focus_handle.clone() + } } +} - fn menu_entry_for_profile( - &self, - profile_id: AgentProfileId, - profile_name: &SharedString, - settings: &AgentSettings, - cx: &App, - ) -> ContextMenuEntry { - let documentation = match profile_name.to_lowercase().as_str() { +impl Render for ProfileSelector { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.provider.profiles_supported(cx) { + return Button::new("tools-not-supported-button", "Tools Unsupported") + .disabled(true) + .label_size(LabelSize::Small) + .color(Color::Muted) + .tooltip(Tooltip::text("This model does not support tools.")) + .into_any_element(); + } + + let picker = self.ensure_picker(window, cx); + + let settings = AgentSettings::get_global(cx); + let profile_id = self.provider.profile_id(cx); + let profile = settings.profiles.get(&profile_id); + + let selected_profile = profile + .map(|profile| profile.name.clone()) + .unwrap_or_else(|| "Unknown".into()); + let focus_handle = self.focus_handle.clone(); + + let icon = if self.picker_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let trigger_button = Button::new("profile-selector", selected_profile) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)); + + PickerPopoverMenu::new( + picker, + trigger_button, + move |_window, cx| { + Tooltip::for_action_in( + "Toggle Profile Menu", + &ToggleProfileSelector, + &focus_handle, + cx, + ) + }, + gpui::Corner::BottomRight, + cx, + ) + .with_handle(self.picker_handle.clone()) + .render(window, cx) + .into_any_element() + } +} + +#[derive(Clone)] +struct ProfileCandidate { + id: AgentProfileId, + name: SharedString, + is_builtin: bool, +} + +#[derive(Clone)] +struct ProfileMatchEntry { + candidate_index: usize, + positions: Vec, +} + +enum ProfilePickerEntry { + Header(SharedString), + Profile(ProfileMatchEntry), +} + +pub(crate) struct ProfilePickerDelegate { + fs: Arc, + provider: Arc, + background: BackgroundExecutor, + candidates: Vec, + string_candidates: Arc>, + filtered_entries: Vec, + selected_index: usize, + query: String, + cancel: Option>, +} + +impl ProfilePickerDelegate { + fn new( + fs: Arc, + provider: Arc, + profiles: AvailableProfiles, + background: BackgroundExecutor, + cx: &mut Context, + ) -> Self { + let candidates = Self::candidates_from(profiles); + let string_candidates = Arc::new(Self::string_candidates(&candidates)); + let filtered_entries = Self::entries_from_candidates(&candidates); + + let mut this = Self { + fs, + provider, + background, + candidates, + string_candidates, + filtered_entries, + selected_index: 0, + query: String::new(), + cancel: None, + }; + + this.selected_index = this + .index_of_profile(&this.provider.profile_id(cx)) + .unwrap_or_else(|| this.first_selectable_index().unwrap_or(0)); + + this + } + + fn refresh_profiles( + &mut self, + profiles: AvailableProfiles, + query: String, + cx: &mut Context>, + ) { + self.candidates = Self::candidates_from(profiles); + self.string_candidates = Arc::new(Self::string_candidates(&self.candidates)); + self.query = query; + + if self.query.is_empty() { + self.filtered_entries = Self::entries_from_candidates(&self.candidates); + } else { + let matches = self.search_blocking(&self.query); + self.filtered_entries = self.entries_from_matches(matches); + } + + self.selected_index = self + .index_of_profile(&self.provider.profile_id(cx)) + .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0)); + cx.notify(); + } + + fn candidates_from(profiles: AvailableProfiles) -> Vec { + profiles + .into_iter() + .map(|(id, name)| ProfileCandidate { + is_builtin: builtin_profiles::is_builtin(&id), + id, + name, + }) + .collect() + } + + fn string_candidates(candidates: &[ProfileCandidate]) -> Vec { + candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatchCandidate::new(index, candidate.name.as_ref())) + .collect() + } + + fn documentation(candidate: &ProfileCandidate) -> Option<&'static str> { + match candidate.id.as_str() { builtin_profiles::WRITE => Some("Get help to write anything."), builtin_profiles::ASK => Some("Chat about your codebase."), builtin_profiles::MINIMAL => Some("Chat about anything with no tools."), _ => None, + } + } + + fn entries_from_candidates(candidates: &[ProfileCandidate]) -> Vec { + let mut entries = Vec::new(); + let mut inserted_custom_header = false; + + for (idx, candidate) in candidates.iter().enumerate() { + if !candidate.is_builtin && !inserted_custom_header { + if !entries.is_empty() { + entries.push(ProfilePickerEntry::Header("Custom Profiles".into())); + } + inserted_custom_header = true; + } + + entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: idx, + positions: Vec::new(), + })); + } + + entries + } + + fn entries_from_matches(&self, matches: Vec) -> Vec { + let mut entries = Vec::new(); + for mat in matches { + if self.candidates.get(mat.candidate_id).is_some() { + entries.push(ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: mat.candidate_id, + positions: mat.positions, + })); + } + } + entries + } + + fn first_selectable_index(&self) -> Option { + self.filtered_entries + .iter() + .position(|entry| matches!(entry, ProfilePickerEntry::Profile(_))) + } + + fn index_of_profile(&self, profile_id: &AgentProfileId) -> Option { + self.filtered_entries.iter().position(|entry| { + matches!(entry, ProfilePickerEntry::Profile(profile) if self + .candidates + .get(profile.candidate_index) + .map(|candidate| &candidate.id == profile_id) + .unwrap_or(false)) + }) + } + + fn search_blocking(&self, query: &str) -> Vec { + if query.is_empty() { + return self + .string_candidates + .iter() + .map(|candidate| StringMatch { + candidate_id: candidate.id, + score: 0.0, + positions: Vec::new(), + string: candidate.string.clone(), + }) + .collect(); + } + + let cancel_flag = AtomicBool::new(false); + + self.background.block(match_strings( + self.string_candidates.as_ref(), + query, + false, + true, + 100, + &cancel_flag, + self.background.clone(), + )) + } +} + +impl PickerDelegate for ProfilePickerDelegate { + type ListItem = AnyElement; + + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { + "Search profiles…".into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + let text = if self.candidates.is_empty() { + "No profiles.".into() + } else { + "No profiles match your search.".into() }; - let thread_profile_id = self.provider.profile_id(cx); + Some(text) + } + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { + self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); + cx.notify(); + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.filtered_entries.get(ix) { + Some(ProfilePickerEntry::Profile(_)) => true, + Some(ProfilePickerEntry::Header(_)) | None => false, + } + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.query.clear(); + self.filtered_entries = Self::entries_from_candidates(&self.candidates); + self.selected_index = self + .index_of_profile(&self.provider.profile_id(cx)) + .unwrap_or_else(|| self.first_selectable_index().unwrap_or(0)); + cx.notify(); + return Task::ready(()); + } + + if let Some(prev) = &self.cancel { + prev.store(true, Ordering::Relaxed); + } + let cancel = Arc::new(AtomicBool::new(false)); + self.cancel = Some(cancel.clone()); + + let string_candidates = self.string_candidates.clone(); + let background = self.background.clone(); + let provider = self.provider.clone(); + self.query = query.clone(); - let entry = ContextMenuEntry::new(profile_name.clone()) - .toggleable(IconPosition::End, profile_id == thread_profile_id); + let cancel_for_future = cancel; - let entry = if let Some(doc_text) = documentation { - entry.documentation_aside(documentation_side(settings.dock), move |_| { - Label::new(doc_text).into_any_element() + cx.spawn_in(window, async move |this, cx| { + let matches = match_strings( + string_candidates.as_ref(), + &query, + false, + true, + 100, + cancel_for_future.as_ref(), + background, + ) + .await; + + this.update_in(cx, |this, _, cx| { + if this.delegate.query != query { + return; + } + + this.delegate.filtered_entries = this.delegate.entries_from_matches(matches); + this.delegate.selected_index = this + .delegate + .index_of_profile(&provider.profile_id(cx)) + .unwrap_or_else(|| this.delegate.first_selectable_index().unwrap_or(0)); + cx.notify(); }) - } else { - entry + .ok(); + }) + } + + fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context>) { + match self.filtered_entries.get(self.selected_index) { + Some(ProfilePickerEntry::Profile(entry)) => { + if let Some(candidate) = self.candidates.get(entry.candidate_index) { + let profile_id = candidate.id.clone(); + let fs = self.fs.clone(); + let provider = self.provider.clone(); + + update_settings_file(fs, cx, { + let profile_id = profile_id.clone(); + move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_profile(profile_id.0); + } + }); + + provider.set_profile(profile_id.clone(), cx); + + telemetry::event!( + "agent_profile_switched", + profile_id = profile_id.as_str(), + source = "picker" + ); + } + + cx.emit(DismissEvent); + } + _ => {} + } + } + + fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + cx.defer_in(window, |picker, window, cx| { + picker.set_query("", window, cx); + }); + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.filtered_entries.get(ix)? { + ProfilePickerEntry::Header(label) => Some( + div() + .px_2p5() + .pb_0p5() + .when(ix > 0, |this| { + this.mt_1p5() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(label.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + ), + ProfilePickerEntry::Profile(entry) => { + let candidate = self.candidates.get(entry.candidate_index)?; + let active_id = self.provider.profile_id(cx); + let is_active = active_id == candidate.id; + + Some( + ListItem::new(SharedString::from(candidate.id.0.clone())) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(HighlightedLabel::new( + candidate.name.clone(), + entry.positions.clone(), + )) + .when(is_active, |this| { + this.end_slot( + div() + .pr_2() + .child(Icon::new(IconName::Check).color(Color::Accent)), + ) + }) + .into_any_element(), + ) + } + } + } + + fn documentation_aside( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + use std::rc::Rc; + + let entry = match self.filtered_entries.get(self.selected_index)? { + ProfilePickerEntry::Profile(entry) => entry, + ProfilePickerEntry::Header(_) => return None, }; - entry.handler({ - let fs = self.fs.clone(); - let provider = self.provider.clone(); - let profile_id = profile_id.clone(); - move |_window, cx| { - update_settings_file::(fs.clone(), cx, { - let profile_id = profile_id.clone(); - move |settings, _cx| { - settings.set_profile(profile_id.clone()); - } - }); + let candidate = self.candidates.get(entry.candidate_index)?; + let docs_aside = Self::documentation(candidate)?.to_string(); - provider.set_profile(profile_id.clone(), cx); + let settings = AgentSettings::get_global(cx); + let side = match settings.dock { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => { + DocumentationSide::Left } + }; + + Some(DocumentationAside { + side, + edge: DocumentationEdge::Top, + render: Rc::new(move |_| Label::new(docs_aside.clone()).into_any_element()), }) } + + fn render_footer( + &self, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + h_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .p_1() + .gap_4() + .justify_between() + .child( + Button::new("configure", "Configure") + .icon(IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(|_, window, cx| { + window.dispatch_action(ManageProfiles::default().boxed_clone(), cx); + }), + ) + .into_any(), + ) + } } -impl Render for ProfileSelector { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = AgentSettings::get_global(cx); - let profile_id = self.provider.profile_id(cx); - let profile = settings.profiles.get(&profile_id); +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; - let selected_profile = profile - .map(|profile| profile.name.clone()) - .unwrap_or_else(|| "Unknown".into()); + #[gpui::test] + fn entries_include_custom_profiles(_cx: &mut TestAppContext) { + let candidates = vec![ + ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }, + ProfileCandidate { + id: AgentProfileId("my-custom".into()), + name: SharedString::from("My Custom"), + is_builtin: false, + }, + ]; - if self.provider.profiles_supported(cx) { - let this = cx.entity(); - let focus_handle = self.focus_handle.clone(); - let trigger_button = Button::new("profile-selector-model", selected_profile) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(IconName::ChevronDown) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::End) - .icon_color(Color::Muted); - - PopoverMenu::new("profile-selector") - .trigger_with_tooltip(trigger_button, { - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - window, - cx, - ) - } - }) - .anchor( - if documentation_side(settings.dock) == DocumentationSide::Left { - gpui::Corner::BottomRight - } else { - gpui::Corner::BottomLeft - }, - ) - .with_handle(self.menu_handle.clone()) - .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) - }) - .into_any_element() - } else { - Button::new("tools-not-supported-button", "Tools Unsupported") - .disabled(true) - .label_size(LabelSize::Small) - .color(Color::Muted) - .tooltip(Tooltip::text("This model does not support tools.")) - .into_any_element() + let entries = ProfilePickerDelegate::entries_from_candidates(&candidates); + + assert!(entries.iter().any(|entry| matches!( + entry, + ProfilePickerEntry::Profile(profile) + if candidates[profile.candidate_index].id.as_str() == "my-custom" + ))); + assert!(entries.iter().any(|entry| matches!( + entry, + ProfilePickerEntry::Header(label) if label.as_ref() == "Custom Profiles" + ))); + } + + #[gpui::test] + fn fuzzy_filter_returns_no_results_and_keeps_configure(cx: &mut TestAppContext) { + let candidates = vec![ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }]; + + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.executor()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.executor(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: Vec::new(), + selected_index: 0, + query: String::new(), + cancel: None, + }; + + let matches = Vec::new(); // No matches + let _entries = delegate.entries_from_matches(matches); + } + + #[gpui::test] + fn active_profile_selection_logic_works(cx: &mut TestAppContext) { + let candidates = vec![ + ProfileCandidate { + id: AgentProfileId("write".into()), + name: SharedString::from("Write"), + is_builtin: true, + }, + ProfileCandidate { + id: AgentProfileId("ask".into()), + name: SharedString::from("Ask"), + is_builtin: true, + }, + ]; + + let delegate = ProfilePickerDelegate { + fs: FakeFs::new(cx.executor()), + provider: Arc::new(TestProfileProvider::new(AgentProfileId("write".into()))), + background: cx.executor(), + candidates, + string_candidates: Arc::new(Vec::new()), + filtered_entries: vec![ + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 0, + positions: Vec::new(), + }), + ProfilePickerEntry::Profile(ProfileMatchEntry { + candidate_index: 1, + positions: Vec::new(), + }), + ], + selected_index: 0, + query: String::new(), + cancel: None, + }; + + // Active profile should be found at index 0 + let active_index = delegate.index_of_profile(&AgentProfileId("write".into())); + assert_eq!(active_index, Some(0)); + } + + struct TestProfileProvider { + profile_id: AgentProfileId, + } + + impl TestProfileProvider { + fn new(profile_id: AgentProfileId) -> Self { + Self { profile_id } } } -} -fn documentation_side(position: AgentDockPosition) -> DocumentationSide { - match position { - AgentDockPosition::Left => DocumentationSide::Right, - AgentDockPosition::Bottom => DocumentationSide::Left, - AgentDockPosition::Right => DocumentationSide::Left, + impl ProfileProvider for TestProfileProvider { + fn profile_id(&self, _cx: &App) -> AgentProfileId { + self.profile_id.clone() + } + + fn set_profile(&self, _profile_id: AgentProfileId, _cx: &mut App) {} + + fn profiles_supported(&self, _cx: &App) -> bool { + true + } } } diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index 6b37c5a2d7d6aaf2c9878efb90a22d11ddac2419..c2f26c4f2ed33860196790746dd296e8c617b810 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -7,7 +7,10 @@ use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window}; use language::{Anchor, Buffer, ToPoint}; use parking_lot::Mutex; -use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation}; +use project::{ + CompletionDisplayOptions, CompletionIntent, CompletionSource, + lsp_store::CompletionDocumentation, +}; use rope::Point; use std::{ ops::Range, @@ -88,8 +91,6 @@ impl SlashCommandCompletionProvider { .map(|(editor, workspace)| { let command_name = mat.string.clone(); let command_range = command_range.clone(); - let editor = editor.clone(); - let workspace = workspace.clone(); Arc::new( move |intent: CompletionIntent, window: &mut Window, @@ -135,6 +136,7 @@ impl SlashCommandCompletionProvider { vec![project::CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), is_incomplete: false, }] }) @@ -158,7 +160,7 @@ impl SlashCommandCompletionProvider { if let Some(command) = self.slash_commands.command(command_name, cx) { let completions = command.complete_argument( arguments, - new_cancel_flag.clone(), + new_cancel_flag, self.workspace.clone(), window, cx, @@ -239,6 +241,7 @@ impl SlashCommandCompletionProvider { Ok(vec![project::CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), // TODO: Could have slash commands indicate whether their completions are incomplete. is_incomplete: true, }]) @@ -246,6 +249,7 @@ impl SlashCommandCompletionProvider { } else { Task::ready(Ok(vec![project::CompletionResponse { completions: Vec::new(), + display_options: CompletionDisplayOptions::default(), is_incomplete: true, }])) } @@ -307,6 +311,7 @@ impl CompletionProvider for SlashCommandCompletionProvider { else { return Task::ready(Ok(vec![project::CompletionResponse { completions: Vec::new(), + display_options: CompletionDisplayOptions::default(), is_incomplete: false, }])); }; diff --git a/crates/agent_ui/src/slash_command_picker.rs b/crates/agent_ui/src/slash_command_picker.rs index 678562e0594b69f43524155c70bb176727f57b46..0c3cf37599887fe8e97dcdc67bb0bd7e28a744a7 100644 --- a/crates/agent_ui/src/slash_command_picker.rs +++ b/crates/agent_ui/src/slash_command_picker.rs @@ -140,12 +140,10 @@ impl PickerDelegate for SlashCommandDelegate { ); ret.push(index - 1); } - } else { - if let SlashCommandEntry::Advert { .. } = command { - previous_is_advert = true; - if index != 0 { - ret.push(index - 1); - } + } else if let SlashCommandEntry::Advert { .. } = command { + previous_is_advert = true; + if index != 0 { + ret.push(index - 1); } } } @@ -157,8 +155,8 @@ impl PickerDelegate for SlashCommandDelegate { match command { SlashCommandEntry::Info(info) => { self.active_context_editor - .update(cx, |context_editor, cx| { - context_editor.insert_command(&info.name, window, cx) + .update(cx, |text_thread_editor, cx| { + text_thread_editor.insert_command(&info.name, window, cx) }) .ok(); } @@ -214,7 +212,7 @@ impl PickerDelegate for SlashCommandDelegate { let mut label = format!("{}", info.name); if let Some(args) = info.args.as_ref().filter(|_| selected) { - label.push_str(&args); + label.push_str(args); } Label::new(label) .single_line() @@ -329,9 +327,7 @@ where }; let picker_view = cx.new(|cx| { - let picker = - Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into())); - picker + Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into())) }); let handle = self diff --git a/crates/agent_ui/src/slash_command_settings.rs b/crates/agent_ui/src/slash_command_settings.rs deleted file mode 100644 index 73e5622aa921ccf03a3813717446e830c21079b8..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/slash_command_settings.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; - -/// Settings for slash commands. -#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)] -pub struct SlashCommandSettings { - /// Settings for the `/cargo-workspace` slash command. - #[serde(default)] - pub cargo_workspace: CargoWorkspaceCommandSettings, -} - -/// Settings for the `/cargo-workspace` slash command. -#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)] -pub struct CargoWorkspaceCommandSettings { - /// Whether `/cargo-workspace` is enabled. - #[serde(default)] - pub enabled: bool, -} - -impl Settings for SlashCommandSettings { - const KEY: Option<&'static str> = Some("slash_commands"); - - type FileContent = Self; - - fn load(sources: SettingsSources, _cx: &mut App) -> Result { - SettingsSources::::json_merge_with( - [sources.default] - .into_iter() - .chain(sources.user) - .chain(sources.server), - ) - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 54f5b52f584cb87fd2953a148aa8ae48ea38b862..5a4a9d560a16e858dcaedf706f2067a24bc12c5f 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -48,7 +48,7 @@ impl TerminalCodegen { let prompt = prompt_task.await; let model_telemetry_id = model.telemetry_id(); let model_provider_id = model.provider_id(); - let response = model.stream_completion_text(prompt, &cx).await; + let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response .as_ref() diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index bcbc308c99da7b80e716fce9e60461352dcb814c..9e653dcce1dcf1487af9998662b57ea4f998c7de 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -1,12 +1,12 @@ -use crate::inline_prompt_editor::{ - CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, -}; -use crate::terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen}; -use agent::{ +use crate::{ context::load_context, context_store::ContextStore, - thread_store::{TextThreadStore, ThreadStore}, + inline_prompt_editor::{ + CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId, + }, + terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen}, }; +use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use client::telemetry::Telemetry; @@ -74,8 +74,7 @@ impl TerminalInlineAssistant { workspace: WeakEntity, project: WeakEntity, prompt_store: Option>, - thread_store: Option>, - text_thread_store: Option>, + thread_store: Option>, initial_prompt: Option, window: &mut Window, cx: &mut App, @@ -88,7 +87,7 @@ impl TerminalInlineAssistant { cx, ) }); - let context_store = cx.new(|_cx| ContextStore::new(project, thread_store.clone())); + let context_store = cx.new(|_cx| ContextStore::new(project)); let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); let prompt_editor = cx.new(|cx| { @@ -101,7 +100,7 @@ impl TerminalInlineAssistant { context_store.clone(), workspace.clone(), thread_store.clone(), - text_thread_store.clone(), + prompt_store.as_ref().map(|s| s.downgrade()), window, cx, ) @@ -238,7 +237,7 @@ impl TerminalInlineAssistant { let latest_output = terminal.last_n_non_empty_lines(DEFAULT_CONTEXT_LINES); let working_directory = terminal .working_directory() - .map(|path| path.to_string_lossy().to_string()); + .map(|path| path.to_string_lossy().into_owned()); (latest_output, working_directory) }) .ok() @@ -282,7 +281,6 @@ impl TerminalInlineAssistant { context_load_task .await - .loaded_context .add_to_request_message(&mut request_message); request_message.content.push(prompt.into()); @@ -388,20 +386,20 @@ impl TerminalInlineAssistant { window: &mut Window, cx: &mut App, ) { - if let Some(assist) = self.assists.get_mut(&assist_id) { - if let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() { - assist - .terminal - .update(cx, |terminal, cx| { - terminal.clear_block_below_cursor(cx); - let block = terminal_view::BlockProperties { - height, - render: Box::new(move |_| prompt_editor.clone().into_any_element()), - }; - terminal.set_block_below_cursor(block, window, cx); - }) - .log_err(); - } + if let Some(assist) = self.assists.get_mut(&assist_id) + && let Some(prompt_editor) = assist.prompt_editor.as_ref().cloned() + { + assist + .terminal + .update(cx, |terminal, cx| { + terminal.clear_block_below_cursor(cx); + let block = terminal_view::BlockProperties { + height, + render: Box::new(move |_| prompt_editor.clone().into_any_element()), + }; + terminal.set_block_below_cursor(block, window, cx); + }) + .log_err(); } } } @@ -432,7 +430,7 @@ impl TerminalInlineAssist { terminal: terminal.downgrade(), prompt_editor: Some(prompt_editor.clone()), codegen: codegen.clone(), - workspace: workspace.clone(), + workspace, context_store, prompt_store, _subscriptions: vec![ @@ -450,23 +448,20 @@ impl TerminalInlineAssist { return; }; - if let CodegenStatus::Error(error) = &codegen.read(cx).status { - if assist.prompt_editor.is_none() { - if let Some(workspace) = assist.workspace.upgrade() { - let error = - format!("Terminal inline assistant error: {}", error); - workspace.update(cx, |workspace, cx| { - struct InlineAssistantError; - - let id = - NotificationId::composite::( - assist_id.0, - ); - - workspace.show_toast(Toast::new(id, error), cx); - }) - } - } + if let CodegenStatus::Error(error) = &codegen.read(cx).status + && assist.prompt_editor.is_none() + && let Some(workspace) = assist.workspace.upgrade() + { + let error = format!("Terminal inline assistant error: {}", error); + workspace.update(cx, |workspace, cx| { + struct InlineAssistantError; + + let id = NotificationId::composite::( + assist_id.0, + ); + + workspace.show_toast(Toast::new(id, error), cx); + }) } if assist.prompt_editor.is_none() { diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 8c1e163ecaf54d305f5f3d440e29d23ea7a0f0f8..667ccb8938b892dcf59232d5cd7ea8dda04bc4b2 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2,7 +2,7 @@ use crate::{ language_model_selector::{LanguageModelSelector, language_model_selector}, ui::BurnModeTooltip, }; -use agent_settings::{AgentSettings, CompletionMode}; +use agent_settings::CompletionMode; use anyhow::Result; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet}; use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases}; @@ -16,6 +16,7 @@ use editor::{ BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, CustomBlockId, FoldId, RenderBlock, ToDisplayPoint, }, + scroll::ScrollOffset, }; use editor::{FoldPlaceholder, display_map::CreaseId}; use fs::Fs; @@ -24,8 +25,8 @@ use gpui::{ Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement, IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size, - StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions, - div, img, percentage, point, prelude::*, pulsating_between, size, + StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, actions, div, img, point, + prelude::*, pulsating_between, size, }; use language::{ BufferSnapshot, LspAdapterDelegate, ToOffset, @@ -40,7 +41,10 @@ use project::{Project, Worktree}; use project::{ProjectPath, lsp_store::LocalLspAdapterDelegate}; use rope::Point; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{ + LanguageModelProviderSetting, LanguageModelSelection, Settings, SettingsStore, + update_settings_file, +}; use std::{ any::TypeId, cmp, @@ -52,8 +56,8 @@ use std::{ }; use text::SelectionGoal; use ui::{ - ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, - prelude::*, + ButtonLike, CommonAnimationExt, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, + TintColor, Tooltip, prelude::*, }; use util::{ResultExt, maybe}; use workspace::{ @@ -67,13 +71,13 @@ use workspace::{ pane, searchable::{SearchEvent, SearchableItem}, }; -use zed_actions::agent::ToggleModelSelector; +use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector}; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; -use assistant_context::{ - AssistantContext, CacheStatus, Content, ContextEvent, ContextId, InvokedSlashCommandId, - InvokedSlashCommandStatus, Message, MessageId, MessageMetadata, MessageStatus, - PendingSlashCommandStatus, ThoughtProcessOutputSection, +use assistant_text_thread::{ + CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId, + MessageMetadata, MessageStatus, PendingSlashCommandStatus, TextThread, TextThreadEvent, + TextThreadId, ThoughtProcessOutputSection, }; actions!( @@ -89,8 +93,6 @@ actions!( CycleMessageRole, /// Inserts the selected text into the active editor. InsertIntoEditor, - /// Quotes the current selection in the assistant conversation. - QuoteSelection, /// Splits the conversation at the current cursor position. Split, ] @@ -106,7 +108,7 @@ pub enum InsertDraggedFiles { #[derive(Copy, Clone, Debug, PartialEq)] struct ScrollPosition { - offset_before_cursor: gpui::Point, + offset_before_cursor: gpui::Point, cursor: Anchor, } @@ -124,14 +126,14 @@ pub enum ThoughtProcessStatus { } pub trait AgentPanelDelegate { - fn active_context_editor( + fn active_text_thread_editor( &self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) -> Option>; - fn open_saved_context( + fn open_local_text_thread( &self, workspace: &mut Workspace, path: Arc, @@ -139,10 +141,10 @@ pub trait AgentPanelDelegate { cx: &mut Context, ) -> Task>; - fn open_remote_context( + fn open_remote_text_thread( &self, workspace: &mut Workspace, - context_id: ContextId, + text_thread_id: TextThreadId, window: &mut Window, cx: &mut Context, ) -> Task>>; @@ -175,7 +177,7 @@ struct GlobalAssistantPanelDelegate(Arc); impl Global for GlobalAssistantPanelDelegate {} pub struct TextThreadEditor { - context: Entity, + text_thread: Entity, fs: Arc, slash_commands: Arc, workspace: WeakEntity, @@ -191,7 +193,6 @@ pub struct TextThreadEditor { invoked_slash_command_creases: HashMap, _subscriptions: Vec, last_error: Option, - show_accept_terms: bool, pub(crate) slash_menu_handle: PopoverMenuHandle>, // dragged_file_worktrees is used to keep references to worktrees that were added @@ -222,8 +223,8 @@ impl TextThreadEditor { .detach(); } - pub fn for_context( - context: Entity, + pub fn for_text_thread( + text_thread: Entity, fs: Arc, workspace: WeakEntity, project: Entity, @@ -232,14 +233,14 @@ impl TextThreadEditor { cx: &mut Context, ) -> Self { let completion_provider = SlashCommandCompletionProvider::new( - context.read(cx).slash_commands().clone(), + text_thread.read(cx).slash_commands().clone(), Some(cx.entity().downgrade()), Some(workspace.clone()), ); let editor = cx.new(|cx| { let mut editor = - Editor::for_buffer(context.read(cx).buffer().clone(), None, window, cx); + Editor::for_buffer(text_thread.read(cx).buffer().clone(), None, window, cx); editor.disable_scrollbars_and_minimap(window, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_line_numbers(false, cx); @@ -263,18 +264,24 @@ impl TextThreadEditor { }); let _subscriptions = vec![ - cx.observe(&context, |_, _, cx| cx.notify()), - cx.subscribe_in(&context, window, Self::handle_context_event), + cx.observe(&text_thread, |_, _, cx| cx.notify()), + cx.subscribe_in(&text_thread, window, Self::handle_text_thread_event), cx.subscribe_in(&editor, window, Self::handle_editor_event), cx.subscribe_in(&editor, window, Self::handle_editor_search_event), cx.observe_global_in::(window, Self::settings_changed), ]; - let slash_command_sections = context.read(cx).slash_command_output_sections().to_vec(); - let thought_process_sections = context.read(cx).thought_process_output_sections().to_vec(); - let slash_commands = context.read(cx).slash_commands().clone(); + let slash_command_sections = text_thread + .read(cx) + .slash_command_output_sections() + .to_vec(); + let thought_process_sections = text_thread + .read(cx) + .thought_process_output_sections() + .to_vec(); + let slash_commands = text_thread.read(cx).slash_commands().clone(); let mut this = Self { - context, + text_thread, slash_commands, editor, lsp_adapter_delegate, @@ -290,18 +297,22 @@ impl TextThreadEditor { invoked_slash_command_creases: HashMap::default(), _subscriptions, last_error: None, - show_accept_terms: false, slash_menu_handle: Default::default(), dragged_file_worktrees: Vec::new(), language_model_selector: cx.new(|cx| { language_model_selector( |cx| LanguageModelRegistry::read_global(cx).default_model(), move |model, cx| { - update_settings_file::( - fs.clone(), - cx, - move |settings, _| settings.set_model(model.clone()), - ); + update_settings_file(fs.clone(), cx, move |settings, _| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings.agent.get_or_insert_default().set_model( + LanguageModelSelection { + provider: LanguageModelProviderSetting(provider), + model, + }, + ) + }); }, window, cx, @@ -332,8 +343,8 @@ impl TextThreadEditor { }); } - pub fn context(&self) -> &Entity { - &self.context + pub fn text_thread(&self) -> &Entity { + &self.text_thread } pub fn editor(&self) -> &Entity { @@ -345,9 +356,9 @@ impl TextThreadEditor { self.editor.update(cx, |editor, cx| { editor.insert(&format!("/{command_name}\n\n"), window, cx) }); - let command = self.context.update(cx, |context, cx| { - context.reparse(cx); - context.parsed_slash_commands()[0].clone() + let command = self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); + text_thread.parsed_slash_commands()[0].clone() }); self.run_command( command.source_range, @@ -364,29 +375,20 @@ impl TextThreadEditor { if self.sending_disabled(cx) { return; } + telemetry::event!("Agent Message Sent", agent = "zed-text"); self.send_to_model(window, cx); } fn send_to_model(&mut self, window: &mut Window, cx: &mut Context) { - let provider = LanguageModelRegistry::read_global(cx) - .default_model() - .map(|default| default.provider); - if provider - .as_ref() - .map_or(false, |provider| provider.must_accept_terms(cx)) - { - self.show_accept_terms = true; - cx.notify(); - return; - } - self.last_error = None; - - if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) { + if let Some(user_message) = self + .text_thread + .update(cx, |text_thread, cx| text_thread.assist(cx)) + { let new_selection = { let cursor = user_message .start - .to_offset(self.context.read(cx).buffer().read(cx)); + .to_offset(self.text_thread.read(cx).buffer().read(cx)); cursor..cursor }; self.editor.update(cx, |editor, cx| { @@ -410,8 +412,8 @@ impl TextThreadEditor { self.last_error = None; if self - .context - .update(cx, |context, cx| context.cancel_last_assist(cx)) + .text_thread + .update(cx, |text_thread, cx| text_thread.cancel_last_assist(cx)) { return; } @@ -426,20 +428,20 @@ impl TextThreadEditor { cx: &mut Context, ) { let cursors = self.cursors(cx); - self.context.update(cx, |context, cx| { - let messages = context + self.text_thread.update(cx, |text_thread, cx| { + let messages = text_thread .messages_for_offsets(cursors, cx) .into_iter() .map(|message| message.id) .collect(); - context.cycle_message_roles(messages, cx) + text_thread.cycle_message_roles(messages, cx) }); } fn cursors(&self, cx: &mut App) -> Vec { - let selections = self - .editor - .update(cx, |editor, cx| editor.selections.all::(cx)); + let selections = self.editor.update(cx, |editor, cx| { + editor.selections.all::(&editor.display_snapshot(cx)) + }); selections .into_iter() .map(|selection| selection.head()) @@ -452,12 +454,15 @@ impl TextThreadEditor { editor.transact(window, cx, |editor, window, cx| { editor.change_selections(Default::default(), window, cx, |s| s.try_cancel()); let snapshot = editor.buffer().read(cx).snapshot(cx); - let newest_cursor = editor.selections.newest::(cx).head(); + let newest_cursor = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); if newest_cursor.column > 0 || snapshot .chars_at(newest_cursor) .next() - .map_or(false, |ch| ch != '\n') + .is_some_and(|ch| ch != '\n') { editor.move_to_end_of_line( &MoveToEndOfLine { @@ -492,14 +497,14 @@ impl TextThreadEditor { return; } - let selections = self.editor.read(cx).selections.disjoint_anchors(); + let selections = self.editor.read(cx).selections.disjoint_anchors_arc(); let mut commands_by_range = HashMap::default(); let workspace = self.workspace.clone(); - self.context.update(cx, |context, cx| { - context.reparse(cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); for selection in selections.iter() { if let Some(command) = - context.pending_command_for_position(selection.head().text_anchor, cx) + text_thread.pending_command_for_position(selection.head().text_anchor, cx) { commands_by_range .entry(command.source_range.clone()) @@ -537,14 +542,14 @@ impl TextThreadEditor { cx: &mut Context, ) { if let Some(command) = self.slash_commands.command(name, cx) { - let context = self.context.read(cx); - let sections = context + let text_thread = self.text_thread.read(cx); + let sections = text_thread .slash_command_output_sections() - .into_iter() - .filter(|section| section.is_valid(context.buffer().read(cx))) + .iter() + .filter(|section| section.is_valid(text_thread.buffer().read(cx))) .cloned() .collect::>(); - let snapshot = context.buffer().read(cx).snapshot(); + let snapshot = text_thread.buffer().read(cx).snapshot(); let output = command.run( arguments, §ions, @@ -554,8 +559,8 @@ impl TextThreadEditor { window, cx, ); - self.context.update(cx, |context, cx| { - context.insert_command_output( + self.text_thread.update(cx, |text_thread, cx| { + text_thread.insert_command_output( command_range, name, output, @@ -566,32 +571,32 @@ impl TextThreadEditor { } } - fn handle_context_event( + fn handle_text_thread_event( &mut self, - _: &Entity, - event: &ContextEvent, + _: &Entity, + event: &TextThreadEvent, window: &mut Window, cx: &mut Context, ) { - let context_editor = cx.entity().downgrade(); + let text_thread_editor = cx.entity().downgrade(); match event { - ContextEvent::MessagesEdited => { + TextThreadEvent::MessagesEdited => { self.update_message_headers(cx); self.update_image_blocks(cx); - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ContextEvent::SummaryChanged => { + TextThreadEvent::SummaryChanged => { cx.emit(EditorEvent::TitleChanged); - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx); }); } - ContextEvent::SummaryGenerated => {} - ContextEvent::PathChanged { .. } => {} - ContextEvent::StartedThoughtProcess(range) => { + TextThreadEvent::SummaryGenerated => {} + TextThreadEvent::PathChanged { .. } => {} + TextThreadEvent::StartedThoughtProcess(range) => { let creases = self.insert_thought_process_output_sections( [( ThoughtProcessOutputSection { @@ -604,14 +609,12 @@ impl TextThreadEditor { ); self.pending_thought_process = Some((creases[0], range.start)); } - ContextEvent::EndedThoughtProcess(end) => { + TextThreadEvent::EndedThoughtProcess(end) => { if let Some((crease_id, start)) = self.pending_thought_process.take() { self.editor.update(cx, |editor, cx| { let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let (excerpt_id, _, _) = multi_buffer_snapshot.as_singleton().unwrap(); - let start_anchor = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, start) - .unwrap(); + let start_anchor = + multi_buffer_snapshot.as_singleton_anchor(start).unwrap(); editor.display_map.update(cx, |display_map, cx| { display_map.unfold_intersecting( @@ -632,13 +635,13 @@ impl TextThreadEditor { ); } } - ContextEvent::StreamedCompletion => { + TextThreadEvent::StreamedCompletion => { self.editor.update(cx, |editor, cx| { if let Some(scroll_position) = self.scroll_position { let snapshot = editor.snapshot(window, cx); let cursor_point = scroll_position.cursor.to_display_point(&snapshot); let scroll_top = - cursor_point.row().as_f32() - scroll_position.offset_before_cursor.y; + cursor_point.row().as_f64() - scroll_position.offset_before_cursor.y; editor.set_scroll_position( point(scroll_position.offset_before_cursor.x, scroll_top), window, @@ -647,7 +650,7 @@ impl TextThreadEditor { } }); } - ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => { + TextThreadEvent::ParsedSlashCommandsUpdated { removed, updated } => { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _, _) = buffer.as_singleton().unwrap(); @@ -663,12 +666,12 @@ impl TextThreadEditor { updated.iter().map(|command| { let workspace = self.workspace.clone(); let confirm_command = Arc::new({ - let context_editor = context_editor.clone(); + let text_thread_editor = text_thread_editor.clone(); let command = command.clone(); move |window: &mut Window, cx: &mut App| { - context_editor - .update(cx, |context_editor, cx| { - context_editor.run_command( + text_thread_editor + .update(cx, |text_thread_editor, cx| { + text_thread_editor.run_command( command.source_range.clone(), &command.name, &command.arguments, @@ -702,13 +705,10 @@ impl TextThreadEditor { } }; - let start = buffer - .anchor_in_excerpt(excerpt_id, command.source_range.start) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, command.source_range.clone()) .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, command.source_range.end) - .unwrap(); - Crease::inline(start..end, placeholder, render_toggle, render_trailer) + Crease::inline(range, placeholder, render_toggle, render_trailer) }), cx, ); @@ -721,17 +721,17 @@ impl TextThreadEditor { ); }) } - ContextEvent::InvokedSlashCommandChanged { command_id } => { + TextThreadEvent::InvokedSlashCommandChanged { command_id } => { self.update_invoked_slash_command(*command_id, window, cx); } - ContextEvent::SlashCommandOutputSectionAdded { section } => { + TextThreadEvent::SlashCommandOutputSectionAdded { section } => { self.insert_slash_command_output_sections([section.clone()], false, window, cx); } - ContextEvent::Operation(_) => {} - ContextEvent::ShowAssistError(error_message) => { + TextThreadEvent::Operation(_) => {} + TextThreadEvent::ShowAssistError(error_message) => { self.last_error = Some(AssistError::Message(error_message.clone())); } - ContextEvent::ShowPaymentRequiredError => { + TextThreadEvent::ShowPaymentRequiredError => { self.last_error = Some(AssistError::PaymentRequired); } } @@ -744,54 +744,46 @@ impl TextThreadEditor { cx: &mut Context, ) { if let Some(invoked_slash_command) = - self.context.read(cx).invoked_slash_command(&command_id) + self.text_thread.read(cx).invoked_slash_command(&command_id) + && let InvokedSlashCommandStatus::Finished = invoked_slash_command.status { - if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status { - let run_commands_in_ranges = invoked_slash_command - .run_commands_in_ranges - .iter() - .cloned() - .collect::>(); - for range in run_commands_in_ranges { - let commands = self.context.update(cx, |context, cx| { - context.reparse(cx); - context - .pending_commands_for_range(range.clone(), cx) - .to_vec() - }); + let run_commands_in_ranges = invoked_slash_command.run_commands_in_ranges.clone(); + for range in run_commands_in_ranges { + let commands = self.text_thread.update(cx, |text_thread, cx| { + text_thread.reparse(cx); + text_thread + .pending_commands_for_range(range.clone(), cx) + .to_vec() + }); - for command in commands { - self.run_command( - command.source_range, - &command.name, - &command.arguments, - false, - self.workspace.clone(), - window, - cx, - ); - } + for command in commands { + self.run_command( + command.source_range, + &command.name, + &command.arguments, + false, + self.workspace.clone(), + window, + cx, + ); } } } self.editor.update(cx, |editor, cx| { if let Some(invoked_slash_command) = - self.context.read(cx).invoked_slash_command(&command_id) + self.text_thread.read(cx).invoked_slash_command(&command_id) { if let InvokedSlashCommandStatus::Finished = invoked_slash_command.status { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _buffer_id, _buffer_snapshot) = buffer.as_singleton().unwrap(); - let start = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start) - .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, invoked_slash_command.range.clone()) .unwrap(); editor.remove_folds_with_type( - &[start..end], + &[range], TypeId::of::(), false, cx, @@ -807,15 +799,12 @@ impl TextThreadEditor { let buffer = editor.buffer().read(cx).snapshot(cx); let (&excerpt_id, _buffer_id, _buffer_snapshot) = buffer.as_singleton().unwrap(); - let context = self.context.downgrade(); - let crease_start = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.start) - .unwrap(); - let crease_end = buffer - .anchor_in_excerpt(excerpt_id, invoked_slash_command.range.end) + let context = self.text_thread.downgrade(); + let range = buffer + .anchor_range_in_excerpt(excerpt_id, invoked_slash_command.range.clone()) .unwrap(); let crease = Crease::inline( - crease_start..crease_end, + range, invoked_slash_command_fold_placeholder(command_id, context), fold_toggle("invoked-slash-command"), |_row, _folded, _window, _cx| Empty.into_any(), @@ -853,17 +842,14 @@ impl TextThreadEditor { let mut buffer_rows_to_fold = BTreeSet::new(); let mut creases = Vec::new(); for (section, status) in sections { - let start = buffer - .anchor_in_excerpt(excerpt_id, section.range.start) - .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, section.range.end) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, section.range) .unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + let buffer_row = MultiBufferRow(range.start.to_point(&buffer).row); buffer_rows_to_fold.insert(buffer_row); creases.push( Crease::inline( - start..end, + range, FoldPlaceholder { render: render_thought_process_fold_icon_button( cx.entity().downgrade(), @@ -905,17 +891,14 @@ impl TextThreadEditor { let mut buffer_rows_to_fold = BTreeSet::new(); let mut creases = Vec::new(); for section in sections { - let start = buffer - .anchor_in_excerpt(excerpt_id, section.range.start) + let range = buffer + .anchor_range_in_excerpt(excerpt_id, section.range) .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, section.range.end) - .unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + let buffer_row = MultiBufferRow(range.start.to_point(&buffer).row); buffer_rows_to_fold.insert(buffer_row); creases.push( Crease::inline( - start..end, + range, FoldPlaceholder { render: render_fold_icon_button( cx.entity().downgrade(), @@ -991,7 +974,7 @@ impl TextThreadEditor { let cursor_row = cursor .to_display_point(&snapshot.display_snapshot) .row() - .as_f32(); + .as_f64(); let scroll_position = editor .scroll_manager .anchor() @@ -1046,7 +1029,7 @@ impl TextThreadEditor { let render_block = |message: MessageMetadata| -> RenderBlock { Arc::new({ - let context = self.context.clone(); + let text_thread = self.text_thread.clone(); move |cx| { let message_id = MessageId(message.timestamp); @@ -1081,15 +1064,7 @@ impl TextThreadEditor { Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate( - percentage(delta), - )) - }, - ) + .with_rotate_animation(2) .into_any_element(), ); note = Some(Self::esc_kbd(cx).into_any_element()); @@ -1118,20 +1093,19 @@ impl TextThreadEditor { .child(label) .children(spinner), ) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Toggle message role", None, "Available roles: You (User), Agent, System", - window, cx, ) }) .on_click({ - let context = context.clone(); + let text_thread = text_thread.clone(); move |_, _window, cx| { - context.update(cx, |context, cx| { - context.cycle_message_roles( + text_thread.update(cx, |text_thread, cx| { + text_thread.cycle_message_roles( HashSet::from_iter(Some(message_id)), cx, ) @@ -1159,12 +1133,11 @@ impl TextThreadEditor { .size(IconSize::XSmall) .color(Color::Hint), ) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Context Cached", None, "Large messages cached to optimize performance", - window, cx, ) }) @@ -1194,11 +1167,11 @@ impl TextThreadEditor { .icon_position(IconPosition::Start) .tooltip(Tooltip::text("View Details")) .on_click({ - let context = context.clone(); + let text_thread = text_thread.clone(); let error = error.clone(); move |_, _window, cx| { - context.update(cx, |_, cx| { - cx.emit(ContextEvent::ShowAssistError( + text_thread.update(cx, |_, cx| { + cx.emit(TextThreadEvent::ShowAssistError( error.clone(), )); }); @@ -1241,8 +1214,8 @@ impl TextThreadEditor { }; let mut new_blocks = vec![]; let mut block_index_to_message = vec![]; - for message in self.context.read(cx).messages(cx) { - if let Some(_) = blocks_to_remove.remove(&message.id) { + for message in self.text_thread.read(cx).messages(cx) { + if blocks_to_remove.remove(&message.id).is_some() { // This is an old message that we might modify. let Some((meta, block_id)) = old_blocks.get_mut(&message.id) else { debug_assert!( @@ -1280,15 +1253,23 @@ impl TextThreadEditor { context_editor_view: &Entity, cx: &mut Context, ) -> Option<(String, bool)> { - const CODE_FENCE_DELIMITER: &'static str = "```"; - - let context_editor = context_editor_view.read(cx).editor.clone(); - context_editor.update(cx, |context_editor, cx| { - if context_editor.selections.newest::(cx).is_empty() { - let snapshot = context_editor.buffer().read(cx).snapshot(cx); + const CODE_FENCE_DELIMITER: &str = "```"; + + let text_thread_editor = context_editor_view.read(cx).editor.clone(); + text_thread_editor.update(cx, |text_thread_editor, cx| { + let display_map = text_thread_editor.display_snapshot(cx); + if text_thread_editor + .selections + .newest::(&display_map) + .is_empty() + { + let snapshot = text_thread_editor.buffer().read(cx).snapshot(cx); let (_, _, snapshot) = snapshot.as_singleton()?; - let head = context_editor.selections.newest::(cx).head(); + let head = text_thread_editor + .selections + .newest::(&display_map) + .head(); let offset = snapshot.point_to_offset(head); let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?; @@ -1305,8 +1286,8 @@ impl TextThreadEditor { (!text.is_empty()).then_some((text, true)) } else { - let selection = context_editor.selections.newest_adjusted(cx); - let buffer = context_editor.buffer().read(cx).snapshot(cx); + let selection = text_thread_editor.selections.newest_adjusted(&display_map); + let buffer = text_thread_editor.buffer().read(cx).snapshot(cx); let selected_text = buffer.text_for_range(selection.range()).collect::(); (!selected_text.is_empty()).then_some((selected_text, false)) @@ -1324,7 +1305,7 @@ impl TextThreadEditor { return; }; let Some(context_editor_view) = - agent_panel_delegate.active_context_editor(workspace, window, cx) + agent_panel_delegate.active_text_thread_editor(workspace, window, cx) else { return; }; @@ -1352,7 +1333,7 @@ impl TextThreadEditor { let result = maybe!({ let agent_panel_delegate = ::try_global(cx)?; let context_editor_view = - agent_panel_delegate.active_context_editor(workspace, window, cx)?; + agent_panel_delegate.active_text_thread_editor(workspace, window, cx)?; Self::get_selection_or_code_block(&context_editor_view, cx) }); let Some((text, is_code_block)) = result else { @@ -1389,7 +1370,7 @@ impl TextThreadEditor { return; }; let Some(context_editor_view) = - agent_panel_delegate.active_context_editor(workspace, window, cx) + agent_panel_delegate.active_text_thread_editor(workspace, window, cx) else { return; }; @@ -1451,10 +1432,14 @@ impl TextThreadEditor { else { continue; }; - let worktree_root_name = worktree.read(cx).root_name().to_string(); - let mut full_path = PathBuf::from(worktree_root_name.clone()); - full_path.push(&project_path.path); - file_slash_command_args.push(full_path.to_string_lossy().to_string()); + let path_style = worktree.read(cx).path_style(); + let full_path = worktree + .read(cx) + .root_name() + .join(&project_path.path) + .display(path_style) + .into_owned(); + file_slash_command_args.push(full_path); } let cmd_name = FileSlashCommand.name(); @@ -1471,7 +1456,7 @@ impl TextThreadEditor { pub fn quote_selection( workspace: &mut Workspace, - _: &QuoteSelection, + _: &AddSelectionToThread, window: &mut Window, cx: &mut Context, ) { @@ -1489,7 +1474,7 @@ impl TextThreadEditor { let selections = editor.update(cx, |editor, cx| { editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .into_iter() .filter_map(|s| { (!s.is_empty()) @@ -1521,7 +1506,10 @@ impl TextThreadEditor { self.editor.update(cx, |editor, cx| { editor.insert("\n", window, cx); for (text, crease_title) in creases { - let point = editor.selections.newest::(cx).head(); + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); let start_row = MultiBufferRow(point.row); editor.insert(&text, window, cx); @@ -1593,7 +1581,9 @@ impl TextThreadEditor { cx: &mut Context, ) -> (String, CopyMetadata, Vec>) { let (mut selection, creases) = self.editor.update(cx, |editor, cx| { - let mut selection = editor.selections.newest_adjusted(cx); + let mut selection = editor + .selections + .newest_adjusted(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); selection.goal = SelectionGoal::None; @@ -1641,29 +1631,33 @@ impl TextThreadEditor { ) }); - let context = self.context.read(cx); + let text_thread = self.text_thread.read(cx); let mut text = String::new(); // If selection is empty, we want to copy the entire line if selection.range().is_empty() { - let snapshot = context.buffer().read(cx).snapshot(); + let snapshot = text_thread.buffer().read(cx).snapshot(); let point = snapshot.offset_to_point(selection.range().start); selection.start = snapshot.point_to_offset(Point::new(point.row, 0)); selection.end = snapshot .point_to_offset(cmp::min(Point::new(point.row + 1, 0), snapshot.max_point())); - for chunk in context.buffer().read(cx).text_for_range(selection.range()) { + for chunk in text_thread + .buffer() + .read(cx) + .text_for_range(selection.range()) + { text.push_str(chunk); } } else { - for message in context.messages(cx) { + for message in text_thread.messages(cx) { if message.offset_range.start >= selection.range().end { break; } else if message.offset_range.end >= selection.range().start { let range = cmp::max(message.offset_range.start, selection.range().start) ..cmp::min(message.offset_range.end, selection.range().end); if !range.is_empty() { - for chunk in context.buffer().read(cx).text_for_range(range) { + for chunk in text_thread.buffer().read(cx).text_for_range(range) { text.push_str(chunk); } if message.offset_range.end < selection.range().end { @@ -1712,7 +1706,10 @@ impl TextThreadEditor { if images.is_empty() { self.editor.update(cx, |editor, cx| { - let paste_position = editor.selections.newest::(cx).head(); + let paste_position = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); editor.paste(action, window, cx); if let Some(metadata) = metadata { @@ -1744,7 +1741,7 @@ impl TextThreadEditor { render_slash_command_output_toggle, |_, _, _, _| Empty.into_any(), ) - .with_metadata(metadata.crease.clone()) + .with_metadata(metadata.crease) }), cx, ); @@ -1759,19 +1756,19 @@ impl TextThreadEditor { editor.transact(window, cx, |editor, _window, cx| { let edits = editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|selection| (selection.start..selection.end, "\n")); editor.edit(edits, cx); let snapshot = editor.buffer().read(cx).snapshot(cx); - for selection in editor.selections.all::(cx) { + for selection in editor.selections.all::(&editor.display_snapshot(cx)) { image_positions.push(snapshot.anchor_before(selection.end)); } }); }); - self.context.update(cx, |context, cx| { + self.text_thread.update(cx, |text_thread, cx| { for image in images { let Some(render_image) = image.to_image_data(cx.svg_renderer()).log_err() else { @@ -1781,7 +1778,7 @@ impl TextThreadEditor { let image_task = LanguageModelImage::from_image(Arc::new(image), cx).shared(); for image_position in image_positions.iter() { - context.insert_content( + text_thread.insert_content( Content::Image { anchor: image_position.text_anchor, image_id, @@ -1802,7 +1799,7 @@ impl TextThreadEditor { let excerpt_id = *buffer.as_singleton().unwrap().0; let old_blocks = std::mem::take(&mut self.image_blocks); let new_blocks = self - .context + .text_thread .read(cx) .contents(cx) .map( @@ -1815,7 +1812,7 @@ impl TextThreadEditor { .filter_map(|(anchor, render_image)| { const MAX_HEIGHT_IN_LINES: u32 = 8; let anchor = buffer.anchor_in_excerpt(excerpt_id, anchor).unwrap(); - let image = render_image.clone(); + let image = render_image; anchor.is_valid(&buffer).then(|| BlockProperties { placement: BlockPlacement::Above(anchor), height: Some(MAX_HEIGHT_IN_LINES), @@ -1850,37 +1847,84 @@ impl TextThreadEditor { } fn split(&mut self, _: &Split, _window: &mut Window, cx: &mut Context) { - self.context.update(cx, |context, cx| { - let selections = self.editor.read(cx).selections.disjoint_anchors(); + self.text_thread.update(cx, |text_thread, cx| { + let selections = self.editor.read(cx).selections.disjoint_anchors_arc(); for selection in selections.as_ref() { let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); let range = selection .map(|endpoint| endpoint.to_offset(&buffer)) .range(); - context.split_message(range, cx); + text_thread.split_message(range, cx); } }); } fn save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context) { - self.context.update(cx, |context, cx| { - context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx) + self.text_thread.update(cx, |text_thread, cx| { + text_thread.save(Some(Duration::from_millis(500)), self.fs.clone(), cx) }); } pub fn title(&self, cx: &App) -> SharedString { - self.context.read(cx).summary().or_default() + self.text_thread.read(cx).summary().or_default() } pub fn regenerate_summary(&mut self, cx: &mut Context) { - self.context - .update(cx, |context, cx| context.summarize(true, cx)); + self.text_thread + .update(cx, |text_thread, cx| text_thread.summarize(true, cx)); + } + + fn render_remaining_tokens(&self, cx: &App) -> Option> { + let (token_count_color, token_count, max_token_count, tooltip) = + match token_state(&self.text_thread, cx)? { + TokenState::NoTokensLeft { + max_token_count, + token_count, + } => ( + Color::Error, + token_count, + max_token_count, + Some("Token Limit Reached"), + ), + TokenState::HasMoreTokens { + max_token_count, + token_count, + over_warn_threshold, + } => { + let (color, tooltip) = if over_warn_threshold { + (Color::Warning, Some("Token Limit is Close to Exhaustion")) + } else { + (Color::Muted, None) + }; + (color, token_count, max_token_count, tooltip) + } + }; + + Some( + h_flex() + .id("token-count") + .gap_0p5() + .child( + Label::new(humanize_token_count(token_count)) + .size(LabelSize::Small) + .color(token_count_color), + ) + .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) + .child( + Label::new(humanize_token_count(max_token_count)) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(tooltip, |element, tooltip| { + element.tooltip(Tooltip::text(tooltip)) + }), + ) } fn render_send_button(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx).clone(); + let focus_handle = self.focus_handle(cx); - let (style, tooltip) = match token_state(&self.context, cx) { + let (style, tooltip) = match token_state(&self.text_thread, cx) { Some(TokenState::NoTokensLeft { .. }) => ( ButtonStyle::Tinted(TintColor::Error), Some(Tooltip::text("Token limit reached")(window, cx)), @@ -1913,7 +1957,7 @@ impl TextThreadEditor { }) .layer(ElevationIndex::ModalSurface) .key_binding( - KeyBinding::for_action_in(&Assist, &focus_handle, window, cx) + KeyBinding::for_action_in(&Assist, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(move |_event, window, cx| { @@ -1936,7 +1980,6 @@ impl TextThreadEditor { ConfigurationError::NoProvider | ConfigurationError::ModelNotFound | ConfigurationError::ProviderNotAuthenticated(_) => true, - ConfigurationError::ProviderPendingTermsAcceptance(_) => self.show_accept_terms, } } @@ -1946,21 +1989,17 @@ impl TextThreadEditor { cx.entity().downgrade(), IconButton::new("trigger", IconName::Plus) .icon_size(IconSize::Small) - .icon_color(Color::Muted), - move |window, cx| { - Tooltip::with_meta( - "Add Context", - None, - "Type / to insert via keyboard", - window, - cx, - ) + .icon_color(Color::Muted) + .selected_icon_color(Color::Accent) + .selected_style(ButtonStyle::Filled), + move |_window, cx| { + Tooltip::with_meta("Add Context", None, "Type / to insert via keyboard", cx) }, ) } fn render_burn_mode_toggle(&self, cx: &mut Context) -> Option { - let context = self.context().read(cx); + let text_thread = self.text_thread().read(cx); let active_model = LanguageModelRegistry::read_global(cx) .default_model() .map(|default| default.model)?; @@ -1968,7 +2007,7 @@ impl TextThreadEditor { return None; } - let active_completion_mode = context.completion_mode(); + let active_completion_mode = text_thread.completion_mode(); let burn_mode_enabled = active_completion_mode == CompletionMode::Burn; let icon = if burn_mode_enabled { IconName::ZedBurnModeOn @@ -1983,8 +2022,8 @@ impl TextThreadEditor { .toggle_state(burn_mode_enabled) .selected_icon_color(Color::Error) .on_click(cx.listener(move |this, _event, _window, cx| { - this.context().update(cx, |context, _cx| { - context.set_completion_mode(match active_completion_mode { + this.text_thread().update(cx, |text_thread, _cx| { + text_thread.set_completion_mode(match active_completion_mode { CompletionMode::Burn => CompletionMode::Normal, CompletionMode::Normal => CompletionMode::Burn, }); @@ -2020,42 +2059,33 @@ impl TextThreadEditor { None => IconName::Ai, }; - let focus_handle = self.editor().focus_handle(cx).clone(); + let focus_handle = self.editor().focus_handle(cx); + let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { + (Color::Accent, IconName::ChevronUp) + } else { + (Color::Muted, IconName::ChevronDown) + }; PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") - .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( h_flex() .gap_0p5() - .child( - Icon::new(provider_icon) - .color(Color::Muted) - .size(IconSize::XSmall), - ) + .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall)) .child( Label::new(model_name) - .color(Color::Muted) + .color(color) .size(LabelSize::Small) .ml_0p5(), ) - .child( - Icon::new(IconName::ChevronDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), + .child(Icon::new(icon).color(color).size(IconSize::XSmall)), ), - move |window, cx| { - Tooltip::for_action_in( - "Change Model", - &ToggleModelSelector, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) }, - gpui::Corner::BottomLeft, + gpui::Corner::BottomRight, cx, ) .with_handle(self.language_model_selector_menu_handle.clone()) @@ -2166,8 +2196,8 @@ impl TextThreadEditor { /// Returns the contents of the *outermost* fenced code block that contains the given offset. fn find_surrounding_code_block(snapshot: &BufferSnapshot, offset: usize) -> Option> { - const CODE_BLOCK_NODE: &'static str = "fenced_code_block"; - const CODE_BLOCK_CONTENT: &'static str = "code_fence_content"; + const CODE_BLOCK_NODE: &str = "fenced_code_block"; + const CODE_BLOCK_CONTENT: &str = "code_fence_content"; let layer = snapshot.syntax_layers().next()?; @@ -2441,9 +2471,14 @@ impl Render for TextThreadEditor { ) .child( h_flex() - .gap_1() - .child(self.render_language_model_selector(window, cx)) - .child(self.render_send_button(window, cx)), + .gap_2p5() + .children(self.render_remaining_tokens(cx)) + .child( + h_flex() + .gap_1() + .child(self.render_language_model_selector(window, cx)) + .child(self.render_send_button(window, cx)), + ), ), ) } @@ -2615,10 +2650,10 @@ impl FollowableItem for TextThreadEditor { } fn to_state_proto(&self, window: &Window, cx: &App) -> Option { - let context = self.context.read(cx); + let text_thread = self.text_thread.read(cx); Some(proto::view::Variant::ContextEditor( proto::view::ContextEditor { - context_id: context.id().to_proto(), + context_id: text_thread.id().to_proto(), editor: if let Some(proto::view::Variant::Editor(proto)) = self.editor.read(cx).to_state_proto(window, cx) { @@ -2644,22 +2679,22 @@ impl FollowableItem for TextThreadEditor { unreachable!() }; - let context_id = ContextId::from_proto(state.context_id); + let text_thread_id = TextThreadId::from_proto(state.context_id); let editor_state = state.editor?; let project = workspace.read(cx).project().clone(); let agent_panel_delegate = ::try_global(cx)?; - let context_editor_task = workspace.update(cx, |workspace, cx| { - agent_panel_delegate.open_remote_context(workspace, context_id, window, cx) + let text_thread_editor_task = workspace.update(cx, |workspace, cx| { + agent_panel_delegate.open_remote_text_thread(workspace, text_thread_id, window, cx) }); Some(window.spawn(cx, async move |cx| { - let context_editor = context_editor_task.await?; - context_editor - .update_in(cx, |context_editor, window, cx| { - context_editor.remote_id = Some(id); - context_editor.editor.update(cx, |editor, cx| { + let text_thread_editor = text_thread_editor_task.await?; + text_thread_editor + .update_in(cx, |text_thread_editor, window, cx| { + text_thread_editor.remote_id = Some(id); + text_thread_editor.editor.update(cx, |editor, cx| { editor.apply_update_proto( &project, proto::update_view::Variant::Editor(proto::update_view::Editor { @@ -2676,7 +2711,7 @@ impl FollowableItem for TextThreadEditor { }) })? .await?; - Ok(context_editor) + Ok(text_thread_editor) })) } @@ -2723,7 +2758,7 @@ impl FollowableItem for TextThreadEditor { } fn dedup(&self, existing: &Self, _window: &Window, cx: &App) -> Option { - if existing.context.read(cx).id() == self.context.read(cx).id() { + if existing.text_thread.read(cx).id() == self.text_thread.read(cx).id() { Some(item::Dedup::KeepExisting) } else { None @@ -2731,73 +2766,21 @@ impl FollowableItem for TextThreadEditor { } } -pub fn render_remaining_tokens( - context_editor: &Entity, - cx: &App, -) -> Option> { - let context = &context_editor.read(cx).context; - - let (token_count_color, token_count, max_token_count, tooltip) = match token_state(context, cx)? - { - TokenState::NoTokensLeft { - max_token_count, - token_count, - } => ( - Color::Error, - token_count, - max_token_count, - Some("Token Limit Reached"), - ), - TokenState::HasMoreTokens { - max_token_count, - token_count, - over_warn_threshold, - } => { - let (color, tooltip) = if over_warn_threshold { - (Color::Warning, Some("Token Limit is Close to Exhaustion")) - } else { - (Color::Muted, None) - }; - (color, token_count, max_token_count, tooltip) - } - }; - - Some( - h_flex() - .id("token-count") - .gap_0p5() - .child( - Label::new(humanize_token_count(token_count)) - .size(LabelSize::Small) - .color(token_count_color), - ) - .child(Label::new("/").size(LabelSize::Small).color(Color::Muted)) - .child( - Label::new(humanize_token_count(max_token_count)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .when_some(tooltip, |element, tooltip| { - element.tooltip(Tooltip::text(tooltip)) - }), - ) -} - enum PendingSlashCommand {} fn invoked_slash_command_fold_placeholder( command_id: InvokedSlashCommandId, - context: WeakEntity, + text_thread: WeakEntity, ) -> FoldPlaceholder { FoldPlaceholder { constrain_width: false, merge_adjacent: false, render: Arc::new(move |fold_id, _, cx| { - let Some(context) = context.upgrade() else { + let Some(text_thread) = text_thread.upgrade() else { return Empty.into_any(); }; - let Some(command) = context.read(cx).invoked_slash_command(&command_id) else { + let Some(command) = text_thread.read(cx).invoked_slash_command(&command_id) else { return Empty.into_any(); }; @@ -2811,11 +2794,7 @@ fn invoked_slash_command_fold_placeholder( .child(Label::new(format!("/{}", command.name))) .map(|parent| match &command.status { InvokedSlashCommandStatus::Running(_) => { - parent.child(Icon::new(IconName::ArrowCircle).with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(4)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - )) + parent.child(Icon::new(IconName::ArrowCircle).with_rotate_animation(4)) } InvokedSlashCommandStatus::Error(message) => parent.child( Label::new(format!("error: {message}")) @@ -2842,14 +2821,15 @@ enum TokenState { }, } -fn token_state(context: &Entity, cx: &App) -> Option { +fn token_state(text_thread: &Entity, cx: &App) -> Option { const WARNING_TOKEN_THRESHOLD: f32 = 0.8; let model = LanguageModelRegistry::read_global(cx) .default_model()? .model; - let token_count = context.read(cx).token_count()?; - let max_token_count = model.max_token_count_for_mode(context.read(cx).completion_mode().into()); + let token_count = text_thread.read(cx).token_count()?; + let max_token_count = + model.max_token_count_for_mode(text_thread.read(cx).completion_mode().into()); let token_state = if max_token_count.saturating_sub(token_count) == 0 { TokenState::NoTokensLeft { max_token_count, @@ -2961,7 +2941,7 @@ mod tests { #[gpui::test] async fn test_copy_paste_whole_message(cx: &mut TestAppContext) { - let (context, context_editor, mut cx) = setup_context_editor_text(vec![ + let (context, text_thread_editor, mut cx) = setup_text_thread_editor_text(vec![ (Role::User, "What is the Zed editor?"), ( Role::Assistant, @@ -2971,8 +2951,8 @@ mod tests { ],cx).await; // Select & Copy whole user message - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_range(&context, 0, &mut cx), indoc! {" What is the Zed editor? @@ -2983,8 +2963,8 @@ mod tests { ); // Select & Copy whole assistant message - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_range(&context, 1, &mut cx), indoc! {" What is the Zed editor? @@ -2998,7 +2978,7 @@ mod tests { #[gpui::test] async fn test_copy_paste_no_selection(cx: &mut TestAppContext) { - let (context, context_editor, mut cx) = setup_context_editor_text( + let (context, text_thread_editor, mut cx) = setup_text_thread_editor_text( vec![ (Role::User, "user1"), (Role::Assistant, "assistant1"), @@ -3011,8 +2991,8 @@ mod tests { // Copy and paste first assistant message let message_2_range = message_range(&context, 1, &mut cx); - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_2_range.start..message_2_range.start, indoc! {" user1 @@ -3025,8 +3005,8 @@ mod tests { // Copy and cut second assistant message let message_3_range = message_range(&context, 2, &mut cx); - assert_copy_paste_context_editor( - &context_editor, + assert_copy_paste_text_thread_editor( + &text_thread_editor, message_3_range.start..message_3_range.start, indoc! {" user1 @@ -3113,94 +3093,93 @@ mod tests { } } - async fn setup_context_editor_text( + async fn setup_text_thread_editor_text( messages: Vec<(Role, &str)>, cx: &mut TestAppContext, ) -> ( - Entity, + Entity, Entity, VisualTestContext, ) { cx.update(init_test); let fs = FakeFs::new(cx.executor()); - let context = create_context_with_messages(messages, cx); + let text_thread = create_text_thread_with_messages(messages, cx); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window, cx); - let context_editor = window + let text_thread_editor = window .update(&mut cx, |_, window, cx| { cx.new(|cx| { - let editor = TextThreadEditor::for_context( - context.clone(), + TextThreadEditor::for_text_thread( + text_thread.clone(), fs, workspace.downgrade(), project, None, window, cx, - ); - editor + ) }) }) .unwrap(); - (context, context_editor, cx) + (text_thread, text_thread_editor, cx) } fn message_range( - context: &Entity, + text_thread: &Entity, message_ix: usize, cx: &mut TestAppContext, ) -> Range { - context.update(cx, |context, cx| { - context + text_thread.update(cx, |text_thread, cx| { + text_thread .messages(cx) .nth(message_ix) .unwrap() .anchor_range - .to_offset(&context.buffer().read(cx).snapshot()) + .to_offset(&text_thread.buffer().read(cx).snapshot()) }) } - fn assert_copy_paste_context_editor( - context_editor: &Entity, + fn assert_copy_paste_text_thread_editor( + text_thread_editor: &Entity, range: Range, expected_text: &str, cx: &mut VisualTestContext, ) { - context_editor.update_in(cx, |context_editor, window, cx| { - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.update_in(cx, |text_thread_editor, window, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([range]) }); }); - context_editor.copy(&Default::default(), window, cx); + text_thread_editor.copy(&Default::default(), window, cx); - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { editor.move_to_end(&Default::default(), window, cx); }); - context_editor.paste(&Default::default(), window, cx); + text_thread_editor.paste(&Default::default(), window, cx); - context_editor.editor.update(cx, |editor, cx| { + text_thread_editor.editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), expected_text); }); }); } - fn create_context_with_messages( + fn create_text_thread_with_messages( mut messages: Vec<(Role, &str)>, cx: &mut TestAppContext, - ) -> Entity { + ) -> Entity { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); cx.new(|cx| { - let mut context = AssistantContext::local( + let mut text_thread = TextThread::local( registry, None, None, @@ -3208,33 +3187,33 @@ mod tests { Arc::new(SlashCommandWorkingSet::default()), cx, ); - let mut message_1 = context.messages(cx).next().unwrap(); + let mut message_1 = text_thread.messages(cx).next().unwrap(); let (role, text) = messages.remove(0); loop { if role == message_1.role { - context.buffer().update(cx, |buffer, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(message_1.offset_range, text)], None, cx); }); break; } let mut ids = HashSet::default(); ids.insert(message_1.id); - context.cycle_message_roles(ids, cx); - message_1 = context.messages(cx).next().unwrap(); + text_thread.cycle_message_roles(ids, cx); + message_1 = text_thread.messages(cx).next().unwrap(); } let mut last_message_id = message_1.id; for (role, text) in messages { - context.insert_message_after(last_message_id, role, MessageStatus::Done, cx); - let message = context.messages(cx).last().unwrap(); + text_thread.insert_message_after(last_message_id, role, MessageStatus::Done, cx); + let message = text_thread.messages(cx).last().unwrap(); last_message_id = message.id; - context.buffer().update(cx, |buffer, cx| { + text_thread.buffer().update(cx, |buffer, cx| { buffer.edit([(message.offset_range, text)], None, cx); }) } - context + text_thread }) } diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs deleted file mode 100644 index 66afe2c2c5835387f10d095c7ee9649bda177f0b..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/thread_history.rs +++ /dev/null @@ -1,913 +0,0 @@ -use crate::{AgentPanel, RemoveSelectedThread}; -use agent::history_store::{HistoryEntry, HistoryStore}; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; -use editor::{Editor, EditorEvent}; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{ - App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, - UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, sync::Arc}; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, - Tooltip, prelude::*, -}; -use util::ResultExt; - -pub struct ThreadHistory { - agent_panel: WeakEntity, - history_store: Entity, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - all_entries: Arc>, - // When the search is empty, we display date separators between history entries - // This vector contains an enum of either a separator or an actual entry - separated_items: Vec, - // Maps entry indexes to list item indexes - separated_item_indexes: Vec, - _separated_items_task: Option>, - search_state: SearchState, - scrollbar_visibility: bool, - scrollbar_state: ScrollbarState, - _subscriptions: Vec, -} - -enum SearchState { - Empty, - Searching { - query: SharedString, - _task: Task<()>, - }, - Searched { - query: SharedString, - matches: Vec, - }, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - index: usize, - format: EntryTimeFormat, - }, -} - -impl ListItemType { - fn entry_index(&self) -> Option { - match self { - ListItemType::BucketSeparator(_) => None, - ListItemType::Entry { index, .. } => Some(*index), - } - } -} - -impl ThreadHistory { - pub(crate) fn new( - agent_panel: WeakEntity, - history_store: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - this.search(query.into(), cx); - } - }); - - let history_store_subscription = cx.observe(&history_store, |this, _, cx| { - this.update_all_entries(cx); - }); - - let scroll_handle = UniformListScrollHandle::default(); - let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); - - let mut this = Self { - agent_panel, - history_store, - scroll_handle, - selected_index: 0, - hovered_index: None, - search_state: SearchState::Empty, - all_entries: Default::default(), - separated_items: Default::default(), - separated_item_indexes: Default::default(), - search_editor, - scrollbar_visibility: true, - scrollbar_state, - _subscriptions: vec![search_editor_subscription, history_store_subscription], - _separated_items_task: None, - }; - this.update_all_entries(cx); - this - } - - fn update_all_entries(&mut self, cx: &mut Context) { - let new_entries: Arc> = self - .history_store - .update(cx, |store, cx| store.entries(cx)) - .into(); - - self._separated_items_task.take(); - - let mut items = Vec::with_capacity(new_entries.len() + 1); - let mut indexes = Vec::with_capacity(new_entries.len() + 1); - - let bg_task = cx.background_spawn(async move { - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for (index, entry) in new_entries.iter().enumerate() { - let entry_date = entry - .updated_at() - .with_timezone(&Local) - .naive_local() - .date(); - let entry_bucket = TimeBucket::from_dates(today, entry_date); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - indexes.push(items.len() as u32); - items.push(ListItemType::Entry { - index, - format: entry_bucket.into(), - }); - } - (new_entries, items, indexes) - }); - - let task = cx.spawn(async move |this, cx| { - let (new_entries, items, indexes) = bg_task.await; - this.update(cx, |this, cx| { - let previously_selected_entry = - this.all_entries.get(this.selected_index).map(|e| e.id()); - - this.all_entries = new_entries; - this.separated_items = items; - this.separated_item_indexes = indexes; - - match &this.search_state { - SearchState::Empty => { - if this.selected_index >= this.all_entries.len() { - this.set_selected_entry_index( - this.all_entries.len().saturating_sub(1), - cx, - ); - } else if let Some(prev_id) = previously_selected_entry { - if let Some(new_ix) = this - .all_entries - .iter() - .position(|probe| probe.id() == prev_id) - { - this.set_selected_entry_index(new_ix, cx); - } - } - } - SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => { - this.search(query.clone(), cx); - } - } - - cx.notify(); - }) - .log_err(); - }); - self._separated_items_task = Some(task); - } - - fn search(&mut self, query: SharedString, cx: &mut Context) { - if query.is_empty() { - self.search_state = SearchState::Empty; - cx.notify(); - return; - } - - let all_entries = self.all_entries.clone(); - - let fuzzy_search_task = cx.background_spawn({ - let query = query.clone(); - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(all_entries.len()); - - for (idx, entry) in all_entries.iter().enumerate() { - match entry { - HistoryEntry::Thread(thread) => { - candidates.push(StringMatchCandidate::new(idx, &thread.summary)); - } - HistoryEntry::Context(context) => { - candidates.push(StringMatchCandidate::new(idx, &context.title)); - } - } - } - - const MAX_MATCHES: usize = 100; - - fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await - } - }); - - let task = cx.spawn({ - let query = query.clone(); - async move |this, cx| { - let matches = fuzzy_search_task.await; - - this.update(cx, |this, cx| { - let SearchState::Searching { - query: current_query, - _task, - } = &this.search_state - else { - return; - }; - - if &query == current_query { - this.search_state = SearchState::Searched { - query: query.clone(), - matches, - }; - - this.set_selected_entry_index(0, cx); - cx.notify(); - }; - }) - .log_err(); - } - }); - - self.search_state = SearchState::Searching { query, _task: task }; - cx.notify(); - } - - fn matched_count(&self) -> usize { - match &self.search_state { - SearchState::Empty => self.all_entries.len(), - SearchState::Searching { .. } => 0, - SearchState::Searched { matches, .. } => matches.len(), - } - } - - fn list_item_count(&self) -> usize { - match &self.search_state { - SearchState::Empty => self.separated_items.len(), - SearchState::Searching { .. } => 0, - SearchState::Searched { matches, .. } => matches.len(), - } - } - - fn search_produced_no_matches(&self) -> bool { - match &self.search_state { - SearchState::Empty => false, - SearchState::Searching { .. } => false, - SearchState::Searched { matches, .. } => matches.is_empty(), - } - } - - fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { - match &self.search_state { - SearchState::Empty => self.all_entries.get(ix), - SearchState::Searching { .. } => None, - SearchState::Searched { matches, .. } => matches - .get(ix) - .and_then(|m| self.all_entries.get(m.candidate_id)), - } - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - let count = self.matched_count(); - if count > 0 { - if self.selected_index == 0 { - self.set_selected_entry_index(count - 1, cx); - } else { - self.set_selected_entry_index(self.selected_index - 1, cx); - } - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - let count = self.matched_count(); - if count > 0 { - if self.selected_index == count - 1 { - self.set_selected_entry_index(0, cx); - } else { - self.set_selected_entry_index(self.selected_index + 1, cx); - } - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - let count = self.matched_count(); - if count > 0 { - self.set_selected_entry_index(0, cx); - } - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - let count = self.matched_count(); - if count > 0 { - self.set_selected_entry_index(count - 1, cx); - } - } - - fn set_selected_entry_index(&mut self, entry_index: usize, cx: &mut Context) { - self.selected_index = entry_index; - - let scroll_ix = match self.search_state { - SearchState::Empty | SearchState::Searching { .. } => self - .separated_item_indexes - .get(entry_index) - .map(|ix| *ix as usize) - .unwrap_or(entry_index + 1), - SearchState::Searched { .. } => entry_index, - }; - - self.scroll_handle - .scroll_to_item(scroll_ix, ScrollStrategy::Top); - - cx.notify(); - } - - fn render_scrollbar(&self, cx: &mut Context) -> Option> { - if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) { - return None; - } - - Some( - div() - .occlude() - .id("thread-history-scroll") - .h_full() - .bg(cx.theme().colors().panel_background.opacity(0.8)) - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .absolute() - .right_1() - .top_0() - .bottom_0() - .w_4() - .pl_1() - .cursor_default() - .on_mouse_move(cx.listener(|_, _, _window, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_scroll_wheel(cx.listener(|_, _, _window, cx| { - cx.notify(); - })) - .children(Scrollbar::vertical(self.scrollbar_state.clone())), - ) - } - - fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - if let Some(entry) = self.get_match(self.selected_index) { - let task_result = match entry { - HistoryEntry::Thread(thread) => self.agent_panel.update(cx, move |this, cx| { - this.open_thread_by_id(&thread.id, window, cx) - }), - HistoryEntry::Context(context) => self.agent_panel.update(cx, move |this, cx| { - this.open_saved_prompt_editor(context.path.clone(), window, cx) - }), - }; - - if let Some(task) = task_result.log_err() { - task.detach_and_log_err(cx); - }; - - cx.notify(); - } - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(entry) = self.get_match(self.selected_index) { - let task_result = match entry { - HistoryEntry::Thread(thread) => self - .agent_panel - .update(cx, |this, cx| this.delete_thread(&thread.id, cx)), - HistoryEntry::Context(context) => self - .agent_panel - .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)), - }; - - if let Some(task) = task_result.log_err() { - task.detach_and_log_err(cx); - }; - - cx.notify(); - } - } - - fn list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - let range_start = range.start; - - match &self.search_state { - SearchState::Empty => self - .separated_items - .get(range) - .iter() - .flat_map(|items| { - items - .iter() - .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx)) - }) - .collect(), - SearchState::Searched { matches, .. } => matches[range] - .iter() - .enumerate() - .map(|(ix, m)| { - self.render_list_item( - Some(range_start + ix), - &ListItemType::Entry { - index: m.candidate_id, - format: EntryTimeFormat::DateAndTime, - }, - m.positions.clone(), - cx, - ) - }) - .collect(), - SearchState::Searching { .. } => { - vec![] - } - } - } - - fn render_list_item( - &self, - list_entry_ix: Option, - item: &ListItemType, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - match item { - ListItemType::Entry { index, format } => match self.all_entries.get(*index) { - Some(entry) => h_flex() - .w_full() - .pb_1() - .child( - HistoryEntryElement::new(entry.clone(), self.agent_panel.clone()) - .highlight_positions(highlight_positions) - .timestamp_format(*format) - .selected(list_entry_ix == Some(self.selected_index)) - .hovered(list_entry_ix == self.hovered_index) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = list_entry_ix; - } else if this.hovered_index == list_entry_ix { - this.hovered_index = None; - } - - cx.notify(); - })) - .into_any_element(), - ) - .into_any(), - None => Empty.into_any_element(), - }, - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } -} - -impl Focusable for ThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistory { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .when(!self.all_entries.is_empty(), |parent| { - parent.child( - h_flex() - .h(px(41.)) // Match the toolbar perfectly - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - }) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if self.all_entries.is_empty() { - view.justify_center() - .child( - h_flex().w_full().justify_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small), - ), - ) - } else if self.search_produced_no_matches() { - view.justify_center().child( - h_flex().w_full().justify_center().child( - Label::new("No threads match your search.").size(LabelSize::Small), - ), - ) - } else { - view.pr_5() - .child( - uniform_list( - "thread-history", - self.list_item_count(), - cx.processor(|this, range: Range, window, cx| { - this.list_items(range, window, cx) - }), - ) - .p_1() - .track_scroll(self.scroll_handle.clone()) - .flex_grow(), - ) - .when_some(self.render_scrollbar(cx), |div, scrollbar| { - div.child(scrollbar) - }) - } - }) - } -} - -#[derive(IntoElement)] -pub struct HistoryEntryElement { - entry: HistoryEntry, - agent_panel: WeakEntity, - selected: bool, - hovered: bool, - highlight_positions: Vec, - timestamp_format: EntryTimeFormat, - on_hover: Box, -} - -impl HistoryEntryElement { - pub fn new(entry: HistoryEntry, agent_panel: WeakEntity) -> Self { - Self { - entry, - agent_panel, - selected: false, - hovered: false, - highlight_positions: vec![], - timestamp_format: EntryTimeFormat::DateAndTime, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn highlight_positions(mut self, positions: Vec) -> Self { - self.highlight_positions = positions; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } - - pub fn timestamp_format(mut self, format: EntryTimeFormat) -> Self { - self.timestamp_format = format; - self - } -} - -impl RenderOnce for HistoryEntryElement { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let (id, summary, timestamp) = match &self.entry { - HistoryEntry::Thread(thread) => ( - thread.id.to_string(), - thread.summary.clone(), - thread.updated_at.timestamp(), - ), - HistoryEntry::Context(context) => ( - context.path.to_string_lossy().to_string(), - context.title.clone(), - context.mtime.timestamp(), - ), - }; - - let thread_timestamp = - self.timestamp_format - .format_timestamp(&self.agent_panel, timestamp, cx); - - ListItem::new(SharedString::from(id)) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(summary, self.highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(thread_timestamp) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if self.hovered || self.selected { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx) - }) - .on_click({ - let agent_panel = self.agent_panel.clone(); - - let f: Box = - match &self.entry { - HistoryEntry::Thread(thread) => { - let id = thread.id.clone(); - - Box::new(move |_event, _window, cx| { - agent_panel - .update(cx, |this, cx| { - this.delete_thread(&id, cx) - .detach_and_log_err(cx); - }) - .ok(); - }) - } - HistoryEntry::Context(context) => { - let path = context.path.clone(); - - Box::new(move |_event, _window, cx| { - agent_panel - .update(cx, |this, cx| { - this.delete_context(path.clone(), cx) - .detach_and_log_err(cx); - }) - .ok(); - }) - } - }; - f - }), - ) - } else { - None - }) - .on_click({ - let agent_panel = self.agent_panel.clone(); - - let f: Box = match &self.entry - { - HistoryEntry::Thread(thread) => { - let id = thread.id.clone(); - Box::new(move |_event, window, cx| { - agent_panel - .update(cx, |this, cx| { - this.open_thread_by_id(&id, window, cx) - .detach_and_log_err(cx); - }) - .ok(); - }) - } - HistoryEntry::Context(context) => { - let path = context.path.clone(); - Box::new(move |_event, window, cx| { - agent_panel - .update(cx, |this, cx| { - this.open_saved_prompt_editor(path.clone(), window, cx) - .detach_and_log_err(cx); - }) - .ok(); - }) - } - }; - f - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp( - &self, - agent_panel: &WeakEntity, - timestamp: i64, - cx: &App, - ) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - let timezone = agent_panel - .read_with(cx, |this, _cx| this.local_timezone()) - .unwrap_or(UtcOffset::UTC); - - match &self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } -} diff --git a/crates/agent_ui/src/tool_compatibility.rs b/crates/agent_ui/src/tool_compatibility.rs deleted file mode 100644 index d4e1da5bb0a532c8307364582349378d98c51a26..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/tool_compatibility.rs +++ /dev/null @@ -1,96 +0,0 @@ -use agent::{Thread, ThreadEvent}; -use assistant_tool::{Tool, ToolSource}; -use collections::HashMap; -use gpui::{App, Context, Entity, IntoElement, Render, Subscription, Window}; -use language_model::{LanguageModel, LanguageModelToolSchemaFormat}; -use std::sync::Arc; -use ui::prelude::*; - -pub struct IncompatibleToolsState { - cache: HashMap>>, - thread: Entity, - _thread_subscription: Subscription, -} - -impl IncompatibleToolsState { - pub fn new(thread: Entity, cx: &mut Context) -> Self { - let _tool_working_set_subscription = - cx.subscribe(&thread, |this, _, event, _| match event { - ThreadEvent::ProfileChanged => { - this.cache.clear(); - } - _ => {} - }); - - Self { - cache: HashMap::default(), - thread, - _thread_subscription: _tool_working_set_subscription, - } - } - - pub fn incompatible_tools( - &mut self, - model: &Arc, - cx: &App, - ) -> &[Arc] { - self.cache - .entry(model.tool_input_format()) - .or_insert_with(|| { - self.thread - .read(cx) - .profile() - .enabled_tools(cx) - .iter() - .filter(|(_, tool)| tool.input_schema(model.tool_input_format()).is_err()) - .map(|(_, tool)| tool.clone()) - .collect() - }) - } -} - -pub struct IncompatibleToolsTooltip { - pub incompatible_tools: Vec>, -} - -impl Render for IncompatibleToolsTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - ui::tooltip_container(window, cx, |container, _, cx| { - container - .w_72() - .child(Label::new("Incompatible Tools").size(LabelSize::Small)) - .child( - Label::new( - "This model is incompatible with the following tools from your MCPs:", - ) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - v_flex() - .my_1p5() - .py_0p5() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .children( - self.incompatible_tools - .iter() - .map(|tool| h_flex().gap_4().child(Label::new(tool.name()).size(LabelSize::Small)).map(|parent| - match tool.source() { - ToolSource::Native => parent, - ToolSource::ContextServer { id } => parent.child(Label::new(id).size(LabelSize::Small).color(Color::Muted)), - } - )), - ), - ) - .child(Label::new("What To Do Instead").size(LabelSize::Small)) - .child( - Label::new( - "Every other tool continues to work with this model, but to specifically use those, switch to another model.", - ) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - } -} diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index beeaf0c43bbaa9384030879654bfaada1e4d9cd1..5363949b904d74d3749c066357e0c60fef19d3b9 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,14 +1,19 @@ +mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; +mod claude_code_onboarding_modal; mod context_pill; mod end_trial_upsell; -// mod new_thread_button; mod onboarding_modal; -pub mod preview; +mod unavailable_editing_tooltip; +mod usage_callout; +pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; +pub use claude_code_onboarding_modal::*; pub use context_pill::*; pub use end_trial_upsell::*; -// pub use new_thread_button::*; pub use onboarding_modal::*; +pub use unavailable_editing_tooltip::*; +pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..8433904fb3b540c2d78c8634b7a6755303d6e15c --- /dev/null +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -0,0 +1,246 @@ +use client::zed_urls; +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, +}; +use ui::{TintColor, Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; + +use crate::agent_panel::{AgentPanel, AgentType}; + +macro_rules! acp_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "ACP Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+); + }; +} + +pub struct AcpOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl AcpOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::Gemini, window, cx); + }); + } + }); + + cx.emit(DismissEvent); + + acp_onboarding_event!("Open Panel Clicked"); + } + + fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url(&zed_urls::external_agents_docs(cx)); + cx.notify(); + + acp_onboarding_event!("Documentation Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AcpOnboardingModal {} + +impl Focusable for AcpOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for AcpOnboardingModal {} + +impl Render for AcpOnboardingModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let illustration_element = |label: bool, opacity: f32| { + h_flex() + .px_1() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.05)) + .border_1() + .border_color(cx.theme().colors().border) + .border_dashed() + .child( + Icon::new(IconName::Stop) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), + ) + .map(|this| { + if label { + this.child( + Label::new("Your Agent Here") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child( + div().w_16().h_1().rounded_full().bg(cx + .theme() + .colors() + .element_active + .opacity(0.6)), + ) + } + }) + .opacity(opacity) + }; + + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(257.), + rems_from_px(47.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ) + .child( + v_flex() + .gap_1p5() + .child(illustration_element(false, 0.15)) + .child(illustration_element(true, 0.3)) + .child( + h_flex() + .pl_1() + .pr_2() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.2)) + .border_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::AiGemini) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)), + ) + .child(illustration_element(true, 0.3)) + .child(illustration_element(false, 0.15)), + ); + + let heading = v_flex() + .w_full() + .gap_1() + .child( + Label::new("Now Available") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large)); + + let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration."; + + let open_panel_button = Button::new("open-panel", "Start with Gemini CLI") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let docs_button = Button::new("add-other-agents", "Add Other Agents") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_docs)); + + let close_button = h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + ); + + v_flex() + .id("acp-onboarding") + .key_context("AcpOnboardingModal") + .relative() + .w(rems(34.)) + .h_full() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child(illustration) + .child( + v_flex() + .p_4() + .gap_2() + .child(heading) + .child(Label::new(copy).color(Color::Muted)) + .child( + v_flex() + .w_full() + .mt_2() + .gap_1() + .child(open_panel_button) + .child(docs_button), + ), + ) + .child(close_button) + } +} diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 68480c047f9cab4cd72f1998422bc727993e1f5e..af2a022f147b79a0a299c17dd26c7e9a8b62aeb9 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -62,6 +62,8 @@ impl AgentNotification { app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, + ..Default::default() } } } diff --git a/crates/agent_ui/src/ui/burn_mode_tooltip.rs b/crates/agent_ui/src/ui/burn_mode_tooltip.rs index 72faaa614d0d531365fef9ba5ff0e62a6fbcf145..ccd7d4bf3190c0d879327dc0ea152994c4a33163 100644 --- a/crates/agent_ui/src/ui/burn_mode_tooltip.rs +++ b/crates/agent_ui/src/ui/burn_mode_tooltip.rs @@ -18,7 +18,7 @@ impl BurnModeTooltip { } impl Render for BurnModeTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let (icon, color) = if self.selected { (IconName::ZedBurnModeOn, Color::Error) } else { @@ -45,16 +45,15 @@ impl Render for BurnModeTooltip { .child(Label::new("Burn Mode")) .when(self.selected, |title| title.child(turned_on)); - let keybinding = KeyBinding::for_action(&ToggleBurnMode, window, cx) - .map(|kb| kb.size(rems_from_px(12.))); + let keybinding = KeyBinding::for_action(&ToggleBurnMode, cx).size(rems_from_px(12.)); - tooltip_container(window, cx, |this, _, _| { + tooltip_container(cx, |this, _| { this .child( h_flex() .justify_between() .child(title) - .children(keybinding) + .child(keybinding) ) .child( div() diff --git a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..06980f18977aefe228bb7f09962e69fe2b3a5068 --- /dev/null +++ b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs @@ -0,0 +1,254 @@ +use client::zed_urls; +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, +}; +use ui::{TintColor, Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; + +use crate::agent_panel::{AgentPanel, AgentType}; + +macro_rules! claude_code_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "ACP Claude Code Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+); + }; +} + +pub struct ClaudeCodeOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl ClaudeCodeOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::ClaudeCode, window, cx); + }); + } + }); + + cx.emit(DismissEvent); + + claude_code_onboarding_event!("Open Panel Clicked"); + } + + fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url(&zed_urls::external_agents_docs(cx)); + cx.notify(); + + claude_code_onboarding_event!("Documentation Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for ClaudeCodeOnboardingModal {} + +impl Focusable for ClaudeCodeOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for ClaudeCodeOnboardingModal {} + +impl Render for ClaudeCodeOnboardingModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let illustration_element = |icon: IconName, label: Option, opacity: f32| { + h_flex() + .px_1() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.05)) + .border_1() + .border_color(cx.theme().colors().border) + .border_dashed() + .child( + Icon::new(icon) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), + ) + .map(|this| { + if let Some(label_text) = label { + this.child( + Label::new(label_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child( + div().w_16().h_1().rounded_full().bg(cx + .theme() + .colors() + .element_active + .opacity(0.6)), + ) + } + }) + .opacity(opacity) + }; + + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(257.), + rems_from_px(47.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ) + .child( + v_flex() + .gap_1p5() + .child(illustration_element(IconName::Stop, None, 0.15)) + .child(illustration_element( + IconName::AiGemini, + Some("New Gemini CLI Thread".into()), + 0.3, + )) + .child( + h_flex() + .pl_1() + .pr_2() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.2)) + .border_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::AiClaude) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("New Claude Code Thread").size(LabelSize::Small)), + ) + .child(illustration_element( + IconName::Stop, + Some("Your Agent Here".into()), + 0.3, + )) + .child(illustration_element(IconName::Stop, None, 0.15)), + ); + + let heading = v_flex() + .w_full() + .gap_1() + .child( + Label::new("Beta Release") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large)); + + let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel."; + + let open_panel_button = Button::new("open-panel", "Start with Claude Code") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let docs_button = Button::new("add-other-agents", "Add Other Agents") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_docs)); + + let close_button = h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + claude_code_onboarding_event!("Canceled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + ); + + v_flex() + .id("acp-onboarding") + .key_context("AcpOnboardingModal") + .relative() + .w(rems(34.)) + .h_full() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + claude_code_onboarding_event!("Canceled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child(illustration) + .child( + v_flex() + .p_4() + .gap_2() + .child(heading) + .child(Label::new(copy).color(Color::Muted)) + .child( + v_flex() + .w_full() + .mt_2() + .gap_1() + .child(open_panel_button) + .child(docs_button), + ), + ) + .child(close_button) + } +} diff --git a/crates/agent_ui/src/ui/context_pill.rs b/crates/agent_ui/src/ui/context_pill.rs index 5dd57de24490df03ce0f2c41a844be33fb675793..89bf618a16d3fb8e7abc5afaf34ee6e8bb43ab67 100644 --- a/crates/agent_ui/src/ui/context_pill.rs +++ b/crates/agent_ui/src/ui/context_pill.rs @@ -11,13 +11,12 @@ use project::Project; use prompt_store::PromptStore; use rope::Point; use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container}; +use util::paths::PathStyle; -use agent::context::{ - AgentContext, AgentContextHandle, ContextId, ContextKind, DirectoryContext, - DirectoryContextHandle, FetchedUrlContext, FileContext, FileContextHandle, ImageContext, - ImageStatus, RulesContext, RulesContextHandle, SelectionContext, SelectionContextHandle, - SymbolContext, SymbolContextHandle, TextThreadContext, TextThreadContextHandle, ThreadContext, - ThreadContextHandle, +use crate::context::{ + AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext, + FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle, + SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle, }; #[derive(IntoElement)] @@ -245,8 +244,8 @@ impl RenderOnce for ContextPill { .truncate(), ), ) - .tooltip(|window, cx| { - Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx) + .tooltip(|_window, cx| { + Tooltip::with_meta("Suggested Context", None, "Click to add it", cx) }) .when_some(on_click.as_ref(), |element, on_click| { let on_click = on_click.clone(); @@ -305,55 +304,54 @@ impl AddedContext { cx: &App, ) -> Option { match handle { - AgentContextHandle::File(handle) => Self::pending_file(handle, cx), + AgentContextHandle::File(handle) => { + Self::pending_file(handle, project.path_style(cx), cx) + } AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx), - AgentContextHandle::Symbol(handle) => Self::pending_symbol(handle, cx), - AgentContextHandle::Selection(handle) => Self::pending_selection(handle, cx), + AgentContextHandle::Symbol(handle) => { + Self::pending_symbol(handle, project.path_style(cx), cx) + } + AgentContextHandle::Selection(handle) => { + Self::pending_selection(handle, project.path_style(cx), cx) + } AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)), AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)), AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)), AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx), - AgentContextHandle::Image(handle) => Some(Self::image(handle, model, cx)), + AgentContextHandle::Image(handle) => { + Some(Self::image(handle, model, project.path_style(cx), cx)) + } } } - pub fn new_attached( - context: &AgentContext, - model: Option<&Arc>, + fn pending_file( + handle: FileContextHandle, + path_style: PathStyle, cx: &App, - ) -> AddedContext { - match context { - AgentContext::File(context) => Self::attached_file(context, cx), - AgentContext::Directory(context) => Self::attached_directory(context), - AgentContext::Symbol(context) => Self::attached_symbol(context, cx), - AgentContext::Selection(context) => Self::attached_selection(context, cx), - AgentContext::FetchedUrl(context) => Self::fetched_url(context.clone()), - AgentContext::Thread(context) => Self::attached_thread(context), - AgentContext::TextThread(context) => Self::attached_text_thread(context), - AgentContext::Rules(context) => Self::attached_rules(context), - AgentContext::Image(context) => Self::image(context.clone(), model, cx), - } - } - - fn pending_file(handle: FileContextHandle, cx: &App) -> Option { - let full_path = handle.buffer.read(cx).file()?.full_path(cx); - Some(Self::file(handle, &full_path, cx)) - } - - fn attached_file(context: &FileContext, cx: &App) -> AddedContext { - Self::file(context.handle.clone(), &context.full_path, cx) + ) -> Option { + let full_path = handle + .buffer + .read(cx) + .file()? + .full_path(cx) + .to_string_lossy() + .to_string(); + Some(Self::file(handle, &full_path, path_style, cx)) } - fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext { - let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into(); - let (name, parent) = - extract_file_name_and_directory_from_full_path(full_path, &full_path_string); + fn file( + handle: FileContextHandle, + full_path: &str, + path_style: PathStyle, + cx: &App, + ) -> AddedContext { + let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); AddedContext { kind: ContextKind::File, name, parent, - tooltip: Some(full_path_string), - icon_path: FileIcons::get_icon(&full_path, cx), + tooltip: Some(SharedString::new(full_path)), + icon_path: FileIcons::get_icon(Path::new(full_path), cx), status: ContextStatus::Ready, render_hover: None, handle: AgentContextHandle::File(handle), @@ -367,23 +365,24 @@ impl AddedContext { ) -> Option { let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx); let entry = worktree.entry_for_id(handle.entry_id)?; - let full_path = worktree.full_path(&entry.path); - Some(Self::directory(handle, &full_path)) - } - - fn attached_directory(context: &DirectoryContext) -> AddedContext { - Self::directory(context.handle.clone(), &context.full_path) + let full_path = worktree + .full_path(&entry.path) + .to_string_lossy() + .to_string(); + Some(Self::directory(handle, &full_path, project.path_style(cx))) } - fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext { - let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into(); - let (name, parent) = - extract_file_name_and_directory_from_full_path(full_path, &full_path_string); + fn directory( + handle: DirectoryContextHandle, + full_path: &str, + path_style: PathStyle, + ) -> AddedContext { + let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style); AddedContext { kind: ContextKind::Directory, name, parent, - tooltip: Some(full_path_string), + tooltip: Some(SharedString::new(full_path)), icon_path: None, status: ContextStatus::Ready, render_hover: None, @@ -391,9 +390,17 @@ impl AddedContext { } } - fn pending_symbol(handle: SymbolContextHandle, cx: &App) -> Option { - let excerpt = - ContextFileExcerpt::new(&handle.full_path(cx)?, handle.enclosing_line_range(cx), cx); + fn pending_symbol( + handle: SymbolContextHandle, + path_style: PathStyle, + cx: &App, + ) -> Option { + let excerpt = ContextFileExcerpt::new( + &handle.full_path(cx)?.to_string_lossy(), + handle.enclosing_line_range(cx), + path_style, + cx, + ); Some(AddedContext { kind: ContextKind::Symbol, name: handle.symbol.clone(), @@ -411,27 +418,17 @@ impl AddedContext { }) } - fn attached_symbol(context: &SymbolContext, cx: &App) -> AddedContext { - let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx); - AddedContext { - kind: ContextKind::Symbol, - name: context.handle.symbol.clone(), - parent: Some(excerpt.file_name_and_range.clone()), - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let text = context.text.clone(); - Some(Rc::new(move |_, cx| { - excerpt.hover_view(text.clone(), cx).into() - })) - }, - handle: AgentContextHandle::Symbol(context.handle.clone()), - } - } - - fn pending_selection(handle: SelectionContextHandle, cx: &App) -> Option { - let excerpt = ContextFileExcerpt::new(&handle.full_path(cx)?, handle.line_range(cx), cx); + fn pending_selection( + handle: SelectionContextHandle, + path_style: PathStyle, + cx: &App, + ) -> Option { + let excerpt = ContextFileExcerpt::new( + &handle.full_path(cx)?.to_string_lossy(), + handle.line_range(cx), + path_style, + cx, + ); Some(AddedContext { kind: ContextKind::Selection, name: excerpt.file_name_and_range.clone(), @@ -449,25 +446,6 @@ impl AddedContext { }) } - fn attached_selection(context: &SelectionContext, cx: &App) -> AddedContext { - let excerpt = ContextFileExcerpt::new(&context.full_path, context.line_range.clone(), cx); - AddedContext { - kind: ContextKind::Selection, - name: excerpt.file_name_and_range.clone(), - parent: excerpt.parent_name.clone(), - tooltip: None, - icon_path: excerpt.icon_path.clone(), - status: ContextStatus::Ready, - render_hover: { - let text = context.text.clone(); - Some(Rc::new(move |_, cx| { - excerpt.hover_view(text.clone(), cx).into() - })) - }, - handle: AgentContextHandle::Selection(context.handle.clone()), - } - } - fn fetched_url(context: FetchedUrlContext) -> AddedContext { AddedContext { kind: ContextKind::FetchedUrl, @@ -488,7 +466,7 @@ impl AddedContext { parent: None, tooltip: None, icon_path: None, - status: if handle.thread.read(cx).is_generating_detailed_summary() { + status: if handle.thread.read(cx).is_generating_summary() { ContextStatus::Loading { message: "Summarizing…".into(), } @@ -498,32 +476,18 @@ impl AddedContext { render_hover: { let thread = handle.thread.clone(); Some(Rc::new(move |_, cx| { - let text = thread.read(cx).latest_detailed_summary_or_text(); - ContextPillHover::new_text(text.clone(), cx).into() + let text = thread + .update(cx, |thread, cx| thread.summary(cx)) + .now_or_never() + .flatten() + .unwrap_or_else(|| SharedString::from(thread.read(cx).to_markdown())); + ContextPillHover::new_text(text, cx).into() })) }, handle: AgentContextHandle::Thread(handle), } } - fn attached_thread(context: &ThreadContext) -> AddedContext { - AddedContext { - kind: ContextKind::Thread, - name: context.title.clone(), - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let text = context.text.clone(); - Some(Rc::new(move |_, cx| { - ContextPillHover::new_text(text.clone(), cx).into() - })) - }, - handle: AgentContextHandle::Thread(context.handle.clone()), - } - } - fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext { AddedContext { kind: ContextKind::TextThread, @@ -533,9 +497,9 @@ impl AddedContext { icon_path: None, status: ContextStatus::Ready, render_hover: { - let context = handle.context.clone(); + let text_thread = handle.text_thread.clone(); Some(Rc::new(move |_, cx| { - let text = context.read(cx).to_xml(cx); + let text = text_thread.read(cx).to_xml(cx); ContextPillHover::new_text(text.into(), cx).into() })) }, @@ -543,24 +507,6 @@ impl AddedContext { } } - fn attached_text_thread(context: &TextThreadContext) -> AddedContext { - AddedContext { - kind: ContextKind::TextThread, - name: context.title.clone(), - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let text = context.text.clone(); - Some(Rc::new(move |_, cx| { - ContextPillHover::new_text(text.clone(), cx).into() - })) - }, - handle: AgentContextHandle::TextThread(context.handle.clone()), - } - } - fn pending_rules( handle: RulesContextHandle, prompt_store: Option<&Entity>, @@ -574,7 +520,7 @@ impl AddedContext { .unwrap_or_else(|| "Unnamed Rule".into()); Some(AddedContext { kind: ContextKind::Rules, - name: title.clone(), + name: title, parent: None, tooltip: None, icon_path: None, @@ -584,38 +530,16 @@ impl AddedContext { }) } - fn attached_rules(context: &RulesContext) -> AddedContext { - let title = context - .title - .clone() - .unwrap_or_else(|| "Unnamed Rule".into()); - AddedContext { - kind: ContextKind::Rules, - name: title, - parent: None, - tooltip: None, - icon_path: None, - status: ContextStatus::Ready, - render_hover: { - let text = context.text.clone(); - Some(Rc::new(move |_, cx| { - ContextPillHover::new_text(text.clone(), cx).into() - })) - }, - handle: AgentContextHandle::Rules(context.handle.clone()), - } - } - fn image( context: ImageContext, model: Option<&Arc>, + path_style: PathStyle, cx: &App, ) -> AddedContext { let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() { - let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into(); let (name, parent) = - extract_file_name_and_directory_from_full_path(full_path, &full_path_string); - let icon_path = FileIcons::get_icon(&full_path, cx); + extract_file_name_and_directory_from_full_path(full_path, path_style); + let icon_path = FileIcons::get_icon(Path::new(full_path), cx); (name, parent, icon_path) } else { ("Image".into(), None, None) @@ -664,19 +588,20 @@ impl AddedContext { } fn extract_file_name_and_directory_from_full_path( - path: &Path, - name_fallback: &SharedString, + path: &str, + path_style: PathStyle, ) -> (SharedString, Option) { - let name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned().into()) - .unwrap_or_else(|| name_fallback.clone()); - let parent = path - .parent() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned().into()); - - (name, parent) + let (parent, file_name) = path_style.split(path); + let parent = parent.and_then(|parent| { + let parent = parent.trim_end_matches(path_style.separator()); + let (_, parent) = path_style.split(parent); + if parent.is_empty() { + None + } else { + Some(SharedString::new(parent)) + } + }); + (SharedString::new(file_name), parent) } #[derive(Debug, Clone)] @@ -688,25 +613,25 @@ struct ContextFileExcerpt { } impl ContextFileExcerpt { - pub fn new(full_path: &Path, line_range: Range, cx: &App) -> Self { - let full_path_string = full_path.to_string_lossy().into_owned(); - let file_name = full_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| full_path_string.clone()); - + pub fn new(full_path: &str, line_range: Range, path_style: PathStyle, cx: &App) -> Self { + let (parent, file_name) = path_style.split(full_path); let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1); - let mut full_path_and_range = full_path_string; + let mut full_path_and_range = full_path.to_owned(); full_path_and_range.push_str(&line_range_text); - let mut file_name_and_range = file_name; + let mut file_name_and_range = file_name.to_owned(); file_name_and_range.push_str(&line_range_text); - let parent_name = full_path - .parent() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned().into()); + let parent_name = parent.and_then(|parent| { + let parent = parent.trim_end_matches(path_style.separator()); + let (_, parent) = path_style.split(parent); + if parent.is_empty() { + None + } else { + Some(SharedString::new(parent)) + } + }); - let icon_path = FileIcons::get_icon(&full_path, cx); + let icon_path = FileIcons::get_icon(Path::new(full_path), cx); ContextFileExcerpt { file_name_and_range: file_name_and_range.into(), @@ -783,7 +708,7 @@ impl ContextPillHover { impl Render for ContextPillHover { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(window, cx, move |this, window, cx| { + tooltip_container(cx, move |this, cx| { this.occlude() .on_mouse_move(|_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) @@ -814,6 +739,7 @@ impl Component for AddedContext { image_task: Task::ready(Some(LanguageModelImage::empty())).shared(), }, None, + PathStyle::local(), cx, ), ); @@ -834,6 +760,7 @@ impl Component for AddedContext { .shared(), }, None, + PathStyle::local(), cx, ), ); @@ -849,6 +776,7 @@ impl Component for AddedContext { image_task: Task::ready(None).shared(), }, None, + PathStyle::local(), cx, ), ); @@ -891,7 +819,8 @@ mod tests { full_path: None, }; - let added_context = AddedContext::image(image_context, Some(&model), cx); + let added_context = + AddedContext::image(image_context, Some(&model), PathStyle::local(), cx); assert!(matches!( added_context.status, @@ -914,7 +843,7 @@ mod tests { full_path: None, }; - let added_context = AddedContext::image(image_context, None, cx); + let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx); assert!( matches!(added_context.status, ContextStatus::Ready), diff --git a/crates/agent_ui/src/ui/end_trial_upsell.rs b/crates/agent_ui/src/ui/end_trial_upsell.rs index 3a8a119800543ad033efd563d7896ccc80add373..9c25519659056354ed5f575be885a46151497c2e 100644 --- a/crates/agent_ui/src/ui/end_trial_upsell.rs +++ b/crates/agent_ui/src/ui/end_trial_upsell.rs @@ -2,24 +2,27 @@ use std::sync::Arc; use ai_onboarding::{AgentPanelOnboardingCard, PlanDefinitions}; use client::zed_urls; +use cloud_llm_client::{Plan, PlanV2}; use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; use ui::{Divider, Tooltip, prelude::*}; #[derive(IntoElement, RegisterComponent)] pub struct EndTrialUpsell { + plan: Plan, dismiss_upsell: Arc, } impl EndTrialUpsell { - pub fn new(dismiss_upsell: Arc) -> Self { - Self { dismiss_upsell } + pub fn new(plan: Plan, dismiss_upsell: Arc) -> Self { + Self { + plan, + dismiss_upsell, + } } } impl RenderOnce for EndTrialUpsell { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let plan_definitions = PlanDefinitions; - let pro_section = v_flex() .gap_1() .child( @@ -33,7 +36,7 @@ impl RenderOnce for EndTrialUpsell { ) .child(Divider::horizontal()), ) - .child(plan_definitions.pro_plan(false)) + .child(PlanDefinitions.pro_plan(self.plan.is_v2(), false)) .child( Button::new("cta-button", "Upgrade to Zed Pro") .full_width() @@ -64,7 +67,7 @@ impl RenderOnce for EndTrialUpsell { ) .child(Divider::horizontal()), ) - .child(plan_definitions.free_plan()); + .child(PlanDefinitions.free_plan(self.plan.is_v2())); AgentPanelOnboardingCard::new() .child(Headline::new("Your Zed Pro Trial has expired")) @@ -109,6 +112,7 @@ impl Component for EndTrialUpsell { Some( v_flex() .child(EndTrialUpsell { + plan: Plan::V2(PlanV2::ZedFree), dismiss_upsell: Arc::new(|_, _| {}), }) .into_any_element(), diff --git a/crates/agent_ui/src/ui/new_thread_button.rs b/crates/agent_ui/src/ui/new_thread_button.rs deleted file mode 100644 index 347d6adcaf14221fef31f87303028e30091d2ec4..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/new_thread_button.rs +++ /dev/null @@ -1,75 +0,0 @@ -use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled}; -use ui::prelude::*; - -#[derive(IntoElement)] -pub struct NewThreadButton { - id: ElementId, - label: SharedString, - icon: IconName, - keybinding: Option, - on_click: Option>, -} - -impl NewThreadButton { - fn new(id: impl Into, label: impl Into, icon: IconName) -> Self { - Self { - id: id.into(), - label: label.into(), - icon, - keybinding: None, - on_click: None, - } - } - - fn keybinding(mut self, keybinding: Option) -> Self { - self.keybinding = keybinding; - self - } - - fn on_click(mut self, handler: F) -> Self - where - F: Fn(&mut Window, &mut App) + 'static, - { - self.on_click = Some(Box::new( - move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx), - )); - self - } -} - -impl RenderOnce for NewThreadButton { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .id(self.id) - .w_full() - .py_1p5() - .px_2() - .gap_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border.opacity(0.4)) - .bg(cx.theme().colors().element_active.opacity(0.2)) - .hover(|style| { - style - .bg(cx.theme().colors().element_hover) - .border_color(cx.theme().colors().border) - }) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(self.icon) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(Label::new(self.label).size(LabelSize::Small)), - ) - .when_some(self.keybinding, |this, keybinding| { - this.child(keybinding.size(rems_from_px(10.))) - }) - .when_some(self.on_click, |this, on_click| { - this.on_click(move |event, window, cx| on_click(event, window, cx)) - }) - } -} diff --git a/crates/agent_ui/src/ui/onboarding_modal.rs b/crates/agent_ui/src/ui/onboarding_modal.rs index b8b038bdfca334ba048aed0a59cecd0f3da285b0..ad404afa784974631f914e6fece2de6b6c7d6a46 100644 --- a/crates/agent_ui/src/ui/onboarding_modal.rs +++ b/crates/agent_ui/src/ui/onboarding_modal.rs @@ -40,7 +40,7 @@ impl AgentOnboardingModal { } fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("http://zed.dev/blog/fastest-ai-code-editor"); + cx.open_url("https://zed.dev/blog/fastest-ai-code-editor"); cx.notify(); agent_onboarding_event!("Blog Link Clicked"); diff --git a/crates/agent_ui/src/ui/preview.rs b/crates/agent_ui/src/ui/preview.rs deleted file mode 100644 index 3ab548dcb4c06ede3b5a716f5247665e01c2b105..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/preview.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod agent_preview; -mod usage_callouts; - -pub use agent_preview::*; -pub use usage_callouts::*; diff --git a/crates/agent_ui/src/ui/preview/agent_preview.rs b/crates/agent_ui/src/ui/preview/agent_preview.rs deleted file mode 100644 index ca189b57a9bfa4eb6ee02a3802e9622058980db7..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/preview/agent_preview.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::sync::OnceLock; - -use collections::HashMap; -use component::ComponentId; -use gpui::{App, Entity, WeakEntity}; -use ui::{AnyElement, Component, ComponentScope, Window}; -use workspace::Workspace; - -use crate::ActiveThread; - -/// Function type for creating agent component previews -pub type PreviewFn = - fn(WeakEntity, Entity, &mut Window, &mut App) -> Option; - -pub struct AgentPreviewFn(fn() -> (ComponentId, PreviewFn)); - -impl AgentPreviewFn { - pub const fn new(f: fn() -> (ComponentId, PreviewFn)) -> Self { - Self(f) - } -} - -inventory::collect!(AgentPreviewFn); - -/// Trait that must be implemented by components that provide agent previews. -pub trait AgentPreview: Component + Sized { - #[allow(unused)] // We can't know this is used due to the distributed slice - fn scope(&self) -> ComponentScope { - ComponentScope::Agent - } - - /// Static method to create a preview for this component type - fn agent_preview( - workspace: WeakEntity, - active_thread: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option; -} - -/// Register an agent preview for the given component type -#[macro_export] -macro_rules! register_agent_preview { - ($type:ty) => { - inventory::submit! { - $crate::ui::preview::AgentPreviewFn::new(|| { - ( - <$type as component::Component>::id(), - <$type as $crate::ui::preview::AgentPreview>::agent_preview, - ) - }) - } - }; -} - -/// Lazy initialized registry of preview functions -static AGENT_PREVIEW_REGISTRY: OnceLock> = OnceLock::new(); - -/// Initialize the agent preview registry if needed -fn get_or_init_registry() -> &'static HashMap { - AGENT_PREVIEW_REGISTRY.get_or_init(|| { - let mut map = HashMap::default(); - for register_fn in inventory::iter::() { - let (id, preview_fn) = (register_fn.0)(); - map.insert(id, preview_fn); - } - map - }) -} - -/// Get a specific agent preview by component ID. -pub fn get_agent_preview( - id: &ComponentId, - workspace: WeakEntity, - active_thread: Entity, - window: &mut Window, - cx: &mut App, -) -> Option { - let registry = get_or_init_registry(); - registry - .get(id) - .and_then(|preview_fn| preview_fn(workspace, active_thread, window, cx)) -} - -/// Get all registered agent previews. -pub fn all_agent_previews() -> Vec { - let registry = get_or_init_registry(); - registry.keys().cloned().collect() -} diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs new file mode 100644 index 0000000000000000000000000000000000000000..2993fb89a989619ecfe3d79b06d82a2a6f71fc31 --- /dev/null +++ b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs @@ -0,0 +1,29 @@ +use gpui::{Context, IntoElement, Render, Window}; +use ui::{prelude::*, tooltip_container}; + +pub struct UnavailableEditingTooltip { + agent_name: SharedString, +} + +impl UnavailableEditingTooltip { + pub fn new(agent_name: SharedString) -> Self { + Self { agent_name } + } +} + +impl Render for UnavailableEditingTooltip { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + tooltip_container(cx, |this, _| { + this.child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + self.agent_name + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + } +} diff --git a/crates/agent_ui/src/ui/preview/usage_callouts.rs b/crates/agent_ui/src/ui/usage_callout.rs similarity index 81% rename from crates/agent_ui/src/ui/preview/usage_callouts.rs rename to crates/agent_ui/src/ui/usage_callout.rs index eef878a9d1b9cac72cb13cde0c8fbd92c1519afc..e31af9f49aca781e735a96384dbaddbd3b446ef7 100644 --- a/crates/agent_ui/src/ui/preview/usage_callouts.rs +++ b/crates/agent_ui/src/ui/usage_callout.rs @@ -1,5 +1,5 @@ use client::{ModelRequestUsage, RequestUsage, zed_urls}; -use cloud_llm_client::{Plan, UsageLimit}; +use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit}; use component::{empty_example, example_group_with_title, single_example}; use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; use ui::{Callout, prelude::*}; @@ -38,20 +38,20 @@ impl RenderOnce for UsageCallout { let (title, message, button_text, url) = if is_limit_reached { match self.plan { - Plan::ZedFree => ( + Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree) => ( "Out of free prompts", "Upgrade to continue, wait for the next reset, or switch to API key." .to_string(), "Upgrade", zed_urls::account_url(cx), ), - Plan::ZedProTrial => ( + Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial) => ( "Out of trial prompts", "Upgrade to Zed Pro to continue, or switch to API key.".to_string(), "Upgrade", zed_urls::account_url(cx), ), - Plan::ZedPro => ( + Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro) => ( "Out of included prompts", "Enable usage-based billing to continue.".to_string(), "Manage", @@ -60,7 +60,7 @@ impl RenderOnce for UsageCallout { } } else { match self.plan { - Plan::ZedFree => ( + Plan::V1(PlanV1::ZedFree) => ( "Reaching free plan limit soon", format!( "{remaining} remaining - Upgrade to increase limit, or switch providers", @@ -68,7 +68,7 @@ impl RenderOnce for UsageCallout { "Upgrade", zed_urls::account_url(cx), ), - Plan::ZedProTrial => ( + Plan::V1(PlanV1::ZedProTrial) => ( "Reaching trial limit soon", format!( "{remaining} remaining - Upgrade to increase limit, or switch providers", @@ -76,35 +76,28 @@ impl RenderOnce for UsageCallout { "Upgrade", zed_urls::account_url(cx), ), - _ => return div().into_any_element(), + Plan::V1(PlanV1::ZedPro) | Plan::V2(_) => return div().into_any_element(), } }; - let icon = if is_limit_reached { - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::XSmall) + let (icon, severity) = if is_limit_reached { + (IconName::Close, Severity::Error) } else { - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall) + (IconName::Warning, Severity::Warning) }; - div() - .border_t_1() - .border_color(cx.theme().colors().border) - .child( - Callout::new() - .icon(icon) - .title(title) - .description(message) - .primary_action( - Button::new("upgrade", button_text) - .label_size(LabelSize::Small) - .on_click(move |_, _, cx| { - cx.open_url(&url); - }), - ), + Callout::new() + .icon(icon) + .severity(severity) + .icon(icon) + .title(title) + .description(message) + .actions_slot( + Button::new("upgrade", button_text) + .label_size(LabelSize::Small) + .on_click(move |_, _, cx| { + cx.open_url(&url); + }), ) .into_any_element() } @@ -126,7 +119,7 @@ impl Component for UsageCallout { single_example( "Approaching limit (90%)", UsageCallout::new( - Plan::ZedFree, + Plan::V1(PlanV1::ZedFree), ModelRequestUsage(RequestUsage { limit: UsageLimit::Limited(50), amount: 45, // 90% of limit @@ -137,7 +130,7 @@ impl Component for UsageCallout { single_example( "Limit reached (100%)", UsageCallout::new( - Plan::ZedFree, + Plan::V1(PlanV1::ZedFree), ModelRequestUsage(RequestUsage { limit: UsageLimit::Limited(50), amount: 50, // 100% of limit @@ -154,7 +147,7 @@ impl Component for UsageCallout { single_example( "Approaching limit (90%)", UsageCallout::new( - Plan::ZedProTrial, + Plan::V1(PlanV1::ZedProTrial), ModelRequestUsage(RequestUsage { limit: UsageLimit::Limited(150), amount: 135, // 90% of limit @@ -165,7 +158,7 @@ impl Component for UsageCallout { single_example( "Limit reached (100%)", UsageCallout::new( - Plan::ZedProTrial, + Plan::V1(PlanV1::ZedProTrial), ModelRequestUsage(RequestUsage { limit: UsageLimit::Limited(150), amount: 150, // 100% of limit @@ -182,7 +175,7 @@ impl Component for UsageCallout { single_example( "Limit reached (100%)", UsageCallout::new( - Plan::ZedPro, + Plan::V1(PlanV1::ZedPro), ModelRequestUsage(RequestUsage { limit: UsageLimit::Limited(500), amount: 500, // 100% of limit diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index 95a45b1a6fbe103f02532d33c21af707f2f51d45..8fb0570e5cf3da5f5f3d6249f76b42f15b8eed7d 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -24,5 +24,4 @@ serde.workspace = true smallvec.workspace = true telemetry.workspace = true ui.workspace = true -workspace-hack.workspace = true zed_actions.workspace = true diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs index b55ad4c89549a8843fe2d8273da60236400cb565..fadc4222ae44f3dbad862fd9479b89321dbd3016 100644 --- a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -11,7 +11,7 @@ impl ApiKeysWithProviders { cx.subscribe( &LanguageModelRegistry::global(cx), |this: &mut Self, _registry, event: &language_model::Event, cx| match event { - language_model::Event::ProviderStateChanged + language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { this.configured_providers = Self::compute_configured_providers(cx) @@ -33,7 +33,7 @@ impl ApiKeysWithProviders { .filter(|provider| { provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID }) - .map(|provider| (provider.icon(), provider.name().0.clone())) + .map(|provider| (provider.icon(), provider.name().0)) .collect() } } diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index f1629eeff81ef51bf2ff823eef0db64c1585a669..3c8ffc1663e0660829698b5449a006de5b3c6009 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use client::{Client, UserStore}; -use cloud_llm_client::Plan; +use cloud_llm_client::{Plan, PlanV1, PlanV2}; use gpui::{Entity, IntoElement, ParentElement}; use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::prelude::*; @@ -25,7 +25,7 @@ impl AgentPanelOnboarding { cx.subscribe( &LanguageModelRegistry::global(cx), |this: &mut Self, _registry, event: &language_model::Event, cx| match event { - language_model::Event::ProviderStateChanged + language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { this.configured_providers = Self::compute_available_providers(cx) @@ -50,15 +50,22 @@ impl AgentPanelOnboarding { .filter(|provider| { provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID }) - .map(|provider| (provider.icon(), provider.name().0.clone())) + .map(|provider| (provider.icon(), provider.name().0)) .collect() } } impl Render for AgentPanelOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let enrolled_in_trial = self.user_store.read(cx).plan() == Some(Plan::ZedProTrial); - let is_pro_user = self.user_store.read(cx).plan() == Some(Plan::ZedPro); + let enrolled_in_trial = self.user_store.read(cx).plan().is_some_and(|plan| { + matches!( + plan, + Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial) + ) + }); + let is_pro_user = self.user_store.read(cx).plan().is_some_and(|plan| { + matches!(plan, Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)) + }); AgentPanelOnboardingCard::new() .child( @@ -74,7 +81,7 @@ impl Render for AgentPanelOnboarding { }), ) .map(|this| { - if enrolled_in_trial || is_pro_user || self.configured_providers.len() >= 1 { + if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() { this } else { this.child(ApiKeysWithoutProviders::new()) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 75177d4bd2bf22b203cf9f50134bb821438a433f..20bb0a5f6895ea225cad59ad8fef6cc6ef168b39 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -10,7 +10,7 @@ pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProvider pub use agent_panel_onboarding_card::AgentPanelOnboardingCard; pub use agent_panel_onboarding_content::AgentPanelOnboarding; pub use ai_upsell_card::AiUpsellCard; -use cloud_llm_client::Plan; +use cloud_llm_client::{Plan, PlanV1, PlanV2}; pub use edit_prediction_onboarding_content::EditPredictionOnboarding; pub use plan_definitions::PlanDefinitions; pub use young_account_banner::YoungAccountBanner; @@ -19,7 +19,7 @@ use std::sync::Arc; use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement}; -use ui::{Divider, RegisterComponent, TintColor, Tooltip, prelude::*}; +use ui::{Divider, RegisterComponent, Tooltip, prelude::*}; #[derive(PartialEq)] pub enum SignInStatus { @@ -43,12 +43,10 @@ impl From for SignInStatus { #[derive(RegisterComponent, IntoElement)] pub struct ZedAiOnboarding { pub sign_in_status: SignInStatus, - pub has_accepted_terms_of_service: bool, pub plan: Option, pub account_too_young: bool, pub continue_with_zed_ai: Arc, pub sign_in: Arc, - pub accept_terms_of_service: Arc, pub dismiss_onboarding: Option>, } @@ -64,17 +62,9 @@ impl ZedAiOnboarding { Self { sign_in_status: status.into(), - has_accepted_terms_of_service: store.has_accepted_terms_of_service(), plan: store.plan(), account_too_young: store.account_too_young(), continue_with_zed_ai, - accept_terms_of_service: Arc::new({ - let store = user_store.clone(); - move |_window, cx| { - let task = store.update(cx, |store, cx| store.accept_terms_of_service(cx)); - task.detach_and_log_err(cx); - } - }), sign_in: Arc::new(move |_window, cx| { cx.spawn({ let client = client.clone(); @@ -94,47 +84,32 @@ impl ZedAiOnboarding { self } - fn render_accept_terms_of_service(&self) -> AnyElement { - v_flex() - .gap_1() - .w_full() - .child(Headline::new("Accept Terms of Service")) - .child( - Label::new("We don’t sell your data, track you across the web, or compromise your privacy.") - .color(Color::Muted) - .mb_2(), - ) - .child( - Button::new("terms_of_service", "Review Terms of Service") - .full_width() - .style(ButtonStyle::Outlined) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - telemetry::event!("Review Terms of Service Clicked"); - cx.open_url(&zed_urls::terms_of_service(cx)) - }), - ) - .child( - Button::new("accept_terms", "Accept") - .full_width() - .style(ButtonStyle::Tinted(TintColor::Accent)) - .on_click({ - let callback = self.accept_terms_of_service.clone(); - move |_, window, cx| { - telemetry::event!("Terms of Service Accepted"); - (callback)(window, cx)} - }), - ) - .into_any_element() + fn render_dismiss_button(&self) -> Option { + self.dismiss_onboarding.as_ref().map(|dismiss_callback| { + let callback = dismiss_callback.clone(); + + h_flex() + .absolute() + .top_0() + .right_0() + .child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| { + telemetry::event!("Banner Dismissed", source = "AI Onboarding",); + callback(window, cx) + }), + ) + .into_any_element() + }) } fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement { let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); - let plan_definitions = PlanDefinitions; v_flex() + .relative() .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( @@ -142,7 +117,7 @@ impl ZedAiOnboarding { .color(Color::Muted) .mb_2(), ) - .child(plan_definitions.pro_plan(false)) + .child(PlanDefinitions.pro_plan(true, false)) .child( Button::new("sign_in", "Try Zed Pro for Free") .disabled(signing_in) @@ -156,20 +131,18 @@ impl ZedAiOnboarding { } }), ) + .children(self.render_dismiss_button()) .into_any_element() } - fn render_free_plan_state(&self, cx: &mut App) -> AnyElement { - let young_account_banner = YoungAccountBanner; - let plan_definitions = PlanDefinitions; - + fn render_free_plan_state(&self, is_v2: bool, cx: &mut App) -> AnyElement { if self.account_too_young { v_flex() .relative() .max_w_full() .gap_1() .child(Headline::new("Welcome to Zed AI")) - .child(young_account_banner) + .child(YoungAccountBanner) .child( v_flex() .mt_2() @@ -185,7 +158,7 @@ impl ZedAiOnboarding { ) .child(Divider::horizontal()), ) - .child(plan_definitions.pro_plan(true)) + .child(PlanDefinitions.pro_plan(is_v2, true)) .child( Button::new("pro", "Get Started") .full_width() @@ -228,29 +201,9 @@ impl ZedAiOnboarding { ) .child(Divider::horizontal()), ) - .child(plan_definitions.free_plan()), - ) - .when_some( - self.dismiss_onboarding.as_ref(), - |this, dismiss_callback| { - let callback = dismiss_callback.clone(); - - this.child( - h_flex().absolute().top_0().right_0().child( - IconButton::new("dismiss_onboarding", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| { - telemetry::event!( - "Banner Dismissed", - source = "AI Onboarding", - ); - callback(window, cx) - }), - ), - ) - }, + .child(PlanDefinitions.free_plan(is_v2)), ) + .children(self.render_dismiss_button()) .child( v_flex() .mt_2() @@ -266,7 +219,7 @@ impl ZedAiOnboarding { ) .child(Divider::horizontal()), ) - .child(plan_definitions.pro_trial(true)) + .child(PlanDefinitions.pro_trial(is_v2, true)) .child( Button::new("pro", "Start Free Trial") .full_width() @@ -284,9 +237,7 @@ impl ZedAiOnboarding { } } - fn render_trial_state(&self, _cx: &mut App) -> AnyElement { - let plan_definitions = PlanDefinitions; - + fn render_trial_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement { v_flex() .relative() .gap_1() @@ -296,33 +247,12 @@ impl ZedAiOnboarding { .color(Color::Muted) .mb_2(), ) - .child(plan_definitions.pro_trial(false)) - .when_some( - self.dismiss_onboarding.as_ref(), - |this, dismiss_callback| { - let callback = dismiss_callback.clone(); - this.child( - h_flex().absolute().top_0().right_0().child( - IconButton::new("dismiss_onboarding", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| { - telemetry::event!( - "Banner Dismissed", - source = "AI Onboarding", - ); - callback(window, cx) - }), - ), - ) - }, - ) + .child(PlanDefinitions.pro_trial(is_v2, false)) + .children(self.render_dismiss_button()) .into_any_element() } - fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement { - let plan_definitions = PlanDefinitions; - + fn render_pro_plan_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement { v_flex() .gap_1() .child(Headline::new("Welcome to Zed Pro")) @@ -331,19 +261,8 @@ impl ZedAiOnboarding { .color(Color::Muted) .mb_2(), ) - .child(plan_definitions.pro_plan(false)) - .child( - Button::new("pro", "Continue with Zed Pro") - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| { - telemetry::event!("Banner Dismissed", source = "AI Onboarding"); - callback(window, cx) - } - }), - ) + .child(PlanDefinitions.pro_plan(is_v2, false)) + .children(self.render_dismiss_button()) .into_any_element() } } @@ -351,14 +270,17 @@ impl ZedAiOnboarding { impl RenderOnce for ZedAiOnboarding { fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement { if matches!(self.sign_in_status, SignInStatus::SignedIn) { - if self.has_accepted_terms_of_service { - match self.plan { - None | Some(Plan::ZedFree) => self.render_free_plan_state(cx), - Some(Plan::ZedProTrial) => self.render_trial_state(cx), - Some(Plan::ZedPro) => self.render_pro_plan_state(cx), + match self.plan { + None => self.render_free_plan_state(true, cx), + Some(plan @ (Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree))) => { + self.render_free_plan_state(plan.is_v2(), cx) + } + Some(plan @ (Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial))) => { + self.render_trial_state(plan.is_v2(), cx) + } + Some(plan @ (Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro))) => { + self.render_pro_plan_state(plan.is_v2(), cx) } - } else { - self.render_accept_terms_of_service() } } else { self.render_sign_in_disclaimer(cx) @@ -382,18 +304,15 @@ impl Component for ZedAiOnboarding { fn preview(_window: &mut Window, _cx: &mut App) -> Option { fn onboarding( sign_in_status: SignInStatus, - has_accepted_terms_of_service: bool, plan: Option, account_too_young: bool, ) -> AnyElement { ZedAiOnboarding { sign_in_status, - has_accepted_terms_of_service, plan, account_too_young, continue_with_zed_ai: Arc::new(|_, _| {}), sign_in: Arc::new(|_, _| {}), - accept_terms_of_service: Arc::new(|_, _| {}), dismiss_onboarding: None, } .into_any_element() @@ -407,27 +326,35 @@ impl Component for ZedAiOnboarding { .children(vec![ single_example( "Not Signed-in", - onboarding(SignInStatus::SignedOut, false, None, false), - ), - single_example( - "Not Accepted ToS", - onboarding(SignInStatus::SignedIn, false, None, false), + onboarding(SignInStatus::SignedOut, None, false), ), single_example( "Young Account", - onboarding(SignInStatus::SignedIn, true, None, true), + onboarding(SignInStatus::SignedIn, None, true), ), single_example( "Free Plan", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedFree), false), + onboarding( + SignInStatus::SignedIn, + Some(Plan::V2(PlanV2::ZedFree)), + false, + ), ), single_example( "Pro Trial", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedProTrial), false), + onboarding( + SignInStatus::SignedIn, + Some(Plan::V2(PlanV2::ZedProTrial)), + false, + ), ), single_example( "Pro Plan", - onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedPro), false), + onboarding( + SignInStatus::SignedIn, + Some(Plan::V2(PlanV2::ZedPro)), + false, + ), ), ]) .into_any_element(), diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index e9639ca075d1190ef6ab13f1bb01dd7333010d86..91191688b556d6b040ad8de760fba5ed0fd3659e 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -1,22 +1,19 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use client::{Client, UserStore, zed_urls}; -use cloud_llm_client::Plan; -use gpui::{ - Animation, AnimationExt, AnyElement, App, Entity, IntoElement, RenderOnce, Transformation, - Window, percentage, -}; -use ui::{Divider, Vector, VectorName, prelude::*}; +use cloud_llm_client::{Plan, PlanV1, PlanV2}; +use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window}; +use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*}; use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions}; #[derive(IntoElement, RegisterComponent)] pub struct AiUpsellCard { - pub sign_in_status: SignInStatus, - pub sign_in: Arc, - pub account_too_young: bool, - pub user_plan: Option, - pub tab_index: Option, + sign_in_status: SignInStatus, + sign_in: Arc, + account_too_young: bool, + user_plan: Option, + tab_index: Option, } impl AiUpsellCard { @@ -43,12 +40,16 @@ impl AiUpsellCard { tab_index: None, } } + + pub fn tab_index(mut self, tab_index: Option) -> Self { + self.tab_index = tab_index; + self + } } impl RenderOnce for AiUpsellCard { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let plan_definitions = PlanDefinitions; - let young_account_banner = YoungAccountBanner; + let is_v2_plan = self.user_plan.map_or(true, |plan| plan.is_v2()); let pro_section = v_flex() .flex_grow() @@ -65,7 +66,7 @@ impl RenderOnce for AiUpsellCard { ) .child(Divider::horizontal()), ) - .child(plan_definitions.pro_plan(false)); + .child(PlanDefinitions.pro_plan(is_v2_plan, false)); let free_section = v_flex() .flex_grow() @@ -82,12 +83,18 @@ impl RenderOnce for AiUpsellCard { ) .child(Divider::horizontal()), ) - .child(plan_definitions.free_plan()); + .child(PlanDefinitions.free_plan(is_v2_plan)); - let grid_bg = h_flex().absolute().inset_0().w_full().h(px(240.)).child( - Vector::new(VectorName::Grid, rems_from_px(500.), rems_from_px(240.)) - .color(Color::Custom(cx.theme().colors().border.opacity(0.05))), - ); + let grid_bg = h_flex() + .absolute() + .inset_0() + .w_full() + .h(px(240.)) + .bg(gpui::pattern_slash( + cx.theme().colors().border.opacity(0.1), + 2., + 25., + )); let gradient_bg = div() .absolute() @@ -142,11 +149,7 @@ impl RenderOnce for AiUpsellCard { rems_from_px(72.), ) .color(Color::Custom(cx.theme().colors().text_accent.alpha(0.3))) - .with_animation( - "loading_stamp", - Animation::new(Duration::from_secs(10)).repeat(), - |this, delta| this.transform(Transformation::rotate(percentage(delta))), - ), + .with_rotate_animation(10), ); let pro_trial_stamp = div() @@ -165,11 +168,11 @@ impl RenderOnce for AiUpsellCard { match self.sign_in_status { SignInStatus::SignedIn => match self.user_plan { - None | Some(Plan::ZedFree) => card + None | Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree)) => card .child(Label::new("Try Zed AI").size(LabelSize::Large)) .map(|this| { if self.account_too_young { - this.child(young_account_banner).child( + this.child(YoungAccountBanner).child( v_flex() .mt_2() .gap_1() @@ -184,7 +187,7 @@ impl RenderOnce for AiUpsellCard { ) .child(Divider::horizontal()), ) - .child(plan_definitions.pro_plan(true)) + .child(PlanDefinitions.pro_plan(is_v2_plan, true)) .child( Button::new("pro", "Get Started") .full_width() @@ -209,7 +212,7 @@ impl RenderOnce for AiUpsellCard { .child( footer_container .child( - Button::new("start_trial", "Start 14-day Free Pro Trial") + Button::new("start_trial", "Start Pro Trial") .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .when_some(self.tab_index, |this, tab_index| { @@ -224,23 +227,24 @@ impl RenderOnce for AiUpsellCard { }), ) .child( - Label::new("No credit card required") + Label::new("14 days, no credit card required") .size(LabelSize::Small) .color(Color::Muted), ), ) } }), - Some(Plan::ZedProTrial) => card - .child(pro_trial_stamp) - .child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large)) - .child( - Label::new("Here's what you get for the next 14 days:") - .color(Color::Muted) - .mb_2(), - ) - .child(plan_definitions.pro_trial(false)), - Some(Plan::ZedPro) => card + Some(plan @ (Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial))) => { + card.child(pro_trial_stamp) + .child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large)) + .child( + Label::new("Here's what you get for the next 14 days:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.pro_trial(plan.is_v2(), false)) + } + Some(plan @ (Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro))) => card .child(certified_user_stamp) .child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large)) .child( @@ -248,7 +252,7 @@ impl RenderOnce for AiUpsellCard { .color(Color::Muted) .mb_2(), ) - .child(plan_definitions.pro_plan(false)), + .child(PlanDefinitions.pro_plan(plan.is_v2(), false)), }, // Signed Out State _ => card @@ -320,7 +324,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), account_too_young: false, - user_plan: Some(Plan::ZedFree), + user_plan: Some(Plan::V2(PlanV2::ZedFree)), tab_index: Some(1), } .into_any_element(), @@ -331,7 +335,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), account_too_young: true, - user_plan: Some(Plan::ZedFree), + user_plan: Some(Plan::V2(PlanV2::ZedFree)), tab_index: Some(1), } .into_any_element(), @@ -342,7 +346,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), account_too_young: false, - user_plan: Some(Plan::ZedProTrial), + user_plan: Some(Plan::V2(PlanV2::ZedProTrial)), tab_index: Some(1), } .into_any_element(), @@ -353,7 +357,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), account_too_young: false, - user_plan: Some(Plan::ZedPro), + user_plan: Some(Plan::V2(PlanV2::ZedPro)), tab_index: Some(1), } .into_any_element(), diff --git a/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs b/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs index e883d8da8ce01bfea3f08676666c308a90f6d650..571f0f8e450ac2974cea2f4b2a7085069bc45c7c 100644 --- a/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs +++ b/crates/ai_onboarding/src/edit_prediction_onboarding_content.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use client::{Client, UserStore}; +use cloud_llm_client::{Plan, PlanV1, PlanV2}; use gpui::{Entity, IntoElement, ParentElement}; use ui::prelude::*; @@ -35,6 +36,10 @@ impl EditPredictionOnboarding { impl Render for EditPredictionOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_free_plan = self.user_store.read(cx).plan().is_some_and(|plan| { + matches!(plan, Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree)) + }); + let github_copilot = v_flex() .gap_1() .child(Label::new(if self.copilot_is_configured { @@ -67,7 +72,8 @@ impl Render for EditPredictionOnboarding { self.continue_with_zed_ai.clone(), cx, )) - .child(ui::Divider::horizontal()) - .child(github_copilot) + .when(is_free_plan, |this| { + this.child(ui::Divider::horizontal()).child(github_copilot) + }) } } diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 8d66f6c3563c482b2356e081b5786219f5bf1de3..11f563117132bd6860d8aef655bd0ed6b392e0e7 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -7,33 +7,62 @@ pub struct PlanDefinitions; impl PlanDefinitions { pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI."; - pub fn free_plan(&self) -> impl IntoElement { - List::new() - .child(ListBulletItem::new("50 prompts with Claude models")) - .child(ListBulletItem::new("2,000 accepted edit predictions")) + pub fn free_plan(&self, is_v2: bool) -> impl IntoElement { + if is_v2 { + List::new() + .child(ListBulletItem::new("2,000 accepted edit predictions")) + .child(ListBulletItem::new( + "Unlimited prompts with your AI API keys", + )) + .child(ListBulletItem::new( + "Unlimited use of external agents like Claude Code", + )) + } else { + List::new() + .child(ListBulletItem::new("50 prompts with Claude models")) + .child(ListBulletItem::new("2,000 accepted edit predictions")) + } } - pub fn pro_trial(&self, period: bool) -> impl IntoElement { - List::new() - .child(ListBulletItem::new("150 prompts with Claude models")) - .child(ListBulletItem::new( - "Unlimited edit predictions with Zeta, our open-source model", - )) - .when(period, |this| { - this.child(ListBulletItem::new( - "Try it out for 14 days for free, no credit card required", + pub fn pro_trial(&self, is_v2: bool, period: bool) -> impl IntoElement { + if is_v2 { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("$20 of tokens")) + .when(period, |this| { + this.child(ListBulletItem::new( + "Try it out for 14 days, no credit card required", + )) + }) + } else { + List::new() + .child(ListBulletItem::new("150 prompts with Claude models")) + .child(ListBulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", )) - }) + .when(period, |this| { + this.child(ListBulletItem::new( + "Try it out for 14 days, no credit card required", + )) + }) + } } - pub fn pro_plan(&self, price: bool) -> impl IntoElement { - List::new() - .child(ListBulletItem::new("500 prompts with Claude models")) - .child(ListBulletItem::new( - "Unlimited edit predictions with Zeta, our open-source model", - )) - .when(price, |this| { - this.child(ListBulletItem::new("$20 USD per month")) - }) + pub fn pro_plan(&self, is_v2: bool, price: bool) -> impl IntoElement { + if is_v2 { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("$5 of tokens")) + .child(ListBulletItem::new("Usage-based billing beyond $5")) + } else { + List::new() + .child(ListBulletItem::new("500 prompts with Claude models")) + .child(ListBulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", + )) + .when(price, |this| { + this.child(ListBulletItem::new("$20 USD per month")) + }) + } } } diff --git a/crates/ai_onboarding/src/young_account_banner.rs b/crates/ai_onboarding/src/young_account_banner.rs index 54f563e4aac8ca71fff16199cd6c2e8f81ad5376..c4a7887364ddb71c2c3e7ebc05b77e6e8a68370e 100644 --- a/crates/ai_onboarding/src/young_account_banner.rs +++ b/crates/ai_onboarding/src/young_account_banner.rs @@ -6,7 +6,7 @@ pub struct YoungAccountBanner; impl RenderOnce for YoungAccountBanner { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing-support@zed.dev."; + const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, GitHub accounts created fewer than 30 days ago are not eligible for the Pro trial. You can request an exception by reaching out to billing-support@zed.dev"; let label = div() .w_full() @@ -17,6 +17,6 @@ impl RenderOnce for YoungAccountBanner { div() .max_w_full() .my_1() - .child(Banner::new().severity(ui::Severity::Warning).child(label)) + .child(Banner::new().severity(Severity::Warning).child(label)) } } diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index 8e82c7cdd6a6a1ac7dd0e8b165f4d924c85d39ab..a9c7208b0caa9a2660aa723c903554205e672fe6 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -23,6 +23,6 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true +settings.workspace = true strum.workspace = true thiserror.workspace = true -workspace-hack.workspace = true diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 3ff1666755d439cf52a14ea635a06a7c3414d9f6..cd2077cdeb1370a9753df83f9b239ef776bab149 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -8,6 +8,7 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B use http_client::http::{self, HeaderMap, HeaderValue}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode}; use serde::{Deserialize, Serialize}; +pub use settings::{AnthropicAvailableModel as AvailableModel, ModelMode}; use strum::{EnumIter, EnumString}; use thiserror::Error; @@ -31,6 +32,24 @@ pub enum AnthropicModelMode { }, } +impl From for AnthropicModelMode { + fn from(value: ModelMode) -> Self { + match value { + ModelMode::Default => AnthropicModelMode::Default, + ModelMode::Thinking { budget_tokens } => AnthropicModelMode::Thinking { budget_tokens }, + } + } +} + +impl From for ModelMode { + fn from(value: AnthropicModelMode) -> Self { + match value { + AnthropicModelMode::Default => ModelMode::Default, + AnthropicModelMode::Thinking { budget_tokens } => ModelMode::Thinking { budget_tokens }, + } + } +} + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { @@ -48,7 +67,6 @@ pub enum Model { alias = "claude-opus-4-1-thinking-latest" )] ClaudeOpus4_1Thinking, - #[default] #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, #[serde( @@ -56,6 +74,14 @@ pub enum Model { alias = "claude-sonnet-4-thinking-latest" )] ClaudeSonnet4Thinking, + #[default] + #[serde(rename = "claude-sonnet-4-5", alias = "claude-sonnet-4-5-latest")] + ClaudeSonnet4_5, + #[serde( + rename = "claude-sonnet-4-5-thinking", + alias = "claude-sonnet-4-5-thinking-latest" + )] + ClaudeSonnet4_5Thinking, #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")] Claude3_7Sonnet, #[serde( @@ -65,6 +91,13 @@ pub enum Model { Claude3_7SonnetThinking, #[serde(rename = "claude-3-5-sonnet", alias = "claude-3-5-sonnet-latest")] Claude3_5Sonnet, + #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] + ClaudeHaiku4_5, + #[serde( + rename = "claude-haiku-4-5-thinking", + alias = "claude-haiku-4-5-thinking-latest" + )] + ClaudeHaiku4_5Thinking, #[serde(rename = "claude-3-5-haiku", alias = "claude-3-5-haiku-latest")] Claude3_5Haiku, #[serde(rename = "claude-3-opus", alias = "claude-3-opus-latest")] @@ -114,6 +147,14 @@ impl Model { return Ok(Self::ClaudeOpus4); } + if id.starts_with("claude-sonnet-4-5-thinking") { + return Ok(Self::ClaudeSonnet4_5Thinking); + } + + if id.starts_with("claude-sonnet-4-5") { + return Ok(Self::ClaudeSonnet4_5); + } + if id.starts_with("claude-sonnet-4-thinking") { return Ok(Self::ClaudeSonnet4Thinking); } @@ -134,6 +175,14 @@ impl Model { return Ok(Self::Claude3_5Sonnet); } + if id.starts_with("claude-haiku-4-5-thinking") { + return Ok(Self::ClaudeHaiku4_5Thinking); + } + + if id.starts_with("claude-haiku-4-5") { + return Ok(Self::ClaudeHaiku4_5); + } + if id.starts_with("claude-3-5-haiku") { return Ok(Self::Claude3_5Haiku); } @@ -161,9 +210,13 @@ impl Model { Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", + Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", + Self::ClaudeSonnet4_5Thinking => "claude-sonnet-4-5-thinking-latest", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", Self::Claude3_7Sonnet => "claude-3-7-sonnet-latest", Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking-latest", + Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", + Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-thinking-latest", Self::Claude3_5Haiku => "claude-3-5-haiku-latest", Self::Claude3Opus => "claude-3-opus-latest", Self::Claude3Sonnet => "claude-3-sonnet-20240229", @@ -178,8 +231,10 @@ impl Model { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", + Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking => "claude-sonnet-4-5-20250929", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest", + Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-20251001", Self::Claude3_5Haiku => "claude-3-5-haiku-latest", Self::Claude3Opus => "claude-3-opus-latest", Self::Claude3Sonnet => "claude-3-sonnet-20240229", @@ -196,9 +251,13 @@ impl Model { Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", + Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", + Self::ClaudeSonnet4_5Thinking => "Claude Sonnet 4.5 Thinking", Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking", + Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", + Self::ClaudeHaiku4_5Thinking => "Claude Haiku 4.5 Thinking", Self::Claude3_5Haiku => "Claude 3.5 Haiku", Self::Claude3Opus => "Claude 3 Opus", Self::Claude3Sonnet => "Claude 3 Sonnet", @@ -217,7 +276,11 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking @@ -242,7 +305,11 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking @@ -261,10 +328,13 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking | Self::Claude3_5Haiku => 8_192, + Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => 64_000, Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => 4_096, Self::Custom { max_output_tokens, .. @@ -280,9 +350,13 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking + | Self::ClaudeHaiku4_5 + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_5Haiku | Self::Claude3Opus | Self::Claude3Sonnet @@ -299,8 +373,10 @@ impl Model { Self::ClaudeOpus4 | Self::ClaudeOpus4_1 | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet + | Self::ClaudeHaiku4_5 | Self::Claude3_5Haiku | Self::Claude3Opus | Self::Claude3Sonnet @@ -308,6 +384,8 @@ impl Model { Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking + | Self::ClaudeHaiku4_5Thinking | Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking { budget_tokens: Some(4_096), }, @@ -363,17 +441,15 @@ pub async fn complete( api_url: &str, api_key: &str, request: Request, + beta_headers: String, ) -> Result { let uri = format!("{api_url}/v1/messages"); - let beta_headers = Model::from_id(&request.model) - .map(|model| model.beta_headers()) - .unwrap_or_else(|_| Model::DEFAULT_BETA_HEADERS.join(",")); let request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Anthropic-Version", "2023-06-01") .header("Anthropic-Beta", beta_headers) - .header("X-Api-Key", api_key) + .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); let serialized_request = @@ -409,8 +485,9 @@ pub async fn stream_completion( api_url: &str, api_key: &str, request: Request, + beta_headers: String, ) -> Result>, AnthropicError> { - stream_completion_with_rate_limit_info(client, api_url, api_key, request) + stream_completion_with_rate_limit_info(client, api_url, api_key, request, beta_headers) .await .map(|output| output.0) } @@ -506,6 +583,7 @@ pub async fn stream_completion_with_rate_limit_info( api_url: &str, api_key: &str, request: Request, + beta_headers: String, ) -> Result< ( BoxStream<'static, Result>, @@ -518,15 +596,13 @@ pub async fn stream_completion_with_rate_limit_info( stream: true, }; let uri = format!("{api_url}/v1/messages"); - let beta_headers = Model::from_id(&request.base.model) - .map(|model| model.beta_headers()) - .unwrap_or_else(|_| Model::DEFAULT_BETA_HEADERS.join(",")); + let request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Anthropic-Version", "2023-06-01") .header("Anthropic-Beta", beta_headers) - .header("X-Api-Key", api_key) + .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); let serialized_request = serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index 0527399af8b6f45ef18650ee5c286c0b51a83608..298d1a736959d1021da49a2c4f4356e12cf014be 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -16,8 +16,14 @@ anyhow.workspace = true futures.workspace = true gpui.workspace = true net.workspace = true -parking_lot.workspace = true smol.workspace = true +log.workspace = true tempfile.workspace = true util.workspace = true -workspace-hack.workspace = true +zeroize.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + +[package.metadata.cargo-machete] +ignored = ["log"] diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index f085a2be72d04d7c1d16f855230011639853ddf2..81cdd355bf7173b3954a8c2731a0728d354253ba 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -1,4 +1,16 @@ -use std::{ffi::OsStr, time::Duration}; +mod encrypted_password; + +pub use encrypted_password::{EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; + +use net::async_net::UnixListener; +use smol::lock::Mutex; +use util::fs::make_file_executable; + +use std::ffi::OsStr; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; use anyhow::{Context as _, Result}; use futures::channel::{mpsc, oneshot}; @@ -8,7 +20,13 @@ use futures::{ }; use gpui::{AsyncApp, BackgroundExecutor, Task}; use smol::fs; -use util::ResultExt as _; +use util::{ResultExt as _, debug_panic, maybe, paths::PathExt, shell::ShellKind}; + +/// Path to the program used for askpass +/// +/// On Unix and remote servers, this defaults to the current executable +/// On Windows, this is set to the CLI variant of zed +static ASKPASS_PROGRAM: OnceLock = OnceLock::new(); #[derive(PartialEq, Eq)] pub enum AskPassResult { @@ -17,39 +35,46 @@ pub enum AskPassResult { } pub struct AskPassDelegate { - tx: mpsc::UnboundedSender<(String, oneshot::Sender)>, + tx: mpsc::UnboundedSender<(String, oneshot::Sender)>, + executor: BackgroundExecutor, _task: Task<()>, } impl AskPassDelegate { pub fn new( cx: &mut AsyncApp, - password_prompt: impl Fn(String, oneshot::Sender, &mut AsyncApp) + Send + Sync + 'static, + password_prompt: impl Fn(String, oneshot::Sender, &mut AsyncApp) + + Send + + Sync + + 'static, ) -> Self { - let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender)>(); + let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<_>)>(); let task = cx.spawn(async move |cx: &mut AsyncApp| { while let Some((prompt, channel)) = rx.next().await { password_prompt(prompt, channel, cx); } }); - Self { tx, _task: task } + Self { + tx, + _task: task, + executor: cx.background_executor().clone(), + } } - pub async fn ask_password(&mut self, prompt: String) -> Result { - let (tx, rx) = oneshot::channel(); - self.tx.send((prompt, tx)).await?; - Ok(rx.await?) + pub fn ask_password(&mut self, prompt: String) -> Task> { + let mut this_tx = self.tx.clone(); + self.executor.spawn(async move { + let (tx, rx) = oneshot::channel(); + this_tx.send((prompt, tx)).await.ok()?; + rx.await.ok() + }) } } pub struct AskPassSession { - #[cfg(not(target_os = "windows"))] - script_path: std::path::PathBuf, - #[cfg(target_os = "windows")] - askpass_helper: String, #[cfg(target_os = "windows")] - secret: std::sync::Arc>, - _askpass_task: Task<()>, + secret: std::sync::Arc>, + askpass_task: PasswordProxy, askpass_opened_rx: Option>, askpass_kill_master_rx: Option>, } @@ -64,102 +89,57 @@ impl AskPassSession { /// You must retain this session until the master process exits. #[must_use] pub async fn new(executor: &BackgroundExecutor, mut delegate: AskPassDelegate) -> Result { - use net::async_net::UnixListener; - use util::fs::make_file_executable; - #[cfg(target_os = "windows")] - let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new())); - let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; - let askpass_socket = temp_dir.path().join("askpass.sock"); - let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); + let secret = std::sync::Arc::new(OnceLock::new()); let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>(); - let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?; - #[cfg(not(target_os = "windows"))] - let zed_path = util::get_shell_safe_zed_path()?; - #[cfg(target_os = "windows")] - let zed_path = std::env::current_exe() - .context("finding current executable path for use in askpass")?; + + let askpass_opened_tx = Arc::new(Mutex::new(Some(askpass_opened_tx))); let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>(); - let mut kill_tx = Some(askpass_kill_master_tx); + let kill_tx = Arc::new(Mutex::new(Some(askpass_kill_master_tx))); #[cfg(target_os = "windows")] let askpass_secret = secret.clone(); - let askpass_task = executor.spawn(async move { - let mut askpass_opened_tx = Some(askpass_opened_tx); + let get_password = { + let executor = executor.clone(); - while let Ok((mut stream, _)) = listener.accept().await { - if let Some(askpass_opened_tx) = askpass_opened_tx.take() { - askpass_opened_tx.send(()).ok(); - } - let mut buffer = Vec::new(); - let mut reader = BufReader::new(&mut stream); - if reader.read_until(b'\0', &mut buffer).await.is_err() { - buffer.clear(); - } - let prompt = String::from_utf8_lossy(&buffer); - if let Some(password) = delegate - .ask_password(prompt.to_string()) - .await - .context("getting askpass password") - .log_err() - { - stream.write_all(password.as_bytes()).await.log_err(); - #[cfg(target_os = "windows")] - { - *askpass_secret.lock() = password; + move |prompt| { + let prompt = delegate.ask_password(prompt); + let kill_tx = kill_tx.clone(); + let askpass_opened_tx = askpass_opened_tx.clone(); + #[cfg(target_os = "windows")] + let askpass_secret = askpass_secret.clone(); + executor.spawn(async move { + if let Some(askpass_opened_tx) = askpass_opened_tx.lock().await.take() { + askpass_opened_tx.send(()).ok(); } - } else { - if let Some(kill_tx) = kill_tx.take() { - kill_tx.send(()).log_err(); + if let Some(password) = prompt.await { + #[cfg(target_os = "windows")] + { + _ = askpass_secret.set(password.clone()); + } + ControlFlow::Continue(Ok(password)) + } else { + if let Some(kill_tx) = kill_tx.lock().await.take() { + kill_tx.send(()).log_err(); + } + ControlFlow::Break(()) } - // note: we expect the caller to drop this task when it's done. - // We need to keep the stream open until the caller is done to avoid - // spurious errors from ssh. - std::future::pending::<()>().await; - drop(stream); - } + }) } - drop(temp_dir) - }); - - // Create an askpass script that communicates back to this process. - let askpass_script = generate_askpass_script(&zed_path, &askpass_socket); - fs::write(&askpass_script_path, askpass_script) - .await - .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; - make_file_executable(&askpass_script_path).await?; - #[cfg(target_os = "windows")] - let askpass_helper = format!( - "powershell.exe -ExecutionPolicy Bypass -File {}", - askpass_script_path.display() - ); + }; + let askpass_task = PasswordProxy::new(get_password, executor.clone()).await?; Ok(Self { - #[cfg(not(target_os = "windows"))] - script_path: askpass_script_path, - #[cfg(target_os = "windows")] secret, - #[cfg(target_os = "windows")] - askpass_helper, - _askpass_task: askpass_task, + askpass_task, askpass_kill_master_rx: Some(askpass_kill_master_rx), askpass_opened_rx: Some(askpass_opened_rx), }) } - #[cfg(not(target_os = "windows"))] - pub fn script_path(&self) -> impl AsRef { - &self.script_path - } - - #[cfg(target_os = "windows")] - pub fn script_path(&self) -> impl AsRef { - &self.askpass_helper - } - // This will run the askpass task forever, resolving as many authentication requests as needed. // The caller is responsible for examining the result of their own commands and cancelling this // future when this is no longer needed. Note that this can only be called once, but due to the @@ -177,22 +157,129 @@ impl AskPassSession { _ = askpass_opened_rx.fuse() => { // Note: this await can only resolve after we are dropped. askpass_kill_master_rx.await.ok(); - return AskPassResult::CancelledByUser + AskPassResult::CancelledByUser } _ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => { - return AskPassResult::Timedout + AskPassResult::Timedout } } } /// This will return the password that was last set by the askpass script. #[cfg(target_os = "windows")] - pub fn get_password(&self) -> String { - self.secret.lock().clone() + pub fn get_password(&self) -> Option { + self.secret.get().cloned() + } + + pub fn script_path(&self) -> impl AsRef { + self.askpass_task.script_path() } } +pub struct PasswordProxy { + _task: Task<()>, + #[cfg(not(target_os = "windows"))] + askpass_script_path: std::path::PathBuf, + #[cfg(target_os = "windows")] + askpass_helper: String, +} + +impl PasswordProxy { + pub async fn new( + mut get_password: impl FnMut(String) -> Task>> + + 'static + + Send + + Sync, + executor: BackgroundExecutor, + ) -> Result { + let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; + let askpass_socket = temp_dir.path().join("askpass.sock"); + let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); + let current_exec = + std::env::current_exe().context("Failed to determine current zed executable path.")?; + + // TODO: inferred from the use of powershell.exe in askpass_helper_script + let shell_kind = if cfg!(windows) { + ShellKind::PowerShell + } else { + ShellKind::Posix + }; + let askpass_program = ASKPASS_PROGRAM + .get_or_init(|| current_exec) + .try_shell_safe(shell_kind) + .context("Failed to shell-escape Askpass program path.")? + .to_string(); + // Create an askpass script that communicates back to this process. + let askpass_script = generate_askpass_script(&askpass_program, &askpass_socket); + let _task = executor.spawn(async move { + maybe!(async move { + let listener = + UnixListener::bind(&askpass_socket).context("creating askpass socket")?; + + while let Ok((mut stream, _)) = listener.accept().await { + let mut buffer = Vec::new(); + let mut reader = BufReader::new(&mut stream); + if reader.read_until(b'\0', &mut buffer).await.is_err() { + buffer.clear(); + } + let prompt = String::from_utf8_lossy(&buffer).into_owned(); + let password = get_password(prompt).await; + match password { + ControlFlow::Continue(password) => { + if let Ok(password) = password + && let Ok(decrypted) = + password.decrypt(IKnowWhatIAmDoingAndIHaveReadTheDocs) + { + stream.write_all(decrypted.as_bytes()).await.log_err(); + } + } + ControlFlow::Break(()) => { + // note: we expect the caller to drop this task when it's done. + // We need to keep the stream open until the caller is done to avoid + // spurious errors from ssh. + std::future::pending::<()>().await; + drop(stream); + } + } + } + drop(temp_dir); + Result::<_, anyhow::Error>::Ok(()) + }) + .await + .log_err(); + }); + + fs::write(&askpass_script_path, askpass_script) + .await + .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; + make_file_executable(&askpass_script_path).await?; + #[cfg(target_os = "windows")] + let askpass_helper = format!( + "powershell.exe -ExecutionPolicy Bypass -File {}", + askpass_script_path.display() + ); + + Ok(Self { + _task, + #[cfg(not(target_os = "windows"))] + askpass_script_path, + #[cfg(target_os = "windows")] + askpass_helper, + }) + } + + pub fn script_path(&self) -> impl AsRef { + #[cfg(not(target_os = "windows"))] + { + &self.askpass_script_path + } + #[cfg(target_os = "windows")] + { + &self.askpass_helper + } + } +} /// The main function for when Zed is running in netcat mode for use in askpass. /// Called from both the remote server binary and the zed binary in their respective main functions. pub fn main(socket: &str) { @@ -215,7 +302,7 @@ pub fn main(socket: &str) { } #[cfg(target_os = "windows")] - while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') { + while buffer.last().is_some_and(|&b| b == b'\n' || b == b'\r') { buffer.pop(); } if buffer.last() != Some(&b'\0') { @@ -239,12 +326,17 @@ pub fn main(socket: &str) { } } +pub fn set_askpass_program(path: std::path::PathBuf) { + if ASKPASS_PROGRAM.set(path).is_err() { + debug_panic!("askpass program has already been set"); + } +} + #[inline] #[cfg(not(target_os = "windows"))] -fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String { +fn generate_askpass_script(askpass_program: &str, askpass_socket: &std::path::Path) -> String { format!( - "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", - zed_exe = zed_path, + "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", askpass_socket = askpass_socket.display(), print_args = "printf '%s\\0' \"$@\"", shebang = "#!/bin/sh", @@ -253,13 +345,12 @@ fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> #[inline] #[cfg(target_os = "windows")] -fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String { +fn generate_askpass_script(askpass_program: &str, askpass_socket: &std::path::Path) -> String { format!( r#" $ErrorActionPreference = 'Stop'; - ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null + ($args -join [char]0) | & {askpass_program} --askpass={askpass_socket} 2> $null "#, - zed_exe = zed_path.display(), askpass_socket = askpass_socket.display(), ) } diff --git a/crates/askpass/src/encrypted_password.rs b/crates/askpass/src/encrypted_password.rs new file mode 100644 index 0000000000000000000000000000000000000000..b5d54c18a1c608fa21a9a16c4e27c04530950c3a --- /dev/null +++ b/crates/askpass/src/encrypted_password.rs @@ -0,0 +1,102 @@ +//! This module provides [EncryptedPassword] for storage of passwords in memory. +//! On Windows that's implemented with CryptProtectMemory/CryptUnprotectMemory; on other platforms it just falls through +//! to string for now. +//! +//! The "safety" of this module lies in exploiting visibility rules of Rust: +//! 1. No outside module has access to the internal representation of [EncryptedPassword]. +//! 2. [EncryptedPassword] cannot be converted into a [String] or any other plaintext representation. +//! All use cases that do need such functionality (of which we have two right now) are implemented within this module. +//! +//! Note that this is not bulletproof. +//! 1. [ProcessExt] is implemented for [smol::process::Command], which is a builder for smol processes. +//! Before the process itself is spawned the contents of [EncryptedPassword] are unencrypted in env var storage of said builder. +//! 2. We're also sending plaintext passwords over RPC with [proto::AskPassResponse]. Go figure how great that is. +//! +//! Still, the goal of this module is to not have passwords laying around nilly-willy in memory. +//! We do not claim that it is fool-proof. +use anyhow::Result; +use zeroize::Zeroize; + +type LengthWithoutPadding = u32; +#[derive(Clone)] +pub struct EncryptedPassword(Vec, LengthWithoutPadding); + +impl Drop for EncryptedPassword { + fn drop(&mut self) { + self.0.zeroize(); + self.1.zeroize(); + } +} + +impl TryFrom<&str> for EncryptedPassword { + type Error = anyhow::Error; + fn try_from(password: &str) -> Result { + let len: u32 = password.len().try_into()?; + #[cfg(windows)] + { + use windows::Win32::Security::Cryptography::{ + CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_SAME_PROCESS, CryptProtectMemory, + }; + let mut value = password.bytes().collect::>(); + let padded_length = len.next_multiple_of(CRYPTPROTECTMEMORY_BLOCK_SIZE); + if padded_length != len { + value.resize(padded_length as usize, 0); + } + if len != 0 { + unsafe { + CryptProtectMemory( + value.as_mut_ptr() as _, + padded_length, + CRYPTPROTECTMEMORY_SAME_PROCESS, + )?; + } + } + Ok(Self(value, len)) + } + #[cfg(not(windows))] + Ok(Self(String::from(password).into(), len)) + } +} + +/// Read the docs for [EncryptedPassword]; please take care of not storing the plaintext string in memory for extended +/// periods of time. +pub struct IKnowWhatIAmDoingAndIHaveReadTheDocs; + +impl EncryptedPassword { + pub fn decrypt(mut self, _: IKnowWhatIAmDoingAndIHaveReadTheDocs) -> Result { + #[cfg(windows)] + { + use anyhow::Context; + use windows::Win32::Security::Cryptography::{ + CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_SAME_PROCESS, + CryptUnprotectMemory, + }; + assert_eq!( + self.0.len() % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize, + 0, + "Violated pre-condition (buffer size <{}> must be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE <{}>) for CryptUnprotectMemory.", + self.0.len(), + CRYPTPROTECTMEMORY_BLOCK_SIZE + ); + if self.1 != 0 { + unsafe { + CryptUnprotectMemory( + self.0.as_mut_ptr() as _, + self.0.len().try_into()?, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + .context("while decrypting a SSH password")? + }; + + { + // Remove padding + _ = self.0.drain(self.1 as usize..); + } + } + + Ok(String::from_utf8(std::mem::take(&mut self.0))?) + } + #[cfg(not(windows))] + Ok(String::from_utf8(std::mem::take(&mut self.0))?) + } +} diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 130394a30b7faf909e40922dd833dfcf9598d848..a56cd109f1be0eaa003d831ba31f4e288c94fd85 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -15,4 +15,3 @@ workspace = true anyhow.workspace = true gpui.workspace = true rust-embed.workspace = true -workspace-hack.workspace = true diff --git a/crates/assistant_slash_command/Cargo.toml b/crates/assistant_slash_command/Cargo.toml index f7b7af9b879492cbb48f4e88d8379b45cbc2d053..1fc3e8448c5e2d0c278254b369ac49fd2e9ce33a 100644 --- a/crates/assistant_slash_command/Cargo.toml +++ b/crates/assistant_slash_command/Cargo.toml @@ -25,8 +25,8 @@ parking_lot.workspace = true serde.workspace = true serde_json.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index 828f115bf5ed8cfedf14c67243b4a8048d07ebd0..2e6bb7325e14ac109d77854e1d848c541a685458 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -9,6 +9,7 @@ use anyhow::Result; use futures::StreamExt; use futures::stream::{self, BoxStream}; use gpui::{App, SharedString, Task, WeakEntity, Window}; +use language::CodeLabelBuilder; use language::HighlightId; use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt}; pub use language_model::Role; @@ -161,7 +162,7 @@ impl SlashCommandOutput { } /// Returns this [`SlashCommandOutput`] as a stream of [`SlashCommandEvent`]s. - pub fn to_event_stream(mut self) -> BoxStream<'static, Result> { + pub fn into_event_stream(mut self) -> BoxStream<'static, Result> { self.ensure_valid_section_ranges(); let mut events = Vec::new(); @@ -328,15 +329,15 @@ impl SlashCommandLine { } pub fn create_label_for_command(command_name: &str, arguments: &[&str], cx: &App) -> CodeLabel { - let mut label = CodeLabel::default(); + let mut label = CodeLabelBuilder::default(); label.push_str(command_name, None); + label.respan_filter_range(None); label.push_str(" ", None); label.push_str( &arguments.join(" "), cx.theme().syntax().highlight_id("comment").map(HighlightId), ); - label.filter_range = 0..command_name.len(); - label + label.build() } #[cfg(test)] @@ -363,7 +364,7 @@ mod tests { run_commands_in_text: false, }; - let events = output.clone().to_event_stream().collect::>().await; + let events = output.clone().into_event_stream().collect::>().await; let events = events .into_iter() .filter_map(|event| event.ok()) @@ -386,7 +387,7 @@ mod tests { ); let new_output = - SlashCommandOutput::from_event_stream(output.clone().to_event_stream()) + SlashCommandOutput::from_event_stream(output.clone().into_event_stream()) .await .unwrap(); @@ -415,7 +416,7 @@ mod tests { run_commands_in_text: false, }; - let events = output.clone().to_event_stream().collect::>().await; + let events = output.clone().into_event_stream().collect::>().await; let events = events .into_iter() .filter_map(|event| event.ok()) @@ -452,7 +453,7 @@ mod tests { ); let new_output = - SlashCommandOutput::from_event_stream(output.clone().to_event_stream()) + SlashCommandOutput::from_event_stream(output.clone().into_event_stream()) .await .unwrap(); @@ -493,7 +494,7 @@ mod tests { run_commands_in_text: false, }; - let events = output.clone().to_event_stream().collect::>().await; + let events = output.clone().into_event_stream().collect::>().await; let events = events .into_iter() .filter_map(|event| event.ok()) @@ -562,7 +563,7 @@ mod tests { ); let new_output = - SlashCommandOutput::from_event_stream(output.clone().to_event_stream()) + SlashCommandOutput::from_event_stream(output.clone().into_event_stream()) .await .unwrap(); diff --git a/crates/assistant_slash_command/src/extension_slash_command.rs b/crates/assistant_slash_command/src/extension_slash_command.rs index 74c46ffb5ffefb2ccbefdba8edec4e9e778489b5..6dd2c05f192358f9fcd843add21df94e301dc6b7 100644 --- a/crates/assistant_slash_command/src/extension_slash_command.rs +++ b/crates/assistant_slash_command/src/extension_slash_command.rs @@ -1,12 +1,11 @@ -use std::path::PathBuf; -use std::sync::{Arc, atomic::AtomicBool}; - use anyhow::Result; use async_trait::async_trait; use extension::{Extension, ExtensionHostProxy, ExtensionSlashCommandProxy, WorktreeDelegate}; use gpui::{App, Task, WeakEntity, Window}; use language::{BufferSnapshot, LspAdapterDelegate}; +use std::sync::{Arc, atomic::AtomicBool}; use ui::prelude::*; +use util::rel_path::RelPath; use workspace::Workspace; use crate::{ @@ -51,10 +50,10 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { } fn root_path(&self) -> String { - self.0.worktree_root_path().to_string_lossy().to_string() + self.0.worktree_root_path().to_string_lossy().into_owned() } - async fn read_text_file(&self, path: PathBuf) -> Result { + async fn read_text_file(&self, path: &RelPath) -> Result { self.0.read_text_file(path).await } @@ -62,7 +61,7 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { self.0 .which(binary_name.as_ref()) .await - .map(|path| path.to_string_lossy().to_string()) + .map(|path| path.to_string_lossy().into_owned()) } async fn shell_env(&self) -> Vec<(String, String)> { @@ -166,7 +165,7 @@ impl SlashCommand for ExtensionSlashCommand { .collect(), run_commands_in_text: false, } - .to_event_stream()) + .into_event_stream()) }) } } diff --git a/crates/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index c054c3ced84825bcd131bdd76644c00595c4c4a9..85dd92501f93fb79ba1d3f70b3a06f1077356cfa 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -14,7 +14,6 @@ path = "src/assistant_slash_commands.rs" [dependencies] anyhow.workspace = true assistant_slash_command.workspace = true -cargo_toml.workspace = true chrono.workspace = true collections.workspace = true context_server.workspace = true @@ -35,14 +34,15 @@ serde.workspace = true serde_json.workspace = true smol.workspace = true text.workspace = true -toml.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true worktree.workspace = true -workspace-hack.workspace = true [dev-dependencies] +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -settings.workspace = true +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/assistant_slash_commands/src/assistant_slash_commands.rs b/crates/assistant_slash_commands/src/assistant_slash_commands.rs index fb00a912197e07942a67ad92418b85c4920ad66b..2bf2573e99d7a5a0140c1972967ec68523b0b56a 100644 --- a/crates/assistant_slash_commands/src/assistant_slash_commands.rs +++ b/crates/assistant_slash_commands/src/assistant_slash_commands.rs @@ -1,4 +1,3 @@ -mod cargo_workspace_command; mod context_server_command; mod default_command; mod delta_command; @@ -12,7 +11,6 @@ mod streaming_example_command; mod symbols_command; mod tab_command; -pub use crate::cargo_workspace_command::*; pub use crate::context_server_command::*; pub use crate::default_command::*; pub use crate::delta_command::*; diff --git a/crates/assistant_slash_commands/src/cargo_workspace_command.rs b/crates/assistant_slash_commands/src/cargo_workspace_command.rs index 8b088ea012de5f1ef6f7c787924c3cb2c6ec44c8..8a6950a4a2ff40d0452669dd388886d05d71022a 100644 --- a/crates/assistant_slash_commands/src/cargo_workspace_command.rs +++ b/crates/assistant_slash_commands/src/cargo_workspace_command.rs @@ -13,6 +13,7 @@ use std::{ sync::{Arc, atomic::AtomicBool}, }; use ui::prelude::*; +use util::rel_path::RelPath; use workspace::Workspace; pub struct CargoWorkspaceSlashCommand; @@ -79,7 +80,7 @@ impl CargoWorkspaceSlashCommand { fn path_to_cargo_toml(project: Entity, cx: &mut App) -> Option> { let worktree = project.read(cx).worktrees(cx).next()?; let worktree = worktree.read(cx); - let entry = worktree.entry_for_path("Cargo.toml")?; + let entry = worktree.entry_for_path(RelPath::new("Cargo.toml").unwrap())?; let path = ProjectPath { worktree_id: worktree.id(), path: entry.path.clone(), @@ -150,7 +151,7 @@ impl SlashCommand for CargoWorkspaceSlashCommand { }], run_commands_in_text: false, } - .to_event_stream()) + .into_event_stream()) }) }); output.unwrap_or_else(|error| Task::ready(Err(error))) diff --git a/crates/assistant_slash_commands/src/context_server_command.rs b/crates/assistant_slash_commands/src/context_server_command.rs index f223d3b184ccf6d795b80caca9a6a616aafc7f33..ee0cbf54c23a595f6503162c91dd1df3be019dd5 100644 --- a/crates/assistant_slash_commands/src/context_server_command.rs +++ b/crates/assistant_slash_commands/src/context_server_command.rs @@ -39,12 +39,12 @@ impl SlashCommand for ContextServerSlashCommand { fn label(&self, cx: &App) -> language::CodeLabel { let mut parts = vec![self.prompt.name.as_str()]; - if let Some(args) = &self.prompt.arguments { - if let Some(arg) = args.first() { - parts.push(arg.name.as_str()); - } + if let Some(args) = &self.prompt.arguments + && let Some(arg) = args.first() + { + parts.push(arg.name.as_str()); } - create_label_for_command(&parts[0], &parts[1..], cx) + create_label_for_command(parts[0], &parts[1..], cx) } fn description(&self) -> String { @@ -62,9 +62,10 @@ impl SlashCommand for ContextServerSlashCommand { } fn requires_argument(&self) -> bool { - self.prompt.arguments.as_ref().map_or(false, |args| { - args.iter().any(|arg| arg.required == Some(true)) - }) + self.prompt + .arguments + .as_ref() + .is_some_and(|args| args.iter().any(|arg| arg.required == Some(true))) } fn complete_argument( @@ -190,7 +191,7 @@ impl SlashCommand for ContextServerSlashCommand { text: prompt, run_commands_in_text: false, } - .to_event_stream()) + .into_event_stream()) }) } else { Task::ready(Err(anyhow!("Context server not found"))) diff --git a/crates/assistant_slash_commands/src/default_command.rs b/crates/assistant_slash_commands/src/default_command.rs index 6fce7f07a46d3d248c1c1292a67f1ad577c43645..01eff881cff0f07db9bf34e25853432e413ed79f 100644 --- a/crates/assistant_slash_commands/src/default_command.rs +++ b/crates/assistant_slash_commands/src/default_command.rs @@ -85,7 +85,7 @@ impl SlashCommand for DefaultSlashCommand { text, run_commands_in_text: true, } - .to_event_stream()) + .into_event_stream()) }) } } diff --git a/crates/assistant_slash_commands/src/delta_command.rs b/crates/assistant_slash_commands/src/delta_command.rs index 8c840c17b2c7fe9d8c8995b21c35cb35980dd71b..ea05fca588d0a496eeb3a2d2128b3861ba8a1e30 100644 --- a/crates/assistant_slash_commands/src/delta_command.rs +++ b/crates/assistant_slash_commands/src/delta_command.rs @@ -66,23 +66,22 @@ impl SlashCommand for DeltaSlashCommand { .metadata .as_ref() .and_then(|value| serde_json::from_value::(value.clone()).ok()) + && paths.insert(metadata.path.clone()) { - if paths.insert(metadata.path.clone()) { - file_command_old_outputs.push( - context_buffer - .as_rope() - .slice(section.range.to_offset(&context_buffer)), - ); - file_command_new_outputs.push(Arc::new(FileSlashCommand).run( - std::slice::from_ref(&metadata.path), - context_slash_command_output_sections, - context_buffer.clone(), - workspace.clone(), - delegate.clone(), - window, - cx, - )); - } + file_command_old_outputs.push( + context_buffer + .as_rope() + .slice(section.range.to_offset(&context_buffer)), + ); + file_command_new_outputs.push(Arc::new(FileSlashCommand).run( + std::slice::from_ref(&metadata.path), + context_slash_command_output_sections, + context_buffer.clone(), + workspace.clone(), + delegate.clone(), + window, + cx, + )); } } @@ -95,31 +94,31 @@ impl SlashCommand for DeltaSlashCommand { .into_iter() .zip(file_command_new_outputs) { - if let Ok(new_output) = new_output { - if let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await - { - if let Some(file_command_range) = new_output.sections.first() { - let new_text = &new_output.text[file_command_range.range.clone()]; - if old_text.chars().ne(new_text.chars()) { - changes_detected = true; - output.sections.extend(new_output.sections.into_iter().map( - |section| SlashCommandOutputSection { - range: output.text.len() + section.range.start - ..output.text.len() + section.range.end, - icon: section.icon, - label: section.label, - metadata: section.metadata, - }, - )); - output.text.push_str(&new_output.text); - } - } + if let Ok(new_output) = new_output + && let Ok(new_output) = SlashCommandOutput::from_event_stream(new_output).await + && let Some(file_command_range) = new_output.sections.first() + { + let new_text = &new_output.text[file_command_range.range.clone()]; + if old_text.chars().ne(new_text.chars()) { + changes_detected = true; + output + .sections + .extend(new_output.sections.into_iter().map(|section| { + SlashCommandOutputSection { + range: output.text.len() + section.range.start + ..output.text.len() + section.range.end, + icon: section.icon, + label: section.label, + metadata: section.metadata, + } + })); + output.text.push_str(&new_output.text); } } } anyhow::ensure!(changes_detected, "no new changes detected"); - Ok(output.to_event_stream()) + Ok(output.into_event_stream()) }) } } diff --git a/crates/assistant_slash_commands/src/diagnostics_command.rs b/crates/assistant_slash_commands/src/diagnostics_command.rs index 2feabd8b1e018cc6495a88fe5a89276e3e19dfb1..3a9c33061575d385652b685dcca70ee87c6cac35 100644 --- a/crates/assistant_slash_commands/src/diagnostics_command.rs +++ b/crates/assistant_slash_commands/src/diagnostics_command.rs @@ -6,19 +6,19 @@ use assistant_slash_command::{ use fuzzy::{PathMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use language::{ - Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate, + Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, LspAdapterDelegate, OffsetRangeExt, ToOffset, }; use project::{DiagnosticSummary, PathMatchCandidateSet, Project}; use rope::Point; use std::{ fmt::Write, - path::{Path, PathBuf}, + path::Path, sync::{Arc, atomic::AtomicBool}, }; use ui::prelude::*; -use util::ResultExt; -use util::paths::PathMatcher; +use util::paths::{PathMatcher, PathStyle}; +use util::{ResultExt, rel_path::RelPath}; use workspace::Workspace; use crate::create_label_for_command; @@ -36,7 +36,7 @@ impl DiagnosticsSlashCommand { if query.is_empty() { let workspace = workspace.read(cx); let entries = workspace.recent_navigation_history(Some(10), cx); - let path_prefix: Arc = Arc::default(); + let path_prefix: Arc = RelPath::empty().into(); Task::ready( entries .into_iter() @@ -44,7 +44,7 @@ impl DiagnosticsSlashCommand { score: 0., positions: Vec::new(), worktree_id: entry.worktree_id.to_usize(), - path: entry.path.clone(), + path: entry.path, path_prefix: path_prefix.clone(), is_dir: false, // Diagnostics can't be produced for directories distance_to_relative_ancestor: 0, @@ -61,7 +61,7 @@ impl DiagnosticsSlashCommand { snapshot: worktree.snapshot(), include_ignored: worktree .root_entry() - .map_or(false, |entry| entry.is_ignored), + .is_some_and(|entry| entry.is_ignored), include_root_name: true, candidates: project::Candidates::Entries, } @@ -73,7 +73,7 @@ impl DiagnosticsSlashCommand { fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), - None, + &None, false, 100, &cancellation_flag, @@ -125,6 +125,7 @@ impl SlashCommand for DiagnosticsSlashCommand { let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; + let path_style = workspace.read(cx).project().read(cx).path_style(cx); let query = arguments.last().cloned().unwrap_or_default(); let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx); @@ -134,11 +135,11 @@ impl SlashCommand for DiagnosticsSlashCommand { .await .into_iter() .map(|path_match| { - format!( - "{}{}", - path_match.path_prefix, - path_match.path.to_string_lossy() - ) + path_match + .path_prefix + .join(&path_match.path) + .display(path_style) + .to_string() }) .collect(); @@ -183,13 +184,15 @@ impl SlashCommand for DiagnosticsSlashCommand { return Task::ready(Err(anyhow!("workspace was dropped"))); }; - let options = Options::parse(arguments); + let project = workspace.read(cx).project(); + let path_style = project.read(cx).path_style(cx); + let options = Options::parse(arguments, path_style); - let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx); + let task = collect_diagnostics(project.clone(), options, cx); window.spawn(cx, async move |_| { task.await? - .map(|output| output.to_event_stream()) + .map(|output| output.into_event_stream()) .context("No diagnostics found") }) } @@ -204,14 +207,14 @@ struct Options { const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings"; impl Options { - fn parse(arguments: &[String]) -> Self { + fn parse(arguments: &[String], path_style: PathStyle) -> Self { let mut include_warnings = false; let mut path_matcher = None; for arg in arguments { if arg == INCLUDE_WARNINGS_ARGUMENT { include_warnings = true; } else { - path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err(); + path_matcher = PathMatcher::new(&[arg.to_owned()], path_style).log_err(); } } Self { @@ -237,21 +240,15 @@ fn collect_diagnostics( None }; + let path_style = project.read(cx).path_style(cx); let glob_is_exact_file_match = if let Some(path) = options .path_matcher .as_ref() .and_then(|pm| pm.sources().first()) { - PathBuf::try_from(path) - .ok() - .and_then(|path| { - project.read(cx).worktrees(cx).find_map(|worktree| { - let worktree = worktree.read(cx); - let worktree_root_path = Path::new(worktree.root_name()); - let relative_path = path.strip_prefix(worktree_root_path).ok()?; - worktree.absolutize(&relative_path).ok() - }) - }) + project + .read(cx) + .find_project_path(Path::new(path), cx) .is_some() } else { false @@ -263,9 +260,8 @@ fn collect_diagnostics( .diagnostic_summaries(false, cx) .flat_map(|(path, _, summary)| { let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?; - let mut path_buf = PathBuf::from(worktree.read(cx).root_name()); - path_buf.push(&path.path); - Some((path, path_buf, summary)) + let full_path = worktree.read(cx).root_name().join(&path.path); + Some((path, full_path, summary)) }) .collect(); @@ -280,10 +276,10 @@ fn collect_diagnostics( let mut project_summary = DiagnosticSummary::default(); for (project_path, path, summary) in diagnostic_summaries { - if let Some(path_matcher) = &options.path_matcher { - if !path_matcher.is_match(&path) { - continue; - } + if let Some(path_matcher) = &options.path_matcher + && !path_matcher.is_match(&path.as_std_path()) + { + continue; } project_summary.error_count += summary.error_count; @@ -294,7 +290,7 @@ fn collect_diagnostics( } let last_end = output.text.len(); - let file_path = path.to_string_lossy().to_string(); + let file_path = path.display(path_style).to_string(); if !glob_is_exact_file_match { writeln!(&mut output.text, "{file_path}").unwrap(); } @@ -365,13 +361,13 @@ pub fn collect_buffer_diagnostics( ) { for (_, group) in snapshot.diagnostic_groups(None) { let entry = &group.entries[group.primary_ix]; - collect_diagnostic(output, entry, &snapshot, include_warnings) + collect_diagnostic(output, entry, snapshot, include_warnings) } } fn collect_diagnostic( output: &mut SlashCommandOutput, - entry: &DiagnosticEntry, + entry: &DiagnosticEntryRef<'_, Anchor>, snapshot: &BufferSnapshot, include_warnings: bool, ) { @@ -396,7 +392,7 @@ fn collect_diagnostic( let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE); let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1; let excerpt_range = - Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot); + Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot); output.text.push_str("```"); if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) { diff --git a/crates/assistant_slash_commands/src/fetch_command.rs b/crates/assistant_slash_commands/src/fetch_command.rs index 4e0bb3d05a7f3c2828206a6c4deeaee8c505ed7e..6d3f66c9a23c896c765ba6c0a43b7a99dbc7ee73 100644 --- a/crates/assistant_slash_commands/src/fetch_command.rs +++ b/crates/assistant_slash_commands/src/fetch_command.rs @@ -177,7 +177,7 @@ impl SlashCommand for FetchSlashCommand { }], run_commands_in_text: false, } - .to_event_stream()) + .into_event_stream()) }) } } diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index c913ccc0f199cb5d03cf0a91d67459f3728b55a9..6fe1a410d3551fe72737500ad8b143a392645d1b 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -7,18 +7,18 @@ use futures::Stream; use futures::channel::mpsc; use fuzzy::PathMatch; use gpui::{App, Entity, Task, WeakEntity}; -use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate}; +use language::{BufferSnapshot, CodeLabelBuilder, HighlightId, LineEnding, LspAdapterDelegate}; use project::{PathMatchCandidateSet, Project}; use serde::{Deserialize, Serialize}; use smol::stream::StreamExt; use std::{ fmt::Write, ops::{Range, RangeInclusive}, - path::{Path, PathBuf}, + path::Path, sync::{Arc, atomic::AtomicBool}, }; use ui::prelude::*; -use util::ResultExt; +use util::{ResultExt, rel_path::RelPath}; use workspace::Workspace; use worktree::ChildEntriesOptions; @@ -48,7 +48,7 @@ impl FileSlashCommand { include_dirs: true, include_ignored: false, }; - let entries = worktree.child_entries_with_options(Path::new(""), options); + let entries = worktree.child_entries_with_options(RelPath::empty(), options); entries.map(move |entry| { ( project::ProjectPath { @@ -61,19 +61,18 @@ impl FileSlashCommand { })) .collect::>(); - let path_prefix: Arc = Arc::default(); + let path_prefix: Arc = RelPath::empty().into(); Task::ready( entries .into_iter() .filter_map(|(entry, is_dir)| { let worktree = project.worktree_for_id(entry.worktree_id, cx)?; - let mut full_path = PathBuf::from(worktree.read(cx).root_name()); - full_path.push(&entry.path); + let full_path = worktree.read(cx).root_name().join(&entry.path); Some(PathMatch { score: 0., positions: Vec::new(), worktree_id: entry.worktree_id.to_usize(), - path: full_path.into(), + path: full_path, path_prefix: path_prefix.clone(), distance_to_relative_ancestor: 0, is_dir, @@ -92,7 +91,7 @@ impl FileSlashCommand { snapshot: worktree.snapshot(), include_ignored: worktree .root_entry() - .map_or(false, |entry| entry.is_ignored), + .is_some_and(|entry| entry.is_ignored), include_root_name: true, candidates: project::Candidates::Entries, } @@ -104,7 +103,7 @@ impl FileSlashCommand { fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), - None, + &None, false, 100, &cancellation_flag, @@ -149,6 +148,8 @@ impl SlashCommand for FileSlashCommand { return Task::ready(Err(anyhow!("workspace was dropped"))); }; + let path_style = workspace.read(cx).path_style(cx); + let paths = self.search_paths( arguments.last().cloned().unwrap_or_default(), cancellation_flag, @@ -161,14 +162,14 @@ impl SlashCommand for FileSlashCommand { .await .into_iter() .filter_map(|path_match| { - let text = format!( - "{}{}", - path_match.path_prefix, - path_match.path.to_string_lossy() - ); - - let mut label = CodeLabel::default(); - let file_name = path_match.path.file_name()?.to_string_lossy(); + let text = path_match + .path_prefix + .join(&path_match.path) + .display(path_style) + .to_string(); + + let mut label = CodeLabelBuilder::default(); + let file_name = path_match.path.file_name()?; let label_text = if path_match.is_dir { format!("{}/ ", file_name) } else { @@ -177,10 +178,10 @@ impl SlashCommand for FileSlashCommand { label.push_str(label_text.as_str(), None); label.push_str(&text, comment_id); - label.filter_range = 0..file_name.len(); + label.respan_filter_range(Some(file_name)); Some(ArgumentCompletion { - label, + label: label.build(), new_text: text, after_completion: AfterCompletion::Compose, replace_previous_arguments: false, @@ -223,7 +224,7 @@ fn collect_files( cx: &mut App, ) -> impl Stream> + use<> { let Ok(matchers) = glob_inputs - .into_iter() + .iter() .map(|glob_input| { custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) .with_context(|| format!("invalid path {glob_input}")) @@ -247,14 +248,13 @@ fn collect_files( cx.spawn(async move |cx| { for snapshot in snapshots { let worktree_id = snapshot.id(); - let mut directory_stack: Vec> = Vec::new(); - let mut folded_directory_names_stack = Vec::new(); + let path_style = snapshot.path_style(); + let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; for entry in snapshot.entries(false, 0) { - let mut path_including_worktree_name = PathBuf::new(); - path_including_worktree_name.push(snapshot.root_name()); - path_including_worktree_name.push(&entry.path); + let path_including_worktree_name = snapshot.root_name().join(&entry.path); if !matchers .iter() @@ -277,13 +277,7 @@ fn collect_files( )))?; } - let filename = entry - .path - .file_name() - .unwrap_or_default() - .to_str() - .unwrap_or_default() - .to_string(); + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { // Auto-fold directories that contain no files @@ -292,24 +286,23 @@ fn collect_files( if child_entries.next().is_none() && child.kind.is_dir() { if is_top_level_directory { is_top_level_directory = false; - folded_directory_names_stack.push( - path_including_worktree_name.to_string_lossy().to_string(), - ); + folded_directory_names = + folded_directory_names.join(&path_including_worktree_name); } else { - folded_directory_names_stack.push(filename.to_string()); + folded_directory_names = + folded_directory_names.join(RelPath::unix(&filename).unwrap()); } continue; } } else { // Skip empty directories - folded_directory_names_stack.clear(); + folded_directory_names = RelPath::empty().into(); continue; } - let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/"); - if prefix_paths.is_empty() { + if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; - path_including_worktree_name.to_string_lossy().to_string() + path_including_worktree_name.display(path_style).to_string() } else { filename }; @@ -320,28 +313,23 @@ fn collect_files( }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { - text: label, + text: label.to_string(), run_commands_in_text: false, }, )))?; directory_stack.push(entry.path.clone()); } else { - // todo(windows) - // Potential bug: this assumes that the path separator is always `\` on Windows - let entry_name = format!( - "{}{}{}", - prefix_paths, - std::path::MAIN_SEPARATOR_STR, - &filename - ); + let entry_name = + folded_directory_names.join(RelPath::unix(&filename).unwrap()); + let entry_name = entry_name.display(path_style); events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection { icon: IconName::Folder, - label: entry_name.clone().into(), + label: entry_name.to_string().into(), metadata: None, }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { - text: entry_name, + text: entry_name.to_string(), run_commands_in_text: false, }, )))?; @@ -356,7 +344,7 @@ fn collect_files( } else if entry.is_file() { let Some(open_buffer_task) = project_handle .update(cx, |project, cx| { - project.open_buffer((worktree_id, &entry.path), cx) + project.open_buffer((worktree_id, entry.path.clone()), cx) }) .ok() else { @@ -367,11 +355,11 @@ fn collect_files( let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; append_buffer_to_output( &snapshot, - Some(&path_including_worktree_name), + Some(path_including_worktree_name.display(path_style).as_ref()), &mut output, ) .log_err(); - let mut buffer_events = output.to_event_stream(); + let mut buffer_events = output.into_event_stream(); while let Some(event) = buffer_events.next().await { events_tx.unbounded_send(event)?; } @@ -379,7 +367,7 @@ fn collect_files( } } - while let Some(_) = directory_stack.pop() { + while directory_stack.pop().is_some() { events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?; } } @@ -392,18 +380,18 @@ fn collect_files( } pub fn codeblock_fence_for_path( - path: Option<&Path>, + path: Option<&str>, row_range: Option>, ) -> String { let mut text = String::new(); write!(text, "```").unwrap(); if let Some(path) = path { - if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) { write!(text, "{} ", extension).unwrap(); } - write!(text, "{}", path.display()).unwrap(); + write!(text, "{path}").unwrap(); } else { write!(text, "untitled").unwrap(); } @@ -423,12 +411,12 @@ pub struct FileCommandMetadata { pub fn build_entry_output_section( range: Range, - path: Option<&Path>, + path: Option<&str>, is_directory: bool, line_range: Option>, ) -> SlashCommandOutputSection { let mut label = if let Some(path) = path { - path.to_string_lossy().to_string() + path.to_string() } else { "untitled".to_string() }; @@ -451,7 +439,7 @@ pub fn build_entry_output_section( } else { path.and_then(|path| { serde_json::to_value(FileCommandMetadata { - path: path.to_string_lossy().to_string(), + path: path.to_string(), }) .ok() }) @@ -462,10 +450,9 @@ pub fn build_entry_output_section( /// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix /// check. Only subpaths pass the prefix check, rather than any prefix. mod custom_path_matcher { - use std::{fmt::Debug as _, path::Path}; - use globset::{Glob, GlobSet, GlobSetBuilder}; - use util::paths::SanitizedPath; + use std::fmt::Debug as _; + use util::{paths::SanitizedPath, rel_path::RelPath}; #[derive(Clone, Debug, Default)] pub struct PathMatcher { @@ -491,13 +478,13 @@ mod custom_path_matcher { impl PathMatcher { pub fn new(globs: &[String]) -> Result { let globs = globs - .into_iter() - .map(|glob| Glob::new(&SanitizedPath::from(glob).to_glob_string())) + .iter() + .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) .collect::, _>>()?; let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); let sources_with_trailing_slash = globs .iter() - .map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR) + .map(|glob| glob.glob().to_string() + "/") .collect(); let mut glob_builder = GlobSetBuilder::new(); for single_glob in globs { @@ -511,16 +498,13 @@ mod custom_path_matcher { }) } - pub fn is_match>(&self, other: P) -> bool { - let other_path = other.as_ref(); + pub fn is_match(&self, other: &RelPath) -> bool { self.sources .iter() .zip(self.sources_with_trailing_slash.iter()) .any(|(source, with_slash)| { - let as_bytes = other_path.as_os_str().as_encoded_bytes(); - // todo(windows) - // Potential bug: this assumes that the path separator is always `\` on Windows - let with_slash = if source.ends_with(std::path::MAIN_SEPARATOR_STR) { + let as_bytes = other.as_unix_str().as_bytes(); + let with_slash = if source.ends_with('/') { source.as_bytes() } else { with_slash.as_bytes() @@ -528,15 +512,15 @@ mod custom_path_matcher { as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) }) - || self.glob.is_match(other_path) - || self.check_with_end_separator(other_path) + || self.glob.is_match(other.as_std_path()) + || self.check_with_end_separator(other) } - fn check_with_end_separator(&self, path: &Path) -> bool { - let path_str = path.to_string_lossy(); - let separator = std::path::MAIN_SEPARATOR_STR; + fn check_with_end_separator(&self, path: &RelPath) -> bool { + let path_str = path.as_unix_str(); + let separator = "/"; if path_str.ends_with(separator) { - return false; + false } else { self.glob.is_match(path_str.to_string() + separator) } @@ -546,7 +530,7 @@ mod custom_path_matcher { pub fn append_buffer_to_output( buffer: &BufferSnapshot, - path: Option<&Path>, + path: Option<&str>, output: &mut SlashCommandOutput, ) -> Result<()> { let prev_len = output.text.len(); diff --git a/crates/assistant_slash_commands/src/now_command.rs b/crates/assistant_slash_commands/src/now_command.rs index e4abef2a7c80fbdc96df28cbd1072d180fd864f3..aec21e7173bafd4cb07e7c37135fa0ad6fa88812 100644 --- a/crates/assistant_slash_commands/src/now_command.rs +++ b/crates/assistant_slash_commands/src/now_command.rs @@ -66,6 +66,6 @@ impl SlashCommand for NowSlashCommand { }], run_commands_in_text: false, } - .to_event_stream())) + .into_event_stream())) } } diff --git a/crates/assistant_slash_commands/src/prompt_command.rs b/crates/assistant_slash_commands/src/prompt_command.rs index c177f9f3599525924aa18700ea09d5fe977a5698..bbd6d3e3ad201c06940d6dc986616f61c8e15547 100644 --- a/crates/assistant_slash_commands/src/prompt_command.rs +++ b/crates/assistant_slash_commands/src/prompt_command.rs @@ -80,7 +80,7 @@ impl SlashCommand for PromptSlashCommand { }; let store = PromptStore::global(cx); - let title = SharedString::from(title.clone()); + let title = SharedString::from(title); let prompt = cx.spawn({ let title = title.clone(); async move |cx| { @@ -117,7 +117,7 @@ impl SlashCommand for PromptSlashCommand { }], run_commands_in_text: true, } - .to_event_stream()) + .into_event_stream()) }) } } diff --git a/crates/assistant_slash_commands/src/selection_command.rs b/crates/assistant_slash_commands/src/selection_command.rs index c5f01ee94c670d359816d80eef3c0230fe8ad0d0..ce6c0b931411d8073ffd6c97b648bb044ad857e7 100644 --- a/crates/assistant_slash_commands/src/selection_command.rs +++ b/crates/assistant_slash_commands/src/selection_command.rs @@ -79,7 +79,7 @@ impl SlashCommand for SelectionCommand { editor.update(cx, |editor, cx| { let selection_ranges = editor .selections - .all_adjusted(cx) + .all_adjusted(&editor.display_snapshot(cx)) .iter() .map(|selection| selection.range()) .collect::>(); @@ -137,7 +137,9 @@ pub fn selections_creases( None }; let language_name = language_name.as_deref().unwrap_or(""); - let filename = snapshot.file_at(range.start).map(|file| file.full_path(cx)); + let filename = snapshot + .file_at(range.start) + .map(|file| file.full_path(cx).to_string_lossy().into_owned()); let text = if language_name == "markdown" { selected_text .lines() @@ -187,9 +189,9 @@ pub fn selections_creases( let start_line = range.start.row + 1; let end_line = range.end.row + 1; if start_line == end_line { - format!("{}, Line {}", path.display(), start_line) + format!("{path}, Line {start_line}") } else { - format!("{}, Lines {} to {}", path.display(), start_line, end_line) + format!("{path}, Lines {start_line} to {end_line}") } } else { "Quoted selection".to_string() diff --git a/crates/assistant_slash_commands/src/symbols_command.rs b/crates/assistant_slash_commands/src/symbols_command.rs index ef9314643116689d36e99b2a9bcb7d69982a776f..c537ced2966bf0bfde912ba326890a935a20f220 100644 --- a/crates/assistant_slash_commands/src/symbols_command.rs +++ b/crates/assistant_slash_commands/src/symbols_command.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use assistant_slash_command::{ ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult, @@ -7,8 +7,8 @@ use editor::Editor; use gpui::{AppContext as _, Task, WeakEntity}; use language::{BufferSnapshot, LspAdapterDelegate}; use std::sync::Arc; -use std::{path::Path, sync::atomic::AtomicBool}; -use ui::{App, IconName, Window}; +use std::sync::atomic::AtomicBool; +use ui::{App, IconName, SharedString, Window}; use workspace::Workspace; pub struct OutlineSlashCommand; @@ -67,15 +67,13 @@ impl SlashCommand for OutlineSlashCommand { }; let snapshot = buffer.read(cx).snapshot(); - let path = snapshot.resolve_file_path(cx, true); + let path = snapshot.resolve_file_path(true, cx); cx.background_spawn(async move { - let outline = snapshot - .outline(None) - .context("no symbols for active tab")?; + let outline = snapshot.outline(None); - let path = path.as_deref().unwrap_or(Path::new("untitled")); - let mut outline_text = format!("Symbols for {}:\n", path.display()); + let path = path.as_deref().unwrap_or("untitled"); + let mut outline_text = format!("Symbols for {path}:\n"); for item in &outline.path_candidates { outline_text.push_str("- "); outline_text.push_str(&item.string); @@ -86,13 +84,13 @@ impl SlashCommand for OutlineSlashCommand { sections: vec![SlashCommandOutputSection { range: 0..outline_text.len(), icon: IconName::ListTree, - label: path.to_string_lossy().to_string().into(), + label: SharedString::new(path), metadata: None, }], text: outline_text, run_commands_in_text: false, } - .to_event_stream()) + .into_event_stream()) }) }); diff --git a/crates/assistant_slash_commands/src/tab_command.rs b/crates/assistant_slash_commands/src/tab_command.rs index ca7601bc4c3a48d9d9c352ad545d72c032e7c47e..a4c0ad412cca3eaf7d03d684cc3fb828be60a93d 100644 --- a/crates/assistant_slash_commands/src/tab_command.rs +++ b/crates/assistant_slash_commands/src/tab_command.rs @@ -7,13 +7,10 @@ use collections::{HashMap, HashSet}; use editor::Editor; use futures::future::join_all; use gpui::{Task, WeakEntity}; -use language::{BufferSnapshot, CodeLabel, HighlightId, LspAdapterDelegate}; -use std::{ - path::PathBuf, - sync::{Arc, atomic::AtomicBool}, -}; +use language::{BufferSnapshot, CodeLabel, CodeLabelBuilder, HighlightId, LspAdapterDelegate}; +use std::sync::{Arc, atomic::AtomicBool}; use ui::{ActiveTheme, App, Window, prelude::*}; -use util::ResultExt; +use util::{ResultExt, paths::PathStyle}; use workspace::Workspace; use crate::file_command::append_buffer_to_output; @@ -72,35 +69,42 @@ impl SlashCommand for TabSlashCommand { return Task::ready(Ok(Vec::new())); } - let active_item_path = workspace.as_ref().and_then(|workspace| { - workspace - .update(cx, |workspace, cx| { - let snapshot = active_item_buffer(workspace, cx).ok()?; - snapshot.resolve_file_path(cx, true) - }) - .ok() - .flatten() + let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { + return Task::ready(Err(anyhow::anyhow!("no workspace"))); + }; + + let active_item_path = workspace.update(cx, |workspace, cx| { + let snapshot = active_item_buffer(workspace, cx).ok()?; + snapshot.resolve_file_path(true, cx) }); + let path_style = workspace.read(cx).path_style(cx); + let current_query = arguments.last().cloned().unwrap_or_default(); - let tab_items_search = - tab_items_for_queries(workspace, &[current_query], cancel, false, window, cx); + let tab_items_search = tab_items_for_queries( + Some(workspace.downgrade()), + &[current_query], + cancel, + false, + window, + cx, + ); let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); window.spawn(cx, async move |_| { let tab_items = tab_items_search.await?; let run_command = tab_items.len() == 1; let tab_completion_items = tab_items.into_iter().filter_map(|(path, ..)| { - let path_string = path.as_deref()?.to_string_lossy().to_string(); - if argument_set.contains(&path_string) { + let path = path?; + if argument_set.contains(&path) { return None; } - if active_item_path.is_some() && active_item_path == path { + if active_item_path.as_ref() == Some(&path) { return None; } - let label = create_tab_completion_label(path.as_ref()?, comment_id); + let label = create_tab_completion_label(&path, path_style, comment_id); Some(ArgumentCompletion { label, - new_text: path_string, + new_text: path, replace_previous_arguments: false, after_completion: run_command.into(), }) @@ -109,8 +113,9 @@ impl SlashCommand for TabSlashCommand { let active_item_completion = active_item_path .as_deref() .map(|active_item_path| { - let path_string = active_item_path.to_string_lossy().to_string(); - let label = create_tab_completion_label(active_item_path, comment_id); + let path_string = active_item_path.to_string(); + let label = + create_tab_completion_label(active_item_path, path_style, comment_id); ArgumentCompletion { label, new_text: path_string, @@ -157,7 +162,7 @@ impl SlashCommand for TabSlashCommand { for (full_path, buffer, _) in tab_items_search.await? { append_buffer_to_output(&buffer, full_path.as_deref(), &mut output).log_err(); } - Ok(output.to_event_stream()) + Ok(output.into_event_stream()) }) } } @@ -169,7 +174,7 @@ fn tab_items_for_queries( strict_match: bool, window: &mut Window, cx: &mut App, -) -> Task, BufferSnapshot, usize)>>> { +) -> Task, BufferSnapshot, usize)>>> { let empty_query = queries.is_empty() || queries.iter().all(|query| query.trim().is_empty()); let queries = queries.to_owned(); window.spawn(cx, async move |cx| { @@ -179,7 +184,7 @@ fn tab_items_for_queries( .update(cx, |workspace, cx| { if strict_match && empty_query { let snapshot = active_item_buffer(workspace, cx)?; - let full_path = snapshot.resolve_file_path(cx, true); + let full_path = snapshot.resolve_file_path(true, cx); return anyhow::Ok(vec![(full_path, snapshot, 0)]); } @@ -195,16 +200,14 @@ fn tab_items_for_queries( } for editor in workspace.items_of_type::(cx) { - if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() { - if let Some(timestamp) = + if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() + && let Some(timestamp) = timestamps_by_entity_id.get(&editor.entity_id()) - { - if visited_buffers.insert(buffer.read(cx).remote_id()) { - let snapshot = buffer.read(cx).snapshot(); - let full_path = snapshot.resolve_file_path(cx, true); - open_buffers.push((full_path, snapshot, *timestamp)); - } - } + && visited_buffers.insert(buffer.read(cx).remote_id()) + { + let snapshot = buffer.read(cx).snapshot(); + let full_path = snapshot.resolve_file_path(true, cx); + open_buffers.push((full_path, snapshot, *timestamp)); } } @@ -226,10 +229,7 @@ fn tab_items_for_queries( let match_candidates = open_buffers .iter() .enumerate() - .filter_map(|(id, (full_path, ..))| { - let path_string = full_path.as_deref()?.to_string_lossy().to_string(); - Some((id, path_string)) - }) + .filter_map(|(id, (full_path, ..))| Some((id, full_path.clone()?))) .fold(HashMap::default(), |mut candidates, (id, path_string)| { candidates .entry(path_string) @@ -251,8 +251,7 @@ fn tab_items_for_queries( .iter() .enumerate() .filter_map(|(id, (full_path, ..))| { - let path_string = full_path.as_deref()?.to_string_lossy().to_string(); - Some(fuzzy::StringMatchCandidate::new(id, &path_string)) + Some(fuzzy::StringMatchCandidate::new(id, full_path.as_ref()?)) }) .collect::>(); let mut processed_matches = HashSet::default(); @@ -304,21 +303,15 @@ fn active_item_buffer( } fn create_tab_completion_label( - path: &std::path::Path, + path: &str, + path_style: PathStyle, comment_id: Option, ) -> CodeLabel { - let file_name = path - .file_name() - .map(|f| f.to_string_lossy()) - .unwrap_or_default(); - let parent_path = path - .parent() - .map(|p| p.to_string_lossy()) - .unwrap_or_default(); - let mut label = CodeLabel::default(); - label.push_str(&file_name, None); + let (parent_path, file_name) = path_style.split(path); + let mut label = CodeLabelBuilder::default(); + label.push_str(file_name, None); label.push_str(" ", None); - label.push_str(&parent_path, comment_id); - label.filter_range = 0..file_name.len(); - label + label.push_str(parent_path.unwrap_or_default(), comment_id); + label.respan_filter_range(Some(file_name)); + label.build() } diff --git a/crates/assistant_context/Cargo.toml b/crates/assistant_text_thread/Cargo.toml similarity index 93% rename from crates/assistant_context/Cargo.toml rename to crates/assistant_text_thread/Cargo.toml index 45c0072418782909829ba3186138f0c6a9456654..8dfdfa3828340217456088a246eee5b1568a7a77 100644 --- a/crates/assistant_context/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "assistant_context" +name = "assistant_text_thread" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,7 +9,7 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/assistant_context.rs" +path = "src/assistant_text_thread.rs" [features] test-support = [] @@ -50,8 +50,8 @@ text.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true -workspace-hack.workspace = true workspace.workspace = true +zed_env_vars.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/assistant_context/LICENSE-GPL b/crates/assistant_text_thread/LICENSE-GPL similarity index 100% rename from crates/assistant_context/LICENSE-GPL rename to crates/assistant_text_thread/LICENSE-GPL diff --git a/crates/assistant_text_thread/src/assistant_text_thread.rs b/crates/assistant_text_thread/src/assistant_text_thread.rs new file mode 100644 index 0000000000000000000000000000000000000000..7eab9800d5d6f43ba8eabec0682961e073781ace --- /dev/null +++ b/crates/assistant_text_thread/src/assistant_text_thread.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod assistant_text_thread_tests; +mod text_thread; +mod text_thread_store; + +pub use crate::text_thread::*; +pub use crate::text_thread_store::*; + +use client::Client; +use gpui::App; +use std::sync::Arc; + +pub fn init(client: Arc, _: &mut App) { + text_thread_store::init(&client.into()); +} diff --git a/crates/assistant_context/src/assistant_context_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs similarity index 71% rename from crates/assistant_context/src/assistant_context_tests.rs rename to crates/assistant_text_thread/src/assistant_text_thread_tests.rs index efcad8ed9654449c747ee4853c7e7aa689c0568b..fbd5dcafa6e142538f1f5821bc9e0a89ccbfd881 100644 --- a/crates/assistant_context/src/assistant_context_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -1,6 +1,6 @@ use crate::{ - AssistantContext, CacheStatus, ContextEvent, ContextId, ContextOperation, ContextSummary, - InvokedSlashCommandId, MessageCacheMetadata, MessageId, MessageStatus, + CacheStatus, InvokedSlashCommandId, MessageCacheMetadata, MessageId, MessageStatus, TextThread, + TextThreadEvent, TextThreadId, TextThreadOperation, TextThreadSummary, }; use anyhow::Result; use assistant_slash_command::{ @@ -47,8 +47,8 @@ fn test_inserting_and_removing_messages(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, None, @@ -57,21 +57,21 @@ fn test_inserting_and_removing_messages(cx: &mut App) { cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); - let message_2 = context.update(cx, |context, cx| { + let message_2 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..1), (message_2.id, Role::Assistant, 1..1) @@ -82,20 +82,20 @@ fn test_inserting_and_removing_messages(cx: &mut App) { buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..3) ] ); - let message_3 = context.update(cx, |context, cx| { + let message_3 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -103,13 +103,13 @@ fn test_inserting_and_removing_messages(cx: &mut App) { ] ); - let message_4 = context.update(cx, |context, cx| { + let message_4 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -122,7 +122,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -134,7 +134,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Deleting across message boundaries merges the messages. buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_3.id, Role::User, 3..4), @@ -144,7 +144,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Undoing the deletion should also undo the merge. buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..2), (message_2.id, Role::Assistant, 2..4), @@ -156,7 +156,7 @@ fn test_inserting_and_removing_messages(cx: &mut App) { // Redoing the deletion should also redo the merge. buffer.update(cx, |buffer, cx| buffer.redo(cx)); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_3.id, Role::User, 3..4), @@ -164,13 +164,13 @@ fn test_inserting_and_removing_messages(cx: &mut App) { ); // Ensure we can still insert after a merged message. - let message_5 = context.update(cx, |context, cx| { + let message_5 = text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) .unwrap() }); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..3), (message_5.id, Role::System, 3..4), @@ -186,8 +186,8 @@ fn test_message_splitting(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, None, @@ -196,11 +196,11 @@ fn test_message_splitting(cx: &mut App) { cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); @@ -208,26 +208,28 @@ fn test_message_splitting(cx: &mut App) { buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) }); - let (_, message_2) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let (_, message_2) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(3..3, cx)); let message_2 = message_2.unwrap(); // We recycle newlines in the middle of a split message assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..16), ] ); - let (_, message_3) = context.update(cx, |context, cx| context.split_message(3..3, cx)); + let (_, message_3) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(3..3, cx)); let message_3 = message_3.unwrap(); // We don't recycle newlines at the end of a split message assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -235,11 +237,12 @@ fn test_message_splitting(cx: &mut App) { ] ); - let (_, message_4) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let (_, message_4) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(9..9, cx)); let message_4 = message_4.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -248,11 +251,12 @@ fn test_message_splitting(cx: &mut App) { ] ); - let (_, message_5) = context.update(cx, |context, cx| context.split_message(9..9, cx)); + let (_, message_5) = + text_thread.update(cx, |text_thread, cx| text_thread.split_message(9..9, cx)); let message_5 = message_5.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -263,12 +267,12 @@ fn test_message_splitting(cx: &mut App) { ); let (message_6, message_7) = - context.update(cx, |context, cx| context.split_message(14..16, cx)); + text_thread.update(cx, |text_thread, cx| text_thread.split_message(14..16, cx)); let message_6 = message_6.unwrap(); let message_7 = message_7.unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_3.id, Role::User, 4..5), @@ -287,8 +291,8 @@ fn test_messages_for_offsets(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, None, @@ -297,32 +301,32 @@ fn test_messages_for_offsets(cx: &mut App) { cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![(message_1.id, Role::User, 0..0)] ); buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); - let message_2 = context - .update(cx, |context, cx| { - context.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) + let message_2 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); - let message_3 = context - .update(cx, |context, cx| { - context.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + let message_3 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..8), @@ -331,22 +335,22 @@ fn test_messages_for_offsets(cx: &mut App) { ); assert_eq!( - message_ids_for_offsets(&context, &[0, 4, 9], cx), + message_ids_for_offsets(&text_thread, &[0, 4, 9], cx), [message_1.id, message_2.id, message_3.id] ); assert_eq!( - message_ids_for_offsets(&context, &[0, 1, 11], cx), + message_ids_for_offsets(&text_thread, &[0, 1, 11], cx), [message_1.id, message_3.id] ); - let message_4 = context - .update(cx, |context, cx| { - context.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) + let message_4 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_3.id, Role::User, MessageStatus::Done, cx) }) .unwrap(); assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\n"); assert_eq!( - messages(&context, cx), + messages(&text_thread, cx), vec![ (message_1.id, Role::User, 0..4), (message_2.id, Role::User, 4..8), @@ -355,12 +359,12 @@ fn test_messages_for_offsets(cx: &mut App) { ] ); assert_eq!( - message_ids_for_offsets(&context, &[0, 4, 8, 12], cx), + message_ids_for_offsets(&text_thread, &[0, 4, 8, 12], cx), [message_1.id, message_2.id, message_3.id, message_4.id] ); fn message_ids_for_offsets( - context: &Entity, + context: &Entity, offsets: &[usize], cx: &App, ) -> Vec { @@ -398,8 +402,8 @@ async fn test_slash_commands(cx: &mut TestAppContext) { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, None, @@ -417,19 +421,19 @@ async fn test_slash_commands(cx: &mut TestAppContext) { } let context_ranges = Rc::new(RefCell::new(ContextRanges::default())); - context.update(cx, |_, cx| { - cx.subscribe(&context, { + text_thread.update(cx, |_, cx| { + cx.subscribe(&text_thread, { let context_ranges = context_ranges.clone(); - move |context, _, event, _| { + move |text_thread, _, event, _| { let mut context_ranges = context_ranges.borrow_mut(); match event { - ContextEvent::InvokedSlashCommandChanged { command_id } => { - let command = context.invoked_slash_command(command_id).unwrap(); + TextThreadEvent::InvokedSlashCommandChanged { command_id } => { + let command = text_thread.invoked_slash_command(command_id).unwrap(); context_ranges .command_outputs .insert(*command_id, command.range.clone()); } - ContextEvent::ParsedSlashCommandsUpdated { removed, updated } => { + TextThreadEvent::ParsedSlashCommandsUpdated { removed, updated } => { for range in removed { context_ranges.parsed_commands.remove(range); } @@ -439,7 +443,7 @@ async fn test_slash_commands(cx: &mut TestAppContext) { .insert(command.source_range.clone()); } } - ContextEvent::SlashCommandOutputSectionAdded { section } => { + TextThreadEvent::SlashCommandOutputSectionAdded { section } => { context_ranges.output_sections.insert(section.range.clone()); } _ => {} @@ -449,7 +453,7 @@ async fn test_slash_commands(cx: &mut TestAppContext) { .detach(); }); - let buffer = context.read_with(cx, |context, _| context.buffer.clone()); + let buffer = text_thread.read_with(cx, |text_thread, _| text_thread.buffer().clone()); // Insert a slash command buffer.update(cx, |buffer, cx| { @@ -508,9 +512,9 @@ async fn test_slash_commands(cx: &mut TestAppContext) { ); let (command_output_tx, command_output_rx) = mpsc::unbounded(); - context.update(cx, |context, cx| { - let command_source_range = context.parsed_slash_commands[0].source_range.clone(); - context.insert_command_output( + text_thread.update(cx, |text_thread, cx| { + let command_source_range = text_thread.parsed_slash_commands[0].source_range.clone(); + text_thread.insert_command_output( command_source_range, "file", Task::ready(Ok(command_output_rx.boxed())), @@ -670,8 +674,8 @@ async fn test_serialization(cx: &mut TestAppContext) { let registry = Arc::new(LanguageRegistry::test(cx.executor())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry.clone(), None, None, @@ -680,15 +684,15 @@ async fn test_serialization(cx: &mut TestAppContext) { cx, ) }); - let buffer = context.read_with(cx, |context, _| context.buffer.clone()); - let message_0 = context.read_with(cx, |context, _| context.message_anchors[0].id); - let message_1 = context.update(cx, |context, cx| { - context + let buffer = text_thread.read_with(cx, |text_thread, _| text_thread.buffer().clone()); + let message_0 = text_thread.read_with(cx, |text_thread, _| text_thread.message_anchors[0].id); + let message_1 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_0, Role::Assistant, MessageStatus::Done, cx) .unwrap() }); - let message_2 = context.update(cx, |context, cx| { - context + let message_2 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) .unwrap() }); @@ -696,15 +700,15 @@ async fn test_serialization(cx: &mut TestAppContext) { buffer.edit([(0..0, "a"), (1..1, "b\nc")], None, cx); buffer.finalize_last_transaction(); }); - let _message_3 = context.update(cx, |context, cx| { - context + let _message_3 = text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_2.id, Role::System, MessageStatus::Done, cx) .unwrap() }); buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n"); assert_eq!( - cx.read(|cx| messages(&context, cx)), + cx.read(|cx| messages(&text_thread, cx)), [ (message_0, Role::User, 0..2), (message_1.id, Role::Assistant, 2..6), @@ -712,9 +716,9 @@ async fn test_serialization(cx: &mut TestAppContext) { ] ); - let serialized_context = context.read_with(cx, |context, cx| context.serialize(cx)); + let serialized_context = text_thread.read_with(cx, |text_thread, cx| text_thread.serialize(cx)); let deserialized_context = cx.new(|cx| { - AssistantContext::deserialize( + TextThread::deserialize( serialized_context, Path::new("").into(), registry.clone(), @@ -726,7 +730,7 @@ async fn test_serialization(cx: &mut TestAppContext) { ) }); let deserialized_buffer = - deserialized_context.read_with(cx, |context, _| context.buffer.clone()); + deserialized_context.read_with(cx, |text_thread, _| text_thread.buffer().clone()); assert_eq!( deserialized_buffer.read_with(cx, |buffer, _| buffer.text()), "a\nb\nc\n" @@ -741,7 +745,7 @@ async fn test_serialization(cx: &mut TestAppContext) { ); } -#[gpui::test(iterations = 100)] +#[gpui::test(iterations = 25)] async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: StdRng) { cx.update(init_test); @@ -762,16 +766,16 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let registry = Arc::new(LanguageRegistry::test(cx.background_executor.clone())); let network = Arc::new(Mutex::new(Network::new(rng.clone()))); - let mut contexts = Vec::new(); + let mut text_threads = Vec::new(); - let num_peers = rng.gen_range(min_peers..=max_peers); - let context_id = ContextId::new(); + let num_peers = rng.random_range(min_peers..=max_peers); + let context_id = TextThreadId::new(); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); for i in 0..num_peers { let context = cx.new(|cx| { - AssistantContext::new( + TextThread::new( context_id.clone(), - i as ReplicaId, + ReplicaId::new(i as u16), language::Capability::ReadWrite, registry.clone(), prompt_builder.clone(), @@ -786,18 +790,18 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std cx.subscribe(&context, { let network = network.clone(); move |_, event, _| { - if let ContextEvent::Operation(op) = event { + if let TextThreadEvent::Operation(op) = event { network .lock() - .broadcast(i as ReplicaId, vec![op.to_proto()]); + .broadcast(ReplicaId::new(i as u16), vec![op.to_proto()]); } } }) .detach(); }); - contexts.push(context); - network.lock().add_peer(i as ReplicaId); + text_threads.push(context); + network.lock().add_peer(ReplicaId::new(i as u16)); } let mut mutation_count = operations; @@ -806,30 +810,30 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std || !network.lock().is_idle() || network.lock().contains_disconnected_peers() { - let context_index = rng.gen_range(0..contexts.len()); - let context = &contexts[context_index]; + let context_index = rng.random_range(0..text_threads.len()); + let text_thread = &text_threads[context_index]; - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..=29 if mutation_count > 0 => { log::info!("Context {}: edit buffer", context_index); - context.update(cx, |context, cx| { - context - .buffer + text_thread.update(cx, |text_thread, cx| { + text_thread + .buffer() .update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx)); }); mutation_count -= 1; } 30..=44 if mutation_count > 0 => { - context.update(cx, |context, cx| { - let range = context.buffer.read(cx).random_byte_range(0, &mut rng); + text_thread.update(cx, |text_thread, cx| { + let range = text_thread.buffer().read(cx).random_byte_range(0, &mut rng); log::info!("Context {}: split message at {:?}", context_index, range); - context.split_message(range, cx); + text_thread.split_message(range, cx); }); mutation_count -= 1; } 45..=59 if mutation_count > 0 => { - context.update(cx, |context, cx| { - if let Some(message) = context.messages(cx).choose(&mut rng) { + text_thread.update(cx, |text_thread, cx| { + if let Some(message) = text_thread.messages(cx).choose(&mut rng) { let role = *[Role::User, Role::Assistant, Role::System] .choose(&mut rng) .unwrap(); @@ -839,13 +843,13 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std message.id, role ); - context.insert_message_after(message.id, role, MessageStatus::Done, cx); + text_thread.insert_message_after(message.id, role, MessageStatus::Done, cx); } }); mutation_count -= 1; } 60..=74 if mutation_count > 0 => { - context.update(cx, |context, cx| { + text_thread.update(cx, |text_thread, cx| { let command_text = "/".to_string() + slash_commands .command_names() @@ -854,7 +858,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std .clone() .as_ref(); - let command_range = context.buffer.update(cx, |buffer, cx| { + let command_range = text_thread.buffer().update(cx, |buffer, cx| { let offset = buffer.random_byte_range(0, &mut rng).start; buffer.edit( [(offset..offset, format!("\n{}\n", command_text))], @@ -874,10 +878,10 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std merge_same_roles: true, })]; - let num_sections = rng.gen_range(0..=3); + let num_sections = rng.random_range(0..=3); let mut section_start = 0; for _ in 0..num_sections { - let mut section_end = rng.gen_range(section_start..=output_text.len()); + let mut section_end = rng.random_range(section_start..=output_text.len()); while !output_text.is_char_boundary(section_end) { section_end += 1; } @@ -908,9 +912,15 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std events.len() ); - let command_range = context.buffer.read(cx).anchor_after(command_range.start) - ..context.buffer.read(cx).anchor_after(command_range.end); - context.insert_command_output( + let command_range = text_thread + .buffer() + .read(cx) + .anchor_after(command_range.start) + ..text_thread + .buffer() + .read(cx) + .anchor_after(command_range.end); + text_thread.insert_command_output( command_range, "/command", Task::ready(Ok(stream::iter(events).boxed())), @@ -922,9 +932,9 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std mutation_count -= 1; } 75..=84 if mutation_count > 0 => { - context.update(cx, |context, cx| { - if let Some(message) = context.messages(cx).choose(&mut rng) { - let new_status = match rng.gen_range(0..3) { + text_thread.update(cx, |text_thread, cx| { + if let Some(message) = text_thread.messages(cx).choose(&mut rng) { + let new_status = match rng.random_range(0..3) { 0 => MessageStatus::Done, 1 => MessageStatus::Pending, _ => MessageStatus::Error(SharedString::from("Random error")), @@ -935,7 +945,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std message.id, new_status ); - context.update_metadata(message.id, cx, |metadata| { + text_thread.update_metadata(message.id, cx, |metadata| { metadata.status = new_status; }); } @@ -943,13 +953,13 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std mutation_count -= 1; } _ => { - let replica_id = context_index as ReplicaId; + let replica_id = ReplicaId::new(context_index as u16); if network.lock().is_disconnected(replica_id) { - network.lock().reconnect_peer(replica_id, 0); + network.lock().reconnect_peer(replica_id, ReplicaId::new(0)); let (ops_to_send, ops_to_receive) = cx.read(|cx| { - let host_context = &contexts[0].read(cx); - let guest_context = context.read(cx); + let host_context = &text_threads[0].read(cx); + let guest_context = text_thread.read(cx); ( guest_context.serialize_ops(&host_context.version(cx), cx), host_context.serialize_ops(&guest_context.version(cx), cx), @@ -959,7 +969,7 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let ops_to_receive = ops_to_receive .await .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() .unwrap(); log::info!( @@ -970,8 +980,10 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std ); network.lock().broadcast(replica_id, ops_to_send); - context.update(cx, |context, cx| context.apply_ops(ops_to_receive, cx)); - } else if rng.gen_bool(0.1) && replica_id != 0 { + text_thread.update(cx, |text_thread, cx| { + text_thread.apply_ops(ops_to_receive, cx) + }); + } else if rng.random_bool(0.1) && replica_id != ReplicaId::new(0) { log::info!("Context {}: disconnecting", context_index); network.lock().disconnect_peer(replica_id); } else if network.lock().has_unreceived(replica_id) { @@ -979,43 +991,43 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std let ops = network.lock().receive(replica_id); let ops = ops .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() .unwrap(); - context.update(cx, |context, cx| context.apply_ops(ops, cx)); + text_thread.update(cx, |text_thread, cx| text_thread.apply_ops(ops, cx)); } } } } cx.read(|cx| { - let first_context = contexts[0].read(cx); - for context in &contexts[1..] { - let context = context.read(cx); - assert!(context.pending_ops.is_empty(), "pending ops: {:?}", context.pending_ops); + let first_context = text_threads[0].read(cx); + for text_thread in &text_threads[1..] { + let text_thread = text_thread.read(cx); + assert!(text_thread.pending_ops.is_empty(), "pending ops: {:?}", text_thread.pending_ops); assert_eq!( - context.buffer.read(cx).text(), - first_context.buffer.read(cx).text(), - "Context {} text != Context 0 text", - context.buffer.read(cx).replica_id() + text_thread.buffer().read(cx).text(), + first_context.buffer().read(cx).text(), + "Context {:?} text != Context 0 text", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.message_anchors, + text_thread.message_anchors, first_context.message_anchors, - "Context {} messages != Context 0 messages", - context.buffer.read(cx).replica_id() + "Context {:?} messages != Context 0 messages", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.messages_metadata, + text_thread.messages_metadata, first_context.messages_metadata, - "Context {} message metadata != Context 0 message metadata", - context.buffer.read(cx).replica_id() + "Context {:?} message metadata != Context 0 message metadata", + text_thread.buffer().read(cx).replica_id() ); assert_eq!( - context.slash_command_output_sections, + text_thread.slash_command_output_sections, first_context.slash_command_output_sections, - "Context {} slash command output sections != Context 0 slash command output sections", - context.buffer.read(cx).replica_id() + "Context {:?} slash command output sections != Context 0 slash command output sections", + text_thread.buffer().read(cx).replica_id() ); } }); @@ -1027,8 +1039,8 @@ fn test_mark_cache_anchors(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); - let context = cx.new(|cx| { - AssistantContext::local( + let text_thread = cx.new(|cx| { + TextThread::local( registry, None, None, @@ -1037,7 +1049,7 @@ fn test_mark_cache_anchors(cx: &mut App) { cx, ) }); - let buffer = context.read(cx).buffer.clone(); + let buffer = text_thread.read(cx).buffer().clone(); // Create a test cache configuration let cache_configuration = &Some(LanguageModelCacheConfiguration { @@ -1046,86 +1058,91 @@ fn test_mark_cache_anchors(cx: &mut App) { min_total_token: 10, }); - let message_1 = context.read(cx).message_anchors[0].clone(); + let message_1 = text_thread.read(cx).message_anchors[0].clone(); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() - .filter(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor)) + .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .count(), 0, "Empty messages should not have any cache anchors." ); buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); - let message_2 = context - .update(cx, |context, cx| { - context.insert_message_after(message_1.id, Role::User, MessageStatus::Pending, cx) + let message_2 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_1.id, Role::User, MessageStatus::Pending, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbbbbbb")], None, cx)); - let message_3 = context - .update(cx, |context, cx| { - context.insert_message_after(message_2.id, Role::User, MessageStatus::Pending, cx) + let message_3 = text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after(message_2.id, Role::User, MessageStatus::Pending, cx) }) .unwrap(); buffer.update(cx, |buffer, cx| buffer.edit([(12..12, "cccccc")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!(buffer.read(cx).text(), "aaa\nbbbbbbb\ncccccc"); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() - .filter(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor)) + .filter(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .count(), 0, "Messages should not be marked for cache before going over the token minimum." ); - context.update(cx, |context, _| { - context.token_count = Some(20); + text_thread.update(cx, |text_thread, _| { + text_thread.token_count = Some(20); }); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, true, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, true, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() - .map(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor)) + .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .collect::>(), vec![true, true, false], "Last message should not be an anchor on speculative request." ); - context - .update(cx, |context, cx| { - context.insert_message_after(message_3.id, Role::Assistant, MessageStatus::Pending, cx) + text_thread + .update(cx, |text_thread, cx| { + text_thread.insert_message_after( + message_3.id, + Role::Assistant, + MessageStatus::Pending, + cx, + ) }) .unwrap(); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() - .map(|(_, cache)| cache.as_ref().map_or(false, |cache| cache.is_anchor)) + .map(|(_, cache)| cache.as_ref().is_some_and(|cache| cache.is_anchor)) .collect::>(), vec![false, true, true, false], "Most recent message should also be cached if not a speculative request." ); - context.update(cx, |context, cx| { - context.update_cache_status_for_completion(cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.update_cache_status_for_completion(cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1141,11 +1158,11 @@ fn test_mark_cache_anchors(cx: &mut App) { ); buffer.update(cx, |buffer, cx| buffer.edit([(14..14, "d")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1160,11 +1177,11 @@ fn test_mark_cache_anchors(cx: &mut App) { "Modifying a message should invalidate it's cache but leave previous messages." ); buffer.update(cx, |buffer, cx| buffer.edit([(2..2, "e")], None, cx)); - context.update(cx, |context, cx| { - context.mark_cache_anchors(cache_configuration, false, cx) + text_thread.update(cx, |text_thread, cx| { + text_thread.mark_cache_anchors(cache_configuration, false, cx) }); assert_eq!( - messages_cache(&context, cx) + messages_cache(&text_thread, cx) .iter() .map(|(_, cache)| cache .as_ref() @@ -1182,31 +1199,36 @@ fn test_mark_cache_anchors(cx: &mut App) { #[gpui::test] async fn test_summarization(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); // Initial state should be pending - context.read_with(cx, |context, _| { - assert!(matches!(context.summary(), ContextSummary::Pending)); - assert_eq!(context.summary().or_default(), ContextSummary::DEFAULT); + text_thread.read_with(cx, |text_thread, _| { + assert!(matches!(text_thread.summary(), TextThreadSummary::Pending)); + assert_eq!( + text_thread.summary().or_default(), + TextThreadSummary::DEFAULT + ); }); - let message_1 = context.read_with(cx, |context, _cx| context.message_anchors[0].clone()); - context.update(cx, |context, cx| { + let message_1 = text_thread.read_with(cx, |text_thread, _cx| { + text_thread.message_anchors[0].clone() + }); + text_thread.update(cx, |context, cx| { context .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap(); }); // Send a message - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); simulate_successful_response(&fake_model, cx); // Should start generating summary when there are >= 2 messages - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); cx.run_until_parked(); @@ -1216,61 +1238,61 @@ async fn test_summarization(cx: &mut TestAppContext) { cx.run_until_parked(); // Summary should be set - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Introduction"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Introduction"); }); // We should be able to manually set a summary - context.update(cx, |context, cx| { - context.set_custom_summary("Brief Intro".into(), cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.set_custom_summary("Brief Intro".into(), cx); }); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Intro"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Intro"); }); } #[gpui::test] async fn test_thread_summary_error_set_manually(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); - test_summarize_error(&fake_model, &context, cx); + test_summarize_error(&fake_model, &text_thread, cx); // Now we should be able to set a summary - context.update(cx, |context, cx| { - context.set_custom_summary("Brief Intro".into(), cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.set_custom_summary("Brief Intro".into(), cx); }); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "Brief Intro"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "Brief Intro"); }); } #[gpui::test] async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { - let (context, fake_model) = setup_context_editor_with_fake_model(cx); + let (text_thread, fake_model) = setup_context_editor_with_fake_model(cx); - test_summarize_error(&fake_model, &context, cx); + test_summarize_error(&fake_model, &text_thread, cx); // Sending another message should not trigger another summarize request - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); simulate_successful_response(&fake_model, cx); - context.read_with(cx, |context, _| { + text_thread.read_with(cx, |text_thread, _| { // State is still Error, not Generating - assert!(matches!(context.summary(), ContextSummary::Error)); + assert!(matches!(text_thread.summary(), TextThreadSummary::Error)); }); // But the summarize request can be invoked manually - context.update(cx, |context, cx| { - context.summarize(true, cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.summarize(true, cx); }); - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); cx.run_until_parked(); @@ -1278,32 +1300,34 @@ async fn test_thread_summary_error_retry(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); cx.run_until_parked(); - context.read_with(cx, |context, _| { - assert_eq!(context.summary().or_default(), "A successful summary"); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(text_thread.summary().or_default(), "A successful summary"); }); } fn test_summarize_error( model: &Arc, - context: &Entity, + text_thread: &Entity, cx: &mut TestAppContext, ) { - let message_1 = context.read_with(cx, |context, _cx| context.message_anchors[0].clone()); - context.update(cx, |context, cx| { - context + let message_1 = text_thread.read_with(cx, |text_thread, _cx| { + text_thread.message_anchors[0].clone() + }); + text_thread.update(cx, |text_thread, cx| { + text_thread .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) .unwrap(); }); // Send a message - context.update(cx, |context, cx| { - context.assist(cx); + text_thread.update(cx, |text_thread, cx| { + text_thread.assist(cx); }); - simulate_successful_response(&model, cx); + simulate_successful_response(model, cx); - context.read_with(cx, |context, _| { - assert!(!context.summary().content().unwrap().done); + text_thread.read_with(cx, |text_thread, _| { + assert!(!text_thread.summary().content().unwrap().done); }); // Simulate summary request ending @@ -1312,16 +1336,19 @@ fn test_summarize_error( cx.run_until_parked(); // State is set to Error and default message - context.read_with(cx, |context, _| { - assert_eq!(*context.summary(), ContextSummary::Error); - assert_eq!(context.summary().or_default(), ContextSummary::DEFAULT); + text_thread.read_with(cx, |text_thread, _| { + assert_eq!(*text_thread.summary(), TextThreadSummary::Error); + assert_eq!( + text_thread.summary().or_default(), + TextThreadSummary::DEFAULT + ); }); } fn setup_context_editor_with_fake_model( cx: &mut TestAppContext, -) -> (Entity, Arc) { - let registry = Arc::new(LanguageRegistry::test(cx.executor().clone())); +) -> (Entity, Arc) { + let registry = Arc::new(LanguageRegistry::test(cx.executor())); let fake_provider = Arc::new(FakeLanguageModelProvider::default()); let fake_model = Arc::new(fake_provider.test_model()); @@ -1329,19 +1356,18 @@ fn setup_context_editor_with_fake_model( cx.update(|cx| { init_test(cx); LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model( - Some(ConfiguredModel { - provider: fake_provider.clone(), - model: fake_model.clone(), - }), - cx, - ) + let configured_model = ConfiguredModel { + provider: fake_provider.clone(), + model: fake_model.clone(), + }; + registry.set_default_model(Some(configured_model.clone()), cx); + registry.set_thread_summary_model(Some(configured_model), cx); }) }); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); let context = cx.new(|cx| { - AssistantContext::local( + TextThread::local( registry, None, None, @@ -1361,7 +1387,7 @@ fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestApp cx.run_until_parked(); } -fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Role, Range)> { +fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Role, Range)> { context .read(cx) .messages(cx) @@ -1370,13 +1396,13 @@ fn messages(context: &Entity, cx: &App) -> Vec<(MessageId, Rol } fn messages_cache( - context: &Entity, + context: &Entity, cx: &App, ) -> Vec<(MessageId, Option)> { context .read(cx) .messages(cx) - .map(|message| (message.id, message.cache.clone())) + .map(|message| (message.id, message.cache)) .collect() } @@ -1436,6 +1462,6 @@ impl SlashCommand for FakeSlashCommand { sections: vec![], run_commands_in_text: false, } - .to_event_stream())) + .into_event_stream())) } } diff --git a/crates/assistant_context/src/assistant_context.rs b/crates/assistant_text_thread/src/text_thread.rs similarity index 89% rename from crates/assistant_context/src/assistant_context.rs rename to crates/assistant_text_thread/src/text_thread.rs index 557f9592e4d12e86c4e73d1bc742dfa74535d66c..9ad383cdfd43eed236268349e2ff97c34a0178c0 100644 --- a/crates/assistant_context/src/assistant_context.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -1,7 +1,3 @@ -#[cfg(test)] -mod assistant_context_tests; -mod context_store; - use agent_settings::{AgentSettings, SUMMARIZE_THREAD_PROMPT}; use anyhow::{Context as _, Result, bail}; use assistant_slash_command::{ @@ -9,7 +5,7 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, Client, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; use clock::ReplicaId; use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; use collections::{HashMap, HashSet}; @@ -27,7 +23,7 @@ use language_model::{ report_assistant_event, }; use open_ai::Model as OpenAiModel; -use paths::contexts_dir; +use paths::text_threads_dir; use project::Project; use prompt_store::PromptBuilder; use serde::{Deserialize, Serialize}; @@ -48,16 +44,10 @@ use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; use uuid::Uuid; -pub use crate::context_store::*; - -pub fn init(client: Arc, _: &mut App) { - context_store::init(&client.into()); -} - #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub struct ContextId(String); +pub struct TextThreadId(String); -impl ContextId { +impl TextThreadId { pub fn new() -> Self { Self(Uuid::new_v4().to_string()) } @@ -130,7 +120,7 @@ impl MessageStatus { } #[derive(Clone, Debug)] -pub enum ContextOperation { +pub enum TextThreadOperation { InsertMessage { anchor: MessageAnchor, metadata: MessageMetadata, @@ -142,7 +132,7 @@ pub enum ContextOperation { version: clock::Global, }, UpdateSummary { - summary: ContextSummaryContent, + summary: TextThreadSummaryContent, version: clock::Global, }, SlashCommandStarted { @@ -170,7 +160,7 @@ pub enum ContextOperation { BufferOperation(language::Operation), } -impl ContextOperation { +impl TextThreadOperation { pub fn from_proto(op: proto::ContextOperation) -> Result { match op.variant.context("invalid variant")? { proto::context_operation::Variant::InsertMessage(insert) => { @@ -212,7 +202,7 @@ impl ContextOperation { version: language::proto::deserialize_version(&update.version), }), proto::context_operation::Variant::UpdateSummary(update) => Ok(Self::UpdateSummary { - summary: ContextSummaryContent { + summary: TextThreadSummaryContent { text: update.summary, done: update.done, timestamp: language::proto::deserialize_timestamp( @@ -453,7 +443,7 @@ impl ContextOperation { } #[derive(Debug, Clone)] -pub enum ContextEvent { +pub enum TextThreadEvent { ShowAssistError(SharedString), ShowPaymentRequiredError, MessagesEdited, @@ -476,24 +466,24 @@ pub enum ContextEvent { SlashCommandOutputSectionAdded { section: SlashCommandOutputSection, }, - Operation(ContextOperation), + Operation(TextThreadOperation), } #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ContextSummary { +pub enum TextThreadSummary { Pending, - Content(ContextSummaryContent), + Content(TextThreadSummaryContent), Error, } -#[derive(Default, Clone, Debug, Eq, PartialEq)] -pub struct ContextSummaryContent { +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextThreadSummaryContent { pub text: String, pub done: bool, pub timestamp: clock::Lamport, } -impl ContextSummary { +impl TextThreadSummary { pub const DEFAULT: &str = "New Text Thread"; pub fn or_default(&self) -> SharedString { @@ -505,44 +495,48 @@ impl ContextSummary { .map_or_else(|| message.into(), |content| content.text.clone().into()) } - pub fn content(&self) -> Option<&ContextSummaryContent> { + pub fn content(&self) -> Option<&TextThreadSummaryContent> { match self { - ContextSummary::Content(content) => Some(content), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } - fn content_as_mut(&mut self) -> Option<&mut ContextSummaryContent> { + fn content_as_mut(&mut self) -> Option<&mut TextThreadSummaryContent> { match self { - ContextSummary::Content(content) => Some(content), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } - fn content_or_set_empty(&mut self) -> &mut ContextSummaryContent { + fn content_or_set_empty(&mut self) -> &mut TextThreadSummaryContent { match self { - ContextSummary::Content(content) => content, - ContextSummary::Pending | ContextSummary::Error => { - let content = ContextSummaryContent::default(); - *self = ContextSummary::Content(content); + TextThreadSummary::Content(content) => content, + TextThreadSummary::Pending | TextThreadSummary::Error => { + let content = TextThreadSummaryContent { + text: "".to_string(), + done: false, + timestamp: clock::Lamport::MIN, + }; + *self = TextThreadSummary::Content(content); self.content_as_mut().unwrap() } } } pub fn is_pending(&self) -> bool { - matches!(self, ContextSummary::Pending) + matches!(self, TextThreadSummary::Pending) } fn timestamp(&self) -> Option { match self { - ContextSummary::Content(content) => Some(content.timestamp), - ContextSummary::Pending | ContextSummary::Error => None, + TextThreadSummary::Content(content) => Some(content.timestamp), + TextThreadSummary::Pending | TextThreadSummary::Error => None, } } } -impl PartialOrd for ContextSummary { +impl PartialOrd for TextThreadSummary { fn partial_cmp(&self, other: &Self) -> Option { self.timestamp().partial_cmp(&other.timestamp()) } @@ -590,17 +584,16 @@ impl From<&Message> for MessageMetadata { impl MessageMetadata { pub fn is_cache_valid(&self, buffer: &BufferSnapshot, range: &Range) -> bool { - let result = match &self.cache { + match &self.cache { Some(MessageCacheMetadata { cached_at, .. }) => !buffer.has_edits_since_in_range( - &cached_at, + cached_at, Range { start: buffer.anchor_at(range.start, Bias::Right), end: buffer.anchor_at(range.end, Bias::Left), }, ), _ => false, - }; - result + } } } @@ -665,27 +658,27 @@ struct PendingCompletion { #[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] pub struct InvokedSlashCommandId(clock::Lamport); -pub struct AssistantContext { - id: ContextId, +pub struct TextThread { + id: TextThreadId, timestamp: clock::Lamport, version: clock::Global, - pending_ops: Vec, - operations: Vec, + pub(crate) pending_ops: Vec, + operations: Vec, buffer: Entity, - parsed_slash_commands: Vec, + pub(crate) parsed_slash_commands: Vec, invoked_slash_commands: HashMap, edits_since_last_parse: language::Subscription, slash_commands: Arc, - slash_command_output_sections: Vec>, + pub(crate) slash_command_output_sections: Vec>, thought_process_output_sections: Vec>, - message_anchors: Vec, + pub(crate) message_anchors: Vec, contents: Vec, - messages_metadata: HashMap, - summary: ContextSummary, + pub(crate) messages_metadata: HashMap, + summary: TextThreadSummary, summary_task: Task>, completion_count: usize, pending_completions: Vec, - token_count: Option, + pub(crate) token_count: Option, pending_token_count: Task>, pending_save: Task>, pending_cache_warming_task: Task>, @@ -708,9 +701,9 @@ impl ContextAnnotation for ParsedSlashCommand { } } -impl EventEmitter for AssistantContext {} +impl EventEmitter for TextThread {} -impl AssistantContext { +impl TextThread { pub fn local( language_registry: Arc, project: Option>, @@ -720,7 +713,7 @@ impl AssistantContext { cx: &mut Context, ) -> Self { Self::new( - ContextId::new(), + TextThreadId::new(), ReplicaId::default(), language::Capability::ReadWrite, language_registry, @@ -741,7 +734,7 @@ impl AssistantContext { } pub fn new( - id: ContextId, + id: TextThreadId, replica_id: ReplicaId, capability: language::Capability, language_registry: Arc, @@ -777,7 +770,7 @@ impl AssistantContext { slash_command_output_sections: Vec::new(), thought_process_output_sections: Vec::new(), edits_since_last_parse: edits_since_last_slash_command_parse, - summary: ContextSummary::Pending, + summary: TextThreadSummary::Pending, summary_task: Task::ready(None), completion_count: Default::default(), pending_completions: Default::default(), @@ -797,7 +790,7 @@ impl AssistantContext { }; let first_message_id = MessageId(clock::Lamport { - replica_id: 0, + replica_id: ReplicaId::LOCAL, value: 0, }); let message = MessageAnchor { @@ -820,12 +813,12 @@ impl AssistantContext { this } - pub(crate) fn serialize(&self, cx: &App) -> SavedContext { + pub(crate) fn serialize(&self, cx: &App) -> SavedTextThread { let buffer = self.buffer.read(cx); - SavedContext { + SavedTextThread { id: Some(self.id.clone()), zed: "context".into(), - version: SavedContext::VERSION.into(), + version: SavedTextThread::VERSION.into(), text: buffer.text(), messages: self .messages(cx) @@ -873,7 +866,7 @@ impl AssistantContext { } pub fn deserialize( - saved_context: SavedContext, + saved_context: SavedTextThread, path: Arc, language_registry: Arc, prompt_builder: Arc, @@ -882,7 +875,7 @@ impl AssistantContext { telemetry: Option>, cx: &mut Context, ) -> Self { - let id = saved_context.id.clone().unwrap_or_else(ContextId::new); + let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); let mut this = Self::new( id, ReplicaId::default(), @@ -903,7 +896,7 @@ impl AssistantContext { this } - pub fn id(&self) -> &ContextId { + pub fn id(&self) -> &TextThreadId { &self.id } @@ -911,9 +904,9 @@ impl AssistantContext { self.timestamp.replica_id } - pub fn version(&self, cx: &App) -> ContextVersion { - ContextVersion { - context: self.version.clone(), + pub fn version(&self, cx: &App) -> TextThreadVersion { + TextThreadVersion { + text_thread: self.version.clone(), buffer: self.buffer.read(cx).version(), } } @@ -935,7 +928,7 @@ impl AssistantContext { pub fn serialize_ops( &self, - since: &ContextVersion, + since: &TextThreadVersion, cx: &App, ) -> Task> { let buffer_ops = self @@ -946,7 +939,7 @@ impl AssistantContext { let mut context_ops = self .operations .iter() - .filter(|op| !since.context.observed(op.timestamp())) + .filter(|op| !since.text_thread.observed(op.timestamp())) .cloned() .collect::>(); context_ops.extend(self.pending_ops.iter().cloned()); @@ -970,13 +963,13 @@ impl AssistantContext { pub fn apply_ops( &mut self, - ops: impl IntoIterator, + ops: impl IntoIterator, cx: &mut Context, ) { let mut buffer_ops = Vec::new(); for op in ops { match op { - ContextOperation::BufferOperation(buffer_op) => buffer_ops.push(buffer_op), + TextThreadOperation::BufferOperation(buffer_op) => buffer_ops.push(buffer_op), op @ _ => self.pending_ops.push(op), } } @@ -985,7 +978,7 @@ impl AssistantContext { self.flush_ops(cx); } - fn flush_ops(&mut self, cx: &mut Context) { + fn flush_ops(&mut self, cx: &mut Context) { let mut changed_messages = HashSet::default(); let mut summary_generated = false; @@ -998,7 +991,7 @@ impl AssistantContext { let timestamp = op.timestamp(); match op.clone() { - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor, metadata, .. } => { if self.messages_metadata.contains_key(&anchor.id) { @@ -1008,7 +1001,7 @@ impl AssistantContext { self.insert_message(anchor, metadata, cx); } } - ContextOperation::UpdateMessage { + TextThreadOperation::UpdateMessage { message_id, metadata: new_metadata, .. @@ -1019,18 +1012,20 @@ impl AssistantContext { changed_messages.insert(message_id); } } - ContextOperation::UpdateSummary { + TextThreadOperation::UpdateSummary { summary: new_summary, .. } => { - if self.summary.timestamp().map_or(true, |current_timestamp| { - new_summary.timestamp > current_timestamp - }) { - self.summary = ContextSummary::Content(new_summary); + if self + .summary + .timestamp() + .is_none_or(|current_timestamp| new_summary.timestamp > current_timestamp) + { + self.summary = TextThreadSummary::Content(new_summary); summary_generated = true; } } - ContextOperation::SlashCommandStarted { + TextThreadOperation::SlashCommandStarted { id, output_range, name, @@ -1047,9 +1042,9 @@ impl AssistantContext { timestamp: id.0, }, ); - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id: id }); } - ContextOperation::SlashCommandOutputSectionAdded { section, .. } => { + TextThreadOperation::SlashCommandOutputSectionAdded { section, .. } => { let buffer = self.buffer.read(cx); if let Err(ix) = self .slash_command_output_sections @@ -1057,10 +1052,10 @@ impl AssistantContext { { self.slash_command_output_sections .insert(ix, section.clone()); - cx.emit(ContextEvent::SlashCommandOutputSectionAdded { section }); + cx.emit(TextThreadEvent::SlashCommandOutputSectionAdded { section }); } } - ContextOperation::ThoughtProcessOutputSectionAdded { section, .. } => { + TextThreadOperation::ThoughtProcessOutputSectionAdded { section, .. } => { let buffer = self.buffer.read(cx); if let Err(ix) = self .thought_process_output_sections @@ -1070,29 +1065,29 @@ impl AssistantContext { .insert(ix, section.clone()); } } - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id, error_message, timestamp, .. } => { - if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) { - if timestamp > slash_command.timestamp { - slash_command.timestamp = timestamp; - match error_message { - Some(message) => { - slash_command.status = - InvokedSlashCommandStatus::Error(message.into()); - } - None => { - slash_command.status = InvokedSlashCommandStatus::Finished; - } + if let Some(slash_command) = self.invoked_slash_commands.get_mut(&id) + && timestamp > slash_command.timestamp + { + slash_command.timestamp = timestamp; + match error_message { + Some(message) => { + slash_command.status = + InvokedSlashCommandStatus::Error(message.into()); + } + None => { + slash_command.status = InvokedSlashCommandStatus::Finished; } - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id: id }); } + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id: id }); } } - ContextOperation::BufferOperation(_) => unreachable!(), + TextThreadOperation::BufferOperation(_) => unreachable!(), } self.version.observe(timestamp); @@ -1102,43 +1097,43 @@ impl AssistantContext { if !changed_messages.is_empty() { self.message_roles_updated(changed_messages, cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); cx.notify(); } if summary_generated { - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); cx.notify(); } } - fn can_apply_op(&self, op: &ContextOperation, cx: &App) -> bool { + fn can_apply_op(&self, op: &TextThreadOperation, cx: &App) -> bool { if !self.version.observed_all(op.version()) { return false; } match op { - ContextOperation::InsertMessage { anchor, .. } => self + TextThreadOperation::InsertMessage { anchor, .. } => self .buffer .read(cx) .version .observed(anchor.start.timestamp), - ContextOperation::UpdateMessage { message_id, .. } => { + TextThreadOperation::UpdateMessage { message_id, .. } => { self.messages_metadata.contains_key(message_id) } - ContextOperation::UpdateSummary { .. } => true, - ContextOperation::SlashCommandStarted { output_range, .. } => { + TextThreadOperation::UpdateSummary { .. } => true, + TextThreadOperation::SlashCommandStarted { output_range, .. } => { self.has_received_operations_for_anchor_range(output_range.clone(), cx) } - ContextOperation::SlashCommandOutputSectionAdded { section, .. } => { + TextThreadOperation::SlashCommandOutputSectionAdded { section, .. } => { self.has_received_operations_for_anchor_range(section.range.clone(), cx) } - ContextOperation::ThoughtProcessOutputSectionAdded { section, .. } => { + TextThreadOperation::ThoughtProcessOutputSectionAdded { section, .. } => { self.has_received_operations_for_anchor_range(section.range.clone(), cx) } - ContextOperation::SlashCommandFinished { .. } => true, - ContextOperation::BufferOperation(_) => { + TextThreadOperation::SlashCommandFinished { .. } => true, + TextThreadOperation::BufferOperation(_) => { panic!("buffer operations should always be applied") } } @@ -1159,9 +1154,9 @@ impl AssistantContext { observed_start && observed_end } - fn push_op(&mut self, op: ContextOperation, cx: &mut Context) { + fn push_op(&mut self, op: TextThreadOperation, cx: &mut Context) { self.operations.push(op.clone()); - cx.emit(ContextEvent::Operation(op)); + cx.emit(TextThreadEvent::Operation(op)); } pub fn buffer(&self) -> &Entity { @@ -1184,7 +1179,7 @@ impl AssistantContext { self.path.as_ref() } - pub fn summary(&self) -> &ContextSummary { + pub fn summary(&self) -> &TextThreadSummary { &self.summary } @@ -1245,13 +1240,13 @@ impl AssistantContext { language::BufferEvent::Operation { operation, is_local: true, - } => cx.emit(ContextEvent::Operation(ContextOperation::BufferOperation( - operation.clone(), - ))), + } => cx.emit(TextThreadEvent::Operation( + TextThreadOperation::BufferOperation(operation.clone()), + )), language::BufferEvent::Edited => { self.count_remaining_tokens(cx); self.reparse(cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } _ => {} } @@ -1339,7 +1334,7 @@ impl AssistantContext { let is_invalid = self .messages_metadata .get(&message_id) - .map_or(true, |metadata| { + .is_none_or(|metadata| { !metadata.is_cache_valid(&buffer, &message.offset_range) || *encountered_invalid }); @@ -1368,10 +1363,10 @@ impl AssistantContext { continue; } - if let Some(last_anchor) = last_anchor { - if message.id == last_anchor { - hit_last_anchor = true; - } + if let Some(last_anchor) = last_anchor + && message.id == last_anchor + { + hit_last_anchor = true; } new_anchor_needs_caching = new_anchor_needs_caching @@ -1406,14 +1401,14 @@ impl AssistantContext { if !self.pending_completions.is_empty() { return; } - if let Some(cache_configuration) = cache_configuration { - if !cache_configuration.should_speculate { - return; - } + if let Some(cache_configuration) = cache_configuration + && !cache_configuration.should_speculate + { + return; } let request = { - let mut req = self.to_completion_request(Some(&model), cx); + let mut req = self.to_completion_request(Some(model), cx); // Skip the last message because it's likely to change and // therefore would be a waste to cache. req.messages.pop(); @@ -1428,7 +1423,7 @@ impl AssistantContext { let model = Arc::clone(model); self.pending_cache_warming_task = cx.spawn(async move |this, cx| { async move { - match model.stream_completion(request, &cx).await { + match model.stream_completion(request, cx).await { Ok(mut stream) => { stream.next().await; log::info!("Cache warming completed successfully"); @@ -1517,7 +1512,7 @@ impl AssistantContext { if !updated_parsed_slash_commands.is_empty() || !removed_parsed_slash_command_ranges.is_empty() { - cx.emit(ContextEvent::ParsedSlashCommandsUpdated { + cx.emit(TextThreadEvent::ParsedSlashCommandsUpdated { removed: removed_parsed_slash_command_ranges, updated: updated_parsed_slash_commands, }); @@ -1552,25 +1547,24 @@ impl AssistantContext { }) .map(ToOwned::to_owned) .collect::>(); - if let Some(command) = self.slash_commands.command(name, cx) { - if !command.requires_argument() || !arguments.is_empty() { - let start_ix = offset + command_line.name.start - 1; - let end_ix = offset - + command_line - .arguments - .last() - .map_or(command_line.name.end, |argument| argument.end); - let source_range = - buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix); - let pending_command = ParsedSlashCommand { - name: name.to_string(), - arguments, - source_range, - status: PendingSlashCommandStatus::Idle, - }; - updated.push(pending_command.clone()); - new_commands.push(pending_command); - } + if let Some(command) = self.slash_commands.command(name, cx) + && (!command.requires_argument() || !arguments.is_empty()) + { + let start_ix = offset + command_line.name.start - 1; + let end_ix = offset + + command_line + .arguments + .last() + .map_or(command_line.name.end, |argument| argument.end); + let source_range = buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix); + let pending_command = ParsedSlashCommand { + name: name.to_string(), + arguments, + source_range, + status: PendingSlashCommandStatus::Idle, + }; + updated.push(pending_command.clone()); + new_commands.push(pending_command); } } @@ -1592,7 +1586,7 @@ impl AssistantContext { && (!command.range.start.is_valid(buffer) || !command.range.end.is_valid(buffer)) { command.status = InvokedSlashCommandStatus::Finished; - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); invalidated_command_ids.push(command_id); } } @@ -1601,7 +1595,7 @@ impl AssistantContext { let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id: command_id, timestamp, error_message: None, @@ -1661,12 +1655,12 @@ impl AssistantContext { ) -> Range { let buffer = self.buffer.read(cx); let start_ix = match all_annotations - .binary_search_by(|probe| probe.range().end.cmp(&range.start, &buffer)) + .binary_search_by(|probe| probe.range().end.cmp(&range.start, buffer)) { Ok(ix) | Err(ix) => ix, }; let end_ix = match all_annotations - .binary_search_by(|probe| probe.range().start.cmp(&range.end, &buffer)) + .binary_search_by(|probe| probe.range().start.cmp(&range.end, buffer)) { Ok(ix) => ix + 1, Err(ix) => ix, @@ -1799,14 +1793,13 @@ impl AssistantContext { }); let end = this.buffer.read(cx).anchor_before(insert_position); - if run_commands_in_text { - if let Some(invoked_slash_command) = + if run_commands_in_text + && let Some(invoked_slash_command) = this.invoked_slash_commands.get_mut(&command_id) - { - invoked_slash_command - .run_commands_in_ranges - .push(start..end); - } + { + invoked_slash_command + .run_commands_in_ranges + .push(start..end); } } SlashCommandEvent::EndSection => { @@ -1862,7 +1855,7 @@ impl AssistantContext { { let newline_offset = insert_position.saturating_sub(1); if buffer.contains_str_at(newline_offset, "\n") - && last_section_range.map_or(true, |last_section_range| { + && last_section_range.is_none_or(|last_section_range| { !last_section_range .to_offset(buffer) .contains(&newline_offset) @@ -1907,9 +1900,9 @@ impl AssistantContext { } } - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); this.push_op( - ContextOperation::SlashCommandFinished { + TextThreadOperation::SlashCommandFinished { id: command_id, timestamp, error_message, @@ -1932,9 +1925,9 @@ impl AssistantContext { timestamp: command_id.0, }, ); - cx.emit(ContextEvent::InvokedSlashCommandChanged { command_id }); + cx.emit(TextThreadEvent::InvokedSlashCommandChanged { command_id }); self.push_op( - ContextOperation::SlashCommandStarted { + TextThreadOperation::SlashCommandStarted { id: command_id, output_range: command_range, name: name.to_string(), @@ -1958,13 +1951,13 @@ impl AssistantContext { }; self.slash_command_output_sections .insert(insertion_ix, section.clone()); - cx.emit(ContextEvent::SlashCommandOutputSectionAdded { + cx.emit(TextThreadEvent::SlashCommandOutputSectionAdded { section: section.clone(), }); let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::SlashCommandOutputSectionAdded { + TextThreadOperation::SlashCommandOutputSectionAdded { timestamp, section, version, @@ -1993,7 +1986,7 @@ impl AssistantContext { let version = self.version.clone(); let timestamp = self.next_timestamp(); self.push_op( - ContextOperation::ThoughtProcessOutputSectionAdded { + TextThreadOperation::ThoughtProcessOutputSectionAdded { timestamp, section, version, @@ -2045,7 +2038,7 @@ impl AssistantContext { let task = cx.spawn({ async move |this, cx| { - let stream = model.stream_completion(request, &cx); + let stream = model.stream_completion(request, cx); let assistant_message_id = assistant_message.id; let mut response_latency = None; let stream_completion = async { @@ -2081,15 +2074,12 @@ impl AssistantContext { match event { LanguageModelCompletionEvent::StatusUpdate(status_update) => { - match status_update { - CompletionRequestStatus::UsageUpdated { amount, limit } => { - this.update_model_request_usage( - amount as u32, - limit, - cx, - ); - } - _ => {} + if let CompletionRequestStatus::UsageUpdated { amount, limit } = status_update { + this.update_model_request_usage( + amount as u32, + limit, + cx, + ); } } LanguageModelCompletionEvent::StartMessage { .. } => {} @@ -2115,7 +2105,7 @@ impl AssistantContext { let end = buffer .anchor_before(message_old_end_offset + chunk_len); context_event = Some( - ContextEvent::StartedThoughtProcess(start..end), + TextThreadEvent::StartedThoughtProcess(start..end), ); } else { // This ensures that all the thinking chunks are inserted inside the thinking tag @@ -2133,7 +2123,7 @@ impl AssistantContext { if let Some(start) = thought_process_stack.pop() { let end = buffer.anchor_before(message_old_end_offset); context_event = - Some(ContextEvent::EndedThoughtProcess(end)); + Some(TextThreadEvent::EndedThoughtProcess(end)); thought_process_output_section = Some(ThoughtProcessOutputSection { range: start..end, @@ -2163,7 +2153,7 @@ impl AssistantContext { cx.emit(context_event); } - cx.emit(ContextEvent::StreamedCompletion); + cx.emit(TextThreadEvent::StreamedCompletion); Some(()) })?; @@ -2184,7 +2174,7 @@ impl AssistantContext { this.update(cx, |this, cx| { let error_message = if let Some(error) = result.as_ref().err() { if error.is::() { - cx.emit(ContextEvent::ShowPaymentRequiredError); + cx.emit(TextThreadEvent::ShowPaymentRequiredError); this.update_metadata(assistant_message_id, cx, |metadata| { metadata.status = MessageStatus::Canceled; }); @@ -2195,7 +2185,7 @@ impl AssistantContext { .map(|err| err.to_string()) .collect::>() .join("\n"); - cx.emit(ContextEvent::ShowAssistError(SharedString::from( + cx.emit(TextThreadEvent::ShowAssistError(SharedString::from( error_message.clone(), ))); this.update_metadata(assistant_message_id, cx, |metadata| { @@ -2286,7 +2276,7 @@ impl AssistantContext { let mut contents = self.contents(cx).peekable(); fn collect_text_content(buffer: &Buffer, range: Range) -> Option { - let text: String = buffer.text_for_range(range.clone()).collect(); + let text: String = buffer.text_for_range(range).collect(); if text.trim().is_empty() { None } else { @@ -2315,10 +2305,7 @@ impl AssistantContext { let mut request_message = LanguageModelRequestMessage { role: message.role, content: Vec::new(), - cache: message - .cache - .as_ref() - .map_or(false, |cache| cache.is_anchor), + cache: message.cache.as_ref().is_some_and(|cache| cache.is_anchor), }; while let Some(content) = contents.peek() { @@ -2415,13 +2402,13 @@ impl AssistantContext { if let Some(metadata) = self.messages_metadata.get_mut(&id) { f(metadata); metadata.timestamp = timestamp; - let operation = ContextOperation::UpdateMessage { + let operation = TextThreadOperation::UpdateMessage { message_id: id, metadata: metadata.clone(), version, }; self.push_op(operation, cx); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); cx.notify(); } } @@ -2452,7 +2439,7 @@ impl AssistantContext { .message_anchors .get(next_message_ix) .map_or(buffer.len(), |message| { - buffer.clip_offset(message.start.to_offset(buffer) - 1, Bias::Left) + buffer.clip_offset(message.start.to_previous_offset(buffer), Bias::Left) }); Some(self.insert_message_at_offset(offset, role, status, cx)) } else { @@ -2485,7 +2472,7 @@ impl AssistantContext { }; self.insert_message(anchor.clone(), metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: anchor.clone(), metadata, version, @@ -2508,7 +2495,7 @@ impl AssistantContext { Err(ix) => ix, }; self.contents.insert(insertion_ix, content); - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } pub fn contents<'a>(&'a self, cx: &'a App) -> impl 'a + Iterator { @@ -2583,7 +2570,7 @@ impl AssistantContext { }; self.insert_message(suffix.clone(), suffix_metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: suffix.clone(), metadata: suffix_metadata, version, @@ -2633,7 +2620,7 @@ impl AssistantContext { }; self.insert_message(selection.clone(), selection_metadata.clone(), cx); self.push_op( - ContextOperation::InsertMessage { + TextThreadOperation::InsertMessage { anchor: selection.clone(), metadata: selection_metadata, version, @@ -2645,7 +2632,7 @@ impl AssistantContext { }; if !edited_buffer { - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); } new_messages } else { @@ -2659,7 +2646,7 @@ impl AssistantContext { new_metadata: MessageMetadata, cx: &mut Context, ) { - cx.emit(ContextEvent::MessagesEdited); + cx.emit(TextThreadEvent::MessagesEdited); self.messages_metadata.insert(new_anchor.id, new_metadata); @@ -2676,7 +2663,7 @@ impl AssistantContext { } pub fn summarize(&mut self, mut replace_old: bool, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else { + let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { return; }; @@ -2695,20 +2682,20 @@ impl AssistantContext { // If there is no summary, it is set with `done: false` so that "Loading Summary…" can // be displayed. match self.summary { - ContextSummary::Pending | ContextSummary::Error => { - self.summary = ContextSummary::Content(ContextSummaryContent { + TextThreadSummary::Pending | TextThreadSummary::Error => { + self.summary = TextThreadSummary::Content(TextThreadSummaryContent { text: "".to_string(), done: false, - timestamp: clock::Lamport::default(), + timestamp: clock::Lamport::MIN, }); replace_old = true; } - ContextSummary::Content(_) => {} + TextThreadSummary::Content(_) => {} } self.summary_task = cx.spawn(async move |this, cx| { let result = async { - let stream = model.model.stream_completion_text(request, &cx); + let stream = model.model.stream_completion_text(request, cx); let mut messages = stream.await?; let mut replaced = !replace_old; @@ -2725,13 +2712,13 @@ impl AssistantContext { } summary.text.extend(lines.next()); summary.timestamp = timestamp; - let operation = ContextOperation::UpdateSummary { + let operation = TextThreadOperation::UpdateSummary { summary: summary.clone(), version, }; this.push_op(operation, cx); - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); })?; // Stop if the LLM generated multiple lines. @@ -2741,10 +2728,10 @@ impl AssistantContext { } this.read_with(cx, |this, _cx| { - if let Some(summary) = this.summary.content() { - if summary.text.is_empty() { - bail!("Model generated an empty summary"); - } + if let Some(summary) = this.summary.content() + && summary.text.is_empty() + { + bail!("Model generated an empty summary"); } Ok(()) })??; @@ -2755,13 +2742,13 @@ impl AssistantContext { if let Some(summary) = this.summary.content_as_mut() { summary.done = true; summary.timestamp = timestamp; - let operation = ContextOperation::UpdateSummary { + let operation = TextThreadOperation::UpdateSummary { summary: summary.clone(), version, }; this.push_op(operation, cx); - cx.emit(ContextEvent::SummaryChanged); - cx.emit(ContextEvent::SummaryGenerated); + cx.emit(TextThreadEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryGenerated); } })?; @@ -2771,8 +2758,8 @@ impl AssistantContext { if let Err(err) = result { this.update(cx, |this, cx| { - this.summary = ContextSummary::Error; - cx.emit(ContextEvent::SummaryChanged); + this.summary = TextThreadSummary::Error; + cx.emit(TextThreadEvent::SummaryChanged); }) .log_err(); log::error!("Error generating context summary: {}", err); @@ -2799,7 +2786,7 @@ impl AssistantContext { let mut current_message = messages.next(); while let Some(offset) = offsets.next() { // Locate the message that contains the offset. - while current_message.as_ref().map_or(false, |message| { + while current_message.as_ref().is_some_and(|message| { !message.offset_range.contains(&offset) && messages.peek().is_some() }) { current_message = messages.next(); @@ -2809,7 +2796,7 @@ impl AssistantContext { }; // Skip offsets that are in the same message. - while offsets.peek().map_or(false, |offset| { + while offsets.peek().is_some_and(|offset| { message.offset_range.contains(offset) || messages.peek().is_none() }) { offsets.next(); @@ -2878,7 +2865,7 @@ impl AssistantContext { &mut self, debounce: Option, fs: Arc, - cx: &mut Context, + cx: &mut Context, ) { if self.replica_id() != ReplicaId::default() { // Prevent saving a remote context for now. @@ -2909,7 +2896,7 @@ impl AssistantContext { let mut discriminant = 1; let mut new_path; loop { - new_path = contexts_dir().join(&format!( + new_path = text_threads_dir().join(&format!( "{} - {}.zed.json", summary.trim(), discriminant @@ -2921,21 +2908,21 @@ impl AssistantContext { } } - fs.create_dir(contexts_dir().as_ref()).await?; + fs.create_dir(text_threads_dir().as_ref()).await?; // rename before write ensures that only one file exists - if let Some(old_path) = old_path.as_ref() { - if new_path.as_path() != old_path.as_ref() { - fs.rename( - &old_path, - &new_path, - RenameOptions { - overwrite: true, - ignore_if_exists: true, - }, - ) - .await?; - } + if let Some(old_path) = old_path.as_ref() + && new_path.as_path() != old_path.as_ref() + { + fs.rename( + old_path, + &new_path, + RenameOptions { + overwrite: true, + ignore_if_exists: true, + }, + ) + .await?; } // update path before write in case it fails @@ -2943,7 +2930,7 @@ impl AssistantContext { let new_path: Arc = new_path.clone().into(); move |this, cx| { this.path = Some(new_path.clone()); - cx.emit(ContextEvent::PathChanged { old_path, new_path }); + cx.emit(TextThreadEvent::PathChanged { old_path, new_path }); } }) .ok(); @@ -2962,7 +2949,7 @@ impl AssistantContext { summary.timestamp = timestamp; summary.done = true; summary.text = custom_summary; - cx.emit(ContextEvent::SummaryChanged); + cx.emit(TextThreadEvent::SummaryChanged); } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut App) { @@ -2982,23 +2969,23 @@ impl AssistantContext { } #[derive(Debug, Default)] -pub struct ContextVersion { - context: clock::Global, +pub struct TextThreadVersion { + text_thread: clock::Global, buffer: clock::Global, } -impl ContextVersion { +impl TextThreadVersion { pub fn from_proto(proto: &proto::ContextVersion) -> Self { Self { - context: language::proto::deserialize_version(&proto.context_version), + text_thread: language::proto::deserialize_version(&proto.context_version), buffer: language::proto::deserialize_version(&proto.buffer_version), } } - pub fn to_proto(&self, context_id: ContextId) -> proto::ContextVersion { + pub fn to_proto(&self, context_id: TextThreadId) -> proto::ContextVersion { proto::ContextVersion { context_id: context_id.to_proto(), - context_version: language::proto::serialize_version(&self.context), + context_version: language::proto::serialize_version(&self.text_thread), buffer_version: language::proto::serialize_version(&self.buffer), } } @@ -3066,8 +3053,8 @@ pub struct SavedMessage { } #[derive(Serialize, Deserialize)] -pub struct SavedContext { - pub id: Option, +pub struct SavedTextThread { + pub id: Option, pub zed: String, pub version: String, pub text: String, @@ -3079,7 +3066,7 @@ pub struct SavedContext { pub thought_process_output_sections: Vec>, } -impl SavedContext { +impl SavedTextThread { pub const VERSION: &'static str = "0.4.0"; pub fn from_json(json: &str) -> Result { @@ -3089,9 +3076,9 @@ impl SavedContext { .context("version not found")? { serde_json::Value::String(version) => match version.as_str() { - SavedContext::VERSION => { - Ok(serde_json::from_value::(saved_context_json)?) - } + SavedTextThread::VERSION => Ok(serde_json::from_value::( + saved_context_json, + )?), SavedContextV0_3_0::VERSION => { let saved_context = serde_json::from_value::(saved_context_json)?; @@ -3116,18 +3103,18 @@ impl SavedContext { fn into_ops( self, buffer: &Entity, - cx: &mut Context, - ) -> Vec { + cx: &mut Context, + ) -> Vec { let mut operations = Vec::new(); let mut version = clock::Global::new(); let mut next_timestamp = clock::Lamport::new(ReplicaId::default()); let mut first_message_metadata = None; for message in self.messages { - if message.id == MessageId(clock::Lamport::default()) { + if message.id == MessageId(clock::Lamport::MIN) { first_message_metadata = Some(message.metadata); } else { - operations.push(ContextOperation::InsertMessage { + operations.push(TextThreadOperation::InsertMessage { anchor: MessageAnchor { id: message.id, start: buffer.read(cx).anchor_before(message.start), @@ -3147,8 +3134,8 @@ impl SavedContext { if let Some(metadata) = first_message_metadata { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::UpdateMessage { - message_id: MessageId(clock::Lamport::default()), + operations.push(TextThreadOperation::UpdateMessage { + message_id: MessageId(clock::Lamport::MIN), metadata: MessageMetadata { role: metadata.role, status: metadata.status, @@ -3163,7 +3150,7 @@ impl SavedContext { let buffer = buffer.read(cx); for section in self.slash_command_output_sections { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::SlashCommandOutputSectionAdded { + operations.push(TextThreadOperation::SlashCommandOutputSectionAdded { timestamp, section: SlashCommandOutputSection { range: buffer.anchor_after(section.range.start) @@ -3180,7 +3167,7 @@ impl SavedContext { for section in self.thought_process_output_sections { let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::ThoughtProcessOutputSectionAdded { + operations.push(TextThreadOperation::ThoughtProcessOutputSectionAdded { timestamp, section: ThoughtProcessOutputSection { range: buffer.anchor_after(section.range.start) @@ -3193,8 +3180,8 @@ impl SavedContext { } let timestamp = next_timestamp.tick(); - operations.push(ContextOperation::UpdateSummary { - summary: ContextSummaryContent { + operations.push(TextThreadOperation::UpdateSummary { + summary: TextThreadSummaryContent { text: self.summary, done: true, timestamp, @@ -3224,7 +3211,7 @@ struct SavedMessageMetadataPreV0_4_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_3_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3237,11 +3224,11 @@ struct SavedContextV0_3_0 { impl SavedContextV0_3_0 { const VERSION: &'static str = "0.3.0"; - fn upgrade(self) -> SavedContext { - SavedContext { + fn upgrade(self) -> SavedTextThread { + SavedTextThread { id: self.id, zed: self.zed, - version: SavedContext::VERSION.into(), + version: SavedTextThread::VERSION.into(), text: self.text, messages: self .messages @@ -3273,7 +3260,7 @@ impl SavedContextV0_3_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_2_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3285,7 +3272,7 @@ struct SavedContextV0_2_0 { impl SavedContextV0_2_0 { const VERSION: &'static str = "0.2.0"; - fn upgrade(self) -> SavedContext { + fn upgrade(self) -> SavedTextThread { SavedContextV0_3_0 { id: self.id, zed: self.zed, @@ -3302,7 +3289,7 @@ impl SavedContextV0_2_0 { #[derive(Serialize, Deserialize)] struct SavedContextV0_1_0 { - id: Option, + id: Option, zed: String, version: String, text: String, @@ -3316,7 +3303,7 @@ struct SavedContextV0_1_0 { impl SavedContextV0_1_0 { const VERSION: &'static str = "0.1.0"; - fn upgrade(self) -> SavedContext { + fn upgrade(self) -> SavedTextThread { SavedContextV0_2_0 { id: self.id, zed: self.zed, @@ -3331,7 +3318,7 @@ impl SavedContextV0_1_0 { } #[derive(Debug, Clone)] -pub struct SavedContextMetadata { +pub struct SavedTextThreadMetadata { pub title: SharedString, pub path: Arc, pub mtime: chrono::DateTime, diff --git a/crates/assistant_context/src/context_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs similarity index 68% rename from crates/assistant_context/src/context_store.rs rename to crates/assistant_text_thread/src/text_thread_store.rs index 622d8867a7194924f0a7eacb520fe4e26f29539b..19c317baf0fa728c77faebc388b5e36008aa39b3 100644 --- a/crates/assistant_context/src/context_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -1,6 +1,6 @@ use crate::{ - AssistantContext, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext, - SavedContextMetadata, + SavedTextThread, SavedTextThreadMetadata, TextThread, TextThreadEvent, TextThreadId, + TextThreadOperation, TextThreadVersion, }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; @@ -11,9 +11,9 @@ use context_server::ContextServerId; use fs::{Fs, RemoveOptions}; use futures::StreamExt; use fuzzy::StringMatchCandidate; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; use language::LanguageRegistry; -use paths::contexts_dir; +use paths::text_threads_dir; use project::{ Project, context_server_store::{ContextServerStatus, ContextServerStore}, @@ -24,26 +24,27 @@ use rpc::AnyProtoClient; use std::sync::LazyLock; use std::{cmp::Reverse, ffi::OsStr, mem, path::Path, sync::Arc, time::Duration}; use util::{ResultExt, TryFutureExt}; +use zed_env_vars::ZED_STATELESS; pub(crate) fn init(client: &AnyProtoClient) { - client.add_entity_message_handler(ContextStore::handle_advertise_contexts); - client.add_entity_request_handler(ContextStore::handle_open_context); - client.add_entity_request_handler(ContextStore::handle_create_context); - client.add_entity_message_handler(ContextStore::handle_update_context); - client.add_entity_request_handler(ContextStore::handle_synchronize_contexts); + client.add_entity_message_handler(TextThreadStore::handle_advertise_contexts); + client.add_entity_request_handler(TextThreadStore::handle_open_context); + client.add_entity_request_handler(TextThreadStore::handle_create_context); + client.add_entity_message_handler(TextThreadStore::handle_update_context); + client.add_entity_request_handler(TextThreadStore::handle_synchronize_contexts); } #[derive(Clone)] -pub struct RemoteContextMetadata { - pub id: ContextId, +pub struct RemoteTextThreadMetadata { + pub id: TextThreadId, pub summary: Option, } -pub struct ContextStore { - contexts: Vec, - contexts_metadata: Vec, +pub struct TextThreadStore { + text_threads: Vec, + text_threads_metadata: Vec, context_server_slash_command_ids: HashMap>, - host_contexts: Vec, + host_text_threads: Vec, fs: Arc, languages: Arc, slash_commands: Arc, @@ -57,34 +58,28 @@ pub struct ContextStore { prompt_builder: Arc, } -pub enum ContextStoreEvent { - ContextCreated(ContextId), +enum TextThreadHandle { + Weak(WeakEntity), + Strong(Entity), } -impl EventEmitter for ContextStore {} - -enum ContextHandle { - Weak(WeakEntity), - Strong(Entity), -} - -impl ContextHandle { - fn upgrade(&self) -> Option> { +impl TextThreadHandle { + fn upgrade(&self) -> Option> { match self { - ContextHandle::Weak(weak) => weak.upgrade(), - ContextHandle::Strong(strong) => Some(strong.clone()), + TextThreadHandle::Weak(weak) => weak.upgrade(), + TextThreadHandle::Strong(strong) => Some(strong.clone()), } } - fn downgrade(&self) -> WeakEntity { + fn downgrade(&self) -> WeakEntity { match self { - ContextHandle::Weak(weak) => weak.clone(), - ContextHandle::Strong(strong) => strong.downgrade(), + TextThreadHandle::Weak(weak) => weak.clone(), + TextThreadHandle::Strong(strong) => strong.downgrade(), } } } -impl ContextStore { +impl TextThreadStore { pub fn new( project: Entity, prompt_builder: Arc, @@ -96,14 +91,14 @@ impl ContextStore { let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); - let (mut events, _) = fs.watch(contexts_dir(), CONTEXT_WATCH_DURATION).await; + let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; let this = cx.new(|cx: &mut Context| { let mut this = Self { - contexts: Vec::new(), - contexts_metadata: Vec::new(), + text_threads: Vec::new(), + text_threads_metadata: Vec::new(), context_server_slash_command_ids: HashMap::default(), - host_contexts: Vec::new(), + host_text_threads: Vec::new(), fs, languages, slash_commands, @@ -141,10 +136,10 @@ impl ContextStore { #[cfg(any(test, feature = "test-support"))] pub fn fake(project: Entity, cx: &mut Context) -> Self { Self { - contexts: Default::default(), - contexts_metadata: Default::default(), + text_threads: Default::default(), + text_threads_metadata: Default::default(), context_server_slash_command_ids: Default::default(), - host_contexts: Default::default(), + host_text_threads: Default::default(), fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), @@ -165,13 +160,13 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - this.host_contexts = envelope + this.host_text_threads = envelope .payload .contexts .into_iter() - .map(|context| RemoteContextMetadata { - id: ContextId::from_proto(context.context_id), - summary: context.summary, + .map(|text_thread| RemoteTextThreadMetadata { + id: TextThreadId::from_proto(text_thread.context_id), + summary: text_thread.summary, }) .collect(); cx.notify(); @@ -183,25 +178,25 @@ impl ContextStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let context_id = ContextId::from_proto(envelope.payload.context_id); + let context_id = TextThreadId::from_proto(envelope.payload.context_id); let operations = this.update(&mut cx, |this, cx| { anyhow::ensure!( !this.project.read(cx).is_via_collab(), "only the host contexts can be opened" ); - let context = this - .loaded_context_for_id(&context_id, cx) + let text_thread = this + .loaded_text_thread_for_id(&context_id, cx) .context("context not found")?; anyhow::ensure!( - context.read(cx).replica_id() == ReplicaId::default(), + text_thread.read(cx).replica_id() == ReplicaId::default(), "context must be opened via the host" ); anyhow::Ok( - context + text_thread .read(cx) - .serialize_ops(&ContextVersion::default(), cx), + .serialize_ops(&TextThreadVersion::default(), cx), ) })??; let operations = operations.await; @@ -221,15 +216,14 @@ impl ContextStore { "can only create contexts as the host" ); - let context = this.create(cx); - let context_id = context.read(cx).id().clone(); - cx.emit(ContextStoreEvent::ContextCreated(context_id.clone())); + let text_thread = this.create(cx); + let context_id = text_thread.read(cx).id().clone(); anyhow::Ok(( context_id, - context + text_thread .read(cx) - .serialize_ops(&ContextVersion::default(), cx), + .serialize_ops(&TextThreadVersion::default(), cx), )) })??; let operations = operations.await; @@ -245,11 +239,11 @@ impl ContextStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - let context_id = ContextId::from_proto(envelope.payload.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { + let context_id = TextThreadId::from_proto(envelope.payload.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&context_id, cx) { let operation_proto = envelope.payload.operation.context("invalid operation")?; - let operation = ContextOperation::from_proto(operation_proto)?; - context.update(cx, |context, cx| context.apply_ops([operation], cx)); + let operation = TextThreadOperation::from_proto(operation_proto)?; + text_thread.update(cx, |text_thread, cx| text_thread.apply_ops([operation], cx)); } Ok(()) })? @@ -268,12 +262,12 @@ impl ContextStore { let mut local_versions = Vec::new(); for remote_version_proto in envelope.payload.contexts { - let remote_version = ContextVersion::from_proto(&remote_version_proto); - let context_id = ContextId::from_proto(remote_version_proto.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { - let context = context.read(cx); - let operations = context.serialize_ops(&remote_version, cx); - local_versions.push(context.version(cx).to_proto(context_id.clone())); + let remote_version = TextThreadVersion::from_proto(&remote_version_proto); + let context_id = TextThreadId::from_proto(remote_version_proto.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&context_id, cx) { + let text_thread = text_thread.read(cx); + let operations = text_thread.serialize_ops(&remote_version, cx); + local_versions.push(text_thread.version(cx).to_proto(context_id.clone())); let client = this.client.clone(); let project_id = envelope.payload.project_id; cx.background_spawn(async move { @@ -307,9 +301,9 @@ impl ContextStore { } if is_shared { - self.contexts.retain_mut(|context| { - if let Some(strong_context) = context.upgrade() { - *context = ContextHandle::Strong(strong_context); + self.text_threads.retain_mut(|text_thread| { + if let Some(strong_context) = text_thread.upgrade() { + *text_thread = TextThreadHandle::Strong(strong_context); true } else { false @@ -320,7 +314,7 @@ impl ContextStore { .client .subscribe_to_entity(remote_id) .log_err() - .map(|subscription| subscription.set_entity(&cx.entity(), &mut cx.to_async())); + .map(|subscription| subscription.set_entity(&cx.entity(), &cx.to_async())); self.advertise_contexts(cx); } else { self.client_subscription = None; @@ -344,12 +338,12 @@ impl ContextStore { self.synchronize_contexts(cx); } project::Event::DisconnectedFromHost => { - self.contexts.retain_mut(|context| { - if let Some(strong_context) = context.upgrade() { - *context = ContextHandle::Weak(context.downgrade()); - strong_context.update(cx, |context, cx| { - if context.replica_id() != ReplicaId::default() { - context.set_capability(language::Capability::ReadOnly, cx); + self.text_threads.retain_mut(|text_thread| { + if let Some(strong_context) = text_thread.upgrade() { + *text_thread = TextThreadHandle::Weak(text_thread.downgrade()); + strong_context.update(cx, |text_thread, cx| { + if text_thread.replica_id() != ReplicaId::default() { + text_thread.set_capability(language::Capability::ReadOnly, cx); } }); true @@ -357,20 +351,24 @@ impl ContextStore { false } }); - self.host_contexts.clear(); + self.host_text_threads.clear(); cx.notify(); } _ => {} } } - pub fn unordered_contexts(&self) -> impl Iterator { - self.contexts_metadata.iter() + pub fn unordered_text_threads(&self) -> impl Iterator { + self.text_threads_metadata.iter() + } + + pub fn host_text_threads(&self) -> impl Iterator { + self.host_text_threads.iter() } - pub fn create(&mut self, cx: &mut Context) -> Entity { + pub fn create(&mut self, cx: &mut Context) -> Entity { let context = cx.new(|cx| { - AssistantContext::local( + TextThread::local( self.languages.clone(), Some(self.project.clone()), Some(self.telemetry.clone()), @@ -379,14 +377,11 @@ impl ContextStore { cx, ) }); - self.register_context(&context, cx); + self.register_text_thread(&context, cx); context } - pub fn create_remote_context( - &mut self, - cx: &mut Context, - ) -> Task>> { + pub fn create_remote(&mut self, cx: &mut Context) -> Task>> { let project = self.project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); @@ -402,10 +397,10 @@ impl ContextStore { let request = self.client.request(proto::CreateContext { project_id }); cx.spawn(async move |this, cx| { let response = request.await?; - let context_id = ContextId::from_proto(response.context_id); + let context_id = TextThreadId::from_proto(response.context_id); let context_proto = response.context.context("invalid context")?; - let context = cx.new(|cx| { - AssistantContext::new( + let text_thread = cx.new(|cx| { + TextThread::new( context_id.clone(), replica_id, capability, @@ -422,29 +417,29 @@ impl ContextStore { context_proto .operations .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() }) .await?; - context.update(cx, |context, cx| context.apply_ops(operations, cx))?; + text_thread.update(cx, |context, cx| context.apply_ops(operations, cx))?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_id(&context_id, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_id(&context_id, cx) { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&text_thread, cx); this.synchronize_contexts(cx); - context + text_thread } }) }) } - pub fn open_local_context( + pub fn open_local( &mut self, path: Arc, cx: &Context, - ) -> Task>> { - if let Some(existing_context) = self.loaded_context_for_path(&path, cx) { + ) -> Task>> { + if let Some(existing_context) = self.loaded_text_thread_for_path(&path, cx) { return Task::ready(Ok(existing_context)); } @@ -456,7 +451,7 @@ impl ContextStore { let path = path.clone(); async move { let saved_context = fs.load(&path).await?; - SavedContext::from_json(&saved_context) + SavedTextThread::from_json(&saved_context) } }); let prompt_builder = self.prompt_builder.clone(); @@ -465,7 +460,7 @@ impl ContextStore { cx.spawn(async move |this, cx| { let saved_context = load.await?; let context = cx.new(|cx| { - AssistantContext::deserialize( + TextThread::deserialize( saved_context, path.clone(), languages, @@ -477,21 +472,17 @@ impl ContextStore { ) })?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_path(&path, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_path(&path, cx) { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&context, cx); context } }) }) } - pub fn delete_local_context( - &mut self, - path: Arc, - cx: &mut Context, - ) -> Task> { + pub fn delete_local(&mut self, path: Arc, cx: &mut Context) -> Task> { let fs = self.fs.clone(); cx.spawn(async move |this, cx| { @@ -505,57 +496,57 @@ impl ContextStore { .await?; this.update(cx, |this, cx| { - this.contexts.retain(|context| { - context + this.text_threads.retain(|text_thread| { + text_thread .upgrade() - .and_then(|context| context.read(cx).path()) + .and_then(|text_thread| text_thread.read(cx).path()) != Some(&path) }); - this.contexts_metadata - .retain(|context| context.path.as_ref() != path.as_ref()); + this.text_threads_metadata + .retain(|text_thread| text_thread.path.as_ref() != path.as_ref()); })?; Ok(()) }) } - fn loaded_context_for_path(&self, path: &Path, cx: &App) -> Option> { - self.contexts.iter().find_map(|context| { - let context = context.upgrade()?; - if context.read(cx).path().map(Arc::as_ref) == Some(path) { - Some(context) + fn loaded_text_thread_for_path(&self, path: &Path, cx: &App) -> Option> { + self.text_threads.iter().find_map(|text_thread| { + let text_thread = text_thread.upgrade()?; + if text_thread.read(cx).path().map(Arc::as_ref) == Some(path) { + Some(text_thread) } else { None } }) } - pub fn loaded_context_for_id( + pub fn loaded_text_thread_for_id( &self, - id: &ContextId, + id: &TextThreadId, cx: &App, - ) -> Option> { - self.contexts.iter().find_map(|context| { - let context = context.upgrade()?; - if context.read(cx).id() == id { - Some(context) + ) -> Option> { + self.text_threads.iter().find_map(|text_thread| { + let text_thread = text_thread.upgrade()?; + if text_thread.read(cx).id() == id { + Some(text_thread) } else { None } }) } - pub fn open_remote_context( + pub fn open_remote( &mut self, - context_id: ContextId, + text_thread_id: TextThreadId, cx: &mut Context, - ) -> Task>> { + ) -> Task>> { let project = self.project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; - if let Some(context) = self.loaded_context_for_id(&context_id, cx) { + if let Some(context) = self.loaded_text_thread_for_id(&text_thread_id, cx) { return Task::ready(Ok(context)); } @@ -566,16 +557,16 @@ impl ContextStore { let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, - context_id: context_id.to_proto(), + context_id: text_thread_id.to_proto(), }); let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); cx.spawn(async move |this, cx| { let response = request.await?; let context_proto = response.context.context("invalid context")?; - let context = cx.new(|cx| { - AssistantContext::new( - context_id.clone(), + let text_thread = cx.new(|cx| { + TextThread::new( + text_thread_id.clone(), replica_id, capability, language_registry, @@ -591,38 +582,40 @@ impl ContextStore { context_proto .operations .into_iter() - .map(ContextOperation::from_proto) + .map(TextThreadOperation::from_proto) .collect::>>() }) .await?; - context.update(cx, |context, cx| context.apply_ops(operations, cx))?; + text_thread.update(cx, |context, cx| context.apply_ops(operations, cx))?; this.update(cx, |this, cx| { - if let Some(existing_context) = this.loaded_context_for_id(&context_id, cx) { + if let Some(existing_context) = this.loaded_text_thread_for_id(&text_thread_id, cx) + { existing_context } else { - this.register_context(&context, cx); + this.register_text_thread(&text_thread, cx); this.synchronize_contexts(cx); - context + text_thread } }) }) } - fn register_context(&mut self, context: &Entity, cx: &mut Context) { + fn register_text_thread(&mut self, text_thread: &Entity, cx: &mut Context) { let handle = if self.project_is_shared { - ContextHandle::Strong(context.clone()) + TextThreadHandle::Strong(text_thread.clone()) } else { - ContextHandle::Weak(context.downgrade()) + TextThreadHandle::Weak(text_thread.downgrade()) }; - self.contexts.push(handle); + self.text_threads.push(handle); self.advertise_contexts(cx); - cx.subscribe(context, Self::handle_context_event).detach(); + cx.subscribe(text_thread, Self::handle_context_event) + .detach(); } fn handle_context_event( &mut self, - context: Entity, - event: &ContextEvent, + text_thread: Entity, + event: &TextThreadEvent, cx: &mut Context, ) { let Some(project_id) = self.project.read(cx).remote_id() else { @@ -630,12 +623,12 @@ impl ContextStore { }; match event { - ContextEvent::SummaryChanged => { + TextThreadEvent::SummaryChanged => { self.advertise_contexts(cx); } - ContextEvent::PathChanged { old_path, new_path } => { + TextThreadEvent::PathChanged { old_path, new_path } => { if let Some(old_path) = old_path.as_ref() { - for metadata in &mut self.contexts_metadata { + for metadata in &mut self.text_threads_metadata { if &metadata.path == old_path { metadata.path = new_path.clone(); break; @@ -643,8 +636,8 @@ impl ContextStore { } } } - ContextEvent::Operation(operation) => { - let context_id = context.read(cx).id().to_proto(); + TextThreadEvent::Operation(operation) => { + let context_id = text_thread.read(cx).id().to_proto(); let operation = operation.to_proto(); self.client .send(proto::UpdateContext { @@ -669,15 +662,15 @@ impl ContextStore { } let contexts = self - .contexts + .text_threads .iter() .rev() - .filter_map(|context| { - let context = context.upgrade()?.read(cx); - if context.replica_id() == ReplicaId::default() { + .filter_map(|text_thread| { + let text_thread = text_thread.upgrade()?.read(cx); + if text_thread.replica_id() == ReplicaId::default() { Some(proto::ContextMetadata { - context_id: context.id().to_proto(), - summary: context + context_id: text_thread.id().to_proto(), + summary: text_thread .summary() .content() .map(|summary| summary.text.clone()), @@ -700,13 +693,13 @@ impl ContextStore { return; }; - let contexts = self - .contexts + let text_threads = self + .text_threads .iter() - .filter_map(|context| { - let context = context.upgrade()?.read(cx); - if context.replica_id() != ReplicaId::default() { - Some(context.version(cx).to_proto(context.id().clone())) + .filter_map(|text_thread| { + let text_thread = text_thread.upgrade()?.read(cx); + if text_thread.replica_id() != ReplicaId::default() { + Some(text_thread.version(cx).to_proto(text_thread.id().clone())) } else { None } @@ -716,26 +709,27 @@ impl ContextStore { let client = self.client.clone(); let request = self.client.request(proto::SynchronizeContexts { project_id, - contexts, + contexts: text_threads, }); cx.spawn(async move |this, cx| { let response = request.await?; - let mut context_ids = Vec::new(); + let mut text_thread_ids = Vec::new(); let mut operations = Vec::new(); this.read_with(cx, |this, cx| { for context_version_proto in response.contexts { - let context_version = ContextVersion::from_proto(&context_version_proto); - let context_id = ContextId::from_proto(context_version_proto.context_id); - if let Some(context) = this.loaded_context_for_id(&context_id, cx) { - context_ids.push(context_id); - operations.push(context.read(cx).serialize_ops(&context_version, cx)); + let text_thread_version = TextThreadVersion::from_proto(&context_version_proto); + let text_thread_id = TextThreadId::from_proto(context_version_proto.context_id); + if let Some(text_thread) = this.loaded_text_thread_for_id(&text_thread_id, cx) { + text_thread_ids.push(text_thread_id); + operations + .push(text_thread.read(cx).serialize_ops(&text_thread_version, cx)); } } })?; let operations = futures::future::join_all(operations).await; - for (context_id, operations) in context_ids.into_iter().zip(operations) { + for (context_id, operations) in text_thread_ids.into_iter().zip(operations) { for operation in operations { client.send(proto::UpdateContext { project_id, @@ -750,8 +744,8 @@ impl ContextStore { .detach_and_log_err(cx); } - pub fn search(&self, query: String, cx: &App) -> Task> { - let metadata = self.contexts_metadata.clone(); + pub fn search(&self, query: String, cx: &App) -> Task> { + let metadata = self.text_threads_metadata.clone(); let executor = cx.background_executor().clone(); cx.background_spawn(async move { if query.is_empty() { @@ -781,22 +775,16 @@ impl ContextStore { }) } - pub fn host_contexts(&self) -> &[RemoteContextMetadata] { - &self.host_contexts - } - fn reload(&mut self, cx: &mut Context) -> Task> { let fs = self.fs.clone(); cx.spawn(async move |this, cx| { - pub static ZED_STATELESS: LazyLock = - LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty())); if *ZED_STATELESS { return Ok(()); } - fs.create_dir(contexts_dir()).await?; + fs.create_dir(text_threads_dir()).await?; - let mut paths = fs.read_dir(contexts_dir()).await?; - let mut contexts = Vec::::new(); + let mut paths = fs.read_dir(text_threads_dir()).await?; + let mut contexts = Vec::::new(); while let Some(path) = paths.next().await { let path = path?; if path.extension() != Some(OsStr::new("json")) { @@ -822,7 +810,7 @@ impl ContextStore { .lines() .next() { - contexts.push(SavedContextMetadata { + contexts.push(SavedTextThreadMetadata { title: title.to_string().into(), path: path.into(), mtime: metadata.mtime.timestamp_for_user().into(), @@ -830,10 +818,10 @@ impl ContextStore { } } } - contexts.sort_unstable_by_key(|context| Reverse(context.mtime)); + contexts.sort_unstable_by_key(|text_thread| Reverse(text_thread.mtime)); this.update(cx, |this, cx| { - this.contexts_metadata = contexts; + this.text_threads_metadata = contexts; cx.notify(); }) }) @@ -862,7 +850,7 @@ impl ContextStore { ContextServerStatus::Running => { self.load_context_server_slash_commands( server_id.clone(), - context_server_store.clone(), + context_server_store, cx, ); } @@ -894,34 +882,33 @@ impl ContextStore { return; }; - if protocol.capable(context_server::protocol::ServerCapability::Prompts) { - if let Some(response) = protocol + if protocol.capable(context_server::protocol::ServerCapability::Prompts) + && let Some(response) = protocol .request::(()) .await .log_err() - { - let slash_command_ids = response - .prompts - .into_iter() - .filter(assistant_slash_commands::acceptable_prompt) - .map(|prompt| { - log::info!("registering context server command: {:?}", prompt.name); - slash_command_working_set.insert(Arc::new( - assistant_slash_commands::ContextServerSlashCommand::new( - context_server_store.clone(), - server.id(), - prompt, - ), - )) - }) - .collect::>(); - - this.update(cx, |this, _cx| { - this.context_server_slash_command_ids - .insert(server_id.clone(), slash_command_ids); + { + let slash_command_ids = response + .prompts + .into_iter() + .filter(assistant_slash_commands::acceptable_prompt) + .map(|prompt| { + log::info!("registering context server command: {:?}", prompt.name); + slash_command_working_set.insert(Arc::new( + assistant_slash_commands::ContextServerSlashCommand::new( + context_server_store.clone(), + server.id(), + prompt, + ), + )) }) - .log_err(); - } + .collect::>(); + + this.update(cx, |this, _cx| { + this.context_server_slash_command_ids + .insert(server_id.clone(), slash_command_ids); + }) + .log_err(); } }) .detach(); diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs deleted file mode 100644 index 9c5825d0f0ecc9c31277bfff5123d3d80501511b..0000000000000000000000000000000000000000 --- a/crates/assistant_tool/src/assistant_tool.rs +++ /dev/null @@ -1,269 +0,0 @@ -pub mod outline; -mod tool_registry; -mod tool_schema; -mod tool_working_set; - -use std::fmt; -use std::fmt::Debug; -use std::fmt::Formatter; -use std::ops::Deref; -use std::sync::Arc; - -use action_log::ActionLog; -use anyhow::Result; -use gpui::AnyElement; -use gpui::AnyWindowHandle; -use gpui::Context; -use gpui::IntoElement; -use gpui::Window; -use gpui::{App, Entity, SharedString, Task, WeakEntity}; -use icons::IconName; -use language_model::LanguageModel; -use language_model::LanguageModelImage; -use language_model::LanguageModelRequest; -use language_model::LanguageModelToolSchemaFormat; -use project::Project; -use workspace::Workspace; - -pub use crate::tool_registry::*; -pub use crate::tool_schema::*; -pub use crate::tool_working_set::*; - -pub fn init(cx: &mut App) { - ToolRegistry::default_global(cx); -} - -#[derive(Debug, Clone)] -pub enum ToolUseStatus { - InputStillStreaming, - NeedsConfirmation, - Pending, - Running, - Finished(SharedString), - Error(SharedString), -} - -impl ToolUseStatus { - pub fn text(&self) -> SharedString { - match self { - ToolUseStatus::NeedsConfirmation => "".into(), - ToolUseStatus::InputStillStreaming => "".into(), - ToolUseStatus::Pending => "".into(), - ToolUseStatus::Running => "".into(), - ToolUseStatus::Finished(out) => out.clone(), - ToolUseStatus::Error(out) => out.clone(), - } - } - - pub fn error(&self) -> Option { - match self { - ToolUseStatus::Error(out) => Some(out.clone()), - _ => None, - } - } -} - -#[derive(Debug)] -pub struct ToolResultOutput { - pub content: ToolResultContent, - pub output: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ToolResultContent { - Text(String), - Image(LanguageModelImage), -} - -impl ToolResultContent { - pub fn len(&self) -> usize { - match self { - ToolResultContent::Text(str) => str.len(), - ToolResultContent::Image(image) => image.len(), - } - } - - pub fn is_empty(&self) -> bool { - match self { - ToolResultContent::Text(str) => str.is_empty(), - ToolResultContent::Image(image) => image.is_empty(), - } - } - - pub fn as_str(&self) -> Option<&str> { - match self { - ToolResultContent::Text(str) => Some(str), - ToolResultContent::Image(_) => None, - } - } -} - -impl From for ToolResultOutput { - fn from(value: String) -> Self { - ToolResultOutput { - content: ToolResultContent::Text(value), - output: None, - } - } -} - -impl Deref for ToolResultOutput { - type Target = ToolResultContent; - - fn deref(&self) -> &Self::Target { - &self.content - } -} - -/// The result of running a tool, containing both the asynchronous output -/// and an optional card view that can be rendered immediately. -pub struct ToolResult { - /// The asynchronous task that will eventually resolve to the tool's output - pub output: Task>, - /// An optional view to present the output of the tool. - pub card: Option, -} - -pub trait ToolCard: 'static + Sized { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement; -} - -#[derive(Clone)] -pub struct AnyToolCard { - entity: gpui::AnyEntity, - render: fn( - entity: gpui::AnyEntity, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement, -} - -impl From> for AnyToolCard { - fn from(entity: Entity) -> Self { - fn downcast_render( - entity: gpui::AnyEntity, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement { - let entity = entity.downcast::().unwrap(); - entity.update(cx, |entity, cx| { - entity - .render(status, window, workspace, cx) - .into_any_element() - }) - } - - Self { - entity: entity.into(), - render: downcast_render::, - } - } -} - -impl AnyToolCard { - pub fn render( - &self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut App, - ) -> AnyElement { - (self.render)(self.entity.clone(), status, window, workspace, cx) - } -} - -impl From>> for ToolResult { - /// Convert from a task to a ToolResult with no card - fn from(output: Task>) -> Self { - Self { output, card: None } - } -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] -pub enum ToolSource { - /// A native tool built-in to Zed. - Native, - /// A tool provided by a context server. - ContextServer { id: SharedString }, -} - -/// A tool that can be used by a language model. -pub trait Tool: 'static + Send + Sync { - /// Returns the name of the tool. - fn name(&self) -> String; - - /// Returns the description of the tool. - fn description(&self) -> String; - - /// Returns the icon for the tool. - fn icon(&self) -> IconName; - - /// Returns the source of the tool. - fn source(&self) -> ToolSource { - ToolSource::Native - } - - /// Returns true if the tool needs the users's confirmation - /// before having permission to run. - fn needs_confirmation( - &self, - input: &serde_json::Value, - project: &Entity, - cx: &App, - ) -> bool; - - /// Returns true if the tool may perform edits. - fn may_perform_edits(&self) -> bool; - - /// Returns the JSON schema that describes the tool's input. - fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result { - Ok(serde_json::Value::Object(serde_json::Map::default())) - } - - /// Returns markdown to be displayed in the UI for this tool. - fn ui_text(&self, input: &serde_json::Value) -> String; - - /// Returns markdown to be displayed in the UI for this tool, while the input JSON is still streaming - /// (so information may be missing). - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - self.ui_text(input) - } - - /// Runs the tool with the provided input. - fn run( - self: Arc, - input: serde_json::Value, - request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult; - - fn deserialize_card( - self: Arc, - _output: serde_json::Value, - _project: Entity, - _window: &mut Window, - _cx: &mut App, - ) -> Option { - None - } -} - -impl Debug for dyn Tool { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.debug_struct("Tool").field("name", &self.name()).finish() - } -} diff --git a/crates/assistant_tool/src/tool_registry.rs b/crates/assistant_tool/src/tool_registry.rs deleted file mode 100644 index 26b4821a6d1af05a5e42d639f465486b9311d427..0000000000000000000000000000000000000000 --- a/crates/assistant_tool/src/tool_registry.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::sync::Arc; - -use collections::HashMap; -use derive_more::{Deref, DerefMut}; -use gpui::Global; -use gpui::{App, ReadGlobal}; -use parking_lot::RwLock; - -use crate::Tool; - -#[derive(Default, Deref, DerefMut)] -struct GlobalToolRegistry(Arc); - -impl Global for GlobalToolRegistry {} - -#[derive(Default)] -struct ToolRegistryState { - tools: HashMap, Arc>, -} - -#[derive(Default)] -pub struct ToolRegistry { - state: RwLock, -} - -impl ToolRegistry { - /// Returns the global [`ToolRegistry`]. - pub fn global(cx: &App) -> Arc { - GlobalToolRegistry::global(cx).0.clone() - } - - /// Returns the global [`ToolRegistry`]. - /// - /// Inserts a default [`ToolRegistry`] if one does not yet exist. - pub fn default_global(cx: &mut App) -> Arc { - cx.default_global::().0.clone() - } - - pub fn new() -> Arc { - Arc::new(Self { - state: RwLock::new(ToolRegistryState { - tools: HashMap::default(), - }), - }) - } - - /// Registers the provided [`Tool`]. - pub fn register_tool(&self, tool: impl Tool) { - let mut state = self.state.write(); - let tool_name: Arc = tool.name().into(); - state.tools.insert(tool_name, Arc::new(tool)); - } - - /// Unregisters the provided [`Tool`]. - pub fn unregister_tool(&self, tool: impl Tool) { - self.unregister_tool_by_name(tool.name().as_str()) - } - - /// Unregisters the tool with the given name. - pub fn unregister_tool_by_name(&self, tool_name: &str) { - let mut state = self.state.write(); - state.tools.remove(tool_name); - } - - /// Returns the list of tools in the registry. - pub fn tools(&self) -> Vec> { - self.state.read().tools.values().cloned().collect() - } - - /// Returns the [`Tool`] with the given name. - pub fn tool(&self, name: &str) -> Option> { - self.state.read().tools.get(name).cloned() - } -} diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs deleted file mode 100644 index c0a358917b499908d85fbc157212cf6db5b5e0eb..0000000000000000000000000000000000000000 --- a/crates/assistant_tool/src/tool_working_set.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::{borrow::Borrow, sync::Arc}; - -use crate::{Tool, ToolRegistry, ToolSource}; -use collections::{HashMap, HashSet, IndexMap}; -use gpui::{App, SharedString}; -use util::debug_panic; - -#[derive(Copy, Clone, PartialEq, Eq, Hash, Default)] -pub struct ToolId(usize); - -/// A unique identifier for a tool within a working set. -#[derive(Clone, PartialEq, Eq, Hash, Default)] -pub struct UniqueToolName(SharedString); - -impl Borrow for UniqueToolName { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for UniqueToolName { - fn from(value: String) -> Self { - UniqueToolName(SharedString::new(value)) - } -} - -impl Into for UniqueToolName { - fn into(self) -> String { - self.0.into() - } -} - -impl std::fmt::Debug for UniqueToolName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::fmt::Display for UniqueToolName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.as_ref()) - } -} - -/// A working set of tools for use in one instance of the Assistant Panel. -#[derive(Default)] -pub struct ToolWorkingSet { - context_server_tools_by_id: HashMap>, - context_server_tools_by_name: HashMap>, - next_tool_id: ToolId, -} - -impl ToolWorkingSet { - pub fn tool(&self, name: &str, cx: &App) -> Option> { - self.context_server_tools_by_name - .get(name) - .cloned() - .or_else(|| ToolRegistry::global(cx).tool(name)) - } - - pub fn tools(&self, cx: &App) -> Vec<(UniqueToolName, Arc)> { - let mut tools = ToolRegistry::global(cx) - .tools() - .into_iter() - .map(|tool| (UniqueToolName(tool.name().into()), tool)) - .collect::>(); - tools.extend(self.context_server_tools_by_name.clone()); - tools - } - - pub fn tools_by_source(&self, cx: &App) -> IndexMap>> { - let mut tools_by_source = IndexMap::default(); - - for (_, tool) in self.tools(cx) { - tools_by_source - .entry(tool.source()) - .or_insert_with(Vec::new) - .push(tool); - } - - for tools in tools_by_source.values_mut() { - tools.sort_by_key(|tool| tool.name()); - } - - tools_by_source.sort_unstable_keys(); - - tools_by_source - } - - pub fn insert(&mut self, tool: Arc, cx: &App) -> ToolId { - let tool_id = self.register_tool(tool); - self.tools_changed(cx); - tool_id - } - - pub fn extend(&mut self, tools: impl Iterator>, cx: &App) -> Vec { - let ids = tools.map(|tool| self.register_tool(tool)).collect(); - self.tools_changed(cx); - ids - } - - pub fn remove(&mut self, tool_ids_to_remove: &[ToolId], cx: &App) { - self.context_server_tools_by_id - .retain(|id, _| !tool_ids_to_remove.contains(id)); - self.tools_changed(cx); - } - - fn register_tool(&mut self, tool: Arc) -> ToolId { - let tool_id = self.next_tool_id; - self.next_tool_id.0 += 1; - self.context_server_tools_by_id - .insert(tool_id, tool.clone()); - tool_id - } - - fn tools_changed(&mut self, cx: &App) { - self.context_server_tools_by_name = resolve_context_server_tool_name_conflicts( - &self - .context_server_tools_by_id - .values() - .cloned() - .collect::>(), - &ToolRegistry::global(cx).tools(), - ); - } -} - -fn resolve_context_server_tool_name_conflicts( - context_server_tools: &[Arc], - native_tools: &[Arc], -) -> HashMap> { - fn resolve_tool_name(tool: &Arc) -> String { - let mut tool_name = tool.name(); - tool_name.truncate(MAX_TOOL_NAME_LENGTH); - tool_name - } - - const MAX_TOOL_NAME_LENGTH: usize = 64; - - let mut duplicated_tool_names = HashSet::default(); - let mut seen_tool_names = HashSet::default(); - seen_tool_names.extend(native_tools.iter().map(|tool| tool.name())); - for tool in context_server_tools { - let tool_name = resolve_tool_name(tool); - if seen_tool_names.contains(&tool_name) { - debug_assert!( - tool.source() != ToolSource::Native, - "Expected MCP tool but got a native tool: {}", - tool_name - ); - duplicated_tool_names.insert(tool_name); - } else { - seen_tool_names.insert(tool_name); - } - } - - if duplicated_tool_names.is_empty() { - return context_server_tools - .into_iter() - .map(|tool| (resolve_tool_name(tool).into(), tool.clone())) - .collect(); - } - - context_server_tools - .into_iter() - .filter_map(|tool| { - let mut tool_name = resolve_tool_name(tool); - if !duplicated_tool_names.contains(&tool_name) { - return Some((tool_name.into(), tool.clone())); - } - match tool.source() { - ToolSource::Native => { - debug_panic!("Expected MCP tool but got a native tool: {}", tool_name); - // Built-in tools always keep their original name - Some((tool_name.into(), tool.clone())) - } - ToolSource::ContextServer { id } => { - // Context server tools are prefixed with the context server ID, and truncated if necessary - tool_name.insert(0, '_'); - if tool_name.len() + id.len() > MAX_TOOL_NAME_LENGTH { - let len = MAX_TOOL_NAME_LENGTH - tool_name.len(); - let mut id = id.to_string(); - id.truncate(len); - tool_name.insert_str(0, &id); - } else { - tool_name.insert_str(0, &id); - } - - tool_name.truncate(MAX_TOOL_NAME_LENGTH); - - if seen_tool_names.contains(&tool_name) { - log::error!("Cannot resolve tool name conflict for tool {}", tool.name()); - None - } else { - Some((tool_name.into(), tool.clone())) - } - } - } - }) - .collect() -} -#[cfg(test)] -mod tests { - use gpui::{AnyWindowHandle, Entity, Task, TestAppContext}; - use language_model::{LanguageModel, LanguageModelRequest}; - use project::Project; - - use crate::{ActionLog, ToolResult}; - - use super::*; - - #[gpui::test] - fn test_unique_tool_names(cx: &mut TestAppContext) { - fn assert_tool( - tool_working_set: &ToolWorkingSet, - unique_name: &str, - expected_name: &str, - expected_source: ToolSource, - cx: &App, - ) { - let tool = tool_working_set.tool(unique_name, cx).unwrap(); - assert_eq!(tool.name(), expected_name); - assert_eq!(tool.source(), expected_source); - } - - let tool_registry = cx.update(ToolRegistry::default_global); - tool_registry.register_tool(TestTool::new("tool1", ToolSource::Native)); - tool_registry.register_tool(TestTool::new("tool2", ToolSource::Native)); - - let mut tool_working_set = ToolWorkingSet::default(); - cx.update(|cx| { - tool_working_set.extend( - vec![ - Arc::new(TestTool::new( - "tool2", - ToolSource::ContextServer { id: "mcp-1".into() }, - )) as Arc, - Arc::new(TestTool::new( - "tool2", - ToolSource::ContextServer { id: "mcp-2".into() }, - )) as Arc, - ] - .into_iter(), - cx, - ); - }); - - cx.update(|cx| { - assert_tool(&tool_working_set, "tool1", "tool1", ToolSource::Native, cx); - assert_tool(&tool_working_set, "tool2", "tool2", ToolSource::Native, cx); - assert_tool( - &tool_working_set, - "mcp-1_tool2", - "tool2", - ToolSource::ContextServer { id: "mcp-1".into() }, - cx, - ); - assert_tool( - &tool_working_set, - "mcp-2_tool2", - "tool2", - ToolSource::ContextServer { id: "mcp-2".into() }, - cx, - ); - }) - } - - #[gpui::test] - fn test_resolve_context_server_tool_name_conflicts() { - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - ], - vec![TestTool::new( - "tool3", - ToolSource::ContextServer { id: "mcp-1".into() }, - )], - vec!["tool3"], - ); - - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - ], - vec![ - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["mcp-1_tool3", "mcp-2_tool3"], - ); - - assert_resolve_context_server_tool_name_conflicts( - vec![ - TestTool::new("tool1", ToolSource::Native), - TestTool::new("tool2", ToolSource::Native), - TestTool::new("tool3", ToolSource::Native), - ], - vec![ - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }), - TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }), - ], - vec!["mcp-1_tool3", "mcp-2_tool3"], - ); - - // Test deduplication of tools with very long names, in this case the mcp server name should be truncated - assert_resolve_context_server_tool_name_conflicts( - vec![TestTool::new( - "tool-with-very-very-very-long-name", - ToolSource::Native, - )], - vec![TestTool::new( - "tool-with-very-very-very-long-name", - ToolSource::ContextServer { - id: "mcp-with-very-very-very-long-name".into(), - }, - )], - vec!["mcp-with-very-very-very-long-_tool-with-very-very-very-long-name"], - ); - - fn assert_resolve_context_server_tool_name_conflicts( - builtin_tools: Vec, - context_server_tools: Vec, - expected: Vec<&'static str>, - ) { - let context_server_tools: Vec> = context_server_tools - .into_iter() - .map(|t| Arc::new(t) as Arc) - .collect(); - let builtin_tools: Vec> = builtin_tools - .into_iter() - .map(|t| Arc::new(t) as Arc) - .collect(); - let tools = - resolve_context_server_tool_name_conflicts(&context_server_tools, &builtin_tools); - assert_eq!(tools.len(), expected.len()); - for (i, (name, _)) in tools.into_iter().enumerate() { - assert_eq!( - name.0.as_ref(), - expected[i], - "Expected '{}' got '{}' at index {}", - expected[i], - name, - i - ); - } - } - } - - struct TestTool { - name: String, - source: ToolSource, - } - - impl TestTool { - fn new(name: impl Into, source: ToolSource) -> Self { - Self { - name: name.into(), - source, - } - } - } - - impl Tool for TestTool { - fn name(&self) -> String { - self.name.clone() - } - - fn icon(&self) -> icons::IconName { - icons::IconName::Ai - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn needs_confirmation( - &self, - _input: &serde_json::Value, - _project: &Entity, - _cx: &App, - ) -> bool { - true - } - - fn source(&self) -> ToolSource { - self.source.clone() - } - - fn description(&self) -> String { - "Test tool".to_string() - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Test tool".to_string() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - ToolResult { - output: Task::ready(Err(anyhow::anyhow!("No content"))), - card: None, - } - } - } -} diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml deleted file mode 100644 index 5a8ca8a5e995fd2c738eb3b309f2bb4ebe9595a1..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/Cargo.toml +++ /dev/null @@ -1,93 +0,0 @@ -[package] -name = "assistant_tools" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/assistant_tools.rs" - -[features] -eval = [] - -[dependencies] -action_log.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_tool.workspace = true -buffer_diff.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -component.workspace = true -derive_more.workspace = true -diffy = "0.4.2" -editor.workspace = true -feature_flags.workspace = true -futures.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -lsp.workspace = true -markdown.workspace = true -open.workspace = true -paths.workspace = true -portable-pty.workspace = true -project.workspace = true -prompt_store.workspace = true -regex.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smallvec.workspace = true -streaming_diff.workspace = true -strsim.workspace = true -task.workspace = true -terminal.workspace = true -terminal_view.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -watch.workspace = true -web_search.workspace = true -which.workspace = true -workspace-hack.workspace = true -workspace.workspace = true - -[dev-dependencies] -lsp = { workspace = true, features = ["test-support"] } -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -gpui_tokio.workspace = true -fs = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } -language_model = { workspace = true, features = ["test-support"] } -language_models.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -pretty_assertions.workspace = true -reqwest_client.workspace = true -settings = { workspace = true, features = ["test-support"] } -smol.workspace = true -task = { workspace = true, features = ["test-support"]} -tempfile.workspace = true -theme.workspace = true -tree-sitter-rust.workspace = true -workspace = { workspace = true, features = ["test-support"] } -unindent.workspace = true -zlog.workspace = true diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs deleted file mode 100644 index bf668e691885d328ecd34b22d0a4e14633be565a..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/assistant_tools.rs +++ /dev/null @@ -1,168 +0,0 @@ -mod copy_path_tool; -mod create_directory_tool; -mod delete_path_tool; -mod diagnostics_tool; -pub mod edit_agent; -mod edit_file_tool; -mod fetch_tool; -mod find_path_tool; -mod grep_tool; -mod list_directory_tool; -mod move_path_tool; -mod now_tool; -mod open_tool; -mod project_notifications_tool; -mod read_file_tool; -mod schema; -pub mod templates; -mod terminal_tool; -mod thinking_tool; -mod ui; -mod web_search_tool; - -use assistant_tool::ToolRegistry; -use copy_path_tool::CopyPathTool; -use gpui::{App, Entity}; -use http_client::HttpClientWithUrl; -use language_model::LanguageModelRegistry; -use move_path_tool::MovePathTool; -use std::sync::Arc; -use web_search_tool::WebSearchTool; - -pub(crate) use templates::*; - -use crate::create_directory_tool::CreateDirectoryTool; -use crate::delete_path_tool::DeletePathTool; -use crate::diagnostics_tool::DiagnosticsTool; -use crate::edit_file_tool::EditFileTool; -use crate::fetch_tool::FetchTool; -use crate::list_directory_tool::ListDirectoryTool; -use crate::now_tool::NowTool; -use crate::thinking_tool::ThinkingTool; - -pub use edit_file_tool::{EditFileMode, EditFileToolInput}; -pub use find_path_tool::*; -pub use grep_tool::{GrepTool, GrepToolInput}; -pub use open_tool::OpenTool; -pub use project_notifications_tool::ProjectNotificationsTool; -pub use read_file_tool::{ReadFileTool, ReadFileToolInput}; -pub use terminal_tool::TerminalTool; - -pub fn init(http_client: Arc, cx: &mut App) { - assistant_tool::init(cx); - - let registry = ToolRegistry::global(cx); - registry.register_tool(TerminalTool::new(cx)); - registry.register_tool(CreateDirectoryTool); - registry.register_tool(CopyPathTool); - registry.register_tool(DeletePathTool); - registry.register_tool(MovePathTool); - registry.register_tool(DiagnosticsTool); - registry.register_tool(ListDirectoryTool); - registry.register_tool(NowTool); - registry.register_tool(OpenTool); - registry.register_tool(ProjectNotificationsTool); - registry.register_tool(FindPathTool); - registry.register_tool(ReadFileTool); - registry.register_tool(GrepTool); - registry.register_tool(ThinkingTool); - registry.register_tool(FetchTool::new(http_client)); - registry.register_tool(EditFileTool); - - register_web_search_tool(&LanguageModelRegistry::global(cx), cx); - cx.subscribe( - &LanguageModelRegistry::global(cx), - move |registry, event, cx| match event { - language_model::Event::DefaultModelChanged => { - register_web_search_tool(®istry, cx); - } - _ => {} - }, - ) - .detach(); -} - -fn register_web_search_tool(registry: &Entity, cx: &mut App) { - let using_zed_provider = registry - .read(cx) - .default_model() - .map_or(false, |default| default.is_provided_by_zed()); - if using_zed_provider { - ToolRegistry::global(cx).register_tool(WebSearchTool); - } else { - ToolRegistry::global(cx).unregister_tool(WebSearchTool); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use agent_settings::AgentSettings; - use client::Client; - use clock::FakeSystemClock; - use http_client::FakeHttpClient; - use schemars::JsonSchema; - use serde::Serialize; - use settings::Settings; - - #[test] - fn test_json_schema() { - #[derive(Serialize, JsonSchema)] - struct GetWeatherTool { - location: String, - } - - let schema = schema::json_schema_for::( - language_model::LanguageModelToolSchemaFormat::JsonSchema, - ) - .unwrap(); - - assert_eq!( - schema, - serde_json::json!({ - "type": "object", - "properties": { - "location": { - "type": "string" - } - }, - "required": ["location"], - "additionalProperties": false - }) - ); - } - - #[gpui::test] - fn test_builtin_tool_schema_compatibility(cx: &mut App) { - settings::init(cx); - AgentSettings::register(cx); - - let client = Client::new( - Arc::new(FakeSystemClock::new()), - FakeHttpClient::with_200_response(), - cx, - ); - language_model::init(client.clone(), cx); - crate::init(client.http_client(), cx); - - for tool in ToolRegistry::global(cx).tools() { - let actual_schema = tool - .input_schema(language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset) - .unwrap(); - let mut expected_schema = actual_schema.clone(); - assistant_tool::adapt_schema_to_format( - &mut expected_schema, - language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset, - ) - .unwrap(); - - let error_message = format!( - "Tool schema for `{}` is not compatible with `language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset` (Gemini Models).\n\ - Are you using `schema::json_schema_for(format)` to generate the schema?", - tool.name(), - ); - - assert_eq!(actual_schema, expected_schema, "{}", error_message) - } - } -} diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs deleted file mode 100644 index c56a864bd45efd83d605607962f6103f8da7d1da..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ /dev/null @@ -1,125 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::AnyWindowHandle; -use gpui::{App, AppContext, Entity, Task}; -use language_model::LanguageModel; -use language_model::{LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CopyPathToolInput { - /// The source path of the file or directory to copy. - /// If a directory is specified, its contents will be copied recursively (like `cp -r`). - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can copy the first file by providing a source_path of "directory1/a/something.txt" - /// - pub source_path: String, - - /// The destination path where the file or directory should be copied to. - /// - /// - /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", - /// provide a destination_path of "directory2/b/copy.txt" - /// - pub destination_path: String, -} - -pub struct CopyPathTool; - -impl Tool for CopyPathTool { - fn name(&self) -> String { - "copy_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./copy_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolCopy - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let src = MarkdownInlineCode(&input.source_path); - let dest = MarkdownInlineCode(&input.destination_path); - format!("Copy {src} to {dest}") - } - Err(_) => "Copy path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let copy_task = project.update(cx, |project, cx| { - match project - .find_project_path(&input.source_path, cx) - .and_then(|project_path| project.entry_for_path(&project_path, cx)) - { - Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => { - project.copy_entry(entity.id, None, project_path.path, cx) - } - None => Task::ready(Err(anyhow!( - "Destination path {} was outside the project.", - input.destination_path - ))), - }, - None => Task::ready(Err(anyhow!( - "Source path {} was not found in the project.", - input.source_path - ))), - } - }); - - cx.background_spawn(async move { - let _ = copy_task.await.with_context(|| { - format!( - "Copying {} to {}", - input.source_path, input.destination_path - ) - })?; - Ok(format!("Copied {} to {}", input.source_path, input.destination_path).into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/copy_path_tool/description.md b/crates/assistant_tools/src/copy_path_tool/description.md deleted file mode 100644 index a5105e6f18c705e93aa9c30b9588f84dd8db542a..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/copy_path_tool/description.md +++ /dev/null @@ -1,6 +0,0 @@ -Copies a file or directory in the project, and returns confirmation that the copy succeeded. -Directory contents will be copied recursively (like `cp -r`). - -This tool should be used when it's desirable to create a copy of a file or directory without modifying the original. -It's much more efficient than doing this by separately reading and then writing the file or directory's contents, -so this tool should be preferred over that approach whenever copying is the goal. diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs deleted file mode 100644 index 85eea463dc1dfd429dd70ded8c18faf6ee8421c5..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ /dev/null @@ -1,100 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::AnyWindowHandle; -use gpui::{App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct CreateDirectoryToolInput { - /// The path of the new directory. - /// - /// - /// If the project has the following structure: - /// - /// - directory1/ - /// - directory2/ - /// - /// You can create a new directory by providing a path of "directory1/new_directory" - /// - pub path: String, -} - -pub struct CreateDirectoryTool; - -impl Tool for CreateDirectoryTool { - fn name(&self) -> String { - "create_directory".into() - } - - fn description(&self) -> String { - include_str!("./create_directory_tool/description.md").into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn icon(&self) -> IconName { - IconName::ToolFolder - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - format!("Create directory {}", MarkdownInlineCode(&input.path)) - } - Err(_) => "Create directory".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let project_path = match project.read(cx).find_project_path(&input.path, cx) { - Some(project_path) => project_path, - None => { - return Task::ready(Err(anyhow!("Path to create was outside the project"))).into(); - } - }; - let destination_path: Arc = input.path.as_str().into(); - - cx.spawn(async move |cx| { - project - .update(cx, |project, cx| { - project.create_entry(project_path.clone(), true, cx) - })? - .await - .with_context(|| format!("Creating directory {destination_path}"))?; - - Ok(format!("Created directory {destination_path}").into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/create_directory_tool/description.md b/crates/assistant_tools/src/create_directory_tool/description.md deleted file mode 100644 index 52056518c23517bf9fd36bf7d41d7e46947b15b6..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/create_directory_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. - -This tool creates a directory and all necessary parent directories (similar to `mkdir -p`). It should be used whenever you need to create new directories within the project. diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs deleted file mode 100644 index b181eeff5ca0f1a45176921ed9e24973aae3839f..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ /dev/null @@ -1,144 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use futures::{SinkExt, StreamExt, channel::mpsc}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, ProjectPath}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct DeletePathToolInput { - /// The path of the file or directory to delete. - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can delete the first file by providing a path of "directory1/a/something.txt" - /// - pub path: String, -} - -pub struct DeletePathTool; - -impl Tool for DeletePathTool { - fn name(&self) -> String { - "delete_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./delete_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolDeleteFile - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Delete “`{}`”", input.path), - Err(_) => "Delete path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let path_str = match serde_json::from_value::(input) { - Ok(input) => input.path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let Some(project_path) = project.read(cx).find_project_path(&path_str, cx) else { - return Task::ready(Err(anyhow!( - "Couldn't delete {path_str} because that path isn't in this project." - ))) - .into(); - }; - - let Some(worktree) = project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!( - "Couldn't delete {path_str} because that path isn't in this project." - ))) - .into(); - }; - - let worktree_snapshot = worktree.read(cx).snapshot(); - let (mut paths_tx, mut paths_rx) = mpsc::channel(256); - cx.background_spawn({ - let project_path = project_path.clone(); - async move { - for entry in - worktree_snapshot.traverse_from_path(true, false, false, &project_path.path) - { - if !entry.path.starts_with(&project_path.path) { - break; - } - paths_tx - .send(ProjectPath { - worktree_id: project_path.worktree_id, - path: entry.path.clone(), - }) - .await?; - } - anyhow::Ok(()) - } - }) - .detach(); - - cx.spawn(async move |cx| { - while let Some(path) = paths_rx.next().await { - if let Ok(buffer) = project - .update(cx, |project, cx| project.open_buffer(path, cx))? - .await - { - action_log.update(cx, |action_log, cx| { - action_log.will_delete_buffer(buffer.clone(), cx) - })?; - } - } - - let deletion_task = project - .update(cx, |project, cx| { - project.delete_file(project_path, false, cx) - })? - .with_context(|| { - format!("Couldn't delete {path_str} because that path isn't in this project.") - })?; - deletion_task - .await - .with_context(|| format!("Deleting {path_str}"))?; - Ok(format!("Deleted {path_str}").into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/delete_path_tool/description.md b/crates/assistant_tools/src/delete_path_tool/description.md deleted file mode 100644 index dfd4388bf04cf32038d04cacf169e9ea4bf05c56..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/delete_path_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion. diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs deleted file mode 100644 index 4ec794e12783746e4e330e79f0c0cb14c84f5d2e..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ /dev/null @@ -1,173 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language::{DiagnosticSeverity, OffsetRangeExt}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{fmt::Write, path::Path, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct DiagnosticsToolInput { - /// The path to get diagnostics for. If not provided, returns a project-wide summary. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - lorem - /// - ipsum - /// - /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`. - /// - #[serde(deserialize_with = "deserialize_path")] - pub path: Option, -} - -fn deserialize_path<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let opt = Option::::deserialize(deserializer)?; - // The model passes an empty string sometimes - Ok(opt.filter(|s| !s.is_empty())) -} - -pub struct DiagnosticsTool; - -impl Tool for DiagnosticsTool { - fn name(&self) -> String { - "diagnostics".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./diagnostics_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolDiagnostics - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - if let Some(path) = serde_json::from_value::(input.clone()) - .ok() - .and_then(|input| match input.path { - Some(path) if !path.is_empty() => Some(path), - _ => None, - }) - { - format!("Check diagnostics for {}", MarkdownInlineCode(&path)) - } else { - "Check project diagnostics".to_string() - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - match serde_json::from_value::(input) - .ok() - .and_then(|input| input.path) - { - Some(path) if !path.is_empty() => { - let Some(project_path) = project.read(cx).find_project_path(&path, cx) else { - return Task::ready(Err(anyhow!("Could not find path {path} in project",))) - .into(); - }; - - let buffer = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - - cx.spawn(async move |cx| { - let mut output = String::new(); - let buffer = buffer.await?; - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - for (_, group) in snapshot.diagnostic_groups(None) { - let entry = &group.entries[group.primary_ix]; - let range = entry.range.to_point(&snapshot); - let severity = match entry.diagnostic.severity { - DiagnosticSeverity::ERROR => "error", - DiagnosticSeverity::WARNING => "warning", - _ => continue, - }; - - writeln!( - output, - "{} at line {}: {}", - severity, - range.start.row + 1, - entry.diagnostic.message - )?; - } - - if output.is_empty() { - Ok("File doesn't have errors or warnings!".to_string().into()) - } else { - Ok(output.into()) - } - }) - .into() - } - _ => { - let project = project.read(cx); - let mut output = String::new(); - let mut has_diagnostics = false; - - for (project_path, _, summary) in project.diagnostic_summaries(true, cx) { - if summary.error_count > 0 || summary.warning_count > 0 { - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) - else { - continue; - }; - - has_diagnostics = true; - output.push_str(&format!( - "{}: {} error(s), {} warning(s)\n", - Path::new(worktree.read(cx).root_name()) - .join(project_path.path) - .display(), - summary.error_count, - summary.warning_count - )); - } - } - - if has_diagnostics { - Task::ready(Ok(output.into())).into() - } else { - Task::ready(Ok("No errors or warnings found in the project." - .to_string() - .into())) - .into() - } - } - } - } -} diff --git a/crates/assistant_tools/src/diagnostics_tool/description.md b/crates/assistant_tools/src/diagnostics_tool/description.md deleted file mode 100644 index 90dc00f1e408c0bd4d79de68833db9d4bafc0d2c..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/diagnostics_tool/description.md +++ /dev/null @@ -1,21 +0,0 @@ -Get errors and warnings for the project or a specific file. - -This tool can be invoked after a series of edits to determine if further edits are necessary, or if the user asks to fix errors or warnings in their codebase. - -When a path is provided, shows all diagnostics for that specific file. -When no path is provided, shows a summary of error and warning counts for all files in the project. - - -To get diagnostics for a specific file: -{ - "path": "src/main.rs" -} - -To get a project-wide diagnostic summary: -{} - - - -- If you think you can fix a diagnostic, make 1-2 attempts and then give up. -- Don't remove code you've generated just because you can't fix an error. The user can help you fix it. - diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs deleted file mode 100644 index e819c51e1edb841954508dbfad0fd1d2e85b51c4..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ /dev/null @@ -1,2441 +0,0 @@ -use crate::{ - Templates, - edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, - schema::json_schema_for, - ui::{COLLAPSED_LINES, ToolOutputPreview}, -}; -use action_log::ActionLog; -use agent_settings; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ - AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey}; -use futures::StreamExt; -use gpui::{ - Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task, - TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px, -}; -use indoc::formatdoc; -use language::{ - Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope, - TextBuffer, - language_settings::{self, FormatOnSave, SoftWrap}, -}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use paths; -use project::{ - Project, ProjectPath, - lsp_store::{FormatTrigger, LspFormatTarget}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{ - cmp::Reverse, - collections::HashSet, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; -use theme::ThemeSettings; -use ui::{Disclosure, Tooltip, prelude::*}; -use util::ResultExt; -use workspace::Workspace; - -pub struct EditFileTool; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be - /// shown in the UI and also passed to another model to perform the edit. - /// - /// Be terse, but also descriptive in what you want to achieve with this - /// edit. Avoid generic instructions. - /// - /// NEVER mention the file path in this description. - /// - /// Fix API endpoint URLs - /// Update copyright year in `page_footer` - /// - /// Make sure to include this field before all the others in the input object - /// so that we can display it immediately. - pub display_description: String, - - /// The full path of the file to create or modify in the project. - /// - /// WARNING: When specifying which file path need changing, you MUST - /// start each path with one of the project's root directories. - /// - /// The following examples assume we have two root directories in the project: - /// - /a/b/backend - /// - /c/d/frontend - /// - /// - /// `backend/src/main.rs` - /// - /// Notice how the file path starts with `backend`. Without that, the path - /// would be ambiguous and the call would fail! - /// - /// - /// - /// `frontend/db.js` - /// - pub path: PathBuf, - - /// The mode of operation on the file. Possible values: - /// - 'edit': Make granular edits to an existing file. - /// - 'create': Create a new file if it doesn't exist. - /// - 'overwrite': Replace the entire contents of an existing file. - /// - /// When a file already exists or you just created it, prefer editing - /// it as opposed to recreating it from scratch. - pub mode: EditFileMode, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum EditFileMode { - Edit, - Create, - Overwrite, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolOutput { - pub original_path: PathBuf, - pub new_text: String, - pub old_text: Arc, - pub raw_output: Option, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -struct PartialInput { - #[serde(default)] - path: String, - #[serde(default)] - display_description: String, -} - -const DEFAULT_UI_TEXT: &str = "Editing file"; - -impl Tool for EditFileTool { - fn name(&self) -> String { - "edit_file".into() - } - - fn needs_confirmation( - &self, - input: &serde_json::Value, - project: &Entity, - cx: &App, - ) -> bool { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return false; - } - - let Ok(input) = serde_json::from_value::(input.clone()) else { - // If it's not valid JSON, it's going to error and confirming won't do anything. - return false; - }; - - // If any path component matches the local settings folder, then this could affect - // the editor in ways beyond the project source, so prompt. - let local_settings_folder = paths::local_settings_folder_relative_path(); - let path = Path::new(&input.path); - if path - .components() - .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) - { - return true; - } - - // It's also possible that the global config dir is configured to be inside the project, - // so check for that edge case too. - if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { - if canonical_path.starts_with(paths::config_dir()) { - return true; - } - } - - // Check if path is inside the global config directory - // First check if it's already inside project - if not, try to canonicalize - let project_path = project.read(cx).find_project_path(&input.path, cx); - - // If the path is inside the project, and it's not one of the above edge cases, - // then no confirmation is necessary. Otherwise, confirmation is necessary. - project_path.is_none() - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("edit_file_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolPencil - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = Path::new(&input.path); - let mut description = input.display_description.clone(); - - // Add context about why confirmation may be needed - let local_settings_folder = paths::local_settings_folder_relative_path(); - if path - .components() - .any(|c| c.as_os_str() == local_settings_folder.as_os_str()) - { - description.push_str(" (local settings)"); - } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { - if canonical_path.starts_with(paths::config_dir()) { - description.push_str(" (global settings)"); - } - } - - description - } - Err(_) => "Editing file".to_string(), - } - } - - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - if let Some(input) = serde_json::from_value::(input.clone()).ok() { - let description = input.display_description.trim(); - if !description.is_empty() { - return description.to_string(); - } - - let path = input.path.trim(); - if !path.is_empty() { - return path.to_string(); - } - } - - DEFAULT_UI_TEXT.to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let project_path = match resolve_path(&input, project.clone(), cx) { - Ok(path) => path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let card = window.and_then(|window| { - window - .update(cx, |_, window, cx| { - cx.new(|cx| { - EditFileToolCard::new(input.path.clone(), project.clone(), window, cx) - }) - }) - .ok() - }); - - let card_clone = card.clone(); - let action_log_clone = action_log.clone(); - let task = cx.spawn(async move |cx: &mut AsyncApp| { - let edit_format = EditFormat::from_model(model.clone())?; - let edit_agent = EditAgent::new( - model, - project.clone(), - action_log_clone, - Templates::new(), - edit_format, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - })? - .await?; - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?; - } - - let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) { - edit_agent.edit( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - } else { - edit_agent.overwrite( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - }; - - let mut hallucinated_old_text = false; - let mut ambiguous_ranges = Vec::new(); - while let Some(event) = events.next().await { - match event { - EditAgentOutputEvent::Edited { .. } => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.update_diff(cx))?; - } - } - EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, - EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, - EditAgentOutputEvent::ResolvingEditRange(range) => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.reveal_range(range, cx))?; - } - } - } - } - let agent_output = output.await?; - - // If format_on_save is enabled, format the buffer - let format_on_save_enabled = buffer - .read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); - !matches!(settings.format_on_save, FormatOnSave::Off) - }) - .unwrap_or(false); - - if format_on_save_enabled { - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - let format_task = project.update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, // Don't push to history since the tool did it. - FormatTrigger::Save, - cx, - ) - })?; - format_task.await.log_err(); - } - - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? - .await?; - - // Notify the action log that we've edited the buffer (*after* formatting has completed). - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let (new_text, diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - - (new_text, diff) - } - }) - .await; - - let output = EditFileToolOutput { - original_path: project_path.path.to_path_buf(), - new_text: new_text.clone(), - old_text, - raw_output: Some(agent_output), - }; - - if let Some(card) = card_clone { - card.update(cx, |card, cx| { - card.update_diff(cx); - card.finalize(cx) - }) - .log_err(); - } - - let input_path = input.path.display(); - if diff.is_empty() { - anyhow::ensure!( - !hallucinated_old_text, - formatdoc! {" - Some edits were produced but none of them could be applied. - Read the relevant sections of {input_path} again so that - I can perform the requested edits. - "} - ); - anyhow::ensure!( - ambiguous_ranges.is_empty(), - { - let line_numbers = ambiguous_ranges - .iter() - .map(|range| range.start.to_string()) - .collect::>() - .join(", "); - formatdoc! {" - matches more than one position in the file (lines: {line_numbers}). Read the - relevant sections of {input_path} again and extend so - that I can perform the requested edits. - "} - } - ); - Ok(ToolResultOutput { - content: ToolResultContent::Text("No edits were made.".into()), - output: serde_json::to_value(output).ok(), - }) - } else { - Ok(ToolResultOutput { - content: ToolResultContent::Text(format!( - "Edited {}:\n\n```diff\n{}\n```", - input_path, diff - )), - output: serde_json::to_value(output).ok(), - }) - } - }); - - ToolResult { - output: task, - card: card.map(AnyToolCard::from), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - project: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let output = match serde_json::from_value::(output) { - Ok(output) => output, - Err(_) => return None, - }; - - let card = cx.new(|cx| { - EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx) - }); - - cx.spawn({ - let path: Arc = output.original_path.into(); - let language_registry = project.read(cx).languages().clone(); - let card = card.clone(); - async move |cx| { - let buffer = - build_buffer(output.new_text, path.clone(), &language_registry, cx).await?; - let buffer_diff = - build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx) - .await?; - card.update(cx, |card, cx| { - card.multibuffer.update(cx, |multibuffer, cx| { - let snapshot = buffer.read(cx).snapshot(); - let diff = buffer_diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) - .collect::>(); - - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - diff_hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - multibuffer.add_diff(buffer_diff, cx); - let end = multibuffer.len(cx); - card.total_lines = - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1); - }); - - cx.notify(); - })?; - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - - Some(card.into()) - } -} - -/// Validate that the file path is valid, meaning: -/// -/// - For `edit` and `overwrite`, the path must point to an existing file. -/// - For `create`, the file must not already exist, but it's parent dir must exist. -fn resolve_path( - input: &EditFileToolInput, - project: Entity, - cx: &mut App, -) -> Result { - let project = project.read(cx); - - match input.mode { - EditFileMode::Edit | EditFileMode::Overwrite => { - let path = project - .find_project_path(&input.path, cx) - .context("Can't edit file: path not found")?; - - let entry = project - .entry_for_path(&path, cx) - .context("Can't edit file: path not found")?; - - anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); - Ok(path) - } - - EditFileMode::Create => { - if let Some(path) = project.find_project_path(&input.path, cx) { - anyhow::ensure!( - project.entry_for_path(&path, cx).is_none(), - "Can't create file: file already exists" - ); - } - - let parent_path = input - .path - .parent() - .context("Can't create file: incorrect path")?; - - let parent_project_path = project.find_project_path(&parent_path, cx); - - let parent_entry = parent_project_path - .as_ref() - .and_then(|path| project.entry_for_path(&path, cx)) - .context("Can't create file: parent directory doesn't exist")?; - - anyhow::ensure!( - parent_entry.is_dir(), - "Can't create file: parent is not a directory" - ); - - let file_name = input - .path - .file_name() - .context("Can't create file: invalid filename")?; - - let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: Arc::from(parent.path.join(file_name)), - ..parent - }); - - new_file_path.context("Can't create file") - } - } -} - -pub struct EditFileToolCard { - path: PathBuf, - editor: Entity, - multibuffer: Entity, - project: Entity, - buffer: Option>, - base_text: Option>, - buffer_diff: Option>, - revealed_ranges: Vec>, - diff_task: Option>>, - preview_expanded: bool, - error_expanded: Option>, - full_height_expanded: bool, - total_lines: Option, -} - -impl EditFileToolCard { - pub fn new(path: PathBuf, project: Entity, window: &mut Window, cx: &mut App) -> Self { - let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card; - let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly)); - - let editor = cx.new(|cx| { - let mut editor = Editor::new( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sized_by_content: true, - }, - multibuffer.clone(), - Some(project.clone()), - window, - cx, - ); - editor.set_show_gutter(false, cx); - editor.disable_inline_diagnostics(); - editor.disable_expand_excerpt_buttons(cx); - // Keep horizontal scrollbar so user can scroll horizontally if needed - editor.set_show_vertical_scrollbar(false, cx); - editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); - editor.set_soft_wrap_mode(SoftWrap::None, cx); - editor.scroll_manager.set_forbid_vertical_scroll(true); - editor.set_show_indent_guides(false, cx); - editor.set_read_only(true); - editor.set_show_breakpoints(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_expand_all_diff_hunks(cx); - editor - }); - Self { - path, - project, - editor, - multibuffer, - buffer: None, - base_text: None, - buffer_diff: None, - revealed_ranges: Vec::new(), - diff_task: None, - preview_expanded: true, - error_expanded: None, - full_height_expanded: expand_edit_card, - total_lines: None, - } - } - - pub fn initialize(&mut self, buffer: Entity, cx: &mut App) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let base_text = buffer_snapshot.text(); - let language_registry = buffer.read(cx).language_registry(); - let text_snapshot = buffer.read(cx).text_snapshot(); - - // Create a buffer diff with the current text as the base - let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - let _ = diff.set_base_text( - buffer_snapshot.clone(), - language_registry, - text_snapshot, - cx, - ); - diff - }); - - self.buffer = Some(buffer.clone()); - self.base_text = Some(base_text.into()); - self.buffer_diff = Some(buffer_diff.clone()); - - // Add the diff to the multibuffer - self.multibuffer - .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx)); - } - - pub fn is_loading(&self) -> bool { - self.total_lines.is_none() - } - - pub fn update_diff(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - let Some(buffer_diff) = self.buffer_diff.as_ref() else { - return; - }; - - let buffer = buffer.clone(); - let buffer_diff = buffer_diff.clone(); - let base_text = self.base_text.clone(); - self.diff_task = Some(cx.spawn(async move |this, cx| { - let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?; - let diff_snapshot = BufferDiff::update_diff( - buffer_diff.clone(), - text_snapshot.clone(), - base_text, - false, - false, - None, - None, - cx, - ) - .await?; - buffer_diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &text_snapshot, cx) - })?; - this.update(cx, |this, cx| this.update_visible_ranges(cx)) - })); - } - - pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { - self.revealed_ranges.push(range); - self.update_visible_ranges(cx); - } - - fn update_visible_ranges(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - - let ranges = self.excerpt_ranges(cx); - self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(buffer, cx), - buffer.clone(), - ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - let end = multibuffer.len(cx); - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1) - }); - cx.notify(); - } - - fn excerpt_ranges(&self, cx: &App) -> Vec> { - let Some(buffer) = self.buffer.as_ref() else { - return Vec::new(); - }; - let Some(diff) = self.buffer_diff.as_ref() else { - return Vec::new(); - }; - - let buffer = buffer.read(cx); - let diff = diff.read(cx); - let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) - .collect::>(); - ranges.extend( - self.revealed_ranges - .iter() - .map(|range| range.to_point(&buffer)), - ); - ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); - - // Merge adjacent ranges - let mut ranges = ranges.into_iter().peekable(); - let mut merged_ranges = Vec::new(); - while let Some(mut range) = ranges.next() { - while let Some(next_range) = ranges.peek() { - if range.end >= next_range.start { - range.end = range.end.max(next_range.end); - ranges.next(); - } else { - break; - } - } - - merged_ranges.push(range); - } - merged_ranges - } - - pub fn finalize(&mut self, cx: &mut Context) -> Result<()> { - let ranges = self.excerpt_ranges(cx); - let buffer = self.buffer.take().context("card was already finalized")?; - let base_text = self - .base_text - .take() - .context("card was already finalized")?; - let language_registry = self.project.read(cx).languages().clone(); - - // Replace the buffer in the multibuffer with the snapshot - let buffer = cx.new(|cx| { - let language = buffer.read(cx).language().cloned(); - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - buffer.read(cx).line_ending(), - buffer.read(cx).as_rope().clone(), - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - }); - - let buffer_diff = cx.spawn({ - let buffer = buffer.clone(); - let language_registry = language_registry.clone(); - async move |_this, cx| { - build_buffer_diff(base_text, &buffer, &language_registry, cx).await - } - }); - - cx.spawn(async move |this, cx| { - let buffer_diff = buffer_diff.await?; - this.update(cx, |this, cx| { - this.multibuffer.update(cx, |multibuffer, cx| { - let path_key = PathKey::for_buffer(&buffer, cx); - multibuffer.clear(cx); - multibuffer.set_excerpts_for_path( - path_key, - buffer, - ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, - cx, - ); - multibuffer.add_diff(buffer_diff.clone(), cx); - }); - - cx.notify(); - }) - }) - .detach_and_log_err(cx); - Ok(()) - } -} - -impl ToolCard for EditFileToolCard { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let error_message = match status { - ToolUseStatus::Error(err) => Some(err), - _ => None, - }; - - let running_or_pending = match status { - ToolUseStatus::Running | ToolUseStatus::Pending => Some(()), - _ => None, - }; - - let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded; - - let path_label_button = h_flex() - .id(("edit-tool-path-label-button", self.editor.entity_id())) - .w_full() - .max_w_full() - .px_1() - .gap_0p5() - .cursor_pointer() - .rounded_sm() - .opacity(0.8) - .hover(|label| { - label - .opacity(1.) - .bg(cx.theme().colors().element_hover.opacity(0.5)) - }) - .tooltip(Tooltip::text("Jump to File")) - .child( - h_flex() - .child( - Icon::new(IconName::ToolPencil) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_size(rems(0.8125)) - .child(self.path.display().to_string()) - .ml_1p5() - .mr_0p5(), - ) - .child( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Ignored), - ), - ) - .on_click({ - let path = self.path.clone(); - let workspace = workspace.clone(); - move |_, window, cx| { - workspace - .update(cx, { - |workspace, cx| { - let Some(project_path) = - workspace.project().read(cx).find_project_path(&path, cx) - else { - return; - }; - let open_task = - workspace.open_path(project_path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = item.downcast::() { - active_editor - .update_in(cx, |editor, window, cx| { - let snapshot = - editor.buffer().read(cx).snapshot(cx); - let first_hunk = editor - .diff_hunks_in_ranges( - &[editor::Anchor::min() - ..editor::Anchor::max()], - &snapshot, - ) - .next(); - if let Some(first_hunk) = first_hunk { - let first_hunk_start = - first_hunk.multi_buffer_range().start; - editor.change_selections( - Default::default(), - window, - cx, - |selections| { - selections.select_anchor_ranges([ - first_hunk_start - ..first_hunk_start, - ]); - }, - ) - } - }) - .log_err(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - }) - .ok(); - } - }) - .into_any_element(); - - let codeblock_header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let codeblock_header = h_flex() - .flex_none() - .p_1() - .gap_1() - .justify_between() - .rounded_t_md() - .when(error_message.is_none(), |header| { - header.bg(codeblock_header_bg) - }) - .child(path_label_button) - .when(should_show_loading, |header| { - header.pr_1p5().child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ), - ) - }) - .when_some(error_message, |header, error_message| { - header.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .child( - Disclosure::new( - ("edit-file-error-disclosure", self.editor.entity_id()), - self.error_expanded.is_some(), - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let error_message = error_message.clone(); - - move |this, _event, _window, cx| { - if this.error_expanded.is_some() { - this.error_expanded.take(); - } else { - this.error_expanded = Some(cx.new(|cx| { - Markdown::new(error_message.clone(), None, None, cx) - })) - } - cx.notify(); - } - })), - ), - ) - }) - .when(error_message.is_none() && !self.is_loading(), |header| { - header.child( - Disclosure::new( - ("edit-file-disclosure", self.editor.entity_id()), - self.preview_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener( - move |this, _event, _window, _cx| { - this.preview_expanded = !this.preview_expanded; - }, - )), - ) - }); - - let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| { - let line_height = editor - .style() - .map(|style| style.text.line_height_in_pixels(window.rem_size())) - .unwrap_or_default(); - - editor.set_text_style_refinement(TextStyleRefinement { - font_size: Some( - TextSize::Small - .rems(cx) - .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) - .into(), - ), - ..TextStyleRefinement::default() - }); - let element = editor.render(window, cx); - (element.into_any_element(), line_height) - }); - - let border_color = cx.theme().colors().border.opacity(0.6); - - let waiting_for_diff = { - let styles = [ - ("w_4_5", (0.1, 0.85), 2000), - ("w_1_4", (0.2, 0.75), 2200), - ("w_2_4", (0.15, 0.64), 1900), - ("w_3_5", (0.25, 0.72), 2300), - ("w_2_5", (0.3, 0.56), 1800), - ]; - - let mut container = v_flex() - .p_3() - .gap_1() - .border_t_1() - .rounded_b_md() - .border_color(border_color) - .bg(cx.theme().colors().editor_background); - - for (width_method, pulse_range, duration_ms) in styles.iter() { - let (min_opacity, max_opacity) = *pulse_range; - let placeholder = match *width_method { - "w_4_5" => div().w_3_4(), - "w_1_4" => div().w_1_4(), - "w_2_4" => div().w_2_4(), - "w_3_5" => div().w_3_5(), - "w_2_5" => div().w_2_5(), - _ => div().w_1_2(), - } - .id("loading_div") - .h_1() - .rounded_full() - .bg(cx.theme().colors().element_active) - .with_animation( - "loading_pulsate", - Animation::new(Duration::from_millis(*duration_ms)) - .repeat() - .with_easing(pulsating_between(min_opacity, max_opacity)), - |label, delta| label.opacity(delta), - ); - - container = container.child(placeholder); - } - - container - }; - - v_flex() - .mb_2() - .border_1() - .when(error_message.is_some(), |card| card.border_dashed()) - .border_color(border_color) - .rounded_md() - .overflow_hidden() - .child(codeblock_header) - .when_some(self.error_expanded.as_ref(), |card, error_markdown| { - card.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_dashed() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .child( - Label::new("Error") - .size(LabelSize::XSmall) - .color(Color::Error), - ) - .child( - div() - .rounded_md() - .text_ui_sm(cx) - .bg(cx.theme().colors().editor_background) - .child(MarkdownElement::new( - error_markdown.clone(), - markdown_style(window, cx), - )), - ), - ) - }) - .when(self.is_loading() && error_message.is_none(), |card| { - card.child(waiting_for_diff) - }) - .when(self.preview_expanded && !self.is_loading(), |card| { - let editor_view = v_flex() - .relative() - .h_full() - .when(!self.full_height_expanded, |editor_container| { - editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0)) - }) - .overflow_hidden() - .border_t_1() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .child(editor); - - card.child( - ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id()) - .with_total_lines(self.total_lines.unwrap_or(0) as usize) - .toggle_state(self.full_height_expanded) - .with_collapsed_fade() - .on_toggle({ - let this = cx.entity().downgrade(); - move |is_expanded, _window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, _cx| { - this.full_height_expanded = is_expanded; - }); - } - } - }), - ) - }) - } -} - -fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = TextSize::Default.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().colors().element_selection_background, - ..Default::default() - } -} - -async fn build_buffer( - mut text: String, - path: Arc, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let line_ending = LineEnding::detect(&text); - LineEnding::normalize(&mut text); - let text = Rope::from(text); - let language = cx - .update(|_cx| language_registry.language_for_file_path(&path))? - .await - .ok(); - let buffer = cx.new(|cx| { - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - line_ending, - text, - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - })?; - Ok(buffer) -} - -async fn build_buffer_diff( - old_text: Arc, - buffer: &Entity, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; - - let old_text_rope = cx - .background_spawn({ - let old_text = old_text.clone(); - async move { Rope::from(old_text.as_str()) } - }) - .await; - let base_buffer = cx - .update(|cx| { - Buffer::build_snapshot( - old_text_rope, - buffer.language().cloned(), - Some(language_registry.clone()), - cx, - ) - })? - .await; - - let diff_snapshot = cx - .update(|cx| { - BufferDiffSnapshot::new_with_base_buffer( - buffer.text.clone(), - Some(old_text), - base_buffer, - cx, - ) - })? - .await; - - let secondary_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer, cx); - diff.set_snapshot(diff_snapshot.clone(), &buffer, cx); - diff - })?; - - cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer.text, cx); - diff.set_snapshot(diff_snapshot, &buffer, cx); - diff.set_secondary_diff(secondary_diff); - diff - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use ::fs::Fs; - use client::TelemetrySettings; - use gpui::{TestAppContext, UpdateGlobal}; - use language_model::fake_provider::FakeLanguageModel; - use serde_json::json; - use settings::SettingsStore; - use std::fs; - use util::path; - - #[gpui::test] - async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Some edit".into(), - path: "root/nonexistent_file.txt".into(), - mode: EditFileMode::Edit, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Create; - - let result = test_resolve_path(mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, "dir/new.txt"); - - let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: file already exists" - ); - - let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: parent directory doesn't exist" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Edit; - - let path_with_root = "root/dir/subdir/existing.txt"; - let path_without_root = "dir/subdir/existing.txt"; - let result = test_resolve_path(mode, path_with_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, path_without_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, "root/nonexistent.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - - let result = test_resolve_path(mode, "root/dir", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path is a directory" - ); - } - - async fn test_resolve_path( - mode: &EditFileMode, - path: &str, - cx: &mut TestAppContext, - ) -> anyhow::Result { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dir": { - "subdir": { - "existing.txt": "hello" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - let input = EditFileToolInput { - display_description: "Some edit".into(), - path: path.into(), - mode: mode.clone(), - }; - - let result = cx.update(|cx| resolve_path(&input, project, cx)); - result - } - - fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { - let actual = path - .expect("Should return valid path") - .path - .to_str() - .unwrap() - .replace("\\", "/"); // Naive Windows paths normalization - assert_eq!(actual, expected); - } - - #[test] - fn still_streaming_ui_text_with_path() { - let input = json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - } - - #[test] - fn still_streaming_ui_text_with_description() { - let input = json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_with_path_and_description() { - let input = json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_no_path_or_description() { - let input = json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - #[test] - fn still_streaming_ui_text_with_null() { - let input = serde_json::Value::Null; - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { - cx.update(|cx| { - // Set custom data directory (config will be under data_dir/config) - paths::set_custom_data_dir(data_dir.to_str().unwrap()); - - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test] - async fn test_format_on_save(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Set up a Rust language with LSP formatting support - let rust_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - // Register the language and fake LSP - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Rust", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - // Create the file - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/src/main.rs"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; - const FORMATTED_CONTENT: &str = - "This file was formatted by the fake formatter in the test.\n"; - - // Get the fake language server and set up formatting handler - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::({ - |_, _| async move { - Ok(Some(vec![lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - new_text: FORMATTED_CONTENT.to_string(), - }])) - } - }); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with format_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::On); - settings.defaults.formatter = - Some(language::language_settings::SelectedFormatter::Auto); - }, - ); - }); - }); - - // Have the model stream unformatted content - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify it was formatted automatically - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - FORMATTED_CONTENT, - "Code should be formatted when format_on_save is enabled" - ); - - let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - assert_eq!( - stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ - This causes the agent to think the file was modified externally when it was just formatted.", - stale_buffer_count - ); - - // Next, test with format_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::Off); - }, - ); - }); - }); - - // Stream unformatted edits again - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file was not formatted - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - UNFORMATTED_CONTENT, - "Code should not be formatted when format_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - // Create a simple file with trailing whitespace - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with remove_trailing_whitespace_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(true); - }, - ); - }); - }); - - const CONTENT_WITH_TRAILING_WHITESPACE: &str = - "fn main() { \n println!(\"Hello!\"); \n}\n"; - - // Have the model stream content that contains trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify trailing whitespace was removed automatically - assert_eq!( - // Ignore carriage returns on Windows - fs.load(path!("/root/src/main.rs").as_ref()) - .await - .unwrap() - .replace("\r\n", "\n"), - "fn main() {\n println!(\"Hello!\");\n}\n", - "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - ); - - // Next, test with remove_trailing_whitespace_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(false); - }, - ); - }); - }); - - // Stream edits again with trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file still has trailing whitespace - // Read the file again - it should still have trailing whitespace - let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - final_content.replace("\r\n", "\n"), - CONTENT_WITH_TRAILING_WHITESPACE, - "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_needs_confirmation(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - - // Test 1: Path with .zed component should require confirmation - let input_with_zed = json!({ - "display_description": "Edit settings", - "path": ".zed/settings.json", - "mode": "edit" - }); - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_with_zed, &project, cx), - "Path with .zed component should require confirmation" - ); - }); - - // Test 2: Absolute path should require confirmation - let input_absolute = json!({ - "display_description": "Edit file", - "path": "/etc/hosts", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_absolute, &project, cx), - "Absolute path should require confirmation" - ); - }); - - // Test 3: Relative path without .zed should not require confirmation - let input_relative = json!({ - "display_description": "Edit file", - "path": "root/src/main.rs", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_relative, &project, cx), - "Relative path without .zed should not require confirmation" - ); - }); - - // Test 4: Path with .zed in the middle should require confirmation - let input_zed_middle = json!({ - "display_description": "Edit settings", - "path": "root/.zed/tasks.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed_middle, &project, cx), - "Path with .zed in any component should require confirmation" - ); - }); - - // Test 5: When always_allow_tool_actions is enabled, no confirmation needed - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - - assert!( - !tool.needs_confirmation(&input_with_zed, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed" - ); - assert!( - !tool.needs_confirmation(&input_absolute, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" - ); - }); - } - - #[gpui::test] - async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { - // Set up a custom config directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - - // Test ui_text shows context for various paths - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create a project in /project directory - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test file outside project requires confirmation - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "File outside project should require confirmation" - ); - }); - - // Test file inside project doesn't require confirmation - let input_inside = json!({ - "display_description": "Edit file", - "path": "project/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_inside, &project, cx), - "File inside project should not require confirmation" - ); - }); - } - - #[gpui::test] - async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/home/user/myproject", json!({})).await; - let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - - // Get the actual local settings folder name - let local_settings_folder = paths::local_settings_folder_relative_path(); - - // Test various config path patterns - let test_cases = vec![ - ( - format!("{}/settings.json", local_settings_folder.display()), - true, - "Top-level local settings file".to_string(), - ), - ( - format!( - "myproject/{}/settings.json", - local_settings_folder.display() - ), - true, - "Local settings in project path".to_string(), - ), - ( - format!("src/{}/config.toml", local_settings_folder.display()), - true, - "Local settings in subdirectory".to_string(), - ), - ( - ".zed.backup/file.txt".to_string(), - true, - ".zed.backup is outside project".to_string(), - ), - ( - "my.zed/file.txt".to_string(), - true, - "my.zed is outside project".to_string(), - ), - ( - "myproject/src/file.zed".to_string(), - false, - ".zed as file extension".to_string(), - ), - ( - "myproject/normal/path/file.rs".to_string(), - false, - "Normal file without config paths".to_string(), - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create test files in the global config directory - let global_config_dir = paths::config_dir(); - fs::create_dir_all(&global_config_dir).unwrap(); - let global_settings_path = global_config_dir.join("settings.json"); - fs::write(&global_settings_path, "{}").unwrap(); - - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test global config paths - let test_cases = vec![ - ( - global_settings_path.to_str().unwrap().to_string(), - true, - "Global settings file should require confirmation", - ), - ( - global_config_dir - .join("keymap.json") - .to_str() - .unwrap() - .to_string(), - true, - "Global keymap file should require confirmation", - ), - ( - "project/normal_file.rs".to_string(), - false, - "Normal project file should not require confirmation", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {}", - description - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create multiple worktree directories - fs.insert_tree( - "/workspace/frontend", - json!({ - "src": { - "main.js": "console.log('frontend');" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/backend", - json!({ - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/shared", - json!({ - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - - // Create project with multiple worktrees - let project = Project::test( - fs.clone(), - [ - path!("/workspace/frontend").as_ref(), - path!("/workspace/backend").as_ref(), - path!("/workspace/shared").as_ref(), - ], - cx, - ) - .await; - - // Test files in different worktrees - let test_cases = vec![ - ("frontend/src/main.js", false, "File in first worktree"), - ("backend/src/main.rs", false, "File in second worktree"), - ( - "shared/.zed/settings.json", - true, - ".zed file in third worktree", - ), - ("/etc/hosts", true, "Absolute path outside all worktrees"), - ( - "../outside/file.txt", - true, - "Relative path outside worktrees", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".zed": { - "settings.json": "{}" - }, - "src": { - ".zed": { - "local.json": "{}" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test edge cases - let test_cases = vec![ - // Empty path - find_project_path returns Some for empty paths - ("", false, "Empty path is treated as project root"), - // Root directory - ("/", true, "Root directory should be outside project"), - // Parent directory references - find_project_path resolves these - ( - "project/../other", - false, - "Path with .. is resolved by find_project_path", - ), - ( - "project/./src/file.rs", - false, - "Path with . should work normally", - ), - // Windows-style paths (if on Windows) - #[cfg(target_os = "windows")] - ("C:\\Windows\\System32\\hosts", true, "Windows system path"), - #[cfg(target_os = "windows")] - ("project\\src\\main.rs", false, "Windows-style project path"), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - - // Test UI text for various scenarios - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "existing.txt": "content", - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test different EditFileMode values - let modes = vec![ - EditFileMode::Edit, - EditFileMode::Create, - EditFileMode::Overwrite, - ]; - - for mode in modes { - // Test .zed path with different modes - let input_zed = json!({ - "display_description": "Edit settings", - "path": "project/.zed/settings.json", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed, &project, cx), - ".zed path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test outside path with different modes - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "Outside path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test normal path with different modes - let input_normal = json!({ - "display_description": "Edit file", - "path": "project/normal.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_normal, &project, cx), - "Normal path should not require confirmation regardless of mode: {:?}", - mode - ); - }); - } - } - - #[gpui::test] - async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { - // Set up with custom directories for deterministic testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Enable always_allow_tool_actions - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Test that all paths that normally require confirmation are bypassed - let global_settings_path = paths::config_dir().join("settings.json"); - fs::create_dir_all(paths::config_dir()).unwrap(); - fs::write(&global_settings_path, "{}").unwrap(); - - let test_cases = vec![ - ".zed/settings.json", - "project/.zed/config.toml", - global_settings_path.to_str().unwrap(), - "/etc/hosts", - "/absolute/path/file.txt", - "../outside/project.txt", - ]; - - for path in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input, &project, cx), - "Path {} should not require confirmation when always_allow_tool_actions is true", - path - ); - }); - } - - // Disable always_allow_tool_actions and verify confirmation is required again - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = false; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Verify .zed path requires confirmation again - let input = json!({ - "display_description": "Edit file", - "path": ".zed/settings.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input, &project, cx), - ".zed path should require confirmation when always_allow_tool_actions is false" - ); - }); - } -} diff --git a/crates/assistant_tools/src/edit_file_tool/description.md b/crates/assistant_tools/src/edit_file_tool/description.md deleted file mode 100644 index 27f8e49dd626a2d1a5266b90413a3a5f8e02e6d8..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/edit_file_tool/description.md +++ /dev/null @@ -1,8 +0,0 @@ -This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. - -Before using this tool: - -1. Use the `read_file` tool to understand the file's contents and context - -2. Verify the directory path is correct (only applicable when creating new files): - - Use the `list_directory` tool to verify the parent directory exists and is the correct location diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs deleted file mode 100644 index 79e205f205d02ba2a3f977163d2296423f71d9da..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/fetch_tool.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::rc::Rc; -use std::sync::Arc; -use std::{borrow::Cow, cell::RefCell}; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow, bail}; -use assistant_tool::{Tool, ToolResult}; -use futures::AsyncReadExt as _; -use gpui::{AnyWindowHandle, App, AppContext as _, Entity, Task}; -use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown}; -use http_client::{AsyncBody, HttpClientWithUrl}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; -use util::markdown::MarkdownEscaped; - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -enum ContentType { - Html, - Plaintext, - Json, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct FetchToolInput { - /// The URL to fetch. - url: String, -} - -pub struct FetchTool { - http_client: Arc, -} - -impl FetchTool { - pub fn new(http_client: Arc) -> Self { - Self { http_client } - } - - async fn build_message(http_client: Arc, url: &str) -> Result { - let url = if !url.starts_with("https://") && !url.starts_with("http://") { - Cow::Owned(format!("https://{url}")) - } else { - Cow::Borrowed(url) - }; - - let mut response = http_client.get(&url, AsyncBody::default(), true).await?; - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading response body")?; - - if response.status().is_client_error() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); - } - - let Some(content_type) = response.headers().get("content-type") else { - bail!("missing Content-Type header"); - }; - let content_type = content_type - .to_str() - .context("invalid Content-Type header")?; - let content_type = match content_type { - "text/html" | "application/xhtml+xml" => ContentType::Html, - "application/json" => ContentType::Json, - _ => ContentType::Plaintext, - }; - - match content_type { - ContentType::Html => { - let mut handlers: Vec = vec![ - Rc::new(RefCell::new(markdown::WebpageChromeRemover)), - Rc::new(RefCell::new(markdown::ParagraphHandler)), - Rc::new(RefCell::new(markdown::HeadingHandler)), - Rc::new(RefCell::new(markdown::ListHandler)), - Rc::new(RefCell::new(markdown::TableHandler::new())), - Rc::new(RefCell::new(markdown::StyledTextHandler)), - ]; - if url.contains("wikipedia.org") { - use html_to_markdown::structure::wikipedia; - - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover))); - handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler))); - handlers.push(Rc::new( - RefCell::new(wikipedia::WikipediaCodeHandler::new()), - )); - } else { - handlers.push(Rc::new(RefCell::new(markdown::CodeHandler))); - } - - convert_html_to_markdown(&body[..], &mut handlers) - } - ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()), - ContentType::Json => { - let json: serde_json::Value = serde_json::from_slice(&body)?; - - Ok(format!( - "```json\n{}\n```", - serde_json::to_string_pretty(&json)? - )) - } - } - } -} - -impl Tool for FetchTool { - fn name(&self) -> String { - "fetch".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./fetch_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolWeb - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Fetch {}", MarkdownEscaped(&input.url)), - Err(_) => "Fetch URL".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let text = cx.background_spawn({ - let http_client = self.http_client.clone(); - async move { Self::build_message(http_client, &input.url).await } - }); - - cx.foreground_executor() - .spawn(async move { - let text = text.await?; - if text.trim().is_empty() { - bail!("no textual content found"); - } - - Ok(text.into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/fetch_tool/description.md b/crates/assistant_tools/src/fetch_tool/description.md deleted file mode 100644 index 007ba6c60864c2185740b40222a32b05d2819bf0..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/fetch_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Fetches a URL and returns the content as Markdown. diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs deleted file mode 100644 index 6b62638a4c33a3a4d29f7af51d3688a06f9c1dee..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/find_path_tool.rs +++ /dev/null @@ -1,464 +0,0 @@ -use crate::{schema::json_schema_for, ui::ToolCallCardHeader}; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{ - Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use editor::Editor; -use futures::channel::oneshot::{self, Receiver}; -use gpui::{ - AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window, -}; -use language; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::{cmp, path::PathBuf, sync::Arc}; -use ui::{Disclosure, Tooltip, prelude::*}; -use util::{ResultExt, paths::PathMatcher}; -use workspace::Workspace; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct FindPathToolInput { - /// The glob to match against every path in the project. - /// - /// - /// If the project has the following root directories: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can get back the first two paths by providing a glob of "*thing*.txt" - /// - pub glob: String, - - /// Optional starting position for paginated results (0-based). - /// When not provided, starts from the beginning. - #[serde(default)] - pub offset: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -struct FindPathToolOutput { - glob: String, - paths: Vec, -} - -const RESULTS_PER_PAGE: usize = 50; - -pub struct FindPathTool; - -impl Tool for FindPathTool { - fn name(&self) -> String { - "find_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./find_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolSearch - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Find paths matching “`{}`”", input.glob), - Err(_) => "Search paths".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let (offset, glob) = match serde_json::from_value::(input) { - Ok(input) => (input.offset, input.glob), - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let (sender, receiver) = oneshot::channel(); - - let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx)); - - let search_paths_task = search_paths(&glob, project, cx); - - let task = cx.background_spawn(async move { - let matches = search_paths_task.await?; - let paginated_matches: &[PathBuf] = &matches[cmp::min(offset, matches.len()) - ..cmp::min(offset + RESULTS_PER_PAGE, matches.len())]; - - sender.send(paginated_matches.to_vec()).log_err(); - - if matches.is_empty() { - Ok("No matches found".to_string().into()) - } else { - let mut message = format!("Found {} total matches.", matches.len()); - if matches.len() > RESULTS_PER_PAGE { - write!( - &mut message, - "\nShowing results {}-{} (provide 'offset' parameter for more results):", - offset + 1, - offset + paginated_matches.len() - ) - .unwrap(); - } - - for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) { - write!(&mut message, "\n{}", mat.display()).unwrap(); - } - - let output = FindPathToolOutput { - glob, - paths: matches, - }; - - Ok(ToolResultOutput { - content: ToolResultContent::Text(message), - output: Some(serde_json::to_value(output)?), - }) - } - }); - - ToolResult { - output: task, - card: Some(card.into()), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - _project: Entity, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let output = serde_json::from_value::(output).ok()?; - let card = cx.new(|_| FindPathToolCard::from_output(output)); - Some(card.into()) - } -} - -fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task>> { - let path_matcher = match PathMatcher::new([ - // Sometimes models try to search for "". In this case, return all paths in the project. - if glob.is_empty() { "*" } else { glob }, - ]) { - Ok(matcher) => matcher, - Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))), - }; - let snapshots: Vec<_> = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect(); - - cx.background_spawn(async move { - Ok(snapshots - .iter() - .flat_map(|snapshot| { - let root_name = PathBuf::from(snapshot.root_name()); - snapshot - .entries(false, 0) - .map(move |entry| root_name.join(&entry.path)) - .filter(|path| path_matcher.is_match(&path)) - }) - .collect()) - }) -} - -struct FindPathToolCard { - paths: Vec, - expanded: bool, - glob: String, - _receiver_task: Option>>, -} - -impl FindPathToolCard { - fn new(glob: String, receiver: Receiver>, cx: &mut Context) -> Self { - let _receiver_task = cx.spawn(async move |this, cx| { - let paths = receiver.await?; - - this.update(cx, |this, _cx| { - this.paths = paths; - }) - .log_err(); - - Ok(()) - }); - - Self { - paths: Vec::new(), - expanded: false, - glob, - _receiver_task: Some(_receiver_task), - } - } - - fn from_output(output: FindPathToolOutput) -> Self { - Self { - glob: output.glob, - paths: output.paths, - expanded: false, - _receiver_task: None, - } - } -} - -impl ToolCard for FindPathToolCard { - fn render( - &mut self, - _status: &ToolUseStatus, - _window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let matches_label: SharedString = if self.paths.len() == 0 { - "No matches".into() - } else if self.paths.len() == 1 { - "1 match".into() - } else { - format!("{} matches", self.paths.len()).into() - }; - - let content = if !self.paths.is_empty() && self.expanded { - Some( - v_flex() - .relative() - .ml_1p5() - .px_1p5() - .gap_0p5() - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .children(self.paths.iter().enumerate().map(|(index, path)| { - let path_clone = path.clone(); - let workspace_clone = workspace.clone(); - let button_label = path.to_string_lossy().to_string(); - - Button::new(("path", index), button_label) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .label_size(LabelSize::Small) - .color(Color::Muted) - .tooltip(Tooltip::text("Jump to File")) - .on_click(move |_, window, cx| { - workspace_clone - .update(cx, |workspace, cx| { - let path = PathBuf::from(&path_clone); - let Some(project_path) = workspace - .project() - .read(cx) - .find_project_path(&path, cx) - else { - return; - }; - let open_task = workspace.open_path( - project_path, - None, - true, - window, - cx, - ); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = - item.downcast::() - { - active_editor - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point( - language::Point::new(0, 0), - window, - cx, - ); - }) - .log_err(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }) - .ok(); - }) - })) - .into_any(), - ) - } else { - None - }; - - v_flex() - .mb_2() - .gap_1() - .child( - ToolCallCardHeader::new(IconName::ToolSearch, matches_label) - .with_code_path(&self.glob) - .disclosure_slot( - Disclosure::new("path-search-disclosure", self.expanded) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .disabled(self.paths.is_empty()) - .on_click(cx.listener(move |this, _, _, _cx| { - this.expanded = !this.expanded; - })), - ), - ) - .children(content) - } -} - -impl Component for FindPathTool { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn sort_name() -> &'static str { - "FindPathTool" - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let successful_card = cx.new(|_| FindPathToolCard { - paths: vec![ - PathBuf::from("src/main.rs"), - PathBuf::from("src/lib.rs"), - PathBuf::from("tests/test.rs"), - ], - expanded: true, - glob: "*.rs".to_string(), - _receiver_task: None, - }); - - let empty_card = cx.new(|_| FindPathToolCard { - paths: Vec::new(), - expanded: false, - glob: "*.nonexistent".to_string(), - _receiver_task: None, - }); - - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "With Paths", - div() - .size_full() - .child(successful_card.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "No Paths", - div() - .size_full() - .child(empty_card.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - ])]) - .into_any_element(), - ) - } -} - -#[cfg(test)] -mod test { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use settings::SettingsStore; - use util::path; - - #[gpui::test] - async fn test_find_path_tool(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - serde_json::json!({ - "apple": { - "banana": { - "carrot": "1", - }, - "bandana": { - "carbonara": "2", - }, - "endive": "3" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - let matches = cx - .update(|cx| search_paths("root/**/car*", project.clone(), cx)) - .await - .unwrap(); - assert_eq!( - matches, - &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") - ] - ); - - let matches = cx - .update(|cx| search_paths("**/car*", project.clone(), cx)) - .await - .unwrap(); - assert_eq!( - matches, - &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") - ] - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } -} diff --git a/crates/assistant_tools/src/find_path_tool/description.md b/crates/assistant_tools/src/find_path_tool/description.md deleted file mode 100644 index f7a697c467b2807c1f4cf1706ef660a77b9ee727..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/find_path_tool/description.md +++ /dev/null @@ -1,7 +0,0 @@ -Fast file path pattern matching tool that works with any codebase size - -- Supports glob patterns like "**/*.js" or "src/**/*.ts" -- Returns matching file paths sorted alphabetically -- Prefer the `grep` tool to this tool when searching for symbols unless you have specific information about paths. -- Use this tool when you need to find files by name patterns -- Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages. diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs deleted file mode 100644 index a5ce07823fd68ff9531c7d834973166384645601..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/grep_tool.rs +++ /dev/null @@ -1,1308 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use futures::StreamExt; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language::{OffsetRangeExt, ParseStatus, Point}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{ - Project, WorktreeSettings, - search::{SearchQuery, SearchResult}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{cmp, fmt::Write, sync::Arc}; -use ui::IconName; -use util::RangeExt; -use util::markdown::MarkdownInlineCode; -use util::paths::PathMatcher; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct GrepToolInput { - /// A regex pattern to search for in the entire project. Note that the regex - /// will be parsed by the Rust `regex` crate. - /// - /// Do NOT specify a path here! This will only be matched against the code **content**. - pub regex: String, - - /// A glob pattern for the paths of files to include in the search. - /// Supports standard glob patterns like "**/*.rs" or "src/**/*.ts". - /// If omitted, all files in the project will be searched. - pub include_pattern: Option, - - /// Optional starting position for paginated results (0-based). - /// When not provided, starts from the beginning. - #[serde(default)] - pub offset: u32, - - /// Whether the regex is case-sensitive. Defaults to false (case-insensitive). - #[serde(default)] - pub case_sensitive: bool, -} - -impl GrepToolInput { - /// Which page of search results this is. - pub fn page(&self) -> u32 { - 1 + (self.offset / RESULTS_PER_PAGE) - } -} - -const RESULTS_PER_PAGE: u32 = 20; - -pub struct GrepTool; - -impl Tool for GrepTool { - fn name(&self) -> String { - "grep".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./grep_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolRegex - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let page = input.page(); - let regex_str = MarkdownInlineCode(&input.regex); - let case_info = if input.case_sensitive { - " (case-sensitive)" - } else { - "" - }; - - if page > 1 { - format!("Get page {page} of search results for regex {regex_str}{case_info}") - } else { - format!("Search files for regex {regex_str}{case_info}") - } - } - Err(_) => "Search with regex".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - const CONTEXT_LINES: u32 = 2; - const MAX_ANCESTOR_LINES: u32 = 10; - - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(error) => { - return Task::ready(Err(anyhow!("Failed to parse input: {error}"))).into(); - } - }; - - let include_matcher = match PathMatcher::new( - input - .include_pattern - .as_ref() - .into_iter() - .collect::>(), - ) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(anyhow!("invalid include glob pattern: {error}"))).into(); - } - }; - - // Exclude global file_scan_exclusions and private_files settings - let exclude_matcher = { - let global_settings = WorktreeSettings::get_global(cx); - let exclude_patterns = global_settings - .file_scan_exclusions - .sources() - .iter() - .chain(global_settings.private_files.sources().iter()); - - match PathMatcher::new(exclude_patterns) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into(); - } - } - }; - - let query = match SearchQuery::regex( - &input.regex, - false, - input.case_sensitive, - false, - false, - include_matcher, - exclude_matcher, - true, // Always match file include pattern against *full project paths* that start with a project root. - None, - ) { - Ok(query) => query, - Err(error) => return Task::ready(Err(error)).into(), - }; - - let results = project.update(cx, |project, cx| project.search(query, cx)); - - cx.spawn(async move |cx| { - futures::pin_mut!(results); - - let mut output = String::new(); - let mut skips_remaining = input.offset; - let mut matches_found = 0; - let mut has_more_matches = false; - - 'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await { - if ranges.is_empty() { - continue; - } - - let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| { - (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status()) - }) else { - continue; - }; - - // Check if this file should be excluded based on its worktree settings - if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| { - project.find_project_path(&path, cx) - }) { - if cx.update(|cx| { - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - worktree_settings.is_path_excluded(&project_path.path) - || worktree_settings.is_path_private(&project_path.path) - }).unwrap_or(false) { - continue; - } - } - - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - let mut ranges = ranges - .into_iter() - .map(|range| { - let matched = range.to_point(&snapshot); - let matched_end_line_len = snapshot.line_len(matched.end.row); - let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len); - let symbols = snapshot.symbols_containing(matched.start, None); - - if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) { - let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot); - let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES); - let end_col = snapshot.line_len(end_row); - let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col); - - if capped_ancestor_range.contains_inclusive(&full_lines) { - return (capped_ancestor_range, Some(full_ancestor_range), symbols) - } - } - - let mut matched = matched; - matched.start.column = 0; - matched.start.row = - matched.start.row.saturating_sub(CONTEXT_LINES); - matched.end.row = cmp::min( - snapshot.max_point().row, - matched.end.row + CONTEXT_LINES, - ); - matched.end.column = snapshot.line_len(matched.end.row); - - (matched, None, symbols) - }) - .peekable(); - - let mut file_header_written = false; - - while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){ - if skips_remaining > 0 { - skips_remaining -= 1; - continue; - } - - // We'd already found a full page of matches, and we just found one more. - if matches_found >= RESULTS_PER_PAGE { - has_more_matches = true; - break 'outer; - } - - while let Some((next_range, _, _)) = ranges.peek() { - if range.end.row >= next_range.start.row { - range.end = next_range.end; - ranges.next(); - } else { - break; - } - } - - if !file_header_written { - writeln!(output, "\n## Matches in {}", path.display())?; - file_header_written = true; - } - - let end_row = range.end.row; - output.push_str("\n### "); - - if let Some(parent_symbols) = &parent_symbols { - for symbol in parent_symbols { - write!(output, "{} › ", symbol.text)?; - } - } - - if range.start.row == end_row { - writeln!(output, "L{}", range.start.row + 1)?; - } else { - writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?; - } - - output.push_str("```\n"); - output.extend(snapshot.text_for_range(range)); - output.push_str("\n```\n"); - - if let Some(ancestor_range) = ancestor_range { - if end_row < ancestor_range.end.row { - let remaining_lines = ancestor_range.end.row - end_row; - writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?; - } - } - - matches_found += 1; - } - } - - if matches_found == 0 { - Ok("No matches found".to_string().into()) - } else if has_more_matches { - Ok(format!( - "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}", - input.offset + 1, - input.offset + matches_found, - input.offset + RESULTS_PER_PAGE, - ).into()) - } else { - Ok(format!("Found {matches_found} matches:\n{output}").into()) - } - }).into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project, WorktreeSettings}; - use serde_json::json; - use settings::SettingsStore; - use unindent::Unindent; - use util::path; - - #[gpui::test] - async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) { - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "src": { - "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}", - "utils": { - "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}", - }, - }, - "tests": { - "test_main.rs": "fn test_main() {\n assert!(true);\n}", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Test with include pattern for Rust files inside the root of the project - let input = serde_json::to_value(GrepToolInput { - regex: "println".to_string(), - include_pattern: Some("root/**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!(result.contains("main.rs"), "Should find matches in main.rs"); - assert!( - result.contains("helper.rs"), - "Should find matches in helper.rs" - ); - assert!( - !result.contains("test_main.rs"), - "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)" - ); - - // Test with include pattern for src directory only - let input = serde_json::to_value(GrepToolInput { - regex: "fn".to_string(), - include_pattern: Some("root/**/src/**".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("main.rs"), - "Should find matches in src/main.rs" - ); - assert!( - result.contains("helper.rs"), - "Should find matches in src/utils/helper.rs" - ); - assert!( - !result.contains("test_main.rs"), - "Should not include test_main.rs as it's not in src directory" - ); - - // Test with empty include pattern (should default to all files) - let input = serde_json::to_value(GrepToolInput { - regex: "fn".to_string(), - include_pattern: None, - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!(result.contains("main.rs"), "Should find matches in main.rs"); - assert!( - result.contains("helper.rs"), - "Should find matches in helper.rs" - ); - assert!( - result.contains("test_main.rs"), - "Should include test_main.rs" - ); - } - - #[gpui::test] - async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) { - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true", - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Test case-insensitive search (default) - let input = serde_json::to_value(GrepToolInput { - regex: "uppercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("UPPERCASE"), - "Case-insensitive search should match uppercase" - ); - - // Test case-sensitive search - let input = serde_json::to_value(GrepToolInput { - regex: "uppercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - !result.contains("UPPERCASE"), - "Case-sensitive search should not match uppercase" - ); - - // Test case-sensitive search - let input = serde_json::to_value(GrepToolInput { - regex: "LOWERCASE".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - - assert!( - !result.contains("lowercase"), - "Case-sensitive search should match lowercase" - ); - - // Test case-sensitive search for lowercase pattern - let input = serde_json::to_value(GrepToolInput { - regex: "lowercase".to_string(), - include_pattern: Some("**/*.txt".to_string()), - offset: 0, - case_sensitive: true, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - assert!( - result.contains("lowercase"), - "Case-sensitive search should match lowercase text" - ); - } - - /// Helper function to set up a syntax test environment - async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity { - use unindent::Unindent; - init_test(cx); - cx.executor().allow_parking(); - - let fs = FakeFs::new(cx.executor().clone()); - - // Create test file with syntax structures - fs.insert_tree( - path!("/root"), - serde_json::json!({ - "test_syntax.rs": r#" - fn top_level_function() { - println!("This is at the top level"); - } - - mod feature_module { - pub mod nested_module { - pub fn nested_function( - first_arg: String, - second_arg: i32, - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - } - } - - struct MyStruct { - field1: String, - field2: i32, - } - - impl MyStruct { - fn method_with_block() { - let condition = true; - if condition { - println!("Inside if block"); - } - } - - fn long_function() { - println!("Line 1"); - println!("Line 2"); - println!("Line 3"); - println!("Line 4"); - println!("Line 5"); - println!("Line 6"); - println!("Line 7"); - println!("Line 8"); - println!("Line 9"); - println!("Line 10"); - println!("Line 11"); - println!("Line 12"); - } - } - - trait Processor { - fn process(&self, input: &str) -> String; - } - - impl Processor for MyStruct { - fn process(&self, input: &str) -> String { - format!("Processed: {}", input) - } - } - "#.unindent().trim(), - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) - }); - - project - } - - #[gpui::test] - async fn test_grep_top_level_function(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line at the top level of the file - let input = serde_json::to_value(GrepToolInput { - regex: "This is at the top level".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### fn top_level_function › L1-3 - ``` - fn top_level_function() { - println!("This is at the top level"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_function_body(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line inside a function body - let input = serde_json::to_value(GrepToolInput { - regex: "Function in nested module".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14 - ``` - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_function_args_and_body(cx: &mut TestAppContext) { - let project = setup_syntax_test(cx).await; - - // Test: Line with a function argument - let input = serde_json::to_value(GrepToolInput { - regex: "second_arg".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14 - ``` - pub fn nested_function( - first_arg: String, - second_arg: i32, - ) { - println!("Function in nested module"); - println!("{first_arg}"); - println!("{second_arg}"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_if_block(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line inside an if block - let input = serde_json::to_value(GrepToolInput { - regex: "Inside if block".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn method_with_block › L26-28 - ``` - if condition { - println!("Inside if block"); - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_long_function_top(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line in the middle of a long function - should show message about remaining lines - let input = serde_json::to_value(GrepToolInput { - regex: "Line 5".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn long_function › L31-41 - ``` - fn long_function() { - println!("Line 1"); - println!("Line 2"); - println!("Line 3"); - println!("Line 4"); - println!("Line 5"); - println!("Line 6"); - println!("Line 7"); - println!("Line 8"); - println!("Line 9"); - println!("Line 10"); - ``` - - 3 lines remaining in ancestor node. Read the file to see all. - "# - .unindent(); - assert_eq!(result, expected); - } - - #[gpui::test] - async fn test_grep_long_function_bottom(cx: &mut TestAppContext) { - use unindent::Unindent; - let project = setup_syntax_test(cx).await; - - // Test: Line in the long function - let input = serde_json::to_value(GrepToolInput { - regex: "Line 12".to_string(), - include_pattern: Some("**/*.rs".to_string()), - offset: 0, - case_sensitive: false, - }) - .unwrap(); - - let result = run_grep_tool(input, project.clone(), cx).await; - let expected = r#" - Found 1 matches: - - ## Matches in root/test_syntax.rs - - ### impl MyStruct › fn long_function › L41-45 - ``` - println!("Line 10"); - println!("Line 11"); - println!("Line 12"); - } - } - ``` - "# - .unindent(); - assert_eq!(result, expected); - } - - async fn run_grep_tool( - input: serde_json::Value, - project: Entity, - cx: &mut TestAppContext, - ) -> String { - let tool = Arc::new(GrepTool); - let action_log = cx.new(|_cx| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let task = - cx.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)); - - match task.output.await { - Ok(result) => { - if cfg!(windows) { - result.content.as_str().unwrap().replace("root\\", "root/") - } else { - result.content.as_str().unwrap().to_string() - } - } - Err(e) => panic!("Failed to run grep tool: {}", e), - } - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - - #[gpui::test] - async fn test_grep_security_boundaries(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - path!("/"), - json!({ - "project_root": { - "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }", - ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }", - ".secretdir": { - "config": "fn special_configuration() { /* excluded */ }" - }, - ".mymetadata": "fn custom_metadata() { /* excluded */ }", - "subdir": { - "normal_file.rs": "fn normal_file_content() { /* Normal */ }", - "special.privatekey": "fn private_key_content() { /* private */ }", - "data.mysensitive": "fn sensitive_data() { /* private */ }" - } - }, - "outside_project": { - "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }" - } - }), - ) - .await; - - cx.update(|cx| { - use gpui::UpdateGlobal; - use project::WorktreeSettings; - use settings::SettingsStore; - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Searching for files outside the project worktree should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "outside_function" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not find files outside the project worktree" - ); - - // Searching within the project should succeed - let result = cx - .update(|cx| { - let input = json!({ - "regex": "main" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.iter().any(|p| p.contains("allowed_file.rs")), - "grep_tool should be able to search files inside worktrees" - ); - - // Searching files that match file_scan_exclusions should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "special_configuration" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search files in .secretdir (file_scan_exclusions)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "custom_metadata" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mymetadata files (file_scan_exclusions)" - ); - - // Searching private files should return no results - let result = cx - .update(|cx| { - let input = json!({ - "regex": "SECRET_KEY" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mysecrets (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "private_key_content" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .privatekey files (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "regex": "sensitive_data" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not search .mysensitive files (private_files)" - ); - - // Searching a normal file should still work, even with private_files configured - let result = cx - .update(|cx| { - let input = json!({ - "regex": "normal_file_content" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.iter().any(|p| p.contains("normal_file.rs")), - "Should be able to search normal files" - ); - - // Path traversal attempts with .. in include_pattern should not escape project - let result = cx - .update(|cx| { - let input = json!({ - "regex": "outside_function", - "include_pattern": "../outside_project/**/*.rs" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let results = result.unwrap(); - let paths = extract_paths_from_results(&results.content.as_str().unwrap()); - assert!( - paths.is_empty(), - "grep_tool should not allow escaping project boundaries with relative paths" - ); - } - - #[gpui::test] - async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private files - fs.insert_tree( - path!("/worktree1"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs"] - }"# - }, - "src": { - "main.rs": "fn main() { let secret_key = \"hidden\"; }", - "secret.rs": "const API_KEY: &str = \"secret_value\";", - "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }" - }, - "tests": { - "test.rs": "fn test_secret() { assert!(true); }", - "fixture.sql": "SELECT * FROM secret_table;" - } - }), - ) - .await; - - // Create second worktree with different private files - fs.insert_tree( - path!("/worktree2"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - }, - "lib": { - "public.js": "export function getSecret() { return 'public'; }", - "private.js": "const SECRET_KEY = \"private_value\";", - "data.json": "{\"secret_data\": \"hidden\"}" - }, - "docs": { - "README.md": "# Documentation with secret info", - "internal.md": "Internal secret documentation" - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - // Wait for worktrees to be fully scanned - cx.executor().run_until_parked(); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Search for "secret" - should exclude files based on worktree-specific settings - let result = cx - .update(|cx| { - let input = json!({ - "regex": "secret", - "case_sensitive": false - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - let paths = extract_paths_from_results(&content); - - // Should find matches in non-private files - assert!( - paths.iter().any(|p| p.contains("main.rs")), - "Should find 'secret' in worktree1/src/main.rs" - ); - assert!( - paths.iter().any(|p| p.contains("test.rs")), - "Should find 'secret' in worktree1/tests/test.rs" - ); - assert!( - paths.iter().any(|p| p.contains("public.js")), - "Should find 'secret' in worktree2/lib/public.js" - ); - assert!( - paths.iter().any(|p| p.contains("README.md")), - "Should find 'secret' in worktree2/docs/README.md" - ); - - // Should NOT find matches in private/excluded files based on worktree settings - assert!( - !paths.iter().any(|p| p.contains("secret.rs")), - "Should not search in worktree1/src/secret.rs (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("fixture.sql")), - "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)" - ); - assert!( - !paths.iter().any(|p| p.contains("private.js")), - "Should not search in worktree2/lib/private.js (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("data.json")), - "Should not search in worktree2/lib/data.json (local private_files)" - ); - assert!( - !paths.iter().any(|p| p.contains("internal.md")), - "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)" - ); - - // Test with `include_pattern` specific to one worktree - let result = cx - .update(|cx| { - let input = json!({ - "regex": "secret", - "include_pattern": "worktree1/**/*.rs" - }); - Arc::new(GrepTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - let paths = extract_paths_from_results(&content); - - // Should only find matches in worktree1 *.rs files (excluding private ones) - assert!( - paths.iter().any(|p| p.contains("main.rs")), - "Should find match in worktree1/src/main.rs" - ); - assert!( - paths.iter().any(|p| p.contains("test.rs")), - "Should find match in worktree1/tests/test.rs" - ); - assert!( - !paths.iter().any(|p| p.contains("secret.rs")), - "Should not find match in excluded worktree1/src/secret.rs" - ); - assert!( - paths.iter().all(|p| !p.contains("worktree2")), - "Should not find any matches in worktree2" - ); - } - - // Helper function to extract file paths from grep results - fn extract_paths_from_results(results: &str) -> Vec { - results - .lines() - .filter(|line| line.starts_with("## Matches in ")) - .map(|line| { - line.strip_prefix("## Matches in ") - .unwrap() - .trim() - .to_string() - }) - .collect() - } -} diff --git a/crates/assistant_tools/src/grep_tool/description.md b/crates/assistant_tools/src/grep_tool/description.md deleted file mode 100644 index e3c0b43f31da53df49ce905e764dedcc5ea530de..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/grep_tool/description.md +++ /dev/null @@ -1,9 +0,0 @@ -Searches the contents of files in the project with a regular expression - -- Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in. -- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) -- Pass an `include_pattern` if you know how to narrow your search on the files system -- Never use this tool to search for paths. Only search file contents with this tool. -- Use this tool when you need to find files containing specific patterns -- Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages. -- DO NOT use HTML entities solely to escape characters in the tool parameters. diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs deleted file mode 100644 index 5471d8923b557ac26d06a16c90fdeffb152049d1..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ /dev/null @@ -1,866 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::{Project, WorktreeSettings}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{fmt::Write, path::Path, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ListDirectoryToolInput { - /// The fully-qualified path of the directory to list in the project. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - directory1 - /// - directory2 - /// - /// You can list the contents of `directory1` by using the path `directory1`. - /// - /// - /// - /// If the project has the following root directories: - /// - /// - foo - /// - bar - /// - /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`. - /// - pub path: String, -} - -pub struct ListDirectoryTool; - -impl Tool for ListDirectoryTool { - fn name(&self) -> String { - "list_directory".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./list_directory_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolFolder - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = MarkdownInlineCode(&input.path); - format!("List the {path} directory's contents") - } - Err(_) => "List directory".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - // Sometimes models will return these even though we tell it to give a path and not a glob. - // When this happens, just list the root worktree directories. - if matches!(input.path.as_str(), "." | "" | "./" | "*") { - let output = project - .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - worktree.read(cx).root_entry().and_then(|entry| { - if entry.is_dir() { - entry.path.to_str() - } else { - None - } - }) - }) - .collect::>() - .join("\n"); - - return Task::ready(Ok(output.into())).into(); - } - - let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { - return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into(); - }; - let Some(worktree) = project - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!("Worktree not found"))).into(); - }; - - // Check if the directory whose contents we're listing is itself excluded or private - let global_settings = WorktreeSettings::get_global(cx); - if global_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if global_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's global `private_files` setting: {}", - &input.path - ))) - .into(); - } - - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - if worktree_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if worktree_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", - &input.path - ))) - .into(); - } - - let worktree_snapshot = worktree.read(cx).snapshot(); - let worktree_root_name = worktree.read(cx).root_name().to_string(); - - let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { - return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into(); - }; - - if !entry.is_dir() { - return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into(); - } - let worktree_snapshot = worktree.read(cx).snapshot(); - - let mut folders = Vec::new(); - let mut files = Vec::new(); - - for entry in worktree_snapshot.child_entries(&project_path.path) { - // Skip private and excluded files and directories - if global_settings.is_path_private(&entry.path) - || global_settings.is_path_excluded(&entry.path) - { - continue; - } - - if project - .read(cx) - .find_project_path(&entry.path, cx) - .map(|project_path| { - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - - worktree_settings.is_path_excluded(&project_path.path) - || worktree_settings.is_path_private(&project_path.path) - }) - .unwrap_or(false) - { - continue; - } - - let full_path = Path::new(&worktree_root_name) - .join(&entry.path) - .display() - .to_string(); - if entry.is_dir() { - folders.push(full_path); - } else { - files.push(full_path); - } - } - - let mut output = String::new(); - - if !folders.is_empty() { - writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap(); - } - - if !files.is_empty() { - writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap(); - } - - if output.is_empty() { - writeln!(output, "{} is empty.", input.path).unwrap(); - } - - Task::ready(Ok(output.into())).into() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use indoc::indoc; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project, WorktreeSettings}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn platform_paths(path_str: &str) -> String { - if cfg!(target_os = "windows") { - path_str.replace("/", "\\") - } else { - path_str.to_string() - } - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test] - async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn hello() {}", - "models": { - "user.rs": "struct User {}", - "post.rs": "struct Post {}" - }, - "utils": { - "helper.rs": "pub fn help() {}" - } - }, - "tests": { - "integration_test.rs": "#[test] fn test() {}" - }, - "README.md": "# Project", - "Cargo.toml": "[package]" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test listing root directory - let input = json!({ - "path": "project" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!( - content, - platform_paths(indoc! {" - # Folders: - project/src - project/tests - - # Files: - project/Cargo.toml - project/README.md - "}) - ); - - // Test listing src directory - let input = json!({ - "path": "project/src" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!( - content, - platform_paths(indoc! {" - # Folders: - project/src/models - project/src/utils - - # Files: - project/src/lib.rs - project/src/main.rs - "}) - ); - - // Test listing directory with only files - let input = json!({ - "path": "project/tests" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(!content.contains("# Folders:")); - assert!(content.contains("# Files:")); - assert!(content.contains(&platform_paths("project/tests/integration_test.rs"))); - } - - #[gpui::test] - async fn test_list_directory_empty_directory(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "empty_dir": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - let input = json!({ - "path": "project/empty_dir" - }); - - let result = cx - .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert_eq!(content, "project/empty_dir is empty.\n"); - } - - #[gpui::test] - async fn test_list_directory_error_cases(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "file.txt": "content" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test non-existent path - let input = json!({ - "path": "project/nonexistent" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Path not found")); - - // Test trying to list a file instead of directory - let input = json!({ - "path": "project/file.txt" - }); - - let result = cx - .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("is not a directory") - ); - } - - #[gpui::test] - async fn test_list_directory_security(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "normal_dir": { - "file1.txt": "content", - "file2.txt": "content" - }, - ".mysecrets": "SECRET_KEY=abc123", - ".secretdir": { - "config": "special configuration", - "secret.txt": "secret content" - }, - ".mymetadata": "custom metadata", - "visible_dir": { - "normal.txt": "normal content", - "special.privatekey": "private key content", - "data.mysensitive": "sensitive data", - ".hidden_subdir": { - "hidden_file.txt": "hidden content" - } - } - }), - ) - .await; - - // Configure settings explicitly - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - "**/.hidden_subdir".to_string(), - ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Listing root directory should exclude private and excluded files - let input = json!({ - "path": "project" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - - // Should include normal directories - assert!(content.contains("normal_dir"), "Should list normal_dir"); - assert!(content.contains("visible_dir"), "Should list visible_dir"); - - // Should NOT include excluded or private files - assert!( - !content.contains(".secretdir"), - "Should not list .secretdir (file_scan_exclusions)" - ); - assert!( - !content.contains(".mymetadata"), - "Should not list .mymetadata (file_scan_exclusions)" - ); - assert!( - !content.contains(".mysecrets"), - "Should not list .mysecrets (private_files)" - ); - - // Trying to list an excluded directory should fail - let input = json!({ - "path": "project/.secretdir" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!( - result.is_err(), - "Should not be able to list excluded directory" - ); - assert!( - result - .unwrap_err() - .to_string() - .contains("file_scan_exclusions"), - "Error should mention file_scan_exclusions" - ); - - // Listing a directory should exclude private files within it - let input = json!({ - "path": "project/visible_dir" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - - // Should include normal files - assert!(content.contains("normal.txt"), "Should list normal.txt"); - - // Should NOT include private files - assert!( - !content.contains("privatekey"), - "Should not list .privatekey files (private_files)" - ); - assert!( - !content.contains("mysensitive"), - "Should not list .mysensitive files (private_files)" - ); - - // Should NOT include subdirectories that match exclusions - assert!( - !content.contains(".hidden_subdir"), - "Should not list .hidden_subdir (file_scan_exclusions)" - ); - } - - #[gpui::test] - async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private files - fs.insert_tree( - path!("/worktree1"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs", "**/config.toml"] - }"# - }, - "src": { - "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", - "secret.rs": "const API_KEY: &str = \"secret_key_1\";", - "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" - }, - "tests": { - "test.rs": "mod tests { fn test_it() {} }", - "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" - } - }), - ) - .await; - - // Create second worktree with different private files - fs.insert_tree( - path!("/worktree2"), - json!({ - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - }, - "lib": { - "public.js": "export function greet() { return 'Hello from worktree2'; }", - "private.js": "const SECRET_TOKEN = \"private_token_2\";", - "data.json": "{\"api_key\": \"json_secret_key\"}" - }, - "docs": { - "README.md": "# Public Documentation", - "internal.md": "# Internal Secrets and Configuration" - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - // Wait for worktrees to be fully scanned - cx.executor().run_until_parked(); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ListDirectoryTool); - - // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings - let input = json!({ - "path": "worktree1/src" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("main.rs"), "Should list main.rs"); - assert!( - !content.contains("secret.rs"), - "Should not list secret.rs (local private_files)" - ); - assert!( - !content.contains("config.toml"), - "Should not list config.toml (local private_files)" - ); - - // Test listing worktree1/tests - should exclude fixture.sql based on local settings - let input = json!({ - "path": "worktree1/tests" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("test.rs"), "Should list test.rs"); - assert!( - !content.contains("fixture.sql"), - "Should not list fixture.sql (local file_scan_exclusions)" - ); - - // Test listing worktree2/lib - should exclude private.js and data.json based on local settings - let input = json!({ - "path": "worktree2/lib" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("public.js"), "Should list public.js"); - assert!( - !content.contains("private.js"), - "Should not list private.js (local private_files)" - ); - assert!( - !content.contains("data.json"), - "Should not list data.json (local private_files)" - ); - - // Test listing worktree2/docs - should exclude internal.md based on local settings - let input = json!({ - "path": "worktree2/docs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - let content = result.content.as_str().unwrap(); - assert!(content.contains("README.md"), "Should list README.md"); - assert!( - !content.contains("internal.md"), - "Should not list internal.md (local file_scan_exclusions)" - ); - - // Test trying to list an excluded directory directly - let input = json!({ - "path": "worktree1/src/secret.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - // This should fail because we're trying to list a file, not a directory - assert!(result.is_err(), "Should fail when trying to list a file"); - } -} diff --git a/crates/assistant_tools/src/list_directory_tool/description.md b/crates/assistant_tools/src/list_directory_tool/description.md deleted file mode 100644 index 30dcc012ff316c944a7495dc14457cfd9df93bb7..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/list_directory_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase. diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs deleted file mode 100644 index 2c065488cea62a73e04c34a659961abc7b94ba54..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/move_path_tool.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{path::Path, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownInlineCode; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct MovePathToolInput { - /// The source path of the file or directory to move/rename. - /// - /// - /// If the project has the following files: - /// - /// - directory1/a/something.txt - /// - directory2/a/things.txt - /// - directory3/a/other.txt - /// - /// You can move the first file by providing a source_path of "directory1/a/something.txt" - /// - pub source_path: String, - - /// The destination path where the file or directory should be moved/renamed to. - /// If the paths are the same except for the filename, then this will be a rename. - /// - /// - /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt", - /// provide a destination_path of "directory2/b/renamed.txt" - /// - pub destination_path: String, -} - -pub struct MovePathTool; - -impl Tool for MovePathTool { - fn name(&self) -> String { - "move_path".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("./move_path_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ArrowRightLeft - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let src = MarkdownInlineCode(&input.source_path); - let dest = MarkdownInlineCode(&input.destination_path); - let src_path = Path::new(&input.source_path); - let dest_path = Path::new(&input.destination_path); - - match dest_path - .file_name() - .and_then(|os_str| os_str.to_os_string().into_string().ok()) - { - Some(filename) if src_path.parent() == dest_path.parent() => { - let filename = MarkdownInlineCode(&filename); - format!("Rename {src} to {filename}") - } - _ => { - format!("Move {src} to {dest}") - } - } - } - Err(_) => "Move path".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let rename_task = project.update(cx, |project, cx| { - match project - .find_project_path(&input.source_path, cx) - .and_then(|project_path| project.entry_for_path(&project_path, cx)) - { - Some(entity) => match project.find_project_path(&input.destination_path, cx) { - Some(project_path) => project.rename_entry(entity.id, project_path.path, cx), - None => Task::ready(Err(anyhow!( - "Destination path {} was outside the project.", - input.destination_path - ))), - }, - None => Task::ready(Err(anyhow!( - "Source path {} was not found in the project.", - input.source_path - ))), - } - }); - - cx.background_spawn(async move { - let _ = rename_task.await.with_context(|| { - format!("Moving {} to {}", input.source_path, input.destination_path) - })?; - Ok(format!("Moved {} to {}", input.source_path, input.destination_path).into()) - }) - .into() - } -} diff --git a/crates/assistant_tools/src/move_path_tool/description.md b/crates/assistant_tools/src/move_path_tool/description.md deleted file mode 100644 index 76bc3003d003c44afdd9036cb6691d5fc432291d..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/move_path_tool/description.md +++ /dev/null @@ -1,5 +0,0 @@ -Moves or rename a file or directory in the project, and returns confirmation that the move succeeded. -If the source and destination directories are the same, but the filename is different, this performs -a rename. Otherwise, it performs a move. - -This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all. diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs deleted file mode 100644 index f50ad065d1cd320aa1a82e4ce17f744d6b04be2c..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/now_tool.rs +++ /dev/null @@ -1,84 +0,0 @@ -use std::sync::Arc; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use chrono::{Local, Utc}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Timezone { - /// Use UTC for the datetime. - Utc, - /// Use local time for the datetime. - Local, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct NowToolInput { - /// The timezone to use for the datetime. - timezone: Timezone, -} - -pub struct NowTool; - -impl Tool for NowTool { - fn name(&self) -> String { - "now".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() - } - - fn icon(&self) -> IconName { - IconName::Info - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Get current time".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - let input: NowToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let now = match input.timezone { - Timezone::Utc => Utc::now().to_rfc3339(), - Timezone::Local => Local::now().to_rfc3339(), - }; - let text = format!("The current datetime is {now}."); - - Task::ready(Ok(text.into())).into() - } -} diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs deleted file mode 100644 index 6dbf66749b932804df6e00fed726360f0492c7f3..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/open_tool.rs +++ /dev/null @@ -1,170 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, AppContext, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{path::PathBuf, sync::Arc}; -use ui::IconName; -use util::markdown::MarkdownEscaped; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct OpenToolInput { - /// The path or URL to open with the default application. - path_or_url: String, -} - -pub struct OpenTool; - -impl Tool for OpenTool { - fn name(&self) -> String { - "open".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - fn may_perform_edits(&self) -> bool { - false - } - fn description(&self) -> String { - include_str!("./open_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ArrowUpRight - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => format!("Open `{}`", MarkdownEscaped(&input.path_or_url)), - Err(_) => "Open file or URL".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input: OpenToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - // If path_or_url turns out to be a path in the project, make it absolute. - let abs_path = to_absolute_path(&input.path_or_url, project, cx); - - cx.background_spawn(async move { - match abs_path { - Some(path) => open::that(path), - None => open::that(&input.path_or_url), - } - .context("Failed to open URL or file path")?; - - Ok(format!("Successfully opened {}", input.path_or_url).into()) - }) - .into() - } -} - -fn to_absolute_path( - potential_path: &str, - project: Entity, - cx: &mut App, -) -> Option { - let project = project.read(cx); - project - .find_project_path(PathBuf::from(potential_path), cx) - .and_then(|project_path| project.absolute_path(&project_path, cx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use project::{FakeFs, Project}; - use settings::SettingsStore; - use std::path::Path; - use tempfile::TempDir; - - #[gpui::test] - async fn test_to_absolute_path(cx: &mut TestAppContext) { - init_test(cx); - let temp_dir = TempDir::new().expect("Failed to create temp directory"); - let temp_path = temp_dir.path().to_string_lossy().to_string(); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - &temp_path, - serde_json::json!({ - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn lib_fn() {}" - }, - "docs": { - "readme.md": "# Project Documentation" - } - }), - ) - .await; - - // Use the temp_path as the root directory, not just its filename - let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; - - // Test cases where the function should return Some - cx.update(|cx| { - // Project-relative paths should return Some - // Create paths using the last segment of the temp path to simulate a project-relative path - let root_dir_name = Path::new(&temp_path) - .file_name() - .unwrap_or_else(|| std::ffi::OsStr::new("temp")) - .to_string_lossy(); - - assert!( - to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx) - .is_some(), - "Failed to resolve main.rs path" - ); - - assert!( - to_absolute_path( - &format!("{root_dir_name}/docs/readme.md",), - project.clone(), - cx, - ) - .is_some(), - "Failed to resolve readme.md path" - ); - - // External URL should return None - let result = to_absolute_path("https://example.com", project.clone(), cx); - assert_eq!(result, None, "External URLs should return None"); - - // Path outside project - let result = to_absolute_path("../invalid/path", project.clone(), cx); - assert_eq!(result, None, "Paths outside the project should return None"); - }); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } -} diff --git a/crates/assistant_tools/src/open_tool/description.md b/crates/assistant_tools/src/open_tool/description.md deleted file mode 100644 index 99ccbb0524473b8c740d6ecd2d9ca9555e1e7028..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/open_tool/description.md +++ /dev/null @@ -1,9 +0,0 @@ -This tool opens a file or URL with the default application associated with it on the user's operating system: -- On macOS, it's equivalent to the `open` command -- On Windows, it's equivalent to `start` -- On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate - -For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc. - -You MUST ONLY use this tool when the user has explicitly requested opening something. You MUST NEVER assume that -the user would like for you to use this tool. diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs deleted file mode 100644 index c65cfd0ca76d91f454982ded5f2893159ab7a32a..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ /dev/null @@ -1,360 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::Result; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{fmt::Write, sync::Arc}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ProjectUpdatesToolInput {} - -pub struct ProjectNotificationsTool; - -impl Tool for ProjectNotificationsTool { - fn name(&self) -> String { - "project_notifications".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - fn may_perform_edits(&self) -> bool { - false - } - fn description(&self) -> String { - include_str!("./project_notifications_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolNotification - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Check project notifications".into() - } - - fn run( - self: Arc, - _input: serde_json::Value, - _request: Arc, - _project: Entity, - action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let Some(user_edits_diff) = - action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx)) - else { - return result("No new notifications"); - }; - - // NOTE: Changes to this prompt require a symmetric update in the LLM Worker - const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); - const MAX_BYTES: usize = 8000; - let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES); - result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n")) - } -} - -fn result(response: &str) -> ToolResult { - Task::ready(Ok(response.to_string().into())).into() -} - -/// Make sure that the patch fits into the size limit (in bytes). -/// Compress the patch by omitting some parts if needed. -/// Unified diff format is assumed. -fn fit_patch_to_size(patch: &str, max_size: usize) -> String { - if patch.len() <= max_size { - return patch.to_string(); - } - - // Compression level 1: remove context lines in diff bodies, but - // leave the counts and positions of inserted/deleted lines - let mut current_size = patch.len(); - let mut file_patches = split_patch(&patch); - file_patches.sort_by_key(|patch| patch.len()); - let compressed_patches = file_patches - .iter() - .rev() - .map(|patch| { - if current_size > max_size { - let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string()); - current_size -= patch.len() - compressed.len(); - compressed - } else { - patch.to_string() - } - }) - .collect::>(); - - if current_size <= max_size { - return compressed_patches.join("\n\n"); - } - - // Compression level 2: list paths of the changed files only - let filenames = file_patches - .iter() - .map(|patch| { - let patch = diffy::Patch::from_str(patch).unwrap(); - let path = patch - .modified() - .and_then(|path| path.strip_prefix("b/")) - .unwrap_or_default(); - format!("- {path}\n") - }) - .collect::>(); - - filenames.join("") -} - -/// Split a potentially multi-file patch into multiple single-file patches -fn split_patch(patch: &str) -> Vec { - let mut result = Vec::new(); - let mut current_patch = String::new(); - - for line in patch.lines() { - if line.starts_with("---") && !current_patch.is_empty() { - result.push(current_patch.trim_end_matches('\n').into()); - current_patch = String::new(); - } - current_patch.push_str(line); - current_patch.push('\n'); - } - - if !current_patch.is_empty() { - result.push(current_patch.trim_end_matches('\n').into()); - } - - result -} - -fn compress_patch(patch: &str) -> anyhow::Result { - let patch = diffy::Patch::from_str(patch)?; - let mut out = String::new(); - - writeln!(out, "--- {}", patch.original().unwrap_or("a"))?; - writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?; - - for hunk in patch.hunks() { - writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?; - writeln!(out, "[...skipped...]")?; - } - - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use assistant_tool::ToolResultContent; - use gpui::{AppContext, TestAppContext}; - use indoc::indoc; - use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use std::sync::Arc; - use util::path; - - #[gpui::test] - async fn test_stale_buffer_notification(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/test"), - json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), - ) - .await; - - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - - let buffer_path = project - .read_with(cx, |project, cx| { - project.find_project_path("test/code.rs", cx) - }) - .unwrap(); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(buffer_path.clone(), cx) - }) - .await - .unwrap(); - - // Start tracking the buffer - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - }); - cx.run_until_parked(); - - // Run the tool before any changes - let tool = Arc::new(ProjectNotificationsTool); - let provider = Arc::new(FakeLanguageModelProvider::default()); - let model: Arc = Arc::new(provider.test_model()); - let request = Arc::new(LanguageModelRequest::default()); - let tool_input = json!({}); - - let result = cx.update(|cx| { - tool.clone().run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - assert_eq!( - response_text.as_str(), - "No new notifications", - "Tool should return 'No new notifications' when no stale buffers" - ); - - // Modify the buffer (makes it stale) - buffer.update(cx, |buffer, cx| { - buffer.edit([(1..1, "\nChange!\n")], None, cx); - }); - cx.run_until_parked(); - - // Run the tool again - let result = cx.update(|cx| { - tool.clone().run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - // This time the buffer is stale, so the tool should return a notification - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - - assert!( - response_text.contains("These files have changed"), - "Tool should return the stale buffer notification" - ); - assert!( - response_text.contains("test/code.rs"), - "Tool should return the stale buffer notification" - ); - - // Run the tool once more without any changes - should get no new notifications - let result = cx.update(|cx| { - tool.run( - tool_input.clone(), - request.clone(), - project.clone(), - action_log, - model.clone(), - None, - cx, - ) - }); - cx.run_until_parked(); - - let response = result.output.await.unwrap(); - let response_text = match &response.content { - ToolResultContent::Text(text) => text.clone(), - _ => panic!("Expected text response"), - }; - - assert_eq!( - response_text.as_str(), - "No new notifications", - "Tool should return 'No new notifications' when running again without changes" - ); - } - - #[test] - fn test_patch_compression() { - // Given a patch that doesn't fit into the size budget - let patch = indoc! {" - --- a/dir/test.txt - +++ b/dir/test.txt - @@ -1,3 +1,3 @@ - line 1 - -line 2 - +CHANGED - line 3 - @@ -10,2 +10,2 @@ - line 10 - -line 11 - +line eleven - - - --- a/dir/another.txt - +++ b/dir/another.txt - @@ -100,1 +1,1 @@ - -before - +after - "}; - - // When the size deficit can be compensated by dropping the body, - // then the body should be trimmed for larger files first - let limit = patch.len() - 10; - let compressed = fit_patch_to_size(patch, limit); - let expected = indoc! {" - --- a/dir/test.txt - +++ b/dir/test.txt - @@ -1,3 +1,3 @@ - [...skipped...] - @@ -10,2 +10,2 @@ - [...skipped...] - - - --- a/dir/another.txt - +++ b/dir/another.txt - @@ -100,1 +1,1 @@ - -before - +after"}; - assert_eq!(compressed, expected); - - // When the size deficit is too large, then only file paths - // should be returned - let limit = 10; - let compressed = fit_patch_to_size(patch, limit); - let expected = indoc! {" - - dir/another.txt - - dir/test.txt - "}; - assert_eq!(compressed, expected); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - assistant_tool::init(cx); - }); - } -} diff --git a/crates/assistant_tools/src/project_notifications_tool/description.md b/crates/assistant_tools/src/project_notifications_tool/description.md deleted file mode 100644 index 24ff678f5e7fd728b94ad4ebce06f2a1dcc6a658..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/project_notifications_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -This tool reports which files have been modified by the user since the agent last accessed them. - -It serves as a notification mechanism to inform the agent of recent changes. No immediate action is required in response to these updates. diff --git a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt deleted file mode 100644 index f743e239c883c7456f7bdc6e089185c6b994cb44..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt +++ /dev/null @@ -1,3 +0,0 @@ -[The following is an auto-generated notification; do not reply] - -These files have changed since the last read: diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs deleted file mode 100644 index 68b870e40f4af23f4eb27f68c8d45d4789f6bc48..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/read_file_tool.rs +++ /dev/null @@ -1,1194 +0,0 @@ -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use assistant_tool::{ToolResultContent, outline}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use project::{ImageItem, image_store}; - -use assistant_tool::ToolResultOutput; -use indoc::formatdoc; -use itertools::Itertools; -use language::{Anchor, Point}; -use language_model::{ - LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat, -}; -use project::{AgentLocation, Project, WorktreeSettings}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::sync::Arc; -use ui::IconName; - -/// If the model requests to read a file whose size exceeds this, then -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ReadFileToolInput { - /// The relative path of the file to read. - /// - /// This path should never be absolute, and the first component - /// of the path should always be a root directory in a project. - /// - /// - /// If the project has the following root directories: - /// - /// - /a/b/directory1 - /// - /c/d/directory2 - /// - /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`. - /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`. - /// - pub path: String, - - /// Optional line number to start reading on (1-based index) - #[serde(default)] - pub start_line: Option, - - /// Optional line number to end reading on (1-based index, inclusive) - #[serde(default)] - pub end_line: Option, -} - -pub struct ReadFileTool; - -impl Tool for ReadFileTool { - fn name(&self) -> String { - "read_file".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./read_file_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::ToolRead - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let path = &input.path; - match (input.start_line, input.end_line) { - (Some(start), Some(end)) => { - format!( - "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", - path, start, end, path, start, end - ) - } - (Some(start), None) => { - format!( - "[Read file `{}` (from line {})](@selection:{}:({}-{}))", - path, start, path, start, start - ) - } - _ => format!("[Read file `{}`](@file:{})", path, path), - } - } - Err(_) => "Read file".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - action_log: Entity, - model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { - return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into(); - }; - - // Error out if this path is either excluded or private in global settings - let global_settings = WorktreeSettings::get_global(cx); - if global_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if global_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the global `private_files` setting: {}", - &input.path - ))) - .into(); - } - - // Error out if this path is either excluded or private in worktree settings - let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); - if worktree_settings.is_path_excluded(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}", - &input.path - ))) - .into(); - } - - if worktree_settings.is_path_private(&project_path.path) { - return Task::ready(Err(anyhow!( - "Cannot read file because its path matches the worktree `private_files` setting: {}", - &input.path - ))) - .into(); - } - - let file_path = input.path.clone(); - - if image_store::is_image_file(&project, &project_path, cx) { - if !model.supports_images() { - return Task::ready(Err(anyhow!( - "Attempted to read an image, but Zed doesn't currently support sending images to {}.", - model.name().0 - ))) - .into(); - } - - let task = cx.spawn(async move |cx| -> Result { - let image_entity: Entity = cx - .update(|cx| { - project.update(cx, |project, cx| { - project.open_image(project_path.clone(), cx) - }) - })? - .await?; - - let image = - image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?; - - let language_model_image = cx - .update(|cx| LanguageModelImage::from_image(image, cx))? - .await - .context("processing image")?; - - Ok(ToolResultOutput { - content: ToolResultContent::Image(language_model_image), - output: None, - }) - }); - - return task.into(); - } - - cx.spawn(async move |cx| { - let buffer = cx - .update(|cx| { - project.update(cx, |project, cx| project.open_buffer(project_path, cx)) - })? - .await?; - if buffer.read_with(cx, |buffer, _| { - buffer - .file() - .as_ref() - .map_or(true, |file| !file.disk_state().exists()) - })? { - anyhow::bail!("{file_path} not found"); - } - - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: Anchor::MIN, - }), - cx, - ); - })?; - - // Check if specific line ranges are provided - if input.start_line.is_some() || input.end_line.is_some() { - let mut anchor = None; - let result = buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); - // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0. - let start = input.start_line.unwrap_or(1).max(1); - let start_row = start - 1; - if start_row <= buffer.max_point().row { - let column = buffer.line_indent_for_row(start_row).raw_len(); - anchor = Some(buffer.anchor_before(Point::new(start_row, column))); - } - - let lines = text.split('\n').skip(start_row as usize); - if let Some(end) = input.end_line { - let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line - Itertools::intersperse(lines.take(count as usize), "\n") - .collect::() - .into() - } else { - Itertools::intersperse(lines, "\n") - .collect::() - .into() - } - })?; - - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - })?; - - if let Some(anchor) = anchor { - project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: anchor, - }), - cx, - ); - })?; - } - - Ok(result) - } else { - // No line ranges specified, so check file size to see if it's too big. - let file_size = buffer.read_with(cx, |buffer, _cx| buffer.text().len())?; - - if file_size <= outline::AUTO_OUTLINE_SIZE { - // File is small enough, so return its contents. - let result = buffer.read_with(cx, |buffer, _cx| buffer.text())?; - - action_log.update(cx, |log, cx| { - log.buffer_read(buffer, cx); - })?; - - Ok(result.into()) - } else { - // File is too big, so return the outline - // and a suggestion to read again with line numbers. - let outline = - outline::file_outline(project, file_path, action_log, None, cx).await?; - Ok(formatdoc! {" - This file was too big to read all at once. - - Here is an outline of its symbols: - - {outline} - - Using the line numbers in this outline, you can call this tool again - while specifying the start_line and end_line fields to see the - implementations of symbols in the outline. - - Alternatively, you can fall back to the `grep` tool (if available) - to search the file for specific content." - } - .into()) - } - } - }) - .into() - } -} - -#[cfg(test)] -mod test { - use super::*; - use gpui::{AppContext, TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; - use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project, WorktreeSettings}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - #[gpui::test] - async fn test_read_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/root"), json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/nonexistent_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "root/nonexistent_file.txt not found" - ); - } - - #[gpui::test] - async fn test_read_small_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "small_file.txt": "This is a small file content" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/small_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap().content.as_str(), - Some("This is a small file content") - ); - } - - #[gpui::test] - async fn test_read_large_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(Arc::new(rust_lang())); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/large_file.rs" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - let content = result.unwrap(); - let content = content.as_str().unwrap(); - assert_eq!( - content.lines().skip(4).take(6).collect::>(), - vec![ - "struct Test0 [L1-4]", - " a [L2]", - " b [L3]", - "struct Test1 [L5-8]", - " a [L6]", - " b [L7]", - ] - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/large_file.rs", - "offset": 1 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - let content = result.unwrap(); - let expected_content = (0..1000) - .flat_map(|i| { - vec![ - format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4), - format!(" a [L{}]", i * 4 + 2), - format!(" b [L{}]", i * 4 + 3), - ] - }) - .collect::>(); - pretty_assertions::assert_eq!( - content - .as_str() - .unwrap() - .lines() - .skip(4) - .take(expected_content.len()) - .collect::>(), - expected_content - ); - } - - #[gpui::test] - async fn test_read_file_with_line_range(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 2, - "end_line": 4 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap().content.as_str(), - Some("Line 2\nLine 3\nLine 4") - ); - } - - #[gpui::test] - async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // start_line of 0 should be treated as 1 - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 0, - "end_line": 2 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2")); - - // end_line of 0 should result in at least 1 line - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 1, - "end_line": 0 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 1")); - - // when start_line > end_line, should still return at least 1 line - let result = cx - .update(|cx| { - let input = json!({ - "path": "root/multiline.txt", - "start_line": 3, - "end_line": 2 - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!(result.unwrap().content.as_str(), Some("Line 3")); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query( - r#" - (line_comment) @annotation - - (struct_item - "struct" @context - name: (_) @name) @item - (enum_item - "enum" @context - name: (_) @name) @item - (enum_variant - name: (_) @name) @item - (field_declaration - name: (_) @name) @item - (impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name - body: (_ "{" (_)* "}")) @item - (function_item - "fn" @context - name: (_) @name) @item - (mod_item - "mod" @context - name: (_) @name) @item - "#, - ) - .unwrap() - } - - #[gpui::test] - async fn test_read_file_security(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - path!("/"), - json!({ - "project_root": { - "allowed_file.txt": "This file is in the project", - ".mysecrets": "SECRET_KEY=abc123", - ".secretdir": { - "config": "special configuration" - }, - ".mymetadata": "custom metadata", - "subdir": { - "normal_file.txt": "Normal file content", - "special.privatekey": "private key content", - "data.mysensitive": "sensitive data" - } - }, - "outside_project": { - "sensitive_file.txt": "This file is outside the project" - } - }), - ) - .await; - - cx.update(|cx| { - use gpui::UpdateGlobal; - use project::WorktreeSettings; - use settings::SettingsStore; - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = Some(vec![ - "**/.secretdir".to_string(), - "**/.mymetadata".to_string(), - ]); - settings.private_files = Some(vec![ - "**/.mysecrets".to_string(), - "**/*.privatekey".to_string(), - "**/*.mysensitive".to_string(), - ]); - }); - }); - }); - - let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // Reading a file outside the project worktree should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "/outside_project/sensitive_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read an absolute path outside a worktree" - ); - - // Reading a file within the project should succeed - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/allowed_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_ok(), - "read_file_tool should be able to read files inside worktrees" - ); - - // Reading files that match file_scan_exclusions should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.secretdir/config" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.mymetadata" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)" - ); - - // Reading private files should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/.mysecrets" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mysecrets (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/special.privatekey" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .privatekey files (private_files)" - ); - - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/data.mysensitive" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read .mysensitive files (private_files)" - ); - - // Reading a normal file should still work, even with private_files configured - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/subdir/normal_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!(result.is_ok(), "Should be able to read normal files"); - assert_eq!( - result.unwrap().content.as_str().unwrap(), - "Normal file content" - ); - - // Path traversal attempts with .. should fail - let result = cx - .update(|cx| { - let input = json!({ - "path": "project_root/../outside_project/sensitive_file.txt" - }); - Arc::new(ReadFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }) - .await; - assert!( - result.is_err(), - "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree" - ); - } - - #[gpui::test] - async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - - // Create first worktree with its own private_files setting - fs.insert_tree( - path!("/worktree1"), - json!({ - "src": { - "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", - "secret.rs": "const API_KEY: &str = \"secret_key_1\";", - "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" - }, - "tests": { - "test.rs": "mod tests { fn test_it() {} }", - "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" - }, - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/fixture.*"], - "private_files": ["**/secret.rs", "**/config.toml"] - }"# - } - }), - ) - .await; - - // Create second worktree with different private_files setting - fs.insert_tree( - path!("/worktree2"), - json!({ - "lib": { - "public.js": "export function greet() { return 'Hello from worktree2'; }", - "private.js": "const SECRET_TOKEN = \"private_token_2\";", - "data.json": "{\"api_key\": \"json_secret_key\"}" - }, - "docs": { - "README.md": "# Public Documentation", - "internal.md": "# Internal Secrets and Configuration" - }, - ".zed": { - "settings.json": r#"{ - "file_scan_exclusions": ["**/internal.*"], - "private_files": ["**/private.js", "**/data.json"] - }"# - } - }), - ) - .await; - - // Set global settings - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, |settings| { - settings.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); - settings.private_files = Some(vec!["**/.env".to_string()]); - }); - }); - }); - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let tool = Arc::new(ReadFileTool); - - // Test reading allowed files in worktree1 - let input = json!({ - "path": "worktree1/src/main.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - assert_eq!( - result.content.as_str().unwrap(), - "fn main() { println!(\"Hello from worktree1\"); }" - ); - - // Test reading private file in worktree1 should fail - let input = json!({ - "path": "worktree1/src/secret.rs" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Error should mention worktree private_files setting" - ); - - // Test reading excluded file in worktree1 should fail - let input = json!({ - "path": "worktree1/tests/fixture.sql" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `file_scan_exclusions` setting"), - "Error should mention worktree file_scan_exclusions setting" - ); - - // Test reading allowed files in worktree2 - let input = json!({ - "path": "worktree2/lib/public.js" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await - .unwrap(); - - assert_eq!( - result.content.as_str().unwrap(), - "export function greet() { return 'Hello from worktree2'; }" - ); - - // Test reading private file in worktree2 should fail - let input = json!({ - "path": "worktree2/lib/private.js" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Error should mention worktree private_files setting" - ); - - // Test reading excluded file in worktree2 should fail - let input = json!({ - "path": "worktree2/docs/internal.md" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `file_scan_exclusions` setting"), - "Error should mention worktree file_scan_exclusions setting" - ); - - // Test that files allowed in one worktree but not in another are handled correctly - // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2) - let input = json!({ - "path": "worktree1/src/config.toml" - }); - - let result = cx - .update(|cx| { - tool.clone().run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - }) - .output - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("worktree `private_files` setting"), - "Config.toml should be blocked by worktree1's private_files setting" - ); - } -} diff --git a/crates/assistant_tools/src/read_file_tool/description.md b/crates/assistant_tools/src/read_file_tool/description.md deleted file mode 100644 index 7bcebc03341541496ab090090ab7ef8beb3f2ebe..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/read_file_tool/description.md +++ /dev/null @@ -1,3 +0,0 @@ -Reads the content of the given file in the project. - -- Never attempt to read a path that hasn't been previously mentioned. diff --git a/crates/assistant_tools/src/schema.rs b/crates/assistant_tools/src/schema.rs deleted file mode 100644 index 10a8bf0acd99131d2c0a80411072f312c9a42f50..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/schema.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::Result; -use language_model::LanguageModelToolSchemaFormat; -use schemars::{ - JsonSchema, Schema, - generate::SchemaSettings, - transform::{Transform, transform_subschemas}, -}; - -pub fn json_schema_for( - format: LanguageModelToolSchemaFormat, -) -> Result { - let schema = root_schema_for::(format); - schema_to_json(&schema, format) -} - -fn schema_to_json( - schema: &Schema, - format: LanguageModelToolSchemaFormat, -) -> Result { - let mut value = serde_json::to_value(schema)?; - assistant_tool::adapt_schema_to_format(&mut value, format)?; - Ok(value) -} - -fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { - let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), - LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() - .with(|settings| { - settings.meta_schema = None; - settings.inline_subschemas = true; - }) - .with_transform(ToJsonSchemaSubsetTransform) - .into_generator(), - }; - generator.root_schema_for::() -} - -#[derive(Debug, Clone)] -struct ToJsonSchemaSubsetTransform; - -impl Transform for ToJsonSchemaSubsetTransform { - fn transform(&mut self, schema: &mut Schema) { - // Ensure that the type field is not an array, this happens when we use - // Option, the type will be [T, "null"]. - if let Some(type_field) = schema.get_mut("type") { - if let Some(types) = type_field.as_array() { - if let Some(first_type) = types.first() { - *type_field = first_type.clone(); - } - } - } - - // oneOf is not supported, use anyOf instead - if let Some(one_of) = schema.remove("oneOf") { - schema.insert("anyOf".to_string(), one_of); - } - - transform_subschemas(self, schema); - } -} diff --git a/crates/assistant_tools/src/templates.rs b/crates/assistant_tools/src/templates.rs deleted file mode 100644 index c83601199cca11e7a92f07e4159ac6241378d725..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/templates.rs +++ /dev/null @@ -1,32 +0,0 @@ -use anyhow::Result; -use handlebars::Handlebars; -use rust_embed::RustEmbed; -use serde::Serialize; -use std::sync::Arc; - -#[derive(RustEmbed)] -#[folder = "src/templates"] -#[include = "*.hbs"] -struct Assets; - -pub struct Templates(Handlebars<'static>); - -impl Templates { - pub fn new() -> Arc { - let mut handlebars = Handlebars::new(); - handlebars.register_embed_templates::().unwrap(); - handlebars.register_escape_fn(|text| text.into()); - Arc::new(Self(handlebars)) - } -} - -pub trait Template: Sized { - const TEMPLATE_NAME: &'static str; - - fn render(&self, templates: &Templates) -> Result - where - Self: Serialize + Sized, - { - Ok(templates.0.render(Self::TEMPLATE_NAME, self)?) - } -} diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs deleted file mode 100644 index 46227f130d6c706c598466045057075786b21cd3..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/terminal_tool.rs +++ /dev/null @@ -1,905 +0,0 @@ -use crate::{ - schema::json_schema_for, - ui::{COLLAPSED_LINES, ToolOutputPreview}, -}; -use action_log::ActionLog; -use agent_settings; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{Tool, ToolCard, ToolResult, ToolUseStatus}; -use futures::{FutureExt as _, future::Shared}; -use gpui::{ - Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, - TextStyleRefinement, Transformation, WeakEntity, Window, percentage, -}; -use language::LineEnding; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use project::{Project, terminals::TerminalKind}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{ - env, - path::{Path, PathBuf}, - process::ExitStatus, - sync::Arc, - time::{Duration, Instant}, -}; -use terminal_view::TerminalView; -use theme::ThemeSettings; -use ui::{Disclosure, Tooltip, prelude::*}; -use util::{ - ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size, - time::duration_alt_display, -}; -use workspace::Workspace; - -const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct TerminalToolInput { - /// The one-liner command to execute. - command: String, - /// Working directory for the command. This must be one of the root directories of the project. - cd: String, -} - -pub struct TerminalTool { - determine_shell: Shared>, -} - -impl TerminalTool { - pub const NAME: &str = "terminal"; - - pub(crate) fn new(cx: &mut App) -> Self { - let determine_shell = cx.background_spawn(async move { - if cfg!(windows) { - return get_system_shell(); - } - - if which::which("bash").is_ok() { - log::info!("agent selected bash for terminal tool"); - "bash".into() - } else { - let shell = get_system_shell(); - log::info!("agent selected {shell} for terminal tool"); - shell - } - }); - Self { - determine_shell: determine_shell.shared(), - } - } -} - -impl Tool for TerminalTool { - fn name(&self) -> String { - Self::NAME.to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - true - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./terminal_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolTerminal - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => { - let mut lines = input.command.lines(); - let first_line = lines.next().unwrap_or_default(); - let remaining_line_count = lines.count(); - match remaining_line_count { - 0 => MarkdownInlineCode(&first_line).to_string(), - 1 => MarkdownInlineCode(&format!( - "{} - {} more line", - first_line, remaining_line_count - )) - .to_string(), - n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n)) - .to_string(), - } - } - Err(_) => "Run terminal command".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - project: Entity, - _action_log: Entity, - _model: Arc, - window: Option, - cx: &mut App, - ) -> ToolResult { - let input: TerminalToolInput = match serde_json::from_value(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let working_dir = match working_dir(&input, &project, cx) { - Ok(dir) => dir, - Err(err) => return Task::ready(Err(err)).into(), - }; - let program = self.determine_shell.clone(); - let command = if cfg!(windows) { - format!("$null | & {{{}}}", input.command.replace("\"", "'")) - } else if let Some(cwd) = working_dir - .as_ref() - .and_then(|cwd| cwd.as_os_str().to_str()) - { - // Make sure once we're *inside* the shell, we cd into `cwd` - format!("(cd {cwd}; {}) project.update(cx, |project, cx| { - project.directory_environment(dir.as_path().into(), cx) - }), - None => Task::ready(None).shared(), - }; - - let env = cx.spawn(async move |_| { - let mut env = env.await.unwrap_or_default(); - if cfg!(unix) { - env.insert("PAGER".into(), "cat".into()); - } - env - }); - - let Some(window) = window else { - // Headless setup, a test or eval. Our terminal subsystem requires a workspace, - // so bypass it and provide a convincing imitation using a pty. - let task = cx.background_spawn(async move { - let env = env.await; - let pty_system = native_pty_system(); - let program = program.await; - let mut cmd = CommandBuilder::new(program); - cmd.args(args); - for (k, v) in env { - cmd.env(k, v); - } - if let Some(cwd) = cwd { - cmd.cwd(cwd); - } - let pair = pty_system.openpty(PtySize { - rows: 24, - cols: 80, - ..Default::default() - })?; - let mut child = pair.slave.spawn_command(cmd)?; - let mut reader = pair.master.try_clone_reader()?; - drop(pair); - let mut content = String::new(); - reader.read_to_string(&mut content)?; - // Massage the pty output a bit to try to match what the terminal codepath gives us - LineEnding::normalize(&mut content); - content = content - .chars() - .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control()) - .collect(); - let content = content.trim_start().trim_start_matches("^D"); - let exit_status = child.wait()?; - let (processed_content, _) = - process_content(content, &input.command, Some(exit_status)); - Ok(processed_content.into()) - }); - return ToolResult { - output: task, - card: None, - }; - }; - - let terminal = cx.spawn({ - let project = project.downgrade(); - async move |cx| { - let program = program.await; - let env = env.await; - let terminal = project - .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task::SpawnInTerminal { - command: Some(program), - args, - cwd, - env, - ..Default::default() - }), - cx, - ) - })? - .await; - terminal - } - }); - - let command_markdown = cx.new(|cx| { - Markdown::new( - format!("```bash\n{}\n```", input.command).into(), - None, - None, - cx, - ) - }); - - let card = cx.new(|cx| { - TerminalToolCard::new( - command_markdown.clone(), - working_dir.clone(), - cx.entity_id(), - cx, - ) - }); - - let output = cx.spawn({ - let card = card.clone(); - async move |cx| { - let terminal = terminal.await?; - let workspace = window - .downcast::() - .and_then(|handle| handle.entity(cx).ok()) - .context("no workspace entity in root of window")?; - - let terminal_view = window.update(cx, |_, window, cx| { - cx.new(|cx| { - let mut view = TerminalView::new( - terminal.clone(), - workspace.downgrade(), - None, - project.downgrade(), - window, - cx, - ); - view.set_embedded_mode(None, cx); - view - }) - })?; - - card.update(cx, |card, _| { - card.terminal = Some(terminal_view.clone()); - card.start_instant = Instant::now(); - }) - .log_err(); - - let exit_status = terminal - .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? - .await; - let (content, content_line_count) = terminal.read_with(cx, |terminal, _| { - (terminal.get_content(), terminal.total_lines()) - })?; - - let previous_len = content.len(); - let (processed_content, finished_with_empty_output) = process_content( - &content, - &input.command, - exit_status.map(portable_pty::ExitStatus::from), - ); - - card.update(cx, |card, _| { - card.command_finished = true; - card.exit_status = exit_status; - card.was_content_truncated = processed_content.len() < previous_len; - card.original_content_len = previous_len; - card.content_line_count = content_line_count; - card.finished_with_empty_output = finished_with_empty_output; - card.elapsed_time = Some(card.start_instant.elapsed()); - }) - .log_err(); - - Ok(processed_content.into()) - } - }); - - ToolResult { - output, - card: Some(card.into()), - } - } -} - -fn process_content( - content: &str, - command: &str, - exit_status: Option, -) -> (String, bool) { - let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT; - - let content = if should_truncate { - let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len()); - while !content.is_char_boundary(end_ix) { - end_ix -= 1; - } - // Don't truncate mid-line, clear the remainder of the last line - end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); - &content[..end_ix] - } else { - content - }; - let content = content.trim(); - let is_empty = content.is_empty(); - let content = format!("```\n{content}\n```"); - let content = if should_truncate { - format!( - "Command output too long. The first {} bytes:\n\n{content}", - content.len(), - ) - } else { - content - }; - - let content = match exit_status { - Some(exit_status) if exit_status.success() => { - if is_empty { - "Command executed successfully.".to_string() - } else { - content.to_string() - } - } - Some(exit_status) => { - if is_empty { - format!( - "Command \"{command}\" failed with exit code {}.", - exit_status.exit_code() - ) - } else { - format!( - "Command \"{command}\" failed with exit code {}.\n\n{content}", - exit_status.exit_code() - ) - } - } - None => { - format!( - "Command failed or was interrupted.\nPartial output captured:\n\n{}", - content, - ) - } - }; - (content, is_empty) -} - -fn working_dir( - input: &TerminalToolInput, - project: &Entity, - cx: &mut App, -) -> Result> { - let project = project.read(cx); - let cd = &input.cd; - - if cd == "." || cd == "" { - // Accept "." or "" as meaning "the one worktree" if we only have one worktree. - let mut worktrees = project.worktrees(cx); - - match worktrees.next() { - Some(worktree) => { - anyhow::ensure!( - worktrees.next().is_none(), - "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.", - ); - Ok(Some(worktree.read(cx).abs_path().to_path_buf())) - } - None => Ok(None), - } - } else { - let input_path = Path::new(cd); - - if input_path.is_absolute() { - // Absolute paths are allowed, but only if they're in one of the project's worktrees. - if project - .worktrees(cx) - .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path())) - { - return Ok(Some(input_path.into())); - } - } else { - if let Some(worktree) = project.worktree_for_root_name(cd, cx) { - return Ok(Some(worktree.read(cx).abs_path().to_path_buf())); - } - } - - anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees."); - } -} - -struct TerminalToolCard { - input_command: Entity, - working_dir: Option, - entity_id: EntityId, - exit_status: Option, - terminal: Option>, - command_finished: bool, - was_content_truncated: bool, - finished_with_empty_output: bool, - content_line_count: usize, - original_content_len: usize, - preview_expanded: bool, - start_instant: Instant, - elapsed_time: Option, -} - -impl TerminalToolCard { - pub fn new( - input_command: Entity, - working_dir: Option, - entity_id: EntityId, - cx: &mut Context, - ) -> Self { - let expand_terminal_card = - agent_settings::AgentSettings::get_global(cx).expand_terminal_card; - Self { - input_command, - working_dir, - entity_id, - exit_status: None, - terminal: None, - command_finished: false, - was_content_truncated: false, - finished_with_empty_output: false, - original_content_len: 0, - content_line_count: 0, - preview_expanded: expand_terminal_card, - start_instant: Instant::now(), - elapsed_time: None, - } - } -} - -impl ToolCard for TerminalToolCard { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - _workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let Some(terminal) = self.terminal.as_ref() else { - return Empty.into_any(); - }; - - let tool_failed = matches!(status, ToolUseStatus::Error(_)); - - let command_failed = - self.command_finished && self.exit_status.is_none_or(|code| !code.success()); - - if (tool_failed || command_failed) && self.elapsed_time.is_none() { - self.elapsed_time = Some(self.start_instant.elapsed()); - } - let time_elapsed = self - .elapsed_time - .unwrap_or_else(|| self.start_instant.elapsed()); - - let header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let border_color = cx.theme().colors().border.opacity(0.6); - - let path = self - .working_dir - .as_ref() - .cloned() - .or_else(|| env::current_dir().ok()) - .map(|path| format!("{}", path.display())) - .unwrap_or_else(|| "current directory".to_string()); - - let header = h_flex() - .flex_none() - .gap_1() - .justify_between() - .rounded_t_md() - .child( - div() - .id(("command-target-path", self.entity_id)) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(path) - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - .when(!self.command_finished, |header| { - header.child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ), - ) - }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", self.entity_id)) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when(command_failed && self.exit_status.is_some(), |this| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - self.exit_status - .and_then(|status| status.code()) - .unwrap_or(-1), - ))) - }) - .when( - !command_failed && tool_failed && status.error().is_some(), - |this| { - this.tooltip(Tooltip::text(format!( - "Error: {}", - status.error().unwrap(), - ))) - }, - ), - ) - }) - .when(self.was_content_truncated, |header| { - let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { - "Output exceeded terminal max lines and was \ - truncated, the model received the first 16 KB." - .to_string() - } else { - format!( - "Output is {} long, to avoid unexpected token usage, \ - only 16 KB was sent back to the model.", - format_file_size(self.original_content_len as u64, true), - ) - }; - header.child( - h_flex() - .id(("terminal-tool-truncated-label", self.entity_id)) - .tooltip(Tooltip::text(tooltip)) - .gap_1() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Ignored), - ) - .child( - Label::new("Truncated") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - }) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }) - .when(!self.finished_with_empty_output, |header| { - header.child( - Disclosure::new( - ("terminal-tool-disclosure", self.entity_id), - self.preview_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener( - move |this, _event, _window, _cx| { - this.preview_expanded = !this.preview_expanded; - }, - )), - ) - }); - - v_flex() - .mb_2() - .border_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .rounded_lg() - .overflow_hidden() - .child( - v_flex() - .p_2() - .gap_0p5() - .bg(header_bg) - .text_xs() - .child(header) - .child( - MarkdownElement::new( - self.input_command.clone(), - markdown_style(window, cx), - ) - .code_block_renderer( - markdown::CodeBlockRenderer::Default { - copy_button: false, - copy_button_on_hover: true, - border: false, - }, - ), - ), - ) - .when( - self.preview_expanded && !self.finished_with_empty_output, - |this| { - this.child( - div() - .pt_2() - .border_t_1() - .when(tool_failed || command_failed, |card| card.border_dashed()) - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .text_ui_sm(cx) - .child({ - let content_mode = terminal.read(cx).content_mode(window, cx); - - if content_mode.is_scrollable() { - div().h_72().child(terminal.clone()).into_any_element() - } else { - ToolOutputPreview::new( - terminal.clone().into_any_element(), - terminal.entity_id(), - ) - .with_total_lines(self.content_line_count) - .toggle_state(!content_mode.is_limited()) - .on_toggle({ - let terminal = terminal.clone(); - move |is_expanded, _, cx| { - terminal.update(cx, |terminal, cx| { - terminal.set_embedded_mode( - if is_expanded { - None - } else { - Some(COLLAPSED_LINES) - }, - cx, - ); - }); - } - }) - .into_any_element() - } - }), - ) - }, - ) - .into_any() - } -} - -fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let buffer_font_size = TextSize::Default.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), - font_features: Some(theme_settings.buffer_font.features.clone()), - font_size: Some(buffer_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().colors().element_selection_background, - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use editor::EditorSettings; - use fs::RealFs; - use gpui::{BackgroundExecutor, TestAppContext}; - use language_model::fake_provider::FakeLanguageModel; - use pretty_assertions::assert_eq; - use serde_json::json; - use settings::{Settings, SettingsStore}; - use terminal::terminal_settings::TerminalSettings; - use theme::ThemeSettings; - use util::{ResultExt as _, test::TempTree}; - - use super::*; - - fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) { - zlog::init_test(); - - executor.allow_parking(); - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - ThemeSettings::register(cx); - TerminalSettings::register(cx); - EditorSettings::register(cx); - }); - } - - #[gpui::test] - async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone()))); - let model = Arc::new(FakeLanguageModel::default()); - - let input = TerminalToolInput { - command: "cat".to_owned(), - cd: tree - .path() - .join("project") - .as_path() - .to_string_lossy() - .to_string(), - }; - let result = cx.update(|cx| { - TerminalTool::run( - Arc::new(TerminalTool::new(cx)), - serde_json::to_value(input).unwrap(), - Arc::default(), - project.clone(), - action_log.clone(), - model, - None, - cx, - ) - }); - - let output = result.output.await.log_err().unwrap().content; - assert_eq!(output.as_str().unwrap(), "Command executed successfully."); - } - - #[gpui::test] - async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) { - if cfg!(windows) { - return; - } - - init_test(&executor, cx); - - let fs = Arc::new(RealFs::new(None, executor)); - let tree = TempTree::new(json!({ - "project": {}, - "other-project": {}, - })); - let project: Entity = - Project::test(fs, [tree.path().join("project").as_path()], cx).await; - let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone()))); - let model = Arc::new(FakeLanguageModel::default()); - - let check = |input, expected, cx: &mut App| { - let headless_result = TerminalTool::run( - Arc::new(TerminalTool::new(cx)), - serde_json::to_value(input).unwrap(), - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ); - cx.spawn(async move |_| { - let output = headless_result.output.await.map(|output| output.content); - assert_eq!( - output - .ok() - .and_then(|content| content.as_str().map(ToString::to_string)), - expected - ); - }) - }; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("project").display() - )), - cx, - ) - }) - .await; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - None, // other-project is a dir, but *not* a worktree (yet) - cx, - ) - }) - .await; - - // Absolute path above the worktree root - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: tree.path().to_string_lossy().into(), - }, - None, - cx, - ) - }) - .await; - - project - .update(cx, |project, cx| { - project.create_worktree(tree.path().join("other-project"), true, cx) - }) - .await - .unwrap(); - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: "other-project".into(), - }, - Some(format!( - "```\n{}\n```", - tree.path().join("other-project").display() - )), - cx, - ) - }) - .await; - - cx.update(|cx| { - check( - TerminalToolInput { - command: "pwd".into(), - cd: ".".into(), - }, - None, - cx, - ) - }) - .await; - } -} diff --git a/crates/assistant_tools/src/terminal_tool/description.md b/crates/assistant_tools/src/terminal_tool/description.md deleted file mode 100644 index 3cb5d87d163b3919abafa899ed2fbdba67500773..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/terminal_tool/description.md +++ /dev/null @@ -1,11 +0,0 @@ -Executes a shell one-liner and returns the combined output. - -This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result. - -The output results will be shown to the user already, only list it again if necessary, avoid being redundant. - -Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error. - -Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. - -Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs deleted file mode 100644 index 17ce4afc2eeeff8c6f37834cd9e8c4ff71e7cd70..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/thinking_tool.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::sync::Arc; - -use crate::schema::json_schema_for; -use action_log::ActionLog; -use anyhow::{Result, anyhow}; -use assistant_tool::{Tool, ToolResult}; -use gpui::{AnyWindowHandle, App, Entity, Task}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::IconName; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ThinkingToolInput { - /// Content to think about. This should be a description of what to think about or - /// a problem to solve. - content: String, -} - -pub struct ThinkingTool; - -impl Tool for ThinkingTool { - fn name(&self) -> String { - "thinking".to_string() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./thinking_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolThink - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Thinking".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - _cx: &mut App, - ) -> ToolResult { - // This tool just "thinks out loud" and doesn't perform any actions. - Task::ready(match serde_json::from_value::(input) { - Ok(_input) => Ok("Finished thinking.".to_string().into()), - Err(err) => Err(anyhow!(err)), - }) - .into() - } -} diff --git a/crates/assistant_tools/src/thinking_tool/description.md b/crates/assistant_tools/src/thinking_tool/description.md deleted file mode 100644 index b625d22f321fa427945fdb9c42aaaed9ab86f6be..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/thinking_tool/description.md +++ /dev/null @@ -1 +0,0 @@ -A tool for thinking through problems, brainstorming ideas, or planning without executing any actions. Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action. diff --git a/crates/assistant_tools/src/ui.rs b/crates/assistant_tools/src/ui.rs deleted file mode 100644 index 793427385456939eb1a7070fff5bba928a6c2643..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/ui.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod tool_call_card_header; -mod tool_output_preview; - -pub use tool_call_card_header::*; -pub use tool_output_preview::*; diff --git a/crates/assistant_tools/src/ui/tool_call_card_header.rs b/crates/assistant_tools/src/ui/tool_call_card_header.rs deleted file mode 100644 index b71453373feb84d91168576a5bc7c22f8d883aa9..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/ui/tool_call_card_header.rs +++ /dev/null @@ -1,134 +0,0 @@ -use gpui::{Animation, AnimationExt, AnyElement, App, IntoElement, pulsating_between}; -use std::time::Duration; -use ui::{Tooltip, prelude::*}; - -/// A reusable header component for tool call cards. -#[derive(IntoElement)] -pub struct ToolCallCardHeader { - icon: IconName, - primary_text: SharedString, - secondary_text: Option, - code_path: Option, - disclosure_slot: Option, - is_loading: bool, - error: Option, -} - -impl ToolCallCardHeader { - pub fn new(icon: IconName, primary_text: impl Into) -> Self { - Self { - icon, - primary_text: primary_text.into(), - secondary_text: None, - code_path: None, - disclosure_slot: None, - is_loading: false, - error: None, - } - } - - pub fn with_secondary_text(mut self, text: impl Into) -> Self { - self.secondary_text = Some(text.into()); - self - } - - pub fn with_code_path(mut self, text: impl Into) -> Self { - self.code_path = Some(text.into()); - self - } - - pub fn disclosure_slot(mut self, element: impl IntoElement) -> Self { - self.disclosure_slot = Some(element.into_any_element()); - self - } - - pub fn loading(mut self) -> Self { - self.is_loading = true; - self - } - - pub fn with_error(mut self, error: impl Into) -> Self { - self.error = Some(error.into()); - self - } -} - -impl RenderOnce for ToolCallCardHeader { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let font_size = rems(0.8125); - let line_height = window.line_height(); - - let secondary_text = self.secondary_text; - let code_path = self.code_path; - - let bullet_divider = || { - div() - .size(px(3.)) - .rounded_full() - .bg(cx.theme().colors().text) - }; - - h_flex() - .id("tool-label-container") - .gap_2() - .max_w_full() - .overflow_x_scroll() - .opacity(0.8) - .child( - h_flex() - .h(line_height) - .gap_1p5() - .text_size(font_size) - .child( - h_flex().h(line_height).justify_center().child( - Icon::new(self.icon) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .map(|this| { - if let Some(error) = &self.error { - this.child(format!("{} failed", self.primary_text)).child( - IconButton::new("error_info", IconName::Warning) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Warning) - .tooltip(Tooltip::text(error.clone())), - ) - } else { - this.child(self.primary_text.clone()) - } - }) - .when_some(secondary_text, |this, secondary_text| { - this.child(bullet_divider()) - .child(div().text_size(font_size).child(secondary_text.clone())) - }) - .when_some(code_path, |this, code_path| { - this.child(bullet_divider()).child( - Label::new(code_path.clone()) - .size(LabelSize::Small) - .inline_code(cx), - ) - }) - .with_animation( - "loading-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.)), - move |this, delta| { - if self.is_loading { - this.opacity(delta) - } else { - this - } - }, - ), - ) - .when_some(self.disclosure_slot, |container, disclosure_slot| { - container - .group("disclosure") - .justify_between() - .child(div().visible_on_hover("disclosure").child(disclosure_slot)) - }) - } -} diff --git a/crates/assistant_tools/src/ui/tool_output_preview.rs b/crates/assistant_tools/src/ui/tool_output_preview.rs deleted file mode 100644 index a672bb8b99daa1fd776f59c4e8be789b8e25240c..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/ui/tool_output_preview.rs +++ /dev/null @@ -1,115 +0,0 @@ -use gpui::{AnyElement, EntityId, prelude::*}; -use ui::{Tooltip, prelude::*}; - -#[derive(IntoElement)] -pub struct ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - content: AnyElement, - entity_id: EntityId, - full_height: bool, - total_lines: usize, - collapsed_fade: bool, - on_toggle: Option, -} - -pub const COLLAPSED_LINES: usize = 10; - -impl ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - pub fn new(content: AnyElement, entity_id: EntityId) -> Self { - Self { - content, - entity_id, - full_height: true, - total_lines: 0, - collapsed_fade: false, - on_toggle: None, - } - } - - pub fn with_total_lines(mut self, total_lines: usize) -> Self { - self.total_lines = total_lines; - self - } - - pub fn toggle_state(mut self, full_height: bool) -> Self { - self.full_height = full_height; - self - } - - pub fn with_collapsed_fade(mut self) -> Self { - self.collapsed_fade = true; - self - } - - pub fn on_toggle(mut self, listener: F) -> Self { - self.on_toggle = Some(listener); - self - } -} - -impl RenderOnce for ToolOutputPreview -where - F: Fn(bool, &mut Window, &mut App) + 'static, -{ - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - if self.total_lines <= COLLAPSED_LINES { - return self.content; - } - let border_color = cx.theme().colors().border.opacity(0.6); - - let (icon, tooltip_label) = if self.full_height { - (IconName::ChevronUp, "Collapse") - } else { - (IconName::ChevronDown, "Expand") - }; - - let gradient_overlay = - if self.collapsed_fade && !self.full_height { - Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg( - gpui::linear_gradient( - 0., - gpui::linear_color_stop(cx.theme().colors().editor_background, 0.), - gpui::linear_color_stop( - cx.theme().colors().editor_background.opacity(0.), - 1., - ), - ), - )) - } else { - None - }; - - v_flex() - .relative() - .child(self.content) - .children(gradient_overlay) - .child( - h_flex() - .id(("expand-button", self.entity_id)) - .flex_none() - .cursor_pointer() - .h_5() - .justify_center() - .border_t_1() - .rounded_b_md() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1))) - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - .tooltip(Tooltip::text(tooltip_label)) - .when_some(self.on_toggle, |this, on_toggle| { - this.on_click({ - move |_, window, cx| { - on_toggle(!self.full_height, window, cx); - } - }) - }), - ) - .into_any() - } -} diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs deleted file mode 100644 index 47a6958b7ad278f01fb654d23b68360d562d73e9..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/web_search_tool.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use crate::schema::json_schema_for; -use crate::ui::ToolCallCardHeader; -use action_log::ActionLog; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ - Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use cloud_llm_client::{WebSearchResponse, WebSearchResult}; -use futures::{Future, FutureExt, TryFutureExt}; -use gpui::{ - AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window, -}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::{IconName, Tooltip, prelude::*}; -use web_search::WebSearchRegistry; -use workspace::Workspace; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct WebSearchToolInput { - /// The search term or question to query on the web. - query: String, -} - -pub struct WebSearchTool; - -impl Tool for WebSearchTool { - fn name(&self) -> String { - "web_search".into() - } - - fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false - } - - fn may_perform_edits(&self) -> bool { - false - } - - fn description(&self) -> String { - "Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into() - } - - fn icon(&self) -> IconName { - IconName::ToolWeb - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { - json_schema_for::(format) - } - - fn ui_text(&self, _input: &serde_json::Value) -> String { - "Searching the Web".to_string() - } - - fn run( - self: Arc, - input: serde_json::Value, - _request: Arc, - _project: Entity, - _action_log: Entity, - _model: Arc, - _window: Option, - cx: &mut App, - ) -> ToolResult { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else { - return Task::ready(Err(anyhow!("Web search is not available."))).into(); - }; - - let search_task = provider.search(input.query, cx).map_err(Arc::new).shared(); - let output = cx.background_spawn({ - let search_task = search_task.clone(); - async move { - let response = search_task.await.map_err(|err| anyhow!(err))?; - Ok(ToolResultOutput { - content: ToolResultContent::Text( - serde_json::to_string(&response) - .context("Failed to serialize search results")?, - ), - output: Some(serde_json::to_value(response)?), - }) - } - }); - - ToolResult { - output, - card: Some(cx.new(|cx| WebSearchToolCard::new(search_task, cx)).into()), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - _project: Entity, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let output = serde_json::from_value::(output).ok()?; - let card = cx.new(|cx| WebSearchToolCard::new(Task::ready(Ok(output)), cx)); - Some(card.into()) - } -} - -#[derive(RegisterComponent)] -struct WebSearchToolCard { - response: Option>, - _task: Task<()>, -} - -impl WebSearchToolCard { - fn new( - search_task: impl 'static + Future>>, - cx: &mut Context, - ) -> Self { - let _task = cx.spawn(async move |this, cx| { - let response = search_task.await.map_err(|err| anyhow!(err)); - this.update(cx, |this, cx| { - this.response = Some(response); - cx.notify(); - }) - .ok(); - }); - - Self { - response: None, - _task, - } - } -} - -impl ToolCard for WebSearchToolCard { - fn render( - &mut self, - _status: &ToolUseStatus, - _window: &mut Window, - _workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let icon = IconName::ToolWeb; - - let header = match self.response.as_ref() { - Some(Ok(response)) => { - let text: SharedString = if response.results.len() == 1 { - "1 result".into() - } else { - format!("{} results", response.results.len()).into() - }; - ToolCallCardHeader::new(icon, "Searched the Web").with_secondary_text(text) - } - Some(Err(error)) => { - ToolCallCardHeader::new(icon, "Web Search").with_error(error.to_string()) - } - None => ToolCallCardHeader::new(icon, "Searching the Web").loading(), - }; - - let content = self.response.as_ref().and_then(|response| match response { - Ok(response) => Some( - v_flex() - .overflow_hidden() - .ml_1p5() - .pl(px(5.)) - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .gap_1() - .children(response.results.iter().enumerate().map(|(index, result)| { - let title = result.title.clone(); - let url = SharedString::from(result.url.clone()); - - Button::new(("result", index), title) - .label_size(LabelSize::Small) - .color(Color::Muted) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .truncate(true) - .tooltip({ - let url = url.clone(); - move |window, cx| { - Tooltip::with_meta( - "Web Search Result", - None, - url.clone(), - window, - cx, - ) - } - }) - .on_click({ - let url = url.clone(); - move |_, _, cx| cx.open_url(&url) - }) - })) - .into_any(), - ), - Err(_) => None, - }); - - v_flex().mb_3().gap_1().child(header).children(content) - } -} - -impl Component for WebSearchToolCard { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let in_progress_search = cx.new(|cx| WebSearchToolCard { - response: None, - _task: cx.spawn(async move |_this, cx| { - loop { - cx.background_executor() - .timer(Duration::from_secs(60)) - .await - } - }), - }); - - let successful_search = cx.new(|_cx| WebSearchToolCard { - response: Some(Ok(example_search_response())), - _task: Task::ready(()), - }); - - let error_search = cx.new(|_cx| WebSearchToolCard { - response: Some(Err(anyhow!("Failed to resolve https://google.com"))), - _task: Task::ready(()), - }); - - Some( - v_flex() - .gap_6() - .children(vec![example_group(vec![ - single_example( - "In Progress", - div() - .size_full() - .child(in_progress_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Pending, - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "Successful", - div() - .size_full() - .child(successful_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Finished("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - single_example( - "Error", - div() - .size_full() - .child(error_search.update(cx, |tool, cx| { - tool.render( - &ToolUseStatus::Error("".into()), - window, - WeakEntity::new_invalid(), - cx, - ) - .into_any_element() - })) - .into_any_element(), - ), - ])]) - .into_any_element(), - ) - } -} - -fn example_search_response() -> WebSearchResponse { - WebSearchResponse { - results: vec![ - WebSearchResult { - title: "Alo".to_string(), - url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(), - text: "Alo is a popular restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Alo".to_string(), - url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(), - text: "Information about Alo restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Edulis".to_string(), - url: "https://www.google.com/maps/search/Edulis%2C+Toronto%2C+Canada".to_string(), - text: "Details about Edulis restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Sushi Masaki Saito".to_string(), - url: "https://www.google.com/maps/search/Sushi+Masaki+Saito%2C+Toronto%2C+Canada" - .to_string(), - text: "Information about Sushi Masaki Saito in Toronto.".to_string(), - }, - WebSearchResult { - title: "Shoushin".to_string(), - url: "https://www.google.com/maps/search/Shoushin%2C+Toronto%2C+Canada".to_string(), - text: "Details about Shoushin restaurant in Toronto.".to_string(), - }, - WebSearchResult { - title: "Restaurant 20 Victoria".to_string(), - url: - "https://www.google.com/maps/search/Restaurant+20+Victoria%2C+Toronto%2C+Canada" - .to_string(), - text: "Information about Restaurant 20 Victoria in Toronto.".to_string(), - }, - ], - } -} diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 5146396b92266e74aaa771c3c789894b33666874..2aee764007a791176c6e41cb77f6efaf19aa3dc4 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -14,10 +14,19 @@ doctest = false [dependencies] anyhow.workspace = true +async-tar.workspace = true collections.workspace = true -derive_more.workspace = true +crossbeam.workspace = true gpui.workspace = true +denoise = { path = "../denoise" } +log.workspace = true parking_lot.workspace = true -rodio = { workspace = true, features = [ "wav", "playback", "tracing" ] } +rodio.workspace = true +serde.workspace = true +settings.workspace = true +smol.workspace = true +thiserror.workspace = true util.workspace = true -workspace-hack.workspace = true + +[target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies] +libwebrtc = { rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d", git = "https://github.com/zed-industries/livekit-rust-sdks" } diff --git a/crates/audio/src/assets.rs b/crates/audio/src/assets.rs deleted file mode 100644 index fd5c935d875960f4fd9bf30494301f4811b22448..0000000000000000000000000000000000000000 --- a/crates/audio/src/assets.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::{io::Cursor, sync::Arc}; - -use anyhow::{Context as _, Result}; -use collections::HashMap; -use gpui::{App, AssetSource, Global}; -use rodio::{Decoder, Source, source::Buffered}; - -type Sound = Buffered>>>; - -pub struct SoundRegistry { - cache: Arc>>, - assets: Box, -} - -struct GlobalSoundRegistry(Arc); - -impl Global for GlobalSoundRegistry {} - -impl SoundRegistry { - pub fn new(source: impl AssetSource) -> Arc { - Arc::new(Self { - cache: Default::default(), - assets: Box::new(source), - }) - } - - pub fn global(cx: &App) -> Arc { - cx.global::().0.clone() - } - - pub(crate) fn set_global(source: impl AssetSource, cx: &mut App) { - cx.set_global(GlobalSoundRegistry(SoundRegistry::new(source))); - } - - pub fn get(&self, name: &str) -> Result + use<>> { - if let Some(wav) = self.cache.lock().get(name) { - return Ok(wav.clone()); - } - - let path = format!("sounds/{}.wav", name); - let bytes = self - .assets - .load(&path)? - .map(anyhow::Ok) - .with_context(|| format!("No asset available for path {path}"))?? - .into_owned(); - let cursor = Cursor::new(bytes); - let source = Decoder::new(cursor)?.buffered(); - - self.cache.lock().insert(name.to_string(), source.clone()); - - Ok(source) - } -} diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 44baa16aa20a3e4b7651744974cfc085dcde7fb1..9ad5a36a374d87b2cdcc6434377da3652af97786 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,18 +1,61 @@ -use assets::SoundRegistry; -use derive_more::{Deref, DerefMut}; -use gpui::{App, AssetSource, BorrowAppContext, Global}; -use rodio::{OutputStream, OutputStreamBuilder}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::{App, BackgroundExecutor, BorrowAppContext, Global}; +use log::info; + +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +mod non_windows_and_freebsd_deps { + pub(super) use gpui::AsyncApp; + pub(super) use libwebrtc::native::apm; + pub(super) use parking_lot::Mutex; + pub(super) use rodio::cpal::Sample; + pub(super) use rodio::source::LimitSettings; + pub(super) use std::sync::Arc; +} + +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +use non_windows_and_freebsd_deps::*; + +use rodio::{ + Decoder, OutputStream, OutputStreamBuilder, Source, mixer::Mixer, nz, source::Buffered, +}; +use settings::Settings; +use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration}; use util::ResultExt; -mod assets; +mod audio_settings; +mod replays; +mod rodio_ext; +pub use audio_settings::AudioSettings; +pub use rodio_ext::RodioExt; + +use crate::audio_settings::LIVE_SETTINGS; -pub fn init(source: impl AssetSource, cx: &mut App) { - SoundRegistry::set_global(source, cx); - cx.set_global(GlobalAudio(Audio::new())); +// We are migrating to 16kHz sample rate from 48kHz. In the future +// once we are reasonably sure most users have upgraded we will +// remove the LEGACY parameters. +// +// We migrate to 16kHz because it is sufficient for speech and required +// by the denoiser and future Speech to Text layers. +pub const SAMPLE_RATE: NonZero = nz!(16000); +pub const CHANNEL_COUNT: NonZero = nz!(1); +pub const BUFFER_SIZE: usize = // echo canceller and livekit want 10ms of audio + (SAMPLE_RATE.get() as usize / 100) * CHANNEL_COUNT.get() as usize; + +pub const LEGACY_SAMPLE_RATE: NonZero = nz!(48000); +pub const LEGACY_CHANNEL_COUNT: NonZero = nz!(2); + +pub const REPLAY_DURATION: Duration = Duration::from_secs(30); + +pub fn init(cx: &mut App) { + AudioSettings::register(cx); + LIVE_SETTINGS.initialize(cx); } +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] pub enum Sound { Joined, + GuestJoined, Leave, Mute, Unmute, @@ -25,6 +68,7 @@ impl Sound { fn file(&self) -> &'static str { match self { Self::Joined => "joined_call", + Self::GuestJoined => "guest_joined_call", Self::Leave => "leave_call", Self::Mute => "mute", Self::Unmute => "unmute", @@ -35,49 +79,242 @@ impl Sound { } } -#[derive(Default)] pub struct Audio { output_handle: Option, + output_mixer: Option, + #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] + pub echo_canceller: Arc>, + source_cache: HashMap>>>>, + replays: replays::Replays, } -#[derive(Deref, DerefMut)] -struct GlobalAudio(Audio); +impl Default for Audio { + fn default() -> Self { + Self { + output_handle: Default::default(), + output_mixer: Default::default(), + #[cfg(not(any( + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" + )))] + echo_canceller: Arc::new(Mutex::new(apm::AudioProcessingModule::new( + true, false, false, false, + ))), + source_cache: Default::default(), + replays: Default::default(), + } + } +} -impl Global for GlobalAudio {} +impl Global for Audio {} impl Audio { - pub fn new() -> Self { - Self::default() - } + fn ensure_output_exists(&mut self) -> Result<&Mixer> { + #[cfg(debug_assertions)] + log::warn!( + "Audio does not sound correct without optimizations. Use a release build to debug audio issues" + ); - fn ensure_output_exists(&mut self) -> Option<&OutputStream> { if self.output_handle.is_none() { - self.output_handle = OutputStreamBuilder::open_default_stream().log_err(); + let output_handle = OutputStreamBuilder::open_default_stream() + .context("Could not open default output stream")?; + info!("Output stream: {:?}", output_handle); + self.output_handle = Some(output_handle); + if let Some(output_handle) = &self.output_handle { + let (mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE); + // or the mixer will end immediately as its empty. + mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)); + self.output_mixer = Some(mixer); + + // The webrtc apm is not yet compiling for windows & freebsd + #[cfg(not(any( + any(all(target_os = "windows", target_env = "gnu")), + target_os = "freebsd" + )))] + let echo_canceller = Arc::clone(&self.echo_canceller); + #[cfg(not(any( + any(all(target_os = "windows", target_env = "gnu")), + target_os = "freebsd" + )))] + let source = source.inspect_buffer::(move |buffer| { + let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); + echo_canceller + .lock() + .process_reverse_stream( + &mut buf, + SAMPLE_RATE.get() as i32, + CHANNEL_COUNT.get().into(), + ) + .expect("Audio input and output threads should not panic"); + }); + output_handle.mixer().add(source); + } } - self.output_handle.as_ref() + Ok(self + .output_mixer + .as_ref() + .expect("we only get here if opening the outputstream succeeded")) + } + + pub fn save_replays( + &self, + executor: BackgroundExecutor, + ) -> gpui::Task> { + self.replays.replays_to_tar(executor) + } + + #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] + pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result { + let stream = rodio::microphone::MicrophoneBuilder::new() + .default_device()? + .default_config()? + .prefer_sample_rates([ + SAMPLE_RATE, // sample rates trivially resamplable to `SAMPLE_RATE` + SAMPLE_RATE.saturating_mul(nz!(2)), + SAMPLE_RATE.saturating_mul(nz!(3)), + SAMPLE_RATE.saturating_mul(nz!(4)), + ]) + .prefer_channel_counts([nz!(1), nz!(2), nz!(3), nz!(4)]) + .prefer_buffer_sizes(512..) + .open_stream()?; + info!("Opened microphone: {:?}", stream.config()); + + let stream = stream + .possibly_disconnected_channels_to_mono() + .constant_samplerate(SAMPLE_RATE) + .limit(LimitSettings::live_performance()) + .process_buffer::(move |buffer| { + let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); + if voip_parts + .echo_canceller + .lock() + .process_stream( + &mut int_buffer, + SAMPLE_RATE.get() as i32, + CHANNEL_COUNT.get() as i32, + ) + .context("livekit audio processor error") + .log_err() + .is_some() + { + for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { + *sample = (*processed).to_sample(); + } + } + }) + .denoise() + .context("Could not set up denoiser")? + .automatic_gain_control(0.90, 1.0, 0.0, 5.0) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source + .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); + let denoise = agc_source.inner_mut(); + denoise.set_enabled(LIVE_SETTINGS.denoise.load(Ordering::Relaxed)); + }); + + let stream = if voip_parts.legacy_audio_compatible { + stream.constant_params(LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) + } else { + stream.constant_params(CHANNEL_COUNT, SAMPLE_RATE) + }; + + let (replay, stream) = stream.replayable(REPLAY_DURATION)?; + voip_parts + .replays + .add_voip_stream("local microphone".to_string(), replay); + + Ok(stream) + } + + pub fn play_voip_stream( + source: impl rodio::Source + Send + 'static, + speaker_name: String, + is_staff: bool, + cx: &mut App, + ) -> anyhow::Result<()> { + let (replay_source, source) = source + .constant_params(CHANNEL_COUNT, SAMPLE_RATE) + .automatic_gain_control(0.90, 1.0, 0.0, 5.0) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); + }) + .replayable(REPLAY_DURATION) + .expect("REPLAY_DURATION is longer than 100ms"); + + cx.update_default_global(|this: &mut Self, _cx| { + let output_mixer = this + .ensure_output_exists() + .context("Could not get output mixer")?; + output_mixer.add(source); + if is_staff { + this.replays.add_voip_stream(speaker_name, replay_source); + } + Ok(()) + }) } pub fn play_sound(sound: Sound, cx: &mut App) { - if !cx.has_global::() { - return; - } + cx.update_default_global(|this: &mut Self, cx| { + let source = this.sound_source(sound, cx).log_err()?; + let output_mixer = this + .ensure_output_exists() + .context("Could not get output mixer") + .log_err()?; - cx.update_global::(|this, cx| { - let output_handle = this.ensure_output_exists()?; - let source = SoundRegistry::global(cx).get(sound.file()).log_err()?; - output_handle.mixer().add(source); + output_mixer.add(source); Some(()) }); } pub fn end_call(cx: &mut App) { - if !cx.has_global::() { - return; - } - - cx.update_global::(|this, _| { + cx.update_default_global(|this: &mut Self, _cx| { this.output_handle.take(); }); } + + fn sound_source(&mut self, sound: Sound, cx: &App) -> Result> { + if let Some(wav) = self.source_cache.get(&sound) { + return Ok(wav.clone()); + } + + let path = format!("sounds/{}.wav", sound.file()); + let bytes = cx + .asset_source() + .load(&path)? + .map(anyhow::Ok) + .with_context(|| format!("No asset available for path {path}"))?? + .into_owned(); + let cursor = Cursor::new(bytes); + let source = Decoder::new(cursor)?.buffered(); + + self.source_cache.insert(sound, source.clone()); + + Ok(source) + } +} + +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +pub struct VoipParts { + echo_canceller: Arc>, + replays: replays::Replays, + legacy_audio_compatible: bool, +} + +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +impl VoipParts { + pub fn new(cx: &AsyncApp) -> anyhow::Result { + let (apm, replays) = cx.try_read_default_global::(|audio, _| { + (Arc::clone(&audio.echo_canceller), audio.replays.clone()) + })?; + let legacy_audio_compatible = + AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible) + .unwrap_or(true); + + Ok(Self { + legacy_audio_compatible, + echo_canceller: apm, + replays, + }) + } } diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..61a993c3358e5e2bf39b626a0764833508bee742 --- /dev/null +++ b/crates/audio/src/audio_settings.rs @@ -0,0 +1,119 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use gpui::App; +use settings::{Settings, SettingsStore}; + +#[derive(Clone, Debug)] +pub struct AudioSettings { + /// Opt into the new audio system. + /// + /// You need to rejoin a call for this setting to apply + pub rodio_audio: bool, // default is false + /// Requires 'rodio_audio: true' + /// + /// Automatically increase or decrease you microphone's volume. This affects how + /// loud you sound to others. + /// + /// Recommended: off (default) + /// Microphones are too quite in zed, until everyone is on experimental + /// audio and has auto speaker volume on this will make you very loud + /// compared to other speakers. + pub auto_microphone_volume: bool, + /// Requires 'rodio_audio: true' + /// + /// Automatically increate or decrease the volume of other call members. + /// This only affects how things sound for you. + pub auto_speaker_volume: bool, + /// Requires 'rodio_audio: true' + /// + /// Remove background noises. Works great for typing, cars, dogs, AC. Does + /// not work well on music. + pub denoise: bool, + /// Requires 'rodio_audio: true' + /// + /// Use audio parameters compatible with the previous versions of + /// experimental audio and non-experimental audio. When this is false you + /// will sound strange to anyone not on the latest experimental audio. In + /// the future we will migrate by setting this to false + /// + /// You need to rejoin a call for this setting to apply + pub legacy_audio_compatible: bool, +} + +/// Configuration of audio in Zed +impl Settings for AudioSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let audio = &content.audio.as_ref().unwrap(); + AudioSettings { + rodio_audio: audio.rodio_audio.unwrap(), + auto_microphone_volume: audio.auto_microphone_volume.unwrap(), + auto_speaker_volume: audio.auto_speaker_volume.unwrap(), + denoise: audio.denoise.unwrap(), + legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(), + } + } +} + +/// See docs on [LIVE_SETTINGS] +pub(crate) struct LiveSettings { + pub(crate) auto_microphone_volume: AtomicBool, + pub(crate) auto_speaker_volume: AtomicBool, + pub(crate) denoise: AtomicBool, +} + +impl LiveSettings { + pub(crate) fn initialize(&self, cx: &mut App) { + cx.observe_global::(move |cx| { + LIVE_SETTINGS.auto_microphone_volume.store( + AudioSettings::get_global(cx).auto_microphone_volume, + Ordering::Relaxed, + ); + LIVE_SETTINGS.auto_speaker_volume.store( + AudioSettings::get_global(cx).auto_speaker_volume, + Ordering::Relaxed, + ); + + let denoise_enabled = AudioSettings::get_global(cx).denoise; + #[cfg(debug_assertions)] + { + static DENOISE_WARNING_SEND: AtomicBool = AtomicBool::new(false); + if denoise_enabled && !DENOISE_WARNING_SEND.load(Ordering::Relaxed) { + DENOISE_WARNING_SEND.store(true, Ordering::Relaxed); + log::warn!("Denoise does not work on debug builds, not enabling") + } + } + #[cfg(not(debug_assertions))] + LIVE_SETTINGS + .denoise + .store(denoise_enabled, Ordering::Relaxed); + }) + .detach(); + + let init_settings = AudioSettings::get_global(cx); + LIVE_SETTINGS + .auto_microphone_volume + .store(init_settings.auto_microphone_volume, Ordering::Relaxed); + LIVE_SETTINGS + .auto_speaker_volume + .store(init_settings.auto_speaker_volume, Ordering::Relaxed); + let denoise_enabled = AudioSettings::get_global(cx).denoise; + #[cfg(debug_assertions)] + if denoise_enabled { + log::warn!("Denoise does not work on debug builds, not enabling") + } + #[cfg(not(debug_assertions))] + LIVE_SETTINGS + .denoise + .store(denoise_enabled, Ordering::Relaxed); + } +} + +/// Allows access to settings from the audio thread. Updated by +/// observer of SettingsStore. Needed because audio playback and recording are +/// real time and must each run in a dedicated OS thread, therefore we can not +/// use the background executor. +pub(crate) static LIVE_SETTINGS: LiveSettings = LiveSettings { + auto_microphone_volume: AtomicBool::new(true), + auto_speaker_volume: AtomicBool::new(true), + denoise: AtomicBool::new(true), +}; diff --git a/crates/audio/src/replays.rs b/crates/audio/src/replays.rs new file mode 100644 index 0000000000000000000000000000000000000000..bb21df51e5642bf633d068d544690cb26a239151 --- /dev/null +++ b/crates/audio/src/replays.rs @@ -0,0 +1,77 @@ +use anyhow::{Context, anyhow}; +use async_tar::{Builder, Header}; +use gpui::{BackgroundExecutor, Task}; + +use collections::HashMap; +use parking_lot::Mutex; +use rodio::Source; +use smol::fs::File; +use std::{io, path::PathBuf, sync::Arc, time::Duration}; + +use crate::{REPLAY_DURATION, rodio_ext::Replay}; + +#[derive(Default, Clone)] +pub(crate) struct Replays(Arc>>); + +impl Replays { + pub(crate) fn add_voip_stream(&self, stream_name: String, source: Replay) { + let mut map = self.0.lock(); + map.retain(|_, replay| replay.source_is_active()); + map.insert(stream_name, source); + } + + pub(crate) fn replays_to_tar( + &self, + executor: BackgroundExecutor, + ) -> Task> { + let map = Arc::clone(&self.0); + executor.spawn(async move { + let recordings: Vec<_> = map + .lock() + .iter_mut() + .map(|(name, replay)| { + let queued = REPLAY_DURATION.min(replay.duration_ready()); + (name.clone(), replay.take_duration(queued).record()) + }) + .collect(); + let longest = recordings + .iter() + .map(|(_, r)| { + r.total_duration() + .expect("SamplesBuffer always returns a total duration") + }) + .max() + .ok_or(anyhow!("There is no audio to capture"))?; + + let path = std::env::current_dir() + .context("Could not get current dir")? + .join("replays.tar"); + let tar = File::create(&path) + .await + .context("Could not create file for tar")?; + + let mut tar = Builder::new(tar); + + for (name, recording) in recordings { + let mut writer = io::Cursor::new(Vec::new()); + rodio::wav_to_writer(recording, &mut writer).context("failed to encode wav")?; + let wav_data = writer.into_inner(); + let path = name.replace(' ', "_") + ".wav"; + let mut header = Header::new_gnu(); + // rw permissions for everyone + header.set_mode(0o666); + header.set_size(wav_data.len() as u64); + tar.append_data(&mut header, path, wav_data.as_slice()) + .await + .context("failed to apped wav to tar")?; + } + tar.into_inner() + .await + .context("Could not finish writing tar")? + .sync_all() + .await + .context("Could not flush tar file to disk")?; + Ok((path, longest)) + }) + } +} diff --git a/crates/audio/src/rodio_ext.rs b/crates/audio/src/rodio_ext.rs new file mode 100644 index 0000000000000000000000000000000000000000..ab74c59fe6661cecab7ec9611dd0b9aa9e7f5aa7 --- /dev/null +++ b/crates/audio/src/rodio_ext.rs @@ -0,0 +1,763 @@ +use std::{ + num::NonZero, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use crossbeam::queue::ArrayQueue; +use denoise::{Denoiser, DenoiserError}; +use log::warn; +use rodio::{ + ChannelCount, Sample, SampleRate, Source, conversions::SampleRateConverter, nz, + source::UniformSourceIterator, +}; + +const MAX_CHANNELS: usize = 8; + +#[derive(Debug, thiserror::Error)] +#[error("Replay duration is too short must be >= 100ms")] +pub struct ReplayDurationTooShort; + +// These all require constant sources (so the span is infinitely long) +// this is not guaranteed by rodio however we know it to be true in all our +// applications. Rodio desperately needs a constant source concept. +pub trait RodioExt: Source + Sized { + fn process_buffer(self, callback: F) -> ProcessBuffer + where + F: FnMut(&mut [Sample; N]); + fn inspect_buffer(self, callback: F) -> InspectBuffer + where + F: FnMut(&[Sample; N]); + fn replayable( + self, + duration: Duration, + ) -> Result<(Replay, Replayable), ReplayDurationTooShort>; + fn take_samples(self, n: usize) -> TakeSamples; + fn denoise(self) -> Result, DenoiserError>; + fn constant_params( + self, + channel_count: ChannelCount, + sample_rate: SampleRate, + ) -> UniformSourceIterator; + fn constant_samplerate(self, sample_rate: SampleRate) -> ConstantSampleRate; + fn possibly_disconnected_channels_to_mono(self) -> ToMono; +} + +impl RodioExt for S { + fn process_buffer(self, callback: F) -> ProcessBuffer + where + F: FnMut(&mut [Sample; N]), + { + ProcessBuffer { + inner: self, + callback, + buffer: [0.0; N], + next: N, + } + } + fn inspect_buffer(self, callback: F) -> InspectBuffer + where + F: FnMut(&[Sample; N]), + { + InspectBuffer { + inner: self, + callback, + buffer: [0.0; N], + free: 0, + } + } + /// Maintains a live replay with a history of at least `duration` seconds. + /// + /// Note: + /// History can be 100ms longer if the source drops before or while the + /// replay is being read + /// + /// # Errors + /// If duration is smaller than 100ms + fn replayable( + self, + duration: Duration, + ) -> Result<(Replay, Replayable), ReplayDurationTooShort> { + if duration < Duration::from_millis(100) { + return Err(ReplayDurationTooShort); + } + + let samples_per_second = self.sample_rate().get() as usize * self.channels().get() as usize; + let samples_to_queue = duration.as_secs_f64() * samples_per_second as f64; + let samples_to_queue = + (samples_to_queue as usize).next_multiple_of(self.channels().get().into()); + + let chunk_size = + (samples_per_second.div_ceil(10)).next_multiple_of(self.channels().get() as usize); + let chunks_to_queue = samples_to_queue.div_ceil(chunk_size); + + let is_active = Arc::new(AtomicBool::new(true)); + let queue = Arc::new(ReplayQueue::new(chunks_to_queue, chunk_size)); + Ok(( + Replay { + rx: Arc::clone(&queue), + buffer: Vec::new().into_iter(), + sleep_duration: duration / 2, + sample_rate: self.sample_rate(), + channel_count: self.channels(), + source_is_active: is_active.clone(), + }, + Replayable { + tx: queue, + inner: self, + buffer: Vec::with_capacity(chunk_size), + chunk_size, + is_active, + }, + )) + } + fn take_samples(self, n: usize) -> TakeSamples { + TakeSamples { + inner: self, + left_to_take: n, + } + } + fn denoise(self) -> Result, DenoiserError> { + let res = Denoiser::try_new(self); + res + } + fn constant_params( + self, + channel_count: ChannelCount, + sample_rate: SampleRate, + ) -> UniformSourceIterator { + UniformSourceIterator::new(self, channel_count, sample_rate) + } + fn constant_samplerate(self, sample_rate: SampleRate) -> ConstantSampleRate { + ConstantSampleRate::new(self, sample_rate) + } + fn possibly_disconnected_channels_to_mono(self) -> ToMono { + ToMono::new(self) + } +} + +pub struct ConstantSampleRate { + inner: SampleRateConverter, + channels: ChannelCount, + sample_rate: SampleRate, +} + +impl ConstantSampleRate { + fn new(source: S, target_rate: SampleRate) -> Self { + let input_sample_rate = source.sample_rate(); + let channels = source.channels(); + let inner = SampleRateConverter::new(source, input_sample_rate, target_rate, channels); + Self { + inner, + channels, + sample_rate: target_rate, + } + } +} + +impl Iterator for ConstantSampleRate { + type Item = rodio::Sample; + + fn next(&mut self) -> Option { + self.inner.next() + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for ConstantSampleRate { + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> ChannelCount { + self.channels + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + None // not supported (not used by us) + } +} + +const TYPICAL_NOISE_FLOOR: Sample = 1e-3; + +/// constant source, only works on a single span +pub struct ToMono { + inner: S, + input_channel_count: ChannelCount, + connected_channels: ChannelCount, + /// running mean of second channel 'volume' + means: [f32; MAX_CHANNELS], +} +impl ToMono { + fn new(input: S) -> Self { + let channels = input + .channels() + .min(const { NonZero::::new(MAX_CHANNELS as u16).unwrap() }); + if channels < input.channels() { + warn!("Ignoring input channels {}..", channels.get()); + } + + Self { + connected_channels: channels, + input_channel_count: channels, + inner: input, + means: [TYPICAL_NOISE_FLOOR; MAX_CHANNELS], + } + } +} + +impl Source for ToMono { + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> ChannelCount { + rodio::nz!(1) + } + + fn sample_rate(&self) -> SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +fn update_mean(mean: &mut f32, sample: Sample) { + const HISTORY: f32 = 500.0; + *mean *= (HISTORY - 1.0) / HISTORY; + *mean += sample.abs() / HISTORY; +} + +impl Iterator for ToMono { + type Item = Sample; + + fn next(&mut self) -> Option { + let mut mono_sample = 0f32; + let mut active_channels = 0; + for channel in 0..self.input_channel_count.get() as usize { + let sample = self.inner.next()?; + mono_sample += sample; + + update_mean(&mut self.means[channel], sample); + if self.means[channel] > TYPICAL_NOISE_FLOOR / 10.0 { + active_channels += 1; + } + } + mono_sample /= self.connected_channels.get() as f32; + self.connected_channels = NonZero::new(active_channels).unwrap_or(nz!(1)); + + Some(mono_sample) + } +} + +/// constant source, only works on a single span +pub struct TakeSamples { + inner: S, + left_to_take: usize, +} + +impl Iterator for TakeSamples { + type Item = Sample; + + fn next(&mut self) -> Option { + if self.left_to_take == 0 { + None + } else { + self.left_to_take -= 1; + self.inner.next() + } + } + + fn size_hint(&self) -> (usize, Option) { + (0, Some(self.left_to_take)) + } +} + +impl Source for TakeSamples { + fn current_span_len(&self) -> Option { + None // does not support spans + } + + fn channels(&self) -> ChannelCount { + self.inner.channels() + } + + fn sample_rate(&self) -> SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + Some(Duration::from_secs_f64( + self.left_to_take as f64 + / self.sample_rate().get() as f64 + / self.channels().get() as f64, + )) + } +} + +/// constant source, only works on a single span +#[derive(Debug)] +struct ReplayQueue { + inner: ArrayQueue>, + normal_chunk_len: usize, + /// The last chunk in the queue may be smaller than + /// the normal chunk size. This is always equal to the + /// size of the last element in the queue. + /// (so normally chunk_size) + last_chunk: Mutex>, +} + +impl ReplayQueue { + fn new(queue_len: usize, chunk_size: usize) -> Self { + Self { + inner: ArrayQueue::new(queue_len), + normal_chunk_len: chunk_size, + last_chunk: Mutex::new(Vec::new()), + } + } + /// Returns the length in samples + fn len(&self) -> usize { + self.inner.len().saturating_sub(1) * self.normal_chunk_len + + self + .last_chunk + .lock() + .expect("Self::push_last can not poison this lock") + .len() + } + + fn pop(&self) -> Option> { + self.inner.pop() // removes element that was inserted first + } + + fn push_last(&self, mut samples: Vec) { + let mut last_chunk = self + .last_chunk + .lock() + .expect("Self::len can not poison this lock"); + std::mem::swap(&mut *last_chunk, &mut samples); + } + + fn push_normal(&self, samples: Vec) { + let _pushed_out_of_ringbuf = self.inner.force_push(samples); + } +} + +/// constant source, only works on a single span +pub struct ProcessBuffer +where + S: Source + Sized, + F: FnMut(&mut [Sample; N]), +{ + inner: S, + callback: F, + /// Buffer used for both input and output. + buffer: [Sample; N], + /// Next already processed sample is at this index + /// in buffer. + /// + /// If this is equal to the length of the buffer we have no more samples and + /// we must get new ones and process them + next: usize, +} + +impl Iterator for ProcessBuffer +where + S: Source + Sized, + F: FnMut(&mut [Sample; N]), +{ + type Item = Sample; + + fn next(&mut self) -> Option { + self.next += 1; + if self.next < self.buffer.len() { + let sample = self.buffer[self.next]; + return Some(sample); + } + + for sample in &mut self.buffer { + *sample = self.inner.next()? + } + (self.callback)(&mut self.buffer); + + self.next = 0; + Some(self.buffer[0]) + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for ProcessBuffer +where + S: Source + Sized, + F: FnMut(&mut [Sample; N]), +{ + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> rodio::ChannelCount { + self.inner.channels() + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +/// constant source, only works on a single span +pub struct InspectBuffer +where + S: Source + Sized, + F: FnMut(&[Sample; N]), +{ + inner: S, + callback: F, + /// Stores already emitted samples, once its full we call the callback. + buffer: [Sample; N], + /// Next free element in buffer. If this is equal to the buffer length + /// we have no more free elements. + free: usize, +} + +impl Iterator for InspectBuffer +where + S: Source + Sized, + F: FnMut(&[Sample; N]), +{ + type Item = Sample; + + fn next(&mut self) -> Option { + let Some(sample) = self.inner.next() else { + return None; + }; + + self.buffer[self.free] = sample; + self.free += 1; + + if self.free == self.buffer.len() { + (self.callback)(&self.buffer); + self.free = 0 + } + + Some(sample) + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for InspectBuffer +where + S: Source + Sized, + F: FnMut(&[Sample; N]), +{ + fn current_span_len(&self) -> Option { + None + } + + fn channels(&self) -> rodio::ChannelCount { + self.inner.channels() + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +/// constant source, only works on a single span +#[derive(Debug)] +pub struct Replayable { + inner: S, + buffer: Vec, + chunk_size: usize, + tx: Arc, + is_active: Arc, +} + +impl Iterator for Replayable { + type Item = Sample; + + fn next(&mut self) -> Option { + if let Some(sample) = self.inner.next() { + self.buffer.push(sample); + // If the buffer is full send it + if self.buffer.len() == self.chunk_size { + self.tx.push_normal(std::mem::take(&mut self.buffer)); + } + Some(sample) + } else { + let last_chunk = std::mem::take(&mut self.buffer); + self.tx.push_last(last_chunk); + self.is_active.store(false, Ordering::Relaxed); + None + } + } + + fn size_hint(&self) -> (usize, Option) { + self.inner.size_hint() + } +} + +impl Source for Replayable { + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + + fn channels(&self) -> ChannelCount { + self.inner.channels() + } + + fn sample_rate(&self) -> SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +/// constant source, only works on a single span +#[derive(Debug)] +pub struct Replay { + rx: Arc, + buffer: std::vec::IntoIter, + sleep_duration: Duration, + sample_rate: SampleRate, + channel_count: ChannelCount, + source_is_active: Arc, +} + +impl Replay { + pub fn source_is_active(&self) -> bool { + // - source could return None and not drop + // - source could be dropped before returning None + self.source_is_active.load(Ordering::Relaxed) && Arc::strong_count(&self.rx) < 2 + } + + /// Duration of what is in the buffer and can be returned without blocking. + pub fn duration_ready(&self) -> Duration { + let samples_per_second = self.channels().get() as u32 * self.sample_rate().get(); + + let seconds_queued = self.samples_ready() as f64 / samples_per_second as f64; + Duration::from_secs_f64(seconds_queued) + } + + /// Number of samples in the buffer and can be returned without blocking. + pub fn samples_ready(&self) -> usize { + self.rx.len() + self.buffer.len() + } +} + +impl Iterator for Replay { + type Item = Sample; + + fn next(&mut self) -> Option { + if let Some(sample) = self.buffer.next() { + return Some(sample); + } + + loop { + if let Some(new_buffer) = self.rx.pop() { + self.buffer = new_buffer.into_iter(); + return self.buffer.next(); + } + + if !self.source_is_active() { + return None; + } + + // The queue does not support blocking on a next item. We want this queue as it + // is quite fast and provides a fixed size. We know how many samples are in a + // buffer so if we do not get one now we must be getting one after `sleep_duration`. + std::thread::sleep(self.sleep_duration); + } + } + + fn size_hint(&self) -> (usize, Option) { + ((self.rx.len() + self.buffer.len()), None) + } +} + +impl Source for Replay { + fn current_span_len(&self) -> Option { + None // source is not compatible with spans + } + + fn channels(&self) -> ChannelCount { + self.channel_count + } + + fn sample_rate(&self) -> SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use rodio::{nz, static_buffer::StaticSamplesBuffer}; + + use super::*; + + const SAMPLES: [Sample; 5] = [0.0, 1.0, 2.0, 3.0, 4.0]; + + fn test_source() -> StaticSamplesBuffer { + StaticSamplesBuffer::new(nz!(1), nz!(1), &SAMPLES) + } + + mod process_buffer { + use super::*; + + #[test] + fn callback_gets_all_samples() { + let input = test_source(); + + let _ = input + .process_buffer::<{ SAMPLES.len() }, _>(|buffer| assert_eq!(*buffer, SAMPLES)) + .count(); + } + #[test] + fn callback_modifies_yielded() { + let input = test_source(); + + let yielded: Vec<_> = input + .process_buffer::<{ SAMPLES.len() }, _>(|buffer| { + for sample in buffer { + *sample += 1.0; + } + }) + .collect(); + assert_eq!( + yielded, + SAMPLES.into_iter().map(|s| s + 1.0).collect::>() + ) + } + #[test] + fn source_truncates_to_whole_buffers() { + let input = test_source(); + + let yielded = input + .process_buffer::<3, _>(|buffer| assert_eq!(buffer, &SAMPLES[..3])) + .count(); + assert_eq!(yielded, 3) + } + } + + mod inspect_buffer { + use super::*; + + #[test] + fn callback_gets_all_samples() { + let input = test_source(); + + let _ = input + .inspect_buffer::<{ SAMPLES.len() }, _>(|buffer| assert_eq!(*buffer, SAMPLES)) + .count(); + } + #[test] + fn source_does_not_truncate() { + let input = test_source(); + + let yielded = input + .inspect_buffer::<3, _>(|buffer| assert_eq!(buffer, &SAMPLES[..3])) + .count(); + assert_eq!(yielded, SAMPLES.len()) + } + } + + mod instant_replay { + use super::*; + + #[test] + fn continues_after_history() { + let input = test_source(); + + let (mut replay, mut source) = input + .replayable(Duration::from_secs(3)) + .expect("longer than 100ms"); + + source.by_ref().take(3).count(); + let yielded: Vec = replay.by_ref().take(3).collect(); + assert_eq!(&yielded, &SAMPLES[0..3],); + + source.count(); + let yielded: Vec = replay.collect(); + assert_eq!(&yielded, &SAMPLES[3..5],); + } + + #[test] + fn keeps_only_latest() { + let input = test_source(); + + let (mut replay, mut source) = input + .replayable(Duration::from_secs(2)) + .expect("longer than 100ms"); + + source.by_ref().take(5).count(); // get all items but do not end the source + let yielded: Vec = replay.by_ref().take(2).collect(); + assert_eq!(&yielded, &SAMPLES[3..5]); + source.count(); // exhaust source + assert_eq!(replay.next(), None); + } + + #[test] + fn keeps_correct_amount_of_seconds() { + let input = StaticSamplesBuffer::new(nz!(1), nz!(16_000), &[0.0; 40_000]); + + let (replay, mut source) = input + .replayable(Duration::from_secs(2)) + .expect("longer than 100ms"); + + // exhaust but do not yet end source + source.by_ref().take(40_000).count(); + + // take all samples we can without blocking + let ready = replay.samples_ready(); + let n_yielded = replay.take_samples(ready).count(); + + let max = source.sample_rate().get() * source.channels().get() as u32 * 2; + let margin = 16_000 / 10; // 100ms + assert!(n_yielded as u32 >= max - margin); + } + + #[test] + fn samples_ready() { + let input = StaticSamplesBuffer::new(nz!(1), nz!(16_000), &[0.0; 40_000]); + let (mut replay, source) = input + .replayable(Duration::from_secs(2)) + .expect("longer than 100ms"); + assert_eq!(replay.by_ref().samples_ready(), 0); + + source.take(8000).count(); // half a second + let margin = 16_000 / 10; // 100ms + let ready = replay.samples_ready(); + assert!(ready >= 8000 - margin); + } + } +} diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 1a772710c98f8437932d6e8918df65d003d7962e..08db9f8a97bb0783da987f84991ad1aaa62c2141 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -21,14 +21,15 @@ http_client.workspace = true log.workspace = true paths.workspace = true release_channel.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true tempfile.workspace = true workspace.workspace = true -workspace-hack.workspace = true [target.'cfg(not(target_os = "windows"))'.dependencies] which.workspace = true + +[dev-dependencies] +gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 4d0d2d59843d4cde885340319d261a4e7315765e..9f93dd27900e4b90de8c6d61d41b3b6c287eaaf0 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -3,16 +3,17 @@ use client::{Client, TelemetrySettings}; use db::RELEASE_CHANNEL; use db::kvp::KEY_VALUE_STORE; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, Global, SemanticVersion, Task, Window, actions, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, SemanticVersion, + Task, Window, actions, }; use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; use paths::remote_servers_dir; use release_channel::{AppCommitSha, ReleaseChannel}; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources, SettingsStore}; +use settings::{Settings, SettingsStore}; use smol::{fs, io::AsyncReadExt}; use smol::{fs::File, process::Command}; +use std::mem; use std::{ env::{ self, @@ -34,7 +35,7 @@ actions!( /// Checks for available updates. Check, /// Dismisses the update error message. - DismissErrorMessage, + DismissMessage, /// Opens the release notes for the current version in a browser. ViewReleaseNotes, ] @@ -55,14 +56,14 @@ pub enum VersionCheckType { Semantic(SemanticVersion), } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone)] pub enum AutoUpdateStatus { Idle, Checking, Downloading { version: VersionCheckType }, Installing { version: VersionCheckType }, Updated { version: VersionCheckType }, - Errored, + Errored { error: Arc }, } impl AutoUpdateStatus { @@ -85,67 +86,49 @@ pub struct JsonRelease { pub url: String, } -struct MacOsUnmounter { +struct MacOsUnmounter<'a> { mount_path: PathBuf, + background_executor: &'a BackgroundExecutor, } -impl Drop for MacOsUnmounter { +impl Drop for MacOsUnmounter<'_> { fn drop(&mut self) { - let unmount_output = std::process::Command::new("hdiutil") - .args(["detach", "-force"]) - .arg(&self.mount_path) - .output(); - - match unmount_output { - Ok(output) if output.status.success() => { - log::info!("Successfully unmounted the disk image"); - } - Ok(output) => { - log::error!( - "Failed to unmount disk image: {:?}", - String::from_utf8_lossy(&output.stderr) - ); - } - Err(error) => { - log::error!("Error while trying to unmount disk image: {:?}", error); - } - } + let mount_path = mem::take(&mut self.mount_path); + self.background_executor + .spawn(async move { + let unmount_output = Command::new("hdiutil") + .args(["detach", "-force"]) + .arg(&mount_path) + .output() + .await; + match unmount_output { + Ok(output) if output.status.success() => { + log::info!("Successfully unmounted the disk image"); + } + Ok(output) => { + log::error!( + "Failed to unmount disk image: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + log::error!("Error while trying to unmount disk image: {:?}", error); + } + } + }) + .detach(); } } +#[derive(Clone, Copy, Debug)] struct AutoUpdateSetting(bool); /// Whether or not to automatically check for updates. /// /// Default: true -#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)] -#[serde(transparent)] -struct AutoUpdateSettingContent(bool); - impl Settings for AutoUpdateSetting { - const KEY: Option<&'static str> = Some("auto_update"); - - type FileContent = Option; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - let auto_update = [ - sources.server, - sources.release_channel, - sources.operating_system, - sources.user, - ] - .into_iter() - .find_map(|value| value.copied().flatten()) - .unwrap_or(sources.default.ok_or_else(Self::missing_default)?); - - Ok(Self(auto_update.0)) - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) { - vscode.enum_setting("update.mode", current, |s| match s { - "none" | "manual" => Some(AutoUpdateSettingContent(false)), - _ => Some(AutoUpdateSettingContent(true)), - }); + fn from_settings(content: &settings::SettingsContent) -> Self { + Self(content.auto_update.unwrap()) } } @@ -327,10 +310,10 @@ impl AutoUpdater { // the app after an update, we use `set_restart_path` to run the auto // update helper instead of the app, so that it can overwrite the app // and then spawn the new binary. - let quit_subscription = Some(cx.on_app_quit(|_, _| async move { - #[cfg(target_os = "windows")] - finalize_auto_update_on_quit(); - })); + #[cfg(target_os = "windows")] + let quit_subscription = Some(cx.on_app_quit(|_, _| finalize_auto_update_on_quit())); + #[cfg(not(target_os = "windows"))] + let quit_subscription = None; cx.on_app_restart(|this, _| { this.quit_subscription.take(); @@ -375,7 +358,9 @@ impl AutoUpdater { } UpdateCheckType::Manual => { log::error!("auto-update failed: error:{:?}", error); - AutoUpdateStatus::Errored + AutoUpdateStatus::Errored { + error: Arc::new(error), + } } }; @@ -394,8 +379,8 @@ impl AutoUpdater { self.status.clone() } - pub fn dismiss_error(&mut self, cx: &mut Context) -> bool { - if self.status == AutoUpdateStatus::Idle { + pub fn dismiss(&mut self, cx: &mut Context) -> bool { + if let AutoUpdateStatus::Idle = self.status { return false; } self.status = AutoUpdateStatus::Idle; @@ -543,7 +528,7 @@ impl AutoUpdater { async fn update(this: Entity, mut cx: AsyncApp) -> Result<()> { let (client, installed_version, previous_status, release_channel) = - this.read_with(&mut cx, |this, cx| { + this.read_with(&cx, |this, cx| { ( this.http_client.clone(), this.current_version, @@ -556,6 +541,7 @@ impl AutoUpdater { this.update(&mut cx, |this, cx| { this.status = AutoUpdateStatus::Checking; + log::info!("Auto Update: checking for updates"); cx.notify(); })?; @@ -663,7 +649,7 @@ impl AutoUpdater { #[cfg(not(target_os = "windows"))] anyhow::ensure!( which::which("rsync").is_ok(), - "Aborting. Could not find rsync which is required for auto-updates." + "Could not auto-update because the required rsync utility was not found." ); Ok(()) } @@ -672,7 +658,7 @@ impl AutoUpdater { let filename = match OS { "macos" => anyhow::Ok("Zed.dmg"), "linux" => Ok("zed.tar.gz"), - "windows" => Ok("zed_editor_installer.exe"), + "windows" => Ok("Zed.exe"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; @@ -918,6 +904,7 @@ async fn install_release_macos( // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits let _unmounter = MacOsUnmounter { mount_path: mount_path.clone(), + background_executor: cx.background_executor(), }; let output = Command::new("rsync") @@ -955,11 +942,12 @@ async fn install_release_windows(downloaded_installer: PathBuf) -> Result
) -> Div { - div() - .w_6() - .bg(cx.theme().colors().element_background) - .hover(|style| style.bg(cx.theme().colors().element_hover).rounded_sm()) - .child(child) - } - - fn render_popover_buttons( - &self, - message_id: Option, - can_delete_message: bool, - can_edit_message: bool, - cx: &mut Context, - ) -> Div { - h_flex() - .absolute() - .right_2() - .overflow_hidden() - .rounded_sm() - .border_color(cx.theme().colors().element_selected) - .border_1() - .when(!self.has_open_menu(message_id), |el| { - el.visible_on_hover("") - }) - .bg(cx.theme().colors().element_background) - .when_some(message_id, |el, message_id| { - el.child( - self.render_popover_button( - cx, - div() - .id("reply") - .child( - IconButton::new(("reply", message_id), IconName::ReplyArrowRight) - .on_click(cx.listener(move |this, _, window, cx| { - this.cancel_edit_message(cx); - - this.message_editor.update(cx, |editor, cx| { - editor.set_reply_to_message_id(message_id); - window.focus(&editor.focus_handle(cx)); - }) - })), - ) - .tooltip(Tooltip::text("Reply")), - ), - ) - }) - .when_some(message_id, |el, message_id| { - el.when(can_edit_message, |el| { - el.child( - self.render_popover_button( - cx, - div() - .id("edit") - .child( - IconButton::new(("edit", message_id), IconName::Pencil) - .on_click(cx.listener(move |this, _, window, cx| { - this.message_editor.update(cx, |editor, cx| { - editor.clear_reply_to_message_id(); - - let message = this - .active_chat() - .and_then(|active_chat| { - active_chat - .read(cx) - .find_loaded_message(message_id) - }) - .cloned(); - - if let Some(message) = message { - let buffer = editor - .editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("message editor must be singleton"); - - buffer.update(cx, |buffer, cx| { - buffer.set_text(message.body.clone(), cx) - }); - - editor.set_edit_message_id(message_id); - editor.focus_handle(cx).focus(window); - } - }) - })), - ) - .tooltip(Tooltip::text("Edit")), - ), - ) - }) - }) - .when_some(message_id, |el, message_id| { - let this = cx.entity(); - - el.child( - self.render_popover_button( - cx, - div() - .child( - PopoverMenu::new(("menu", message_id)) - .trigger(IconButton::new( - ("trigger", message_id), - IconName::Ellipsis, - )) - .menu(move |window, cx| { - Some(Self::render_message_menu( - &this, - message_id, - can_delete_message, - window, - cx, - )) - }), - ) - .id("more") - .tooltip(Tooltip::text("More")), - ), - ) - }) - } - - fn render_message_menu( - this: &Entity, - message_id: u64, - can_delete_message: bool, - window: &mut Window, - cx: &mut App, - ) -> Entity { - let menu = { - ContextMenu::build(window, cx, move |menu, window, _| { - menu.entry( - "Copy message text", - None, - window.handler_for(this, move |this, _, cx| { - if let Some(message) = this.active_chat().and_then(|active_chat| { - active_chat.read(cx).find_loaded_message(message_id) - }) { - let text = message.body.clone(); - cx.write_to_clipboard(ClipboardItem::new_string(text)) - } - }), - ) - .when(can_delete_message, |menu| { - menu.entry( - "Delete message", - None, - window.handler_for(this, move |this, _, cx| { - this.remove_message(message_id, cx) - }), - ) - }) - }) - }; - this.update(cx, |this, cx| { - let subscription = cx.subscribe_in( - &menu, - window, - |this: &mut Self, _, _: &DismissEvent, _, _| { - this.open_context_menu = None; - }, - ); - this.open_context_menu = Some((message_id, subscription)); - }); - menu - } - - fn render_markdown_with_mentions( - language_registry: &Arc, - current_user_id: u64, - message: &channel::ChannelMessage, - local_timezone: UtcOffset, - cx: &App, - ) -> RichText { - let mentions = message - .mentions - .iter() - .map(|(range, user_id)| rich_text::Mention { - range: range.clone(), - is_self_mention: *user_id == current_user_id, - }) - .collect::>(); - - const MESSAGE_EDITED: &str = " (edited)"; - - let mut body = message.body.clone(); - - if message.edited_at.is_some() { - body.push_str(MESSAGE_EDITED); - } - - let mut rich_text = RichText::new(body, &mentions, language_registry); - - if message.edited_at.is_some() { - let range = (rich_text.text.len() - MESSAGE_EDITED.len())..rich_text.text.len(); - rich_text.highlights.push(( - range.clone(), - Highlight::Highlight(HighlightStyle { - color: Some(cx.theme().colors().text_muted), - ..Default::default() - }), - )); - - if let Some(edit_timestamp) = message.edited_at { - let edit_timestamp_text = time_format::format_localized_timestamp( - edit_timestamp, - OffsetDateTime::now_utc(), - local_timezone, - time_format::TimestampFormat::Absolute, - ); - - rich_text.custom_ranges.push(range); - rich_text.set_tooltip_builder_for_custom_ranges(move |_, _, _, cx| { - Some(Tooltip::simple(edit_timestamp_text.clone(), cx)) - }) - } - } - rich_text - } - - fn send(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - if let Some((chat, _)) = self.active_chat.as_ref() { - let message = self - .message_editor - .update(cx, |editor, cx| editor.take_message(window, cx)); - - if let Some(id) = self.message_editor.read(cx).edit_message_id() { - self.message_editor.update(cx, |editor, _| { - editor.clear_edit_message_id(); - }); - - if let Some(task) = chat - .update(cx, |chat, cx| chat.update_message(id, message, cx)) - .log_err() - { - task.detach(); - } - } else if let Some(task) = chat - .update(cx, |chat, cx| chat.send_message(message, cx)) - .log_err() - { - task.detach(); - } - } - } - - fn remove_message(&mut self, id: u64, cx: &mut Context) { - if let Some((chat, _)) = self.active_chat.as_ref() { - chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach()) - } - } - - fn load_more_messages(&mut self, cx: &mut Context) { - if let Some((chat, _)) = self.active_chat.as_ref() { - chat.update(cx, |channel, cx| { - if let Some(task) = channel.load_more_messages(cx) { - task.detach(); - } - }) - } - } - - pub fn select_channel( - &mut self, - selected_channel_id: ChannelId, - scroll_to_message_id: Option, - cx: &mut Context, - ) -> Task> { - let open_chat = self - .active_chat - .as_ref() - .and_then(|(chat, _)| { - (chat.read(cx).channel_id == selected_channel_id) - .then(|| Task::ready(anyhow::Ok(chat.clone()))) - }) - .unwrap_or_else(|| { - self.channel_store.update(cx, |store, cx| { - store.open_channel_chat(selected_channel_id, cx) - }) - }); - - cx.spawn(async move |this, cx| { - let chat = open_chat.await?; - let highlight_message_id = scroll_to_message_id; - let scroll_to_message_id = this.update(cx, |this, cx| { - this.set_active_chat(chat.clone(), cx); - - scroll_to_message_id.or(this.last_acknowledged_message_id) - })?; - - if let Some(message_id) = scroll_to_message_id { - if let Some(item_ix) = - ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone()) - .await - { - this.update(cx, |this, cx| { - if let Some(highlight_message_id) = highlight_message_id { - let task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - this.update(cx, |this, cx| { - this.highlighted_message.take(); - cx.notify(); - }) - .ok(); - }); - - this.highlighted_message = Some((highlight_message_id, task)); - } - - if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) { - this.message_list.scroll_to(ListOffset { - item_ix, - offset_in_item: px(0.0), - }); - cx.notify(); - } - })?; - } - } - - Ok(()) - }) - } - - fn close_reply_preview(&mut self, cx: &mut Context) { - self.message_editor - .update(cx, |editor, _| editor.clear_reply_to_message_id()); - } - - fn cancel_edit_message(&mut self, cx: &mut Context) { - self.message_editor.update(cx, |editor, cx| { - // only clear the editor input if we were editing a message - if editor.edit_message_id().is_none() { - return; - } - - editor.clear_edit_message_id(); - - let buffer = editor - .editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("message editor must be singleton"); - - buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); - }); - } -} - -impl Render for ChatPanel { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let channel_id = self - .active_chat - .as_ref() - .map(|(c, _)| c.read(cx).channel_id); - let message_editor = self.message_editor.read(cx); - - let reply_to_message_id = message_editor.reply_to_message_id(); - let edit_message_id = message_editor.edit_message_id(); - - v_flex() - .key_context("ChatPanel") - .track_focus(&self.focus_handle) - .size_full() - .on_action(cx.listener(Self::send)) - .child( - h_flex().child( - TabBar::new("chat_header").child( - h_flex() - .w_full() - .h(Tab::container_height(cx)) - .px_2() - .child(Label::new( - self.active_chat - .as_ref() - .and_then(|c| { - Some(format!("#{}", c.0.read(cx).channel(cx)?.name)) - }) - .unwrap_or("Chat".to_string()), - )), - ), - ), - ) - .child(div().flex_grow().px_2().map(|this| { - if self.active_chat.is_some() { - this.child( - list( - self.message_list.clone(), - cx.processor(Self::render_message), - ) - .size_full(), - ) - } else { - this.child( - div() - .size_full() - .p_4() - .child( - Label::new("Select a channel to chat in.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - div().pt_1().w_full().items_center().child( - Button::new("toggle-collab", "Open") - .full_width() - .key_binding(KeyBinding::for_action( - &collab_panel::ToggleFocus, - window, - cx, - )) - .on_click(|_, window, cx| { - window.dispatch_action( - collab_panel::ToggleFocus.boxed_clone(), - cx, - ) - }), - ), - ), - ) - } - })) - .when(!self.is_scrolled_to_bottom, |el| { - el.child(div().border_t_1().border_color(cx.theme().colors().border)) - }) - .when_some(edit_message_id, |el, _| { - el.child( - h_flex() - .px_2() - .text_ui_xs(cx) - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child("Editing message") - .child( - IconButton::new("cancel-edit-message", IconName::Close) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Cancel edit message")) - .on_click(cx.listener(move |this, _, _, cx| { - this.cancel_edit_message(cx); - })), - ), - ) - }) - .when_some(reply_to_message_id, |el, reply_to_message_id| { - let reply_message = self - .active_chat() - .and_then(|active_chat| { - active_chat - .read(cx) - .find_loaded_message(reply_to_message_id) - }) - .cloned(); - - el.when_some(reply_message, |el, reply_message| { - let user_being_replied_to = reply_message.sender.clone(); - - el.child( - h_flex() - .when(!self.is_scrolled_to_bottom, |el| { - el.border_t_1().border_color(cx.theme().colors().border) - }) - .justify_between() - .overflow_hidden() - .items_start() - .py_1() - .px_2() - .bg(cx.theme().colors().background) - .child( - div().flex_shrink().overflow_hidden().child( - h_flex() - .id(("reply-preview", reply_to_message_id)) - .child(Label::new("Replying to ").size(LabelSize::Small)) - .child( - Label::new(format!( - "@{}", - user_being_replied_to.github_login - )) - .size(LabelSize::Small) - .weight(FontWeight::BOLD), - ) - .when_some(channel_id, |this, channel_id| { - this.cursor_pointer().on_click(cx.listener( - move |chat_panel, _, _, cx| { - chat_panel - .select_channel( - channel_id, - reply_to_message_id.into(), - cx, - ) - .detach_and_log_err(cx) - }, - )) - }), - ), - ) - .child( - IconButton::new("close-reply-preview", IconName::Close) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Close reply")) - .on_click(cx.listener(move |this, _, _, cx| { - this.close_reply_preview(cx); - })), - ), - ) - }) - }) - .children( - Some( - h_flex() - .p_2() - .on_action(cx.listener(|this, _: &actions::Cancel, _, cx| { - this.cancel_edit_message(cx); - this.close_reply_preview(cx); - })) - .map(|el| el.child(self.message_editor.clone())), - ) - .filter(|_| self.active_chat.is_some()), - ) - .into_any() - } -} - -impl Focusable for ChatPanel { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - if self.active_chat.is_some() { - self.message_editor.read(cx).focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} - -impl Panel for ChatPanel { - fn position(&self, _: &Window, cx: &App) -> DockPosition { - ChatPanelSettings::get_global(cx).dock - } - - fn position_is_valid(&self, position: DockPosition) -> bool { - matches!(position, DockPosition::Left | DockPosition::Right) - } - - fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| settings.dock = Some(position), - ); - } - - fn size(&self, _: &Window, cx: &App) -> Pixels { - self.width - .unwrap_or_else(|| ChatPanelSettings::get_global(cx).default_width) - } - - fn set_size(&mut self, size: Option, _: &mut Window, cx: &mut Context) { - self.width = size; - self.serialize(cx); - cx.notify(); - } - - fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { - self.active = active; - if active { - self.acknowledge_last_message(cx); - } - } - - fn persistent_name() -> &'static str { - "ChatPanel" - } - - fn icon(&self, _window: &Window, cx: &App) -> Option { - self.enabled(cx).then(|| ui::IconName::Chat) - } - - fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> { - Some("Chat Panel") - } - - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } - - fn starts_open(&self, _: &Window, cx: &App) -> bool { - ActiveCall::global(cx) - .read(cx) - .room() - .is_some_and(|room| room.read(cx).contains_guests()) - } - - fn activation_priority(&self) -> u32 { - 7 - } - - fn enabled(&self, cx: &App) -> bool { - match ChatPanelSettings::get_global(cx).button { - ChatPanelButton::Never => false, - ChatPanelButton::Always => true, - ChatPanelButton::WhenInCall => { - let is_in_call = ActiveCall::global(cx) - .read(cx) - .room() - .map_or(false, |room| room.read(cx).contains_guests()); - - self.active || is_in_call - } - } - } -} - -impl EventEmitter for ChatPanel {} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::HighlightStyle; - use pretty_assertions::assert_eq; - use rich_text::Highlight; - use time::OffsetDateTime; - use util::test::marked_text_ranges; - - #[gpui::test] - fn test_render_markdown_with_mentions(cx: &mut App) { - let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false); - let message = channel::ChannelMessage { - id: ChannelMessageId::Saved(0), - body, - timestamp: OffsetDateTime::now_utc(), - sender: Arc::new(client::User { - github_login: "fgh".into(), - avatar_uri: "avatar_fgh".into(), - id: 103, - name: None, - }), - nonce: 5, - mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)], - reply_to_message_id: None, - edited_at: None, - }; - - let message = ChatPanel::render_markdown_with_mentions( - &language_registry, - 102, - &message, - UtcOffset::UTC, - cx, - ); - - // Note that the "'" was replaced with ’ due to smart punctuation. - let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false); - assert_eq!(message.text, body); - assert_eq!( - message.highlights, - vec![ - ( - ranges[0].clone(), - HighlightStyle { - font_style: Some(gpui::FontStyle::Italic), - ..Default::default() - } - .into() - ), - (ranges[1].clone(), Highlight::Mention), - ( - ranges[2].clone(), - HighlightStyle { - font_weight: Some(gpui::FontWeight::BOLD), - ..Default::default() - } - .into() - ), - (ranges[3].clone(), Highlight::SelfMention) - ] - ); - } - - #[gpui::test] - fn test_render_markdown_with_auto_detect_links(cx: &mut App) { - let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let message = channel::ChannelMessage { - id: ChannelMessageId::Saved(0), - body: "Here is a link https://zed.dev to zeds website".to_string(), - timestamp: OffsetDateTime::now_utc(), - sender: Arc::new(client::User { - github_login: "fgh".into(), - avatar_uri: "avatar_fgh".into(), - id: 103, - name: None, - }), - nonce: 5, - mentions: Vec::new(), - reply_to_message_id: None, - edited_at: None, - }; - - let message = ChatPanel::render_markdown_with_mentions( - &language_registry, - 102, - &message, - UtcOffset::UTC, - cx, - ); - - // Note that the "'" was replaced with ’ due to smart punctuation. - let (body, ranges) = - marked_text_ranges("Here is a link «https://zed.dev» to zeds website", false); - assert_eq!(message.text, body); - assert_eq!(1, ranges.len()); - assert_eq!( - message.highlights, - vec![( - ranges[0].clone(), - HighlightStyle { - underline: Some(gpui::UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - } - .into() - ),] - ); - } - - #[gpui::test] - fn test_render_markdown_with_auto_detect_links_and_additional_formatting(cx: &mut App) { - let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let message = channel::ChannelMessage { - id: ChannelMessageId::Saved(0), - body: "**Here is a link https://zed.dev to zeds website**".to_string(), - timestamp: OffsetDateTime::now_utc(), - sender: Arc::new(client::User { - github_login: "fgh".into(), - avatar_uri: "avatar_fgh".into(), - id: 103, - name: None, - }), - nonce: 5, - mentions: Vec::new(), - reply_to_message_id: None, - edited_at: None, - }; - - let message = ChatPanel::render_markdown_with_mentions( - &language_registry, - 102, - &message, - UtcOffset::UTC, - cx, - ); - - // Note that the "'" was replaced with ’ due to smart punctuation. - let (body, ranges) = marked_text_ranges( - "«Here is a link »«https://zed.dev»« to zeds website»", - false, - ); - assert_eq!(message.text, body); - assert_eq!(3, ranges.len()); - assert_eq!( - message.highlights, - vec![ - ( - ranges[0].clone(), - HighlightStyle { - font_weight: Some(gpui::FontWeight::BOLD), - ..Default::default() - } - .into() - ), - ( - ranges[1].clone(), - HighlightStyle { - font_weight: Some(gpui::FontWeight::BOLD), - underline: Some(gpui::UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - } - .into() - ), - ( - ranges[2].clone(), - HighlightStyle { - font_weight: Some(gpui::FontWeight::BOLD), - ..Default::default() - } - .into() - ), - ] - ); - } -} diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs deleted file mode 100644 index 03d39cb8ced169f59167b1a1f6e91102a268a37d..0000000000000000000000000000000000000000 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ /dev/null @@ -1,548 +0,0 @@ -use anyhow::{Context as _, Result}; -use channel::{ChannelChat, ChannelStore, MessageParams}; -use client::{UserId, UserStore}; -use collections::HashSet; -use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId}; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{ - AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight, - HighlightStyle, IntoElement, Render, Task, TextStyle, WeakEntity, Window, -}; -use language::{ - Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset, - language_settings::SoftWrap, -}; -use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery}; -use settings::Settings; -use std::{ - ops::Range, - rc::Rc, - sync::{Arc, LazyLock}, - time::Duration, -}; -use theme::ThemeSettings; -use ui::{TextSize, prelude::*}; - -use crate::panel_settings::MessageEditorSettings; - -const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); - -static MENTIONS_SEARCH: LazyLock = LazyLock::new(|| { - SearchQuery::regex( - "@[-_\\w]+", - false, - false, - false, - false, - Default::default(), - Default::default(), - false, - None, - ) - .unwrap() -}); - -pub struct MessageEditor { - pub editor: Entity, - user_store: Entity, - channel_chat: Option>, - mentions: Vec, - mentions_task: Option>, - reply_to_message_id: Option, - edit_message_id: Option, -} - -struct MessageEditorCompletionProvider(WeakEntity); - -impl CompletionProvider for MessageEditorCompletionProvider { - fn completions( - &self, - _excerpt_id: ExcerptId, - buffer: &Entity, - buffer_position: language::Anchor, - _: editor::CompletionContext, - _window: &mut Window, - cx: &mut Context, - ) -> Task>> { - let Some(handle) = self.0.upgrade() else { - return Task::ready(Ok(Vec::new())); - }; - handle.update(cx, |message_editor, cx| { - message_editor.completions(buffer, buffer_position, cx) - }) - } - - fn is_completion_trigger( - &self, - _buffer: &Entity, - _position: language::Anchor, - text: &str, - _trigger_in_words: bool, - _menu_is_open: bool, - _cx: &mut Context, - ) -> bool { - text == "@" - } -} - -impl MessageEditor { - pub fn new( - language_registry: Arc, - user_store: Entity, - channel_chat: Option>, - editor: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let this = cx.entity().downgrade(); - editor.update(cx, |editor, cx| { - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_offset_content(false, cx); - editor.set_use_autoclose(false); - editor.set_show_gutter(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_completion_provider(Some(Rc::new(MessageEditorCompletionProvider(this)))); - editor.set_auto_replace_emoji_shortcode( - MessageEditorSettings::get_global(cx) - .auto_replace_emoji_shortcode - .unwrap_or_default(), - ); - }); - - let buffer = editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("message editor must be singleton"); - - cx.subscribe_in(&buffer, window, Self::on_buffer_event) - .detach(); - cx.observe_global::(|this, cx| { - this.editor.update(cx, |editor, cx| { - editor.set_auto_replace_emoji_shortcode( - MessageEditorSettings::get_global(cx) - .auto_replace_emoji_shortcode - .unwrap_or_default(), - ) - }) - }) - .detach(); - - let markdown = language_registry.language_for_name("Markdown"); - cx.spawn_in(window, async move |_, cx| { - let markdown = markdown.await.context("failed to load Markdown language")?; - buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)) - }) - .detach_and_log_err(cx); - - Self { - editor, - user_store, - channel_chat, - mentions: Vec::new(), - mentions_task: None, - reply_to_message_id: None, - edit_message_id: None, - } - } - - pub fn reply_to_message_id(&self) -> Option { - self.reply_to_message_id - } - - pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) { - self.reply_to_message_id = Some(reply_to_message_id); - } - - pub fn clear_reply_to_message_id(&mut self) { - self.reply_to_message_id = None; - } - - pub fn edit_message_id(&self) -> Option { - self.edit_message_id - } - - pub fn set_edit_message_id(&mut self, edit_message_id: u64) { - self.edit_message_id = Some(edit_message_id); - } - - pub fn clear_edit_message_id(&mut self) { - self.edit_message_id = None; - } - - pub fn set_channel_chat(&mut self, chat: Entity, cx: &mut Context) { - let channel_id = chat.read(cx).channel_id; - self.channel_chat = Some(chat); - let channel_name = ChannelStore::global(cx) - .read(cx) - .channel_for_id(channel_id) - .map(|channel| channel.name.clone()); - self.editor.update(cx, |editor, cx| { - if let Some(channel_name) = channel_name { - editor.set_placeholder_text(format!("Message #{channel_name}"), cx); - } else { - editor.set_placeholder_text("Message Channel", cx); - } - }); - } - - pub fn take_message(&mut self, window: &mut Window, cx: &mut Context) -> MessageParams { - self.editor.update(cx, |editor, cx| { - let highlights = editor.text_highlights::(cx); - let text = editor.text(cx); - let snapshot = editor.buffer().read(cx).snapshot(cx); - let mentions = if let Some((_, ranges)) = highlights { - ranges - .iter() - .map(|range| range.to_offset(&snapshot)) - .zip(self.mentions.iter().copied()) - .collect() - } else { - Vec::new() - }; - - editor.clear(window, cx); - self.mentions.clear(); - let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id); - - MessageParams { - text, - mentions, - reply_to_message_id, - } - }) - } - - fn on_buffer_event( - &mut self, - buffer: &Entity, - event: &language::BufferEvent, - window: &mut Window, - cx: &mut Context, - ) { - if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event { - let buffer = buffer.read(cx).snapshot(); - self.mentions_task = Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(MENTIONS_DEBOUNCE_INTERVAL) - .await; - Self::find_mentions(this, buffer, cx).await; - })); - } - } - - fn completions( - &mut self, - buffer: &Entity, - end_anchor: Anchor, - cx: &mut Context, - ) -> Task>> { - if let Some((start_anchor, query, candidates)) = - self.collect_mention_candidates(buffer, end_anchor, cx) - { - if !candidates.is_empty() { - return cx.spawn(async move |_, cx| { - let completion_response = Self::completions_for_candidates( - &cx, - query.as_str(), - &candidates, - start_anchor..end_anchor, - Self::completion_for_mention, - ) - .await; - Ok(vec![completion_response]) - }); - } - } - - if let Some((start_anchor, query, candidates)) = - self.collect_emoji_candidates(buffer, end_anchor, cx) - { - if !candidates.is_empty() { - return cx.spawn(async move |_, cx| { - let completion_response = Self::completions_for_candidates( - &cx, - query.as_str(), - candidates, - start_anchor..end_anchor, - Self::completion_for_emoji, - ) - .await; - Ok(vec![completion_response]) - }); - } - } - - Task::ready(Ok(vec![CompletionResponse { - completions: Vec::new(), - is_incomplete: false, - }])) - } - - async fn completions_for_candidates( - cx: &AsyncApp, - query: &str, - candidates: &[StringMatchCandidate], - range: Range, - completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel), - ) -> CompletionResponse { - const LIMIT: usize = 10; - let matches = fuzzy::match_strings( - candidates, - query, - true, - true, - LIMIT, - &Default::default(), - cx.background_executor().clone(), - ) - .await; - - let completions = matches - .into_iter() - .map(|mat| { - let (new_text, label) = completion_fn(&mat); - Completion { - replace_range: range.clone(), - new_text, - label, - icon_path: None, - confirm: None, - documentation: None, - insert_text_mode: None, - source: CompletionSource::Custom, - } - }) - .collect::>(); - - CompletionResponse { - is_incomplete: completions.len() >= LIMIT, - completions, - } - } - - fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) { - let label = CodeLabel { - filter_range: 1..mat.string.len() + 1, - text: format!("@{}", mat.string), - runs: Vec::new(), - }; - (mat.string.clone(), label) - } - - fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) { - let emoji = emojis::get_by_shortcode(&mat.string).unwrap(); - let label = CodeLabel { - filter_range: 1..mat.string.len() + 1, - text: format!(":{}: {}", mat.string, emoji), - runs: Vec::new(), - }; - (emoji.to_string(), label) - } - - fn collect_mention_candidates( - &mut self, - buffer: &Entity, - end_anchor: Anchor, - cx: &mut Context, - ) -> Option<(Anchor, String, Vec)> { - let end_offset = end_anchor.to_offset(buffer.read(cx)); - - let query = buffer.read_with(cx, |buffer, _| { - let mut query = String::new(); - for ch in buffer.reversed_chars_at(end_offset).take(100) { - if ch == '@' { - return Some(query.chars().rev().collect::()); - } - if ch.is_whitespace() || !ch.is_ascii() { - break; - } - query.push(ch); - } - None - })?; - - let start_offset = end_offset - query.len(); - let start_anchor = buffer.read(cx).anchor_before(start_offset); - - let mut names = HashSet::default(); - if let Some(chat) = self.channel_chat.as_ref() { - let chat = chat.read(cx); - for participant in ChannelStore::global(cx) - .read(cx) - .channel_participants(chat.channel_id) - { - names.insert(participant.github_login.clone()); - } - for message in chat - .messages_in_range(chat.message_count().saturating_sub(100)..chat.message_count()) - { - names.insert(message.sender.github_login.clone()); - } - } - - let candidates = names - .into_iter() - .map(|user| StringMatchCandidate::new(0, &user)) - .collect::>(); - - Some((start_anchor, query, candidates)) - } - - fn collect_emoji_candidates( - &mut self, - buffer: &Entity, - end_anchor: Anchor, - cx: &mut Context, - ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> { - static EMOJI_FUZZY_MATCH_CANDIDATES: LazyLock> = - LazyLock::new(|| { - let emojis = emojis::iter() - .flat_map(|s| s.shortcodes()) - .map(|emoji| StringMatchCandidate::new(0, emoji)) - .collect::>(); - emojis - }); - - let end_offset = end_anchor.to_offset(buffer.read(cx)); - - let query = buffer.read_with(cx, |buffer, _| { - let mut query = String::new(); - for ch in buffer.reversed_chars_at(end_offset).take(100) { - if ch == ':' { - let next_char = buffer - .reversed_chars_at(end_offset - query.len() - 1) - .next(); - // Ensure we are at the start of the message or that the previous character is a whitespace - if next_char.is_none() || next_char.unwrap().is_whitespace() { - return Some(query.chars().rev().collect::()); - } - - // If the previous character is not a whitespace, we are in the middle of a word - // and we only want to complete the shortcode if the word is made up of other emojis - let mut containing_word = String::new(); - for ch in buffer - .reversed_chars_at(end_offset - query.len() - 1) - .take(100) - { - if ch.is_whitespace() { - break; - } - containing_word.push(ch); - } - let containing_word = containing_word.chars().rev().collect::(); - if util::word_consists_of_emojis(containing_word.as_str()) { - return Some(query.chars().rev().collect::()); - } - break; - } - if ch.is_whitespace() || !ch.is_ascii() { - break; - } - query.push(ch); - } - None - })?; - - let start_offset = end_offset - query.len() - 1; - let start_anchor = buffer.read(cx).anchor_before(start_offset); - - Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES)) - } - - async fn find_mentions( - this: WeakEntity, - buffer: BufferSnapshot, - cx: &mut AsyncWindowContext, - ) { - let (buffer, ranges) = cx - .background_spawn(async move { - let ranges = MENTIONS_SEARCH.search(&buffer, None).await; - (buffer, ranges) - }) - .await; - - this.update(cx, |this, cx| { - let mut anchor_ranges = Vec::new(); - let mut mentioned_user_ids = Vec::new(); - let mut text = String::new(); - - this.editor.update(cx, |editor, cx| { - let multi_buffer = editor.buffer().read(cx).snapshot(cx); - for range in ranges { - text.clear(); - text.extend(buffer.text_for_range(range.clone())); - if let Some(username) = text.strip_prefix('@') { - if let Some(user) = this - .user_store - .read(cx) - .cached_user_by_github_login(username) - { - let start = multi_buffer.anchor_after(range.start); - let end = multi_buffer.anchor_after(range.end); - - mentioned_user_ids.push(user.id); - anchor_ranges.push(start..end); - } - } - } - - editor.clear_highlights::(cx); - editor.highlight_text::( - anchor_ranges, - HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }, - cx, - ) - }); - - this.mentions = mentioned_user_ids; - this.mentions_task.take(); - }) - .ok(); - } - - pub(crate) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle { - self.editor.read(cx).focus_handle(cx) - } -} - -impl Render for MessageEditor { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: if self.editor.read(cx).read_only(cx) { - cx.theme().colors().text_disabled - } else { - cx.theme().colors().text - }, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: TextSize::Small.rems(cx).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - ..Default::default() - }; - - div() - .w_full() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .rounded_sm() - .child(EditorElement::new( - &self.editor, - EditorStyle { - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - )) - } -} diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index c2cc6a7ad5cb9813ec618df5ca45f47aa1075305..29eff951d973027a96bb5ac6f8fd28981d8ebc93 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2,7 +2,7 @@ mod channel_modal; mod contact_finder; use self::channel_modal::ChannelModal; -use crate::{CollaborationPanelSettings, channel_view::ChannelView, chat_panel::ChatPanel}; +use crate::{CollaborationPanelSettings, channel_view::ChannelView}; use anyhow::Context as _; use call::ActiveCall; use channel::{Channel, ChannelEvent, ChannelStore}; @@ -24,7 +24,7 @@ use rpc::{ ErrorCode, ErrorExt, proto::{self, ChannelVisibility, PeerId}, }; -use serde_derive::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; use std::{mem, sync::Arc}; @@ -38,7 +38,7 @@ use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt}, + notifications::{DetachAndPromptErr, NotifyResultExt}, }; actions!( @@ -54,6 +54,10 @@ actions!( CollapseSelectedChannel, /// Expands the selected channel in the tree view. ExpandSelectedChannel, + /// Opens the meeting notes for the selected channel in the panel. + /// + /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. + OpenSelectedChannelNotes, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -261,9 +265,6 @@ enum ListEntry { ChannelNotes { channel_id: ChannelId, }, - ChannelChat { - channel_id: ChannelId, - }, ChannelEditor { depth: usize, }, @@ -283,7 +284,7 @@ impl CollabPanel { cx.new(|cx| { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Filter...", cx); + editor.set_placeholder_text("Filter...", window, cx); editor }); @@ -311,10 +312,10 @@ impl CollabPanel { window, |this: &mut Self, _, event, window, cx| { if let editor::EditorEvent::Blurred = event { - if let Some(state) = &this.channel_editing_state { - if state.pending_name().is_some() { - return; - } + if let Some(state) = &this.channel_editing_state + && state.pending_name().is_some() + { + return; } this.take_editing_state(window, cx); this.update_entries(false, cx); @@ -491,11 +492,10 @@ impl CollabPanel { if !self.collapsed_sections.contains(&Section::ActiveCall) { let room = room.read(cx); - if query.is_empty() { - if let Some(channel_id) = room.channel_id() { - self.entries.push(ListEntry::ChannelNotes { channel_id }); - self.entries.push(ListEntry::ChannelChat { channel_id }); - } + if query.is_empty() + && let Some(channel_id) = room.channel_id() + { + self.entries.push(ListEntry::ChannelNotes { channel_id }); } // Populate the active user. @@ -639,10 +639,10 @@ impl CollabPanel { &Default::default(), executor.clone(), )); - if let Some(state) = &self.channel_editing_state { - if matches!(state, ChannelEditingState::Create { location: None, .. }) { - self.entries.push(ListEntry::ChannelEditor { depth: 0 }); - } + if let Some(state) = &self.channel_editing_state + && matches!(state, ChannelEditingState::Create { location: None, .. }) + { + self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } let mut collapse_depth = None; for mat in matches { @@ -664,9 +664,7 @@ impl CollabPanel { let has_children = channel_store .channel_at_index(mat.candidate_id + 1) - .map_or(false, |next_channel| { - next_channel.parent_path.ends_with(&[channel.id]) - }); + .is_some_and(|next_channel| next_channel.parent_path.ends_with(&[channel.id])); match &self.channel_editing_state { Some(ChannelEditingState::Create { @@ -928,7 +926,7 @@ impl CollabPanel { ListItem::new(user.github_login.clone()) .start_slot(Avatar::new(user.avatar_uri.clone())) - .child(Label::new(user.github_login.clone())) + .child(render_participant_name_and_handle(user)) .toggle_state(is_selected) .end_slot(if is_pending { Label::new("Calling").color(Color::Muted).into_any_element() @@ -1091,41 +1089,8 @@ impl CollabPanel { .tooltip(Tooltip::text("Open Channel Notes")) } - fn render_channel_chat( - &self, - channel_id: ChannelId, - is_selected: bool, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let channel_store = self.channel_store.read(cx); - let has_messages_notification = channel_store.has_new_messages(channel_id); - ListItem::new("channel-chat") - .toggle_state(is_selected) - .on_click(cx.listener(move |this, _, window, cx| { - this.join_channel_chat(channel_id, window, cx); - })) - .start_slot( - h_flex() - .relative() - .gap_1() - .child(render_tree_branch(false, false, window, cx)) - .child(IconButton::new(0, IconName::Chat)) - .children(has_messages_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(2.)) - .top(px(4.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) - .child(Label::new("chat")) - .tooltip(Tooltip::text("Open Chat")) - } - fn has_subchannels(&self, ix: usize) -> bool { - self.entries.get(ix).map_or(false, |entry| { + self.entries.get(ix).is_some_and(|entry| { if let ListEntry::Channel { has_children, .. } = entry { *has_children } else { @@ -1299,17 +1264,17 @@ impl CollabPanel { }), ) .entry( - "Open Chat", + "Copy Channel Link", None, - window.handler_for(&this, move |this, window, cx| { - this.join_channel_chat(channel_id, window, cx) + window.handler_for(&this, move |this, _, cx| { + this.copy_channel_link(channel_id, cx) }), ) .entry( - "Copy Channel Link", + "Copy Channel Notes Link", None, window.handler_for(&this, move |this, _, cx| { - this.copy_channel_link(channel_id, cx) + this.copy_channel_notes_link(channel_id, cx) }), ); @@ -1552,98 +1517,90 @@ impl CollabPanel { return; } - if let Some(selection) = self.selection { - if let Some(entry) = self.entries.get(selection) { - match entry { - ListEntry::Header(section) => match section { - Section::ActiveCall => Self::leave_call(window, cx), - Section::Channels => self.new_root_channel(window, cx), - Section::Contacts => self.toggle_contact_finder(window, cx), - Section::ContactRequests - | Section::Online - | Section::Offline - | Section::ChannelInvites => { - self.toggle_section_expanded(*section, cx); - } - }, - ListEntry::Contact { contact, calling } => { - if contact.online && !contact.busy && !calling { - self.call(contact.user.id, window, cx); - } + if let Some(selection) = self.selection + && let Some(entry) = self.entries.get(selection) + { + match entry { + ListEntry::Header(section) => match section { + Section::ActiveCall => Self::leave_call(window, cx), + Section::Channels => self.new_root_channel(window, cx), + Section::Contacts => self.toggle_contact_finder(window, cx), + Section::ContactRequests + | Section::Online + | Section::Offline + | Section::ChannelInvites => { + self.toggle_section_expanded(*section, cx); } - ListEntry::ParticipantProject { - project_id, - host_user_id, - .. - } => { - if let Some(workspace) = self.workspace.upgrade() { - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_in_room_project( - *project_id, - *host_user_id, - app_state, - cx, - ) + }, + ListEntry::Contact { contact, calling } => { + if contact.online && !contact.busy && !calling { + self.call(contact.user.id, window, cx); + } + } + ListEntry::ParticipantProject { + project_id, + host_user_id, + .. + } => { + if let Some(workspace) = self.workspace.upgrade() { + let app_state = workspace.read(cx).app_state().clone(); + workspace::join_in_room_project(*project_id, *host_user_id, app_state, cx) .detach_and_prompt_err( "Failed to join project", window, cx, |_, _, _| None, ); - } } - ListEntry::ParticipantScreen { peer_id, .. } => { - let Some(peer_id) = peer_id else { - return; - }; - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.open_shared_screen(*peer_id, window, cx) - }); - } - } - ListEntry::Channel { channel, .. } => { - let is_active = maybe!({ - let call_channel = ActiveCall::global(cx) - .read(cx) - .room()? - .read(cx) - .channel_id()?; - - Some(call_channel == channel.id) - }) - .unwrap_or(false); - if is_active { - self.open_channel_notes(channel.id, window, cx) - } else { - self.join_channel(channel.id, window, cx) - } - } - ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx), - ListEntry::CallParticipant { user, peer_id, .. } => { - if Some(user) == self.user_store.read(cx).current_user().as_ref() { - Self::leave_call(window, cx); - } else if let Some(peer_id) = peer_id { - self.workspace - .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx)) - .ok(); - } - } - ListEntry::IncomingRequest(user) => { - self.respond_to_contact_request(user.id, true, window, cx) - } - ListEntry::ChannelInvite(channel) => { - self.respond_to_channel_invite(channel.id, true, cx) + } + ListEntry::ParticipantScreen { peer_id, .. } => { + let Some(peer_id) = peer_id else { + return; + }; + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.open_shared_screen(*peer_id, window, cx) + }); } - ListEntry::ChannelNotes { channel_id } => { - self.open_channel_notes(*channel_id, window, cx) + } + ListEntry::Channel { channel, .. } => { + let is_active = maybe!({ + let call_channel = ActiveCall::global(cx) + .read(cx) + .room()? + .read(cx) + .channel_id()?; + + Some(call_channel == channel.id) + }) + .unwrap_or(false); + if is_active { + self.open_channel_notes(channel.id, window, cx) + } else { + self.join_channel(channel.id, window, cx) } - ListEntry::ChannelChat { channel_id } => { - self.join_channel_chat(*channel_id, window, cx) + } + ListEntry::ContactPlaceholder => self.toggle_contact_finder(window, cx), + ListEntry::CallParticipant { user, peer_id, .. } => { + if Some(user) == self.user_store.read(cx).current_user().as_ref() { + Self::leave_call(window, cx); + } else if let Some(peer_id) = peer_id { + self.workspace + .update(cx, |workspace, cx| workspace.follow(*peer_id, window, cx)) + .ok(); } - ListEntry::OutgoingRequest(_) => {} - ListEntry::ChannelEditor { .. } => {} } + ListEntry::IncomingRequest(user) => { + self.respond_to_contact_request(user.id, true, window, cx) + } + ListEntry::ChannelInvite(channel) => { + self.respond_to_channel_invite(channel.id, true, cx) + } + ListEntry::ChannelNotes { channel_id } => { + self.open_channel_notes(*channel_id, window, cx) + } + ListEntry::OutgoingRequest(_) => {} + ListEntry::ChannelEditor { .. } => {} } } } @@ -1828,10 +1785,10 @@ impl CollabPanel { } fn select_channel_editor(&mut self) { - self.selection = self.entries.iter().position(|entry| match entry { - ListEntry::ChannelEditor { .. } => true, - _ => false, - }); + self.selection = self + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::ChannelEditor { .. })); } fn new_subchannel( @@ -1903,6 +1860,17 @@ impl CollabPanel { } } + fn open_selected_channel_notes( + &mut self, + _: &OpenSelectedChannelNotes, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.open_channel_notes(channel.id, window, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2265,34 +2233,21 @@ impl CollabPanel { .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None) } - fn join_channel_chat( - &mut self, - channel_id: ChannelId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace.upgrade() else { + fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context) { + let channel_store = self.channel_store.read(cx); + let Some(channel) = channel_store.channel_for_id(channel_id) else { return; }; - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.focus_panel::(window, cx) { - panel.update(cx, |panel, cx| { - panel - .select_channel(channel_id, None, cx) - .detach_and_notify_err(window, cx); - }); - } - }); - }); + let item = ClipboardItem::new_string(channel.link(cx)); + cx.write_to_clipboard(item) } - fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context) { + fn copy_channel_notes_link(&mut self, channel_id: ChannelId, cx: &mut Context) { let channel_store = self.channel_store.read(cx); let Some(channel) = channel_store.channel_for_id(channel_id) else { return; }; - let item = ClipboardItem::new_string(channel.link(cx)); + let item = ClipboardItem::new_string(channel.notes_link(None, cx)); cx.write_to_clipboard(item) } @@ -2317,7 +2272,7 @@ impl CollabPanel { let client = this.client.clone(); cx.spawn_in(window, async move |_, cx| { client - .connect(true, &cx) + .connect(true, cx) .await .into_response() .notify_async_err(cx); @@ -2326,7 +2281,7 @@ impl CollabPanel { })), ) .child( - div().flex().w_full().items_center().child( + v_flex().w_full().items_center().child( Label::new("Sign in to enable collaboration.") .color(Color::Muted) .size(LabelSize::Small), @@ -2405,9 +2360,6 @@ impl CollabPanel { ListEntry::ChannelNotes { channel_id } => self .render_channel_notes(*channel_id, is_selected, window, cx) .into_any_element(), - ListEntry::ChannelChat { channel_id } => self - .render_channel_chat(*channel_id, is_selected, window, cx) - .into_any_element(), } } @@ -2514,7 +2466,7 @@ impl CollabPanel { let button = match section { Section::ActiveCall => channel_link.map(|channel_link| { - let channel_link_copy = channel_link.clone(); + let channel_link_copy = channel_link; IconButton::new("channel-link", IconName::Copy) .icon_size(IconSize::Small) .size(ButtonSize::None) @@ -2584,7 +2536,7 @@ impl CollabPanel { h_flex() .w_full() .justify_between() - .child(Label::new(github_login.clone())) + .child(render_participant_name_and_handle(&contact.user)) .when(calling, |el| { el.child(Label::new("Calling").color(Color::Muted)) }) @@ -2698,7 +2650,7 @@ impl CollabPanel { h_flex() .w_full() .justify_between() - .child(Label::new(github_login.clone())) + .child(Label::new(github_login)) .child(h_flex().children(controls)), ) .start_slot(Avatar::new(user.avatar_uri.clone())) @@ -2788,7 +2740,6 @@ impl CollabPanel { let disclosed = has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err()); - let has_messages_notification = channel_store.has_new_messages(channel_id); let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id); const FACEPILE_LIMIT: usize = 3; @@ -2912,24 +2863,10 @@ impl CollabPanel { h_flex().absolute().right(rems(0.)).h_full().child( h_flex() .h_full() + .bg(cx.theme().colors().background) + .rounded_l_sm() .gap_1() .px_1() - .child( - IconButton::new("channel_chat", IconName::Chat) - .style(ButtonStyle::Filled) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_messages_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.join_channel_chat(channel_id, window, cx) - })) - .tooltip(Tooltip::text("Open channel chat")) - .visible_on_hover(""), - ) .child( IconButton::new("channel_notes", IconName::Reader) .style(ButtonStyle::Filled) @@ -2943,9 +2880,9 @@ impl CollabPanel { .on_click(cx.listener(move |this, _, window, cx| { this.open_channel_notes(channel_id, window, cx) })) - .tooltip(Tooltip::text("Open channel notes")) - .visible_on_hover(""), - ), + .tooltip(Tooltip::text("Open channel notes")), + ) + .visible_on_hover(""), ), ) .tooltip({ @@ -3034,6 +2971,14 @@ fn render_tree_branch( .h(line_height) } +fn render_participant_name_and_handle(user: &User) -> impl IntoElement { + Label::new(if let Some(ref display_name) = user.name { + format!("{display_name} ({})", user.github_login) + } else { + user.github_login.to_string() + }) +} + impl Render for CollabPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() @@ -3046,6 +2991,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::remove_selected_channel)) .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) + .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3053,7 +2999,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::move_channel_down)) .track_focus(&self.focus_handle) .size_full() - .child(if !self.client.status().borrow().is_connected() { + .child(if !self.client.status().borrow().is_or_was_connected() { self.render_signed_out(cx) } else { self.render_signed_in(window, cx) @@ -3087,11 +3033,9 @@ impl Panel for CollabPanel { _window: &mut Window, cx: &mut Context, ) { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| settings.dock = Some(position), - ); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.collaboration_panel.get_or_insert_default().dock = Some(position.into()) + }); } fn size(&self, _window: &Window, cx: &App) -> Pixels { @@ -3125,6 +3069,10 @@ impl Panel for CollabPanel { "CollabPanel" } + fn panel_key() -> &'static str { + COLLABORATION_PANEL_KEY + } + fn activation_priority(&self) -> u32 { 6 } @@ -3132,7 +3080,7 @@ impl Panel for CollabPanel { impl Focusable for CollabPanel { fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.filter_editor.focus_handle(cx).clone() + self.filter_editor.focus_handle(cx) } } @@ -3189,14 +3137,6 @@ impl PartialEq for ListEntry { return channel_id == other_id; } } - ListEntry::ChannelChat { channel_id } => { - if let ListEntry::ChannelChat { - channel_id: other_id, - } = other - { - return channel_id == other_id; - } - } ListEntry::ChannelInvite(channel_1) => { if let ListEntry::ChannelInvite(channel_2) = other { return channel_1.id == channel_2.id; @@ -3274,8 +3214,8 @@ struct JoinChannelTooltip { } impl Render for JoinChannelTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(window, cx, |container, _, cx| { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + tooltip_container(cx, |container, cx| { let participants = self .channel_store .read(cx) @@ -3287,7 +3227,7 @@ impl Render for JoinChannelTooltip { h_flex() .gap_2() .child(Avatar::new(participant.avatar_uri.clone())) - .child(Label::new(participant.github_login.clone())) + .child(render_participant_name_and_handle(participant)) })) }) } diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 3c23ccc017838e8b97ec334dd432840e516ed413..e5823d0e78d9bf73ae3ded307116f608d7c06b22 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -148,7 +148,7 @@ impl PickerDelegate for ContactFinderDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { - let user = &self.potential_contacts[ix]; + let user = &self.potential_contacts.get(ix)?; let request_status = self.user_store.read(cx).contact_request_status(user); let icon_path = match request_status { diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index f9a2fa492562a89f66459510b1c4aa99edf57080..c43e865ef2dcbcd05a9b75cdde5e06bb0679de89 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,5 +1,4 @@ pub mod channel_view; -pub mod chat_panel; pub mod collab_panel; pub mod notification_panel; pub mod notifications; @@ -12,10 +11,7 @@ use gpui::{ App, Pixels, PlatformDisplay, Size, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, point, }; -use panel_settings::MessageEditorSettings; -pub use panel_settings::{ - ChatPanelButton, ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, -}; +pub use panel_settings::{CollaborationPanelSettings, NotificationPanelSettings}; use release_channel::ReleaseChannel; use settings::Settings; use ui::px; @@ -23,12 +19,9 @@ use workspace::AppState; pub fn init(app_state: &Arc, cx: &mut App) { CollaborationPanelSettings::register(cx); - ChatPanelSettings::register(cx); NotificationPanelSettings::register(cx); - MessageEditorSettings::register(cx); channel_view::init(cx); - chat_panel::init(cx); collab_panel::init(cx); notification_panel::init(cx); notifications::init(app_state, cx); @@ -66,5 +59,7 @@ fn notification_window_options( app_id: Some(app_id.to_owned()), window_min_size: None, window_decorations: Some(WindowDecorations::Client), + tabbing_identifier: None, + ..Default::default() } } diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index a3420d603b02b9e9d54d2b5bb441a9ba119840aa..99203bc867ff7da9e140bc4a886e291252a5153d 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -1,4 +1,4 @@ -use crate::{NotificationPanelSettings, chat_panel::ChatPanel}; +use crate::NotificationPanelSettings; use anyhow::Result; use channel::ChannelStore; use client::{ChannelId, Client, Notification, User, UserStore}; @@ -6,8 +6,8 @@ use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::StreamExt; use gpui::{ - AnyElement, App, AsyncWindowContext, ClickEvent, Context, CursorStyle, DismissEvent, Element, - Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, + AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task, WeakEntity, Window, actions, div, img, list, px, }; @@ -71,7 +71,6 @@ pub struct NotificationPresenter { pub text: String, pub icon: &'static str, pub needs_response: bool, - pub can_navigate: bool, } actions!( @@ -121,13 +120,12 @@ impl NotificationPanel { let notification_list = ListState::new(0, ListAlignment::Top, px(1000.)); notification_list.set_scroll_handler(cx.listener( |this, event: &ListScrollEvent, _, cx| { - if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD { - if let Some(task) = this + if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD + && let Some(task) = this .notification_store .update(cx, |store, cx| store.load_more_notifications(false, cx)) - { - task.detach(); - } + { + task.detach(); } }, )); @@ -235,7 +233,6 @@ impl NotificationPanel { actor, text, needs_response, - can_navigate, .. } = self.present_notification(entry, cx)?; @@ -270,14 +267,6 @@ impl NotificationPanel { .py_1() .gap_2() .hover(|style| style.bg(cx.theme().colors().element_hover)) - .when(can_navigate, |el| { - el.cursor(CursorStyle::PointingHand).on_click({ - let notification = notification.clone(); - cx.listener(move |this, _, window, cx| { - this.did_click_notification(¬ification, window, cx) - }) - }) - }) .children(actor.map(|actor| { img(actor.avatar_uri.clone()) .flex_none() @@ -290,7 +279,7 @@ impl NotificationPanel { .gap_1() .size_full() .overflow_hidden() - .child(Label::new(text.clone())) + .child(Label::new(text)) .child( h_flex() .child( @@ -370,7 +359,6 @@ impl NotificationPanel { text: format!("{} wants to add you as a contact", requester.github_login), needs_response: user_store.has_incoming_contact_request(requester.id), actor: Some(requester), - can_navigate: false, }) } Notification::ContactRequestAccepted { responder_id } => { @@ -380,7 +368,6 @@ impl NotificationPanel { text: format!("{} accepted your contact invite", responder.github_login), needs_response: false, actor: Some(responder), - can_navigate: false, }) } Notification::ChannelInvitation { @@ -397,29 +384,6 @@ impl NotificationPanel { ), needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)), actor: Some(inviter), - can_navigate: false, - }) - } - Notification::ChannelMessageMention { - sender_id, - channel_id, - message_id, - } => { - let sender = user_store.get_cached_user(sender_id)?; - let channel = channel_store.channel_for_id(ChannelId(channel_id))?; - let message = self - .notification_store - .read(cx) - .channel_message_for_id(message_id)?; - Some(NotificationPresenter { - icon: "icons/conversations.svg", - text: format!( - "{} mentioned you in #{}:\n{}", - sender.github_login, channel.name, message.body, - ), - needs_response: false, - actor: Some(sender), - can_navigate: true, }) } } @@ -434,9 +398,7 @@ impl NotificationPanel { ) { let should_mark_as_read = match notification { Notification::ContactRequestAccepted { .. } => true, - Notification::ContactRequest { .. } - | Notification::ChannelInvitation { .. } - | Notification::ChannelMessageMention { .. } => false, + Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } => false, }; if should_mark_as_read { @@ -458,56 +420,6 @@ impl NotificationPanel { } } - fn did_click_notification( - &mut self, - notification: &Notification, - window: &mut Window, - cx: &mut Context, - ) { - if let Notification::ChannelMessageMention { - message_id, - channel_id, - .. - } = notification.clone() - { - if let Some(workspace) = self.workspace.upgrade() { - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = workspace.focus_panel::(window, cx) { - panel.update(cx, |panel, cx| { - panel - .select_channel(ChannelId(channel_id), Some(message_id), cx) - .detach_and_log_err(cx); - }); - } - }); - }); - } - } - } - - fn is_showing_notification(&self, notification: &Notification, cx: &mut Context) -> bool { - if !self.active { - return false; - } - - if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { - if let Some(workspace) = self.workspace.upgrade() { - return if let Some(panel) = workspace.read(cx).panel::(cx) { - let panel = panel.read(cx); - panel.is_scrolled_to_bottom() - && panel - .active_chat() - .map_or(false, |chat| chat.read(cx).channel_id.0 == *channel_id) - } else { - false - }; - } - } - - false - } - fn on_notification_event( &mut self, _: &Entity, @@ -517,9 +429,7 @@ impl NotificationPanel { ) { match event { NotificationEvent::NewNotification { entry } => { - if !self.is_showing_notification(&entry.notification, cx) { - self.unseen_notifications.push(entry.clone()); - } + self.unseen_notifications.push(entry.clone()); self.add_toast(entry, window, cx); } NotificationEvent::NotificationRemoved { entry } @@ -543,10 +453,6 @@ impl NotificationPanel { window: &mut Window, cx: &mut Context, ) { - if self.is_showing_notification(&entry.notification, cx) { - return; - } - let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx) else { return; @@ -570,7 +476,6 @@ impl NotificationPanel { workspace.show_notification(id, cx, |cx| { let workspace = cx.entity().downgrade(); cx.new(|cx| NotificationToast { - notification_id, actor, text, workspace, @@ -582,16 +487,16 @@ impl NotificationPanel { } fn remove_toast(&mut self, notification_id: u64, cx: &mut Context) { - if let Some((current_id, _)) = &self.current_notification_toast { - if *current_id == notification_id { - self.current_notification_toast.take(); - self.workspace - .update(cx, |workspace, cx| { - let id = NotificationId::unique::(); - workspace.dismiss_notification(&id, cx) - }) - .ok(); - } + if let Some((current_id, _)) = &self.current_notification_toast + && *current_id == notification_id + { + self.current_notification_toast.take(); + self.workspace + .update(cx, |workspace, cx| { + let id = NotificationId::unique::(); + workspace.dismiss_notification(&id, cx) + }) + .ok(); } } @@ -643,7 +548,7 @@ impl Render for NotificationPanel { let client = client.clone(); window .spawn(cx, async move |cx| { - match client.connect(true, &cx).await { + match client.connect(true, cx).await { util::ConnectionResult::Timeout => { log::error!("Connection timeout"); } @@ -707,6 +612,10 @@ impl Panel for NotificationPanel { "NotificationPanel" } + fn panel_key() -> &'static str { + NOTIFICATION_PANEL_KEY + } + fn position(&self, _: &Window, cx: &App) -> DockPosition { NotificationPanelSettings::get_global(cx).dock } @@ -716,11 +625,9 @@ impl Panel for NotificationPanel { } fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| settings.dock = Some(position), - ); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.notification_panel.get_or_insert_default().dock = Some(position.into()) + }); } fn size(&self, _: &Window, cx: &App) -> Pixels { @@ -783,7 +690,6 @@ impl Panel for NotificationPanel { } pub struct NotificationToast { - notification_id: u64, actor: Option>, text: String, workspace: WeakEntity, @@ -801,22 +707,10 @@ impl WorkspaceNotification for NotificationToast {} impl NotificationToast { fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context) { let workspace = self.workspace.clone(); - let notification_id = self.notification_id; window.defer(cx, move |window, cx| { workspace .update(cx, |workspace, cx| { - if let Some(panel) = workspace.focus_panel::(window, cx) { - panel.update(cx, |panel, cx| { - let store = panel.notification_store.read(cx); - if let Some(entry) = store.notification_for_id(notification_id) { - panel.did_click_notification( - &entry.clone().notification, - window, - cx, - ); - } - }); - } + workspace.focus_panel::(window, cx) }) .ok(); }) @@ -844,19 +738,17 @@ impl Render for NotificationToast { .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify())) .child( IconButton::new(close_id, close_icon) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if suppress { Tooltip::for_action( "Suppress.\nClose with click.", &workspace::SuppressNotification, - window, cx, ) } else { Tooltip::for_action( "Close.\nSuppress with shift-click", &menu::Cancel, - window, cx, ) } diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index 652d9eb67f6ce1f0ab583e20e4feab05cfb743e3..cd19835c164161543030f552650ec35d7e6e0fe6 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -1,136 +1,41 @@ use gpui::Pixels; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use settings::Settings; +use ui::px; use workspace::dock::DockPosition; -#[derive(Deserialize, Debug)] +#[derive(Debug)] pub struct CollaborationPanelSettings { pub button: bool, pub dock: DockPosition, pub default_width: Pixels, } -#[derive(Clone, Copy, Default, Serialize, Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum ChatPanelButton { - Never, - Always, - #[default] - WhenInCall, -} - -#[derive(Deserialize, Debug)] -pub struct ChatPanelSettings { - pub button: ChatPanelButton, - pub dock: DockPosition, - pub default_width: Pixels, -} - -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct ChatPanelSettingsContent { - /// When to show the panel button in the status bar. - /// - /// Default: only when in a call - pub button: Option, - /// Where to dock the panel. - /// - /// Default: right - pub dock: Option, - /// Default width of the panel in pixels. - /// - /// Default: 240 - pub default_width: Option, -} - -#[derive(Deserialize, Debug)] +#[derive(Debug)] pub struct NotificationPanelSettings { pub button: bool, pub dock: DockPosition, pub default_width: Pixels, } -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct PanelSettingsContent { - /// Whether to show the panel button in the status bar. - /// - /// Default: true - pub button: Option, - /// Where to dock the panel. - /// - /// Default: left - pub dock: Option, - /// Default width of the panel in pixels. - /// - /// Default: 240 - pub default_width: Option, -} - -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct MessageEditorSettings { - /// Whether to automatically replace emoji shortcodes with emoji characters. - /// For example: typing `:wave:` gets replaced with `👋`. - /// - /// Default: false - pub auto_replace_emoji_shortcode: Option, -} - impl Settings for CollaborationPanelSettings { - const KEY: Option<&'static str> = Some("collaboration_panel"); - - type FileContent = PanelSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} - -impl Settings for ChatPanelSettings { - const KEY: Option<&'static str> = Some("chat_panel"); - - type FileContent = ChatPanelSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() + fn from_settings(content: &settings::SettingsContent) -> Self { + let panel = content.collaboration_panel.as_ref().unwrap(); + + Self { + button: panel.button.unwrap(), + dock: panel.dock.unwrap().into(), + default_width: panel.default_width.map(px).unwrap(), + } } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } impl Settings for NotificationPanelSettings { - const KEY: Option<&'static str> = Some("notification_panel"); - - type FileContent = PanelSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() + fn from_settings(content: &settings::SettingsContent) -> Self { + let panel = content.notification_panel.as_ref().unwrap(); + return Self { + button: panel.button.unwrap(), + dock: panel.dock.unwrap().into(), + default_width: panel.default_width.map(px).unwrap(), + }; } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} - -impl Settings for MessageEditorSettings { - const KEY: Option<&'static str> = Some("message_editor"); - - type FileContent = MessageEditorSettings; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index bec87ce1eafc6af5f686dd38ed60d7ecb17c9f38..8675504347f171397ea7372841cb00b7959eafe3 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -2,8 +2,9 @@ name = "collections" version = "0.1.0" edition.workspace = true -publish.workspace = true +publish = false license = "Apache-2.0" +description = "Standard collection type re-exports used by Zed and GPUI" [lints] workspace = true @@ -18,4 +19,3 @@ test-support = [] [dependencies] indexmap.workspace = true rustc-hash.workspace = true -workspace-hack.workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index be7bbdb59f646682e0eb84ddc40d3e260ef96d94..ea5ea7332fb14e5e2ac33ba2d6f957dbfdc28c7a 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -1,27 +1,9 @@ -#[cfg(feature = "test-support")] pub type HashMap = FxHashMap; - -#[cfg(feature = "test-support")] pub type HashSet = FxHashSet; - -#[cfg(feature = "test-support")] pub type IndexMap = indexmap::IndexMap; - -#[cfg(feature = "test-support")] pub type IndexSet = indexmap::IndexSet; -#[cfg(not(feature = "test-support"))] -pub type HashMap = std::collections::HashMap; - -#[cfg(not(feature = "test-support"))] -pub type HashSet = std::collections::HashSet; - -#[cfg(not(feature = "test-support"))] -pub type IndexMap = indexmap::IndexMap; - -#[cfg(not(feature = "test-support"))] -pub type IndexSet = indexmap::IndexSet; - +pub use indexmap::Equivalent; pub use rustc_hash::FxHasher; pub use rustc_hash::{FxHashMap, FxHashSet}; pub use std::collections::*; diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index c97d1421528325b107186a9158e57da277c97bb3..f21c202721fa29644e17df499fcfb288a72dc492 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -20,6 +20,7 @@ command_palette_hooks.workspace = true db.workspace = true fuzzy.workspace = true gpui.workspace = true +menu.workspace = true log.workspace = true picker.workspace = true postage.workspace = true @@ -32,7 +33,6 @@ util.workspace = true telemetry.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index b8800ff91284e6f105c029f7fffe9b4b83b6bcd1..aacc7c5262c87bf8bcf2d17f7bbda1a63b020f91 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -9,7 +9,8 @@ use std::{ use client::parse_zed_link; use command_palette_hooks::{ - CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor, + CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter, + GlobalCommandPaletteInterceptor, }; use fuzzy::{StringMatch, StringMatchCandidate}; @@ -21,7 +22,7 @@ use persistence::COMMAND_PALETTE_HISTORY; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, h_flex, prelude::*, v_flex}; +use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{ModalView, Workspace, WorkspaceSettings}; use zed_actions::{OpenZedUrl, command_palette::Toggle}; @@ -81,14 +82,17 @@ impl CommandPalette { let Some(previous_focus_handle) = window.focused(cx) else { return; }; + + let entity = cx.weak_entity(); workspace.toggle_modal(window, cx, move |window, cx| { - CommandPalette::new(previous_focus_handle, query, window, cx) + CommandPalette::new(previous_focus_handle, query, entity, window, cx) }); } fn new( previous_focus_handle: FocusHandle, query: &str, + entity: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -109,8 +113,12 @@ impl CommandPalette { }) .collect(); - let delegate = - CommandPaletteDelegate::new(cx.entity().downgrade(), commands, previous_focus_handle); + let delegate = CommandPaletteDelegate::new( + cx.entity().downgrade(), + entity, + commands, + previous_focus_handle, + ); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx); @@ -135,7 +143,7 @@ impl Focusable for CommandPalette { } impl Render for CommandPalette { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") .w(rems(34.)) @@ -146,6 +154,7 @@ impl Render for CommandPalette { pub struct CommandPaletteDelegate { latest_query: String, command_palette: WeakEntity, + workspace: WeakEntity, all_commands: Vec, commands: Vec, matches: Vec, @@ -153,7 +162,7 @@ pub struct CommandPaletteDelegate { previous_focus_handle: FocusHandle, updating_matches: Option<( Task<()>, - postage::dispatch::Receiver<(Vec, Vec)>, + postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, } @@ -174,11 +183,13 @@ impl Clone for Command { impl CommandPaletteDelegate { fn new( command_palette: WeakEntity, + workspace: WeakEntity, commands: Vec, previous_focus_handle: FocusHandle, ) -> Self { Self { command_palette, + workspace, all_commands: commands.clone(), matches: vec![], commands, @@ -194,30 +205,19 @@ impl CommandPaletteDelegate { query: String, mut commands: Vec, mut matches: Vec, - cx: &mut Context>, + intercept_result: CommandInterceptResult, + _: &mut Context>, ) { self.updating_matches.take(); - self.latest_query = query.clone(); - - let mut intercept_results = CommandPaletteInterceptor::try_global(cx) - .map(|interceptor| interceptor.intercept(&query, cx)) - .unwrap_or_default(); - - if parse_zed_link(&query, cx).is_some() { - intercept_results = vec![CommandInterceptResult { - action: OpenZedUrl { url: query.clone() }.boxed_clone(), - string: query.clone(), - positions: vec![], - }] - } + self.latest_query = query; let mut new_matches = Vec::new(); - for CommandInterceptResult { + for CommandInterceptItem { action, string, positions, - } in intercept_results + } in intercept_result.results { if let Some(idx) = matches .iter() @@ -236,7 +236,9 @@ impl CommandPaletteDelegate { score: 0.0, }) } - new_matches.append(&mut matches); + if !intercept_result.exclusive { + new_matches.append(&mut matches); + } self.commands = commands; self.matches = new_matches; if self.matches.is_empty() { @@ -259,6 +261,17 @@ impl CommandPaletteDelegate { HashMap::new() } } + + fn selected_command(&self) -> Option<&Command> { + let action_ix = self + .matches + .get(self.selected_ix) + .map(|m| m.candidate_id) + .unwrap_or(self.selected_ix); + // this gets called in headless tests where there are no commands loaded + // so we need to return an Option here + self.commands.get(action_ix) + } } impl PickerDelegate for CommandPaletteDelegate { @@ -295,12 +308,22 @@ impl PickerDelegate for CommandPaletteDelegate { if let Some(alias) = settings.command_aliases.get(&query) { query = alias.to_string(); } + + let workspace = self.workspace.clone(); + + let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx); + let (mut tx, mut rx) = postage::dispatch::channel(1); + + let query_str = query.as_str(); + let is_zed_link = parse_zed_link(query_str, cx).is_some(); + let task = cx.background_spawn({ let mut commands = self.all_commands.clone(); let hit_counts = self.hit_counts(); let executor = cx.background_executor().clone(); - let query = normalize_action_query(query.as_str()); + let query = normalize_action_query(query_str); + let query_for_link = query_str.to_string(); async move { commands.sort_by_key(|action| { ( @@ -326,13 +349,34 @@ impl PickerDelegate for CommandPaletteDelegate { ) .await; - tx.send((commands, matches)).await.log_err(); + let intercept_result = if is_zed_link { + CommandInterceptResult { + results: vec![CommandInterceptItem { + action: OpenZedUrl { + url: query_for_link.clone(), + } + .boxed_clone(), + string: query_for_link, + positions: vec![], + }], + exclusive: false, + } + } else if let Some(task) = intercept_task { + task.await + } else { + CommandInterceptResult::default() + }; + + tx.send((commands, matches, intercept_result)) + .await + .log_err(); } }); + self.updating_matches = Some((task, rx.clone())); cx.spawn_in(window, async move |picker, cx| { - let Some((commands, matches)) = rx.recv().await else { + let Some((commands, matches, intercept_result)) = rx.recv().await else { return; }; @@ -340,7 +384,7 @@ impl PickerDelegate for CommandPaletteDelegate { .update(cx, |picker, cx| { picker .delegate - .matches_updated(query, commands, matches, cx) + .matches_updated(query, commands, matches, intercept_result, cx) }) .log_err(); }) @@ -361,8 +405,8 @@ impl PickerDelegate for CommandPaletteDelegate { .background_executor() .block_with_timeout(duration, rx.clone().recv()) { - Ok(Some((commands, matches))) => { - self.matches_updated(query, commands, matches, cx); + Ok(Some((commands, matches, interceptor_result))) => { + self.matches_updated(query, commands, matches, interceptor_result, cx); true } _ => { @@ -378,7 +422,20 @@ impl PickerDelegate for CommandPaletteDelegate { .log_err(); } - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + if secondary { + let Some(selected_command) = self.selected_command() else { + return; + }; + let action_name = selected_command.action.name(); + let open_keymap = Box::new(zed_actions::ChangeKeybinding { + action: action_name.to_string(), + }); + window.dispatch_action(open_keymap, cx); + self.dismissed(window, cx); + return; + } + if self.matches.is_empty() { self.dismissed(window, cx); return; @@ -410,11 +467,12 @@ impl PickerDelegate for CommandPaletteDelegate { &self, ix: usize, selected: bool, - window: &mut Window, + _: &mut Window, cx: &mut Context>, ) -> Option { let matching_command = self.matches.get(ix)?; let command = self.commands.get(matching_command.candidate_id)?; + Some( ListItem::new(ix) .inset(true) @@ -429,15 +487,67 @@ impl PickerDelegate for CommandPaletteDelegate { command.name.clone(), matching_command.positions.clone(), )) - .children(KeyBinding::for_action_in( + .child(KeyBinding::for_action_in( &*command.action, &self.previous_focus_handle, - window, cx, )), ), ) } + + fn render_footer( + &self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + let selected_command = self.selected_command()?; + let keybind = + KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx); + + let focus_handle = &self.previous_focus_handle; + let keybinding_buttons = if keybind.has_binding(window) { + Button::new("change", "Change Keybinding…") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }) + } else { + Button::new("add", "Add Keybinding…") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }) + }; + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(keybinding_buttons) + .child( + Button::new("run-action", "Run") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + .into_any(), + ) + } } pub fn humanize_action_name(name: &str) -> String { @@ -665,7 +775,11 @@ mod tests { editor.update_in(cx, |editor, window, cx| { assert!(editor.focus_handle(cx).is_focused(window)); assert_eq!( - editor.selections.last::(cx).range().start, + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + .start, Point::new(2, 0) ); }); diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index 5be97c36bc57cea59b51272270fd39ae1a9ab70d..feaed72570d56f4895ff05eef891fc81c2e5e0b6 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -1,12 +1,16 @@ use anyhow::Result; use db::{ - define_connection, query, - sqlez::{bindable::Column, statement::Statement}, + query, + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, sqlez_macros::sql, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; +#[cfg(test)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub(crate) struct SerializedCommandInvocation { pub(crate) command_name: String, @@ -36,6 +40,7 @@ impl Column for SerializedCommandUsage { } } +#[cfg(test)] impl Column for SerializedCommandInvocation { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { let (command_name, next_index): (String, i32) = Column::column(statement, start_index)?; @@ -50,8 +55,11 @@ impl Column for SerializedCommandInvocation { } } -define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> = - &[sql!( +pub struct CommandPaletteDB(ThreadSafeConnection); + +impl Domain for CommandPaletteDB { + const NAME: &str = stringify!(CommandPaletteDB); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS command_invocations( id INTEGER PRIMARY KEY AUTOINCREMENT, command_name TEXT NOT NULL, @@ -59,7 +67,9 @@ define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL ) STRICT; )]; -); +} + +db::static_connection!(COMMAND_PALETTE_HISTORY, CommandPaletteDB, []); impl CommandPaletteDB { pub async fn write_command_invocation( @@ -76,8 +86,9 @@ impl CommandPaletteDB { .await } + #[cfg(test)] query! { - pub fn get_last_invoked(command: &str) -> Result> { + pub(crate) fn get_last_invoked(command: &str) -> Result> { SELECT command_name, user_query, diff --git a/crates/command_palette_hooks/Cargo.toml b/crates/command_palette_hooks/Cargo.toml index dd0b44c57dafe0266737e6c589f8cc6f763f2f4d..6ba771562d374a1c5f2499a9759cbbe3bb0229a4 100644 --- a/crates/command_palette_hooks/Cargo.toml +++ b/crates/command_palette_hooks/Cargo.toml @@ -16,4 +16,4 @@ doctest = false collections.workspace = true derive_more.workspace = true gpui.workspace = true -workspace-hack.workspace = true +workspace.workspace = true diff --git a/crates/command_palette_hooks/src/command_palette_hooks.rs b/crates/command_palette_hooks/src/command_palette_hooks.rs index df64d53874b4907b3bf586ee7935302c2e6979ae..bd8f9375b77ec9372a1657724a41dcb851537ece 100644 --- a/crates/command_palette_hooks/src/command_palette_hooks.rs +++ b/crates/command_palette_hooks/src/command_palette_hooks.rs @@ -2,16 +2,16 @@ #![deny(missing_docs)] -use std::any::TypeId; +use std::{any::TypeId, rc::Rc}; use collections::HashSet; use derive_more::{Deref, DerefMut}; -use gpui::{Action, App, BorrowAppContext, Global}; +use gpui::{Action, App, BorrowAppContext, Global, Task, WeakEntity}; +use workspace::Workspace; /// Initializes the command palette hooks. pub fn init(cx: &mut App) { cx.set_global(GlobalCommandPaletteFilter::default()); - cx.set_global(GlobalCommandPaletteInterceptor::default()); } /// A filter for the command palette. @@ -76,7 +76,7 @@ impl CommandPaletteFilter { } /// Hides all actions with the given types. - pub fn hide_action_types(&mut self, action_types: &[TypeId]) { + pub fn hide_action_types<'a>(&mut self, action_types: impl IntoIterator) { for action_type in action_types { self.hidden_action_types.insert(*action_type); self.shown_action_types.remove(action_type); @@ -84,7 +84,7 @@ impl CommandPaletteFilter { } /// Shows all actions with the given types. - pub fn show_action_types<'a>(&mut self, action_types: impl Iterator) { + pub fn show_action_types<'a>(&mut self, action_types: impl IntoIterator) { for action_type in action_types { self.shown_action_types.insert(*action_type); self.hidden_action_types.remove(action_type); @@ -94,61 +94,60 @@ impl CommandPaletteFilter { /// The result of intercepting a command palette command. #[derive(Debug)] -pub struct CommandInterceptResult { +pub struct CommandInterceptItem { /// The action produced as a result of the interception. pub action: Box, - // TODO: Document this field. - #[allow(missing_docs)] + /// The display string to show in the command palette for this result. pub string: String, - // TODO: Document this field. - #[allow(missing_docs)] + /// The character positions in the string that match the query. + /// Used for highlighting matched characters in the command palette UI. pub positions: Vec, } +/// The result of intercepting a command palette command. +#[derive(Default, Debug)] +pub struct CommandInterceptResult { + /// The items + pub results: Vec, + /// Whether or not to continue to show the normal matches + pub exclusive: bool, +} + /// An interceptor for the command palette. -#[derive(Default)] -pub struct CommandPaletteInterceptor( - Option Vec>>, +#[derive(Clone)] +pub struct GlobalCommandPaletteInterceptor( + Rc, &mut App) -> Task>, ); -#[derive(Default)] -struct GlobalCommandPaletteInterceptor(CommandPaletteInterceptor); - impl Global for GlobalCommandPaletteInterceptor {} -impl CommandPaletteInterceptor { - /// Returns the global [`CommandPaletteInterceptor`], if one is set. - pub fn try_global(cx: &App) -> Option<&CommandPaletteInterceptor> { - cx.try_global::() - .map(|interceptor| &interceptor.0) - } - - /// Updates the global [`CommandPaletteInterceptor`] using the given closure. - pub fn update_global(cx: &mut App, update: F) -> R - where - F: FnOnce(&mut Self, &mut App) -> R, - { - cx.update_global(|this: &mut GlobalCommandPaletteInterceptor, cx| update(&mut this.0, cx)) - } - - /// Intercepts the given query from the command palette. - pub fn intercept(&self, query: &str, cx: &App) -> Vec { - if let Some(handler) = self.0.as_ref() { - (handler)(query, cx) - } else { - Vec::new() - } +impl GlobalCommandPaletteInterceptor { + /// Sets the global interceptor. + /// + /// This will override the previous interceptor, if it exists. + pub fn set( + cx: &mut App, + interceptor: impl Fn(&str, WeakEntity, &mut App) -> Task + + 'static, + ) { + cx.set_global(Self(Rc::new(interceptor))); } /// Clears the global interceptor. - pub fn clear(&mut self) { - self.0 = None; + pub fn clear(cx: &mut App) { + if cx.has_global::() { + cx.remove_global::(); + } } - /// Sets the global interceptor. - /// - /// This will override the previous interceptor, if it exists. - pub fn set(&mut self, handler: Box Vec>) { - self.0 = Some(handler); + /// Intercepts the given query from the command palette. + pub fn intercept( + query: &str, + workspace: WeakEntity, + cx: &mut App, + ) -> Option> { + let interceptor = cx.try_global::()?; + let handler = interceptor.0.clone(); + Some(handler(query, workspace, cx)) } } diff --git a/crates/component/Cargo.toml b/crates/component/Cargo.toml index 92249de454d7140343cc6f814f6ac1bd99685cda..4ca95cbbbdf1f1c8d6c49a966849d8971842ffe1 100644 --- a/crates/component/Cargo.toml +++ b/crates/component/Cargo.toml @@ -18,7 +18,9 @@ inventory.workspace = true parking_lot.workspace = true strum.workspace = true theme.workspace = true -workspace-hack.workspace = true + +[dev-dependencies] +documented.workspace = true [features] default = [] diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs index 0c05ba4a97f4598e9f7982cbc294831a955f1fc6..8c7b7ea4d7347ff087c84880c31df5d355870f65 100644 --- a/crates/component/src/component.rs +++ b/crates/component/src/component.rs @@ -227,6 +227,8 @@ pub trait Component { /// Example: /// /// ``` + /// use documented::Documented; + /// /// /// This is a doc comment. /// #[derive(Documented)] /// struct MyComponent; diff --git a/crates/component/src/component_layout.rs b/crates/component/src/component_layout.rs index 58bf1d8f0c85533a4a06bd38c07f840c08cc6de3..a840d520a62b57516f20c190f2a5148505ccfed4 100644 --- a/crates/component/src/component_layout.rs +++ b/crates/component/src/component_layout.rs @@ -42,7 +42,7 @@ impl RenderOnce for ComponentExample { div() .text_size(rems(0.875)) .text_color(cx.theme().colors().text_muted) - .child(description.clone()), + .child(description), ) }), ) diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 5e4f8369c45f0edb58efda1618bf8fe0aad55749..846a53fde4b6f87493ec2b75da6c08d2b081df47 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -25,10 +25,10 @@ net.workspace = true parking_lot.workspace = true postage.workspace = true schemars.workspace = true -serde.workspace = true serde_json.workspace = true +serde.workspace = true +settings.workspace = true smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true -workspace-hack.workspace = true diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index 65283afa87d94fae3ec51f8a89574713080bded2..f891e96250f3334540aa859fe438c87297fc0100 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -25,7 +25,7 @@ use crate::{ }; const JSON_RPC_VERSION: &str = "2.0"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); // Standard JSON-RPC error codes pub const PARSE_ERROR: i32 = -32700; @@ -60,6 +60,7 @@ pub(crate) struct Client { executor: BackgroundExecutor, #[allow(dead_code)] transport: Arc, + request_timeout: Option, } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -67,11 +68,7 @@ pub(crate) struct Client { pub(crate) struct ContextServerId(pub Arc); fn is_null_value(value: &T) -> bool { - if let Ok(Value::Null) = serde_json::to_value(value) { - true - } else { - false - } + matches!(serde_json::to_value(value), Ok(Value::Null)) } #[derive(Serialize, Deserialize)] @@ -130,6 +127,10 @@ struct Notification<'a, T> { #[derive(Debug, Clone, Deserialize)] struct AnyNotification<'a> { + #[expect( + unused, + reason = "Part of the JSON-RPC protocol - we expect the field to be present in a valid JSON-RPC notification" + )] jsonrpc: &'a str, method: String, #[serde(default)] @@ -147,6 +148,7 @@ pub struct ModelContextServerBinary { pub executable: PathBuf, pub args: Vec, pub env: Option>, + pub timeout: Option, } impl Client { @@ -161,7 +163,7 @@ impl Client { working_directory: &Option, cx: AsyncApp, ) -> Result { - log::info!( + log::debug!( "starting context server (executable={:?}, args={:?})", binary.executable, &binary.args @@ -170,11 +172,12 @@ impl Client { let server_name = binary .executable .file_name() - .map(|name| name.to_string_lossy().to_string()) + .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(String::new); + let timeout = binary.timeout.map(Duration::from_millis); let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?); - Self::new(server_id, server_name.into(), transport, cx) + Self::new(server_id, server_name.into(), transport, timeout, cx) } /// Creates a new Client instance for a context server. @@ -182,6 +185,7 @@ impl Client { server_id: ContextServerId, server_name: Arc, transport: Arc, + request_timeout: Option, cx: AsyncApp, ) -> Result { let (outbound_tx, outbound_rx) = channel::unbounded::(); @@ -241,6 +245,7 @@ impl Client { io_tasks: Mutex::new(Some((input_task, output_task))), output_done_rx: Mutex::new(Some(output_done_rx)), transport, + request_timeout, }) } @@ -271,10 +276,10 @@ impl Client { ); } } else if let Ok(response) = serde_json::from_str::(&message) { - if let Some(handlers) = response_handlers.lock().as_mut() { - if let Some(handler) = handlers.remove(&response.id) { - handler(Ok(message.to_string())); - } + if let Some(handlers) = response_handlers.lock().as_mut() + && let Some(handler) = handlers.remove(&response.id) + { + handler(Ok(message.to_string())); } } else if let Ok(notification) = serde_json::from_str::(&message) { let mut notification_handlers = notification_handlers.lock(); @@ -295,7 +300,7 @@ impl Client { /// Continuously reads and logs any error messages from the server. async fn handle_err(transport: Arc) -> anyhow::Result<()> { while let Some(err) = transport.receive_err().next().await { - log::warn!("context server stderr: {}", err.trim()); + log::debug!("context server stderr: {}", err.trim()); } Ok(()) @@ -331,8 +336,13 @@ impl Client { method: &str, params: impl Serialize, ) -> Result { - self.request_with(method, params, None, Some(REQUEST_TIMEOUT)) - .await + self.request_with( + method, + params, + None, + self.request_timeout.or(Some(DEFAULT_REQUEST_TIMEOUT)), + ) + .await } pub async fn request_with( diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 34fa29678d5d68f864de7d9df3bef82d4c667f05..52ed524220947430df3e63fced367ca4eb223fff 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -12,12 +12,9 @@ use std::{fmt::Display, path::PathBuf}; use anyhow::Result; use client::Client; -use collections::HashMap; use gpui::AsyncApp; use parking_lot::RwLock; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use util::redact::should_redact; +pub use settings::ContextServerCommand; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ContextServerId(pub Arc); @@ -28,30 +25,6 @@ impl Display for ContextServerId { } } -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] -pub struct ContextServerCommand { - #[serde(rename = "command")] - pub path: PathBuf, - pub args: Vec, - pub env: Option>, -} - -impl std::fmt::Debug for ContextServerCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let filtered_env = self.env.as_ref().map(|env| { - env.iter() - .map(|(k, v)| (k, if should_redact(k) { "[REDACTED]" } else { v })) - .collect::>() - }); - - f.debug_struct("ContextServerCommand") - .field("path", &self.path) - .field("args", &self.args) - .field("env", &filtered_env) - .finish() - } -} - enum ContextServerTransport { Stdio(ContextServerCommand, Option), Custom(Arc), @@ -123,6 +96,7 @@ impl ContextServer { executable: Path::new(&command.path).to_path_buf(), args: command.args.clone(), env: command.env.clone(), + timeout: command.timeout, }, working_directory, cx.clone(), @@ -131,13 +105,14 @@ impl ContextServer { client::ContextServerId(self.id.0.clone()), self.id().0, transport.clone(), + None, cx.clone(), )?, }) } async fn initialize(&self, client: Client) -> Result<()> { - log::info!("starting context server {}", self.id); + log::debug!("starting context server {}", self.id); let protocol = crate::protocol::ModelContextProtocol::new(client); let client_info = types::Implementation { name: "Zed".to_string(), diff --git a/crates/context_server/src/listener.rs b/crates/context_server/src/listener.rs index 0e85fb21292739ab0a92d0898fc449a31efe6f29..b71d59d760242d7f927e35dc1fef2351b462af32 100644 --- a/crates/context_server/src/listener.rs +++ b/crates/context_server/src/listener.rs @@ -14,6 +14,7 @@ use serde::de::DeserializeOwned; use serde_json::{json, value::RawValue}; use smol::stream::StreamExt; use std::{ + any::TypeId, cell::RefCell, path::{Path, PathBuf}, rc::Rc, @@ -77,7 +78,7 @@ impl McpServer { socket_path, _server_task: server_task, tools, - handlers: handlers, + handlers, }) }) } @@ -87,23 +88,30 @@ impl McpServer { settings.inline_subschemas = true; let mut generator = settings.into_generator(); - let output_schema = generator.root_schema_for::(); - let unit_schema = generator.root_schema_for::(); + let input_schema = generator.root_schema_for::(); + + let description = input_schema + .get("description") + .and_then(|desc| desc.as_str()) + .map(|desc| desc.to_string()); + debug_assert!( + description.is_some(), + "Input schema struct must include a doc comment for the tool description" + ); let registered_tool = RegisteredTool { tool: Tool { name: T::NAME.into(), - description: Some(tool.description().into()), - input_schema: generator.root_schema_for::().into(), - output_schema: if output_schema == unit_schema { + description, + input_schema: input_schema.into(), + output_schema: if TypeId::of::() == TypeId::of::<()>() { None } else { - Some(output_schema.into()) + Some(generator.root_schema_for::().into()) }, annotations: Some(tool.annotations()), }, handler: Box::new({ - let tool = tool.clone(); move |input_value, cx| { let input = match input_value { Some(input) => serde_json::from_value(input), @@ -315,12 +323,12 @@ impl McpServer { Self::send_err( request_id, format!("Tool not found: {}", params.name), - &outgoing_tx, + outgoing_tx, ); } } Err(err) => { - Self::send_err(request_id, err.to_string(), &outgoing_tx); + Self::send_err(request_id, err.to_string(), outgoing_tx); } } } @@ -399,8 +407,6 @@ pub trait McpServerTool { const NAME: &'static str; - fn description(&self) -> &'static str; - fn annotations(&self) -> ToolAnnotations { ToolAnnotations { title: None, @@ -418,6 +424,7 @@ pub trait McpServerTool { ) -> impl Future>>; } +#[derive(Debug)] pub struct ToolResponse { pub content: Vec, pub structured_content: T, @@ -431,13 +438,3 @@ struct RawRequest { #[serde(skip_serializing_if = "Option::is_none")] params: Option>, } - -#[derive(Serialize, Deserialize)] -struct RawResponse { - jsonrpc: &'static str, - id: RequestId, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option>, -} diff --git a/crates/context_server/src/test.rs b/crates/context_server/src/test.rs index dedf589664215a733b7d6bd5c2273af246863f42..008542ab246bc2d68a62d779e985e5941ac16856 100644 --- a/crates/context_server/src/test.rs +++ b/crates/context_server/src/test.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use collections::HashMap; -use futures::{Stream, StreamExt as _, lock::Mutex}; +use futures::{FutureExt, Stream, StreamExt as _, future::BoxFuture, lock::Mutex}; use gpui::BackgroundExecutor; use std::{pin::Pin, sync::Arc}; @@ -14,9 +14,12 @@ pub fn create_fake_transport( executor: BackgroundExecutor, ) -> FakeTransport { let name = name.into(); - FakeTransport::new(executor).on_request::(move |_params| { - create_initialize_response(name.clone()) - }) + FakeTransport::new(executor).on_request::( + move |_params| { + let name = name.clone(); + async move { create_initialize_response(name.clone()) } + }, + ) } fn create_initialize_response(server_name: String) -> InitializeResponse { @@ -32,8 +35,10 @@ fn create_initialize_response(server_name: String) -> InitializeResponse { } pub struct FakeTransport { - request_handlers: - HashMap<&'static str, Arc serde_json::Value + Send + Sync>>, + request_handlers: HashMap< + &'static str, + Arc BoxFuture<'static, serde_json::Value>>, + >, tx: futures::channel::mpsc::UnboundedSender, rx: Arc>>, executor: BackgroundExecutor, @@ -50,18 +55,25 @@ impl FakeTransport { } } - pub fn on_request( + pub fn on_request( mut self, - handler: impl Fn(T::Params) -> T::Response + Send + Sync + 'static, - ) -> Self { + handler: impl 'static + Send + Sync + Fn(T::Params) -> Fut, + ) -> Self + where + T: crate::types::Request, + Fut: 'static + Send + Future, + { self.request_handlers.insert( T::METHOD, Arc::new(move |value| { - let params = value.get("params").expect("Missing parameters").clone(); + let params = value + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); let params: T::Params = serde_json::from_value(params).expect("Invalid parameters received"); let response = handler(params); - serde_json::to_value(response).unwrap() + async move { serde_json::to_value(response.await).unwrap() }.boxed() }), ); self @@ -77,7 +89,7 @@ impl Transport for FakeTransport { if let Some(method) = msg.get("method") { let method = method.as_str().expect("Invalid method received"); if let Some(handler) = self.request_handlers.get(method) { - let payload = handler(msg); + let payload = handler(msg).await; let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 443b8c16f160394f4bede9a72315b4e80c652726..83908b46829c4cfe3b536ecca1155c909ee424dd 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -41,12 +41,9 @@ impl StdioTransport { command.current_dir(working_directory); } - let mut server = command.spawn().with_context(|| { - format!( - "failed to spawn command. (path={:?}, args={:?})", - binary.executable, &binary.args - ) - })?; + let mut server = command + .spawn() + .with_context(|| format!("failed to spawn command {command:?})",))?; let stdin = server.stdin.take().unwrap(); let stdout = server.stdout.take().unwrap(); diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index 5fa2420a3d40ce04ee97b4f88c1105711dea8793..03aca4f3caf7995091bbc8e049494b324674a9d3 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -691,7 +691,7 @@ impl CallToolResponse { let mut text = String::new(); for chunk in &self.content { if let ToolResponseContent::Text { text: chunk } = chunk { - text.push_str(&chunk) + text.push_str(chunk) }; } text @@ -711,6 +711,16 @@ pub enum ToolResponseContent { Resource { resource: ResourceContents }, } +impl ToolResponseContent { + pub fn text(&self) -> Option<&str> { + if let ToolResponseContent::Text { text } = self { + Some(text) + } else { + None + } + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListToolsResponse { diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 0fc119f31125f4ef3925799fd98fd47cac7ca9da..d9ea4709eadcfab2f6a91c793ac63933dbae545a 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -43,6 +43,7 @@ node_runtime.workspace = true parking_lot.workspace = true paths.workspace = true project.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true @@ -51,7 +52,6 @@ task.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true itertools.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index dcebeae7212119867bc582ce930d2f51fae49d34..41c8a17c2d251e23f7c2d6b27fbd2ff488c1c0e4 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,5 +1,6 @@ pub mod copilot_chat; mod copilot_completion_provider; +pub mod copilot_responses; pub mod request; mod sign_in; @@ -25,6 +26,7 @@ use node_runtime::{NodeRuntime, VersionStrategy}; use parking_lot::Mutex; use project::DisableAiSettings; use request::StatusNotification; +use semver::Version; use serde_json::json; use settings::Settings; use settings::SettingsStore; @@ -40,6 +42,7 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; +use util::rel_path::RelPath; use util::{ResultExt, fs::remove_matching}; use workspace::Workspace; @@ -81,10 +84,7 @@ pub fn init( }; copilot_chat::init(fs.clone(), http.clone(), configuration, cx); - let copilot = cx.new({ - let node_runtime = node_runtime.clone(); - move |cx| Copilot::start(new_server_id, fs, node_runtime, cx) - }); + let copilot = cx.new(move |cx| Copilot::start(new_server_id, fs, node_runtime, cx)); Copilot::set_global(copilot.clone(), cx); cx.observe(&copilot, |copilot, cx| { copilot.update(cx, |copilot, cx| copilot.update_action_visibilities(cx)); @@ -129,7 +129,7 @@ impl CopilotServer { fn as_authenticated(&mut self) -> Result<&mut RunningCopilotServer> { let server = self.as_running()?; anyhow::ensure!( - matches!(server.sign_in_status, SignInStatus::Authorized { .. }), + matches!(server.sign_in_status, SignInStatus::Authorized), "must sign in before using copilot" ); Ok(server) @@ -200,7 +200,7 @@ impl Status { } struct RegisteredBuffer { - uri: lsp::Url, + uri: lsp::Uri, language_id: String, snapshot: BufferSnapshot, snapshot_version: i32, @@ -271,7 +271,7 @@ impl RegisteredBuffer { server .lsp .notify::( - &lsp::DidChangeTextDocumentParams { + lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( buffer.uri.clone(), buffer.snapshot_version, @@ -487,6 +487,8 @@ impl Copilot { let start_language_server = async { let server_path = get_copilot_lsp(fs, node_runtime.clone()).await?; let node_path = node_runtime.binary_path().await?; + ensure_node_version_for_copilot(&node_path).await?; + let arguments: Vec = vec![server_path.into(), "--stdio".into()]; let binary = LanguageServerBinary { path: node_path, @@ -581,12 +583,12 @@ impl Copilot { pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { - SignInStatus::Authorized { .. } => Task::ready(Ok(())).shared(), + SignInStatus::Authorized => Task::ready(Ok(())).shared(), SignInStatus::SigningIn { task, .. } => { cx.notify(); task.clone() } - SignInStatus::SignedOut { .. } | SignInStatus::Unauthorized { .. } => { + SignInStatus::SignedOut { .. } | SignInStatus::Unauthorized => { let lsp = server.lsp.clone(); let task = cx .spawn(async move |this, cx| { @@ -608,15 +610,13 @@ impl Copilot { sign_in_status: status, .. }) = &mut this.server - { - if let SignInStatus::SigningIn { + && let SignInStatus::SigningIn { prompt: prompt_flow, .. } = status - { - *prompt_flow = Some(flow.clone()); - cx.notify(); - } + { + *prompt_flow = Some(flow.clone()); + cx.notify(); } })?; let response = lsp @@ -732,7 +732,7 @@ impl Copilot { .. }) = &mut self.server { - if !matches!(status, SignInStatus::Authorized { .. }) { + if !matches!(status, SignInStatus::Authorized) { return; } @@ -745,7 +745,7 @@ impl Copilot { let snapshot = buffer.read(cx).snapshot(); server .notify::( - &lsp::DidOpenTextDocumentParams { + lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem { uri: uri.clone(), language_id: language_id.clone(), @@ -782,59 +782,61 @@ impl Copilot { event: &language::BufferEvent, cx: &mut Context, ) -> Result<()> { - if let Ok(server) = self.server.as_running() { - if let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id()) - { - match event { - language::BufferEvent::Edited => { - drop(registered_buffer.report_changes(&buffer, cx)); - } - language::BufferEvent::Saved => { + if let Ok(server) = self.server.as_running() + && let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id()) + { + match event { + language::BufferEvent::Edited => { + drop(registered_buffer.report_changes(&buffer, cx)); + } + language::BufferEvent::Saved => { + server + .lsp + .notify::( + lsp::DidSaveTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new( + registered_buffer.uri.clone(), + ), + text: None, + }, + ) + .ok(); + } + language::BufferEvent::FileHandleChanged + | language::BufferEvent::LanguageChanged => { + let new_language_id = id_for_language(buffer.read(cx).language()); + let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { + return Ok(()); + }; + if new_uri != registered_buffer.uri + || new_language_id != registered_buffer.language_id + { + let old_uri = mem::replace(&mut registered_buffer.uri, new_uri); + registered_buffer.language_id = new_language_id; server .lsp - .notify::( - &lsp::DidSaveTextDocumentParams { - text_document: lsp::TextDocumentIdentifier::new( + .notify::( + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(old_uri), + }, + ) + .ok(); + server + .lsp + .notify::( + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( registered_buffer.uri.clone(), + registered_buffer.language_id.clone(), + registered_buffer.snapshot_version, + registered_buffer.snapshot.text(), ), - text: None, }, - )?; + ) + .ok(); } - language::BufferEvent::FileHandleChanged - | language::BufferEvent::LanguageChanged => { - let new_language_id = id_for_language(buffer.read(cx).language()); - let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { - return Ok(()); - }; - if new_uri != registered_buffer.uri - || new_language_id != registered_buffer.language_id - { - let old_uri = mem::replace(&mut registered_buffer.uri, new_uri); - registered_buffer.language_id = new_language_id; - server - .lsp - .notify::( - &lsp::DidCloseTextDocumentParams { - text_document: lsp::TextDocumentIdentifier::new(old_uri), - }, - )?; - server - .lsp - .notify::( - &lsp::DidOpenTextDocumentParams { - text_document: lsp::TextDocumentItem::new( - registered_buffer.uri.clone(), - registered_buffer.language_id.clone(), - registered_buffer.snapshot_version, - registered_buffer.snapshot.text(), - ), - }, - )?; - } - } - _ => {} } + _ => {} } } @@ -842,17 +844,17 @@ impl Copilot { } fn unregister_buffer(&mut self, buffer: &WeakEntity) { - if let Ok(server) = self.server.as_running() { - if let Some(buffer) = server.registered_buffers.remove(&buffer.entity_id()) { - server - .lsp - .notify::( - &lsp::DidCloseTextDocumentParams { - text_document: lsp::TextDocumentIdentifier::new(buffer.uri), - }, - ) - .ok(); - } + if let Ok(server) = self.server.as_running() + && let Some(buffer) = server.registered_buffers.remove(&buffer.entity_id()) + { + server + .lsp + .notify::( + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(buffer.uri), + }, + ) + .ok(); } } @@ -969,8 +971,7 @@ impl Copilot { let hard_tabs = settings.hard_tabs; let relative_path = buffer .file() - .map(|file| file.path().to_path_buf()) - .unwrap_or_default(); + .map_or(RelPath::empty().into(), |file| file.path().clone()); cx.background_spawn(async move { let (version, snapshot) = snapshot.await?; @@ -981,7 +982,7 @@ impl Copilot { tab_size: tab_size.into(), indent_size: 1, insert_spaces: !hard_tabs, - relative_path: relative_path.to_string_lossy().into(), + relative_path: relative_path.to_proto(), position: point_to_lsp(position), version: version.try_into().unwrap(), }, @@ -1015,8 +1016,8 @@ impl Copilot { CopilotServer::Error(error) => Status::Error(error.clone()), CopilotServer::Running(RunningCopilotServer { sign_in_status, .. }) => { match sign_in_status { - SignInStatus::Authorized { .. } => Status::Authorized, - SignInStatus::Unauthorized { .. } => Status::Unauthorized, + SignInStatus::Authorized => Status::Authorized, + SignInStatus::Unauthorized => Status::Unauthorized, SignInStatus::SigningIn { prompt, .. } => Status::SigningIn { prompt: prompt.clone(), }, @@ -1101,7 +1102,7 @@ impl Copilot { _ => { filter.hide_action_types(&signed_in_actions); filter.hide_action_types(&auth_actions); - filter.show_action_types(no_auth_actions.iter()); + filter.show_action_types(&no_auth_actions); } } } @@ -1114,9 +1115,9 @@ fn id_for_language(language: Option<&Arc>) -> String { .unwrap_or_else(|| "plaintext".to_string()) } -fn uri_for_buffer(buffer: &Entity, cx: &App) -> Result { +fn uri_for_buffer(buffer: &Entity, cx: &App) -> Result { if let Some(file) = buffer.read(cx).file().and_then(|file| file.as_local()) { - lsp::Url::from_file_path(file.abs_path(cx)) + lsp::Uri::from_file_path(file.abs_path(cx)) } else { format!("buffer://{}", buffer.entity_id()) .parse() @@ -1154,9 +1155,12 @@ fn notify_did_change_config_to_server( } }); - server.notify::(&lsp::DidChangeConfigurationParams { - settings, - }) + server + .notify::(lsp::DidChangeConfigurationParams { + settings, + }) + .ok(); + Ok(()) } async fn clear_copilot_dir() { @@ -1167,6 +1171,44 @@ async fn clear_copilot_config_dir() { remove_matching(copilot_chat::copilot_chat_config_dir(), |_| true).await } +async fn ensure_node_version_for_copilot(node_path: &Path) -> anyhow::Result<()> { + const MIN_COPILOT_NODE_VERSION: Version = Version::new(20, 8, 0); + + log::info!("Checking Node.js version for Copilot at: {:?}", node_path); + + let output = util::command::new_smol_command(node_path) + .arg("--version") + .output() + .await + .with_context(|| format!("checking Node.js version at {:?}", node_path))?; + + if !output.status.success() { + anyhow::bail!( + "failed to run node --version for Copilot. stdout: {}, stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + + let version_str = String::from_utf8_lossy(&output.stdout); + let version = Version::parse(version_str.trim().trim_start_matches('v')) + .with_context(|| format!("parsing Node.js version from '{}'", version_str.trim()))?; + + if version < MIN_COPILOT_NODE_VERSION { + anyhow::bail!( + "GitHub Copilot language server requires Node.js {MIN_COPILOT_NODE_VERSION} or later, but found {version}. \ + Please update your Node.js version or configure a different Node.js path in settings." + ); + } + + log::info!( + "Node.js version {} meets Copilot requirements (>= {})", + version, + MIN_COPILOT_NODE_VERSION + ); + Ok(()) +} + async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow::Result { const PACKAGE_NAME: &str = "@github/copilot-language-server"; const SERVER_PATH: &str = @@ -1200,14 +1242,14 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: mod tests { use super::*; use gpui::TestAppContext; - use util::path; + use util::{path, paths::PathStyle, rel_path::rel_path}; #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { let (copilot, mut lsp) = Copilot::fake(cx); let buffer_1 = cx.new(|cx| Buffer::local("Hello", cx)); - let buffer_1_uri: lsp::Url = format!("buffer://{}", buffer_1.entity_id().as_u64()) + let buffer_1_uri: lsp::Uri = format!("buffer://{}", buffer_1.entity_id().as_u64()) .parse() .unwrap(); copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_1, cx)); @@ -1225,7 +1267,7 @@ mod tests { ); let buffer_2 = cx.new(|cx| Buffer::local("Goodbye", cx)); - let buffer_2_uri: lsp::Url = format!("buffer://{}", buffer_2.entity_id().as_u64()) + let buffer_2_uri: lsp::Uri = format!("buffer://{}", buffer_2.entity_id().as_u64()) .parse() .unwrap(); copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_2, cx)); @@ -1264,7 +1306,7 @@ mod tests { buffer.file_updated( Arc::new(File { abs_path: path!("/root/child/buffer-1").into(), - path: Path::new("child/buffer-1").into(), + path: rel_path("child/buffer-1").into(), }), cx, ) @@ -1276,7 +1318,7 @@ mod tests { text_document: lsp::TextDocumentIdentifier::new(buffer_1_uri), } ); - let buffer_1_uri = lsp::Url::from_file_path(path!("/root/child/buffer-1")).unwrap(); + let buffer_1_uri = lsp::Uri::from_file_path(path!("/root/child/buffer-1")).unwrap(); assert_eq!( lsp.receive_notification::() .await, @@ -1361,7 +1403,7 @@ mod tests { struct File { abs_path: PathBuf, - path: Arc, + path: Arc, } impl language::File for File { @@ -1375,15 +1417,19 @@ mod tests { } } - fn path(&self) -> &Arc { + fn path(&self) -> &Arc { &self.path } + fn path_style(&self, _: &App) -> PathStyle { + PathStyle::local() + } + fn full_path(&self, _: &App) -> PathBuf { unimplemented!() } - fn file_name<'a>(&'a self, _: &'a App) -> &'a std::ffi::OsStr { + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { unimplemented!() } diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 4c91b4fedb790ab3500273ff21aba767cacd28e0..5d22760942dbbcfd72f1dacb83c249a08f2fe72a 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -10,10 +10,13 @@ use fs::Fs; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use gpui::WeakEntity; use gpui::{App, AsyncApp, Global, prelude::*}; +use http_client::HttpRequestExt; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use itertools::Itertools; use paths::home_dir; use serde::{Deserialize, Serialize}; + +use crate::copilot_responses as responses; use settings::watch_config_dir; pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN"; @@ -41,10 +44,14 @@ impl CopilotChatConfiguration { } } - pub fn api_url_from_endpoint(&self, endpoint: &str) -> String { + pub fn chat_completions_url_from_endpoint(&self, endpoint: &str) -> String { format!("{}/chat/completions", endpoint) } + pub fn responses_url_from_endpoint(&self, endpoint: &str) -> String { + format!("{}/responses", endpoint) + } + pub fn models_url_from_endpoint(&self, endpoint: &str) -> String { format!("{}/models", endpoint) } @@ -62,12 +69,6 @@ impl CopilotChatConfiguration { } } -// Copilot's base model; defined by Microsoft in premium requests table -// This will be moved to the front of the Copilot model list, and will be used for -// 'fast' requests (e.g. title generation) -// https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests -const DEFAULT_MODEL_ID: &str = "gpt-4.1"; - #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Role { @@ -76,6 +77,14 @@ pub enum Role { System, } +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum ModelSupportedEndpoint { + #[serde(rename = "/chat/completions")] + ChatCompletions, + #[serde(rename = "/responses")] + Responses, +} + #[derive(Deserialize)] struct ModelSchema { #[serde(deserialize_with = "deserialize_models_skip_errors")] @@ -101,14 +110,31 @@ where Ok(models) } -#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Model { + billing: ModelBilling, capabilities: ModelCapabilities, id: String, name: String, policy: Option, vendor: ModelVendor, + is_chat_default: bool, + // The model with this value true is selected by VSCode copilot if a premium request limit is + // reached. Zed does not currently implement this behaviour + is_chat_fallback: bool, model_picker_enabled: bool, + #[serde(default)] + supported_endpoints: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +struct ModelBilling { + is_premium: bool, + multiplier: f64, + // List of plans a model is restricted to + // Field is not present if a model is available for all plans + #[serde(default)] + restricted_to: Option>, } #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -117,6 +143,10 @@ struct ModelCapabilities { #[serde(default)] limits: ModelLimits, supports: ModelSupportedFeatures, + #[serde(rename = "type")] + model_type: String, + #[serde(default)] + tokenizer: Option, } #[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -153,6 +183,11 @@ pub enum ModelVendor { OpenAI, Google, Anthropic, + #[serde(rename = "xAI")] + XAI, + /// Unknown vendor that we don't explicitly support yet + #[serde(other)] + Unknown, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] @@ -201,6 +236,20 @@ impl Model { pub fn supports_parallel_tool_calls(&self) -> bool { self.capabilities.supports.parallel_tool_calls } + + pub fn tokenizer(&self) -> Option<&str> { + self.capabilities.tokenizer.as_deref() + } + + pub fn supports_response(&self) -> bool { + self.supported_endpoints.len() > 0 + && !self + .supported_endpoints + .contains(&ModelSupportedEndpoint::ChatCompletions) + && self + .supported_endpoints + .contains(&ModelSupportedEndpoint::Responses) + } } #[derive(Serialize, Deserialize)] @@ -230,7 +279,7 @@ pub enum Tool { Function { function: Function }, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, @@ -323,7 +372,7 @@ pub struct Usage { #[derive(Debug, Deserialize)] pub struct ResponseChoice { - pub index: usize, + pub index: Option, pub finish_reason: Option, pub delta: Option, pub message: Option, @@ -336,10 +385,9 @@ pub struct ResponseDelta { #[serde(default)] pub tool_calls: Vec, } - #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct ToolCallChunk { - pub index: usize, + pub index: Option, pub id: Option, pub function: Option, } @@ -484,7 +532,7 @@ impl CopilotChat { }; if this.oauth_token.is_some() { - cx.spawn(async move |this, mut cx| Self::update_models(&this, &mut cx).await) + cx.spawn(async move |this, cx| Self::update_models(&this, cx).await) .detach_and_log_err(cx); } @@ -531,13 +579,47 @@ impl CopilotChat { is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { + let (client, token, configuration) = Self::get_auth_details(&mut cx).await?; + + let api_url = configuration.chat_completions_url_from_endpoint(&token.api_endpoint); + stream_completion( + client.clone(), + token.api_key, + api_url.into(), + request, + is_user_initiated, + ) + .await + } + + pub async fn stream_response( + request: responses::Request, + is_user_initiated: bool, + mut cx: AsyncApp, + ) -> Result>> { + let (client, token, configuration) = Self::get_auth_details(&mut cx).await?; + + let api_url = configuration.responses_url_from_endpoint(&token.api_endpoint); + responses::stream_response( + client.clone(), + token.api_key, + api_url, + request, + is_user_initiated, + ) + .await + } + + async fn get_auth_details( + cx: &mut AsyncApp, + ) -> Result<(Arc, ApiToken, CopilotChatConfiguration)> { let this = cx .update(|cx| Self::global(cx)) .ok() .flatten() .context("Copilot chat is not enabled")?; - let (oauth_token, api_token, client, configuration) = this.read_with(&cx, |this, _| { + let (oauth_token, api_token, client, configuration) = this.read_with(cx, |this, _| { ( this.oauth_token.clone(), this.api_token.clone(), @@ -549,12 +631,12 @@ impl CopilotChat { let oauth_token = oauth_token.context("No OAuth token available")?; let token = match api_token { - Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token.clone(), + Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token, _ => { let token_url = configuration.token_url(); let token = request_api_token(&oauth_token, token_url.into(), client.clone()).await?; - this.update(&mut cx, |this, cx| { + this.update(cx, |this, cx| { this.api_token = Some(token.clone()); cx.notify(); })?; @@ -562,15 +644,7 @@ impl CopilotChat { } }; - let api_url = configuration.api_url_from_endpoint(&token.api_endpoint); - stream_completion( - client.clone(), - token.api_key, - api_url.into(), - request, - is_user_initiated, - ) - .await + Ok((client, token, configuration)) } pub fn set_configuration( @@ -602,6 +676,7 @@ async fn get_models( .into_iter() .filter(|model| { model.model_picker_enabled + && model.capabilities.model_type.as_str() == "chat" && model .policy .as_ref() @@ -610,9 +685,7 @@ async fn get_models( .dedup_by(|a, b| a.capabilities.family == b.capabilities.family) .collect(); - if let Some(default_model_position) = - models.iter().position(|model| model.id == DEFAULT_MODEL_ID) - { + if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) { let default_model = models.remove(default_model_position); models.insert(0, default_model); } @@ -630,7 +703,9 @@ async fn request_models( .uri(models_url.as_ref()) .header("Authorization", format!("Bearer {}", api_token)) .header("Content-Type", "application/json") - .header("Copilot-Integration-Id", "vscode-chat"); + .header("Copilot-Integration-Id", "vscode-chat") + .header("Editor-Version", "vscode/1.103.2") + .header("x-github-api-version", "2025-05-01"); let request = request_builder.body(AsyncBody::empty())?; @@ -718,7 +793,7 @@ async fn stream_completion( let request_initiator = if is_user_initiated { "user" } else { "agent" }; - let mut request_builder = HttpRequest::builder() + let request_builder = HttpRequest::builder() .method(Method::POST) .uri(completion_url.as_ref()) .header( @@ -731,12 +806,10 @@ async fn stream_completion( .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .header("Copilot-Integration-Id", "vscode-chat") - .header("X-Initiator", request_initiator); - - if is_vision_request { - request_builder = - request_builder.header("Copilot-Vision-Request", is_vision_request.to_string()); - } + .header("X-Initiator", request_initiator) + .when(is_vision_request, |builder| { + builder.header("Copilot-Vision-Request", is_vision_request.to_string()) + }); let is_streaming = request.stream; @@ -801,6 +874,10 @@ mod tests { let json = r#"{ "data": [ { + "billing": { + "is_premium": false, + "multiplier": 0 + }, "capabilities": { "family": "gpt-4", "limits": { @@ -814,6 +891,8 @@ mod tests { "type": "chat" }, "id": "gpt-4", + "is_chat_default": false, + "is_chat_fallback": false, "model_picker_enabled": false, "name": "GPT 4", "object": "model", @@ -825,6 +904,16 @@ mod tests { "some-unknown-field": 123 }, { + "billing": { + "is_premium": true, + "multiplier": 1, + "restricted_to": [ + "pro", + "pro_plus", + "business", + "enterprise" + ] + }, "capabilities": { "family": "claude-3.7-sonnet", "limits": { @@ -848,6 +937,8 @@ mod tests { "type": "chat" }, "id": "claude-3.7-sonnet", + "is_chat_default": false, + "is_chat_fallback": false, "model_picker_enabled": true, "name": "Claude 3.7 Sonnet", "object": "model", @@ -863,10 +954,51 @@ mod tests { "object": "list" }"#; - let schema: ModelSchema = serde_json::from_str(&json).unwrap(); + let schema: ModelSchema = serde_json::from_str(json).unwrap(); assert_eq!(schema.data.len(), 2); assert_eq!(schema.data[0].id, "gpt-4"); assert_eq!(schema.data[1].id, "claude-3.7-sonnet"); } + + #[test] + fn test_unknown_vendor_resilience() { + let json = r#"{ + "data": [ + { + "billing": { + "is_premium": false, + "multiplier": 1 + }, + "capabilities": { + "family": "future-model", + "limits": { + "max_context_window_tokens": 128000, + "max_output_tokens": 8192, + "max_prompt_tokens": 120000 + }, + "object": "model_capabilities", + "supports": { "streaming": true, "tool_calls": true }, + "type": "chat" + }, + "id": "future-model-v1", + "is_chat_default": false, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "Future Model v1", + "object": "model", + "preview": false, + "vendor": "SomeNewVendor", + "version": "v1.0" + } + ], + "object": "list" + }"#; + + let schema: ModelSchema = serde_json::from_str(json).unwrap(); + + assert_eq!(schema.data.len(), 1); + assert_eq!(schema.data[0].id, "future-model-v1"); + assert_eq!(schema.data[0].vendor, ModelVendor::Unknown); + } } diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 2fd6df27b9e15d4247d85edca4d8836c35b23df1..6027c081ccef31bfdeb83cb944dcba861bc95da8 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -3,7 +3,6 @@ use anyhow::Result; use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; use gpui::{App, Context, Entity, EntityId, Task}; use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings}; -use project::Project; use settings::Settings; use std::{path::Path, time::Duration}; @@ -84,7 +83,6 @@ impl EditPredictionProvider for CopilotCompletionProvider { fn refresh( &mut self, - _project: Option>, buffer: Entity, cursor_position: language::Anchor, debounce: bool, @@ -249,7 +247,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { None } else { let position = cursor_position.bias_right(buffer); - Some(EditPrediction { + Some(EditPrediction::Local { id: None, edits: vec![(position..position, completion_text.into())], edit_preview: None, @@ -281,14 +279,11 @@ mod tests { use indoc::indoc; use language::{ Point, - language_settings::{ - AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings, LspInsertMode, - WordsCompletionMode, - }, + language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode}, }; use project::Project; use serde_json::json; - use settings::SettingsStore; + use settings::{AllLanguageSettingsContent, SettingsStore}; use std::future::Future; use util::{ path, @@ -299,11 +294,11 @@ mod tests { async fn test_copilot(executor: BackgroundExecutor, cx: &mut TestAppContext) { // flaky init_test(cx, |settings| { - settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Disabled, - lsp: true, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::Insert, + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -531,11 +526,11 @@ mod tests { ) { // flaky init_test(cx, |settings| { - settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Disabled, - lsp: true, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::Insert, + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -1083,7 +1078,7 @@ mod tests { let replace_range_marker: TextRangeMarker = ('<', '>').into(); let (_, mut marked_ranges) = marked_text_ranges_by( marked_string, - vec![complete_from_marker.clone(), replace_range_marker.clone()], + vec![complete_from_marker, replace_range_marker.clone()], ); let replace_range = @@ -1126,7 +1121,7 @@ mod tests { Project::init_settings(cx); workspace::init_settings(cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { - store.update_user_settings::(cx, f); + store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); }); }); } diff --git a/crates/copilot/src/copilot_responses.rs b/crates/copilot/src/copilot_responses.rs new file mode 100644 index 0000000000000000000000000000000000000000..c1e066208823dcab34a32096cfa447dd0ec9592f --- /dev/null +++ b/crates/copilot/src/copilot_responses.rs @@ -0,0 +1,414 @@ +use super::*; +use anyhow::{Result, anyhow}; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +pub use settings::OpenAiReasoningEffort as ReasoningEffort; + +#[derive(Serialize, Debug)] +pub struct Request { + pub model: String, + pub input: Vec, + #[serde(default)] + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ResponseIncludable { + #[serde(rename = "reasoning.encrypted_content")] + ReasoningEncryptedContent, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolDefinition { + Function { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + strict: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ToolChoice { + Auto, + Any, + None, + #[serde(untagged)] + Other(ToolDefinition), +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningSummary { + Auto, + Concise, + Detailed, +} + +#[derive(Serialize, Debug)] +pub struct ReasoningConfig { + pub effort: ReasoningEffort, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum ResponseImageDetail { + Low, + High, + #[default] + Auto, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseInputContent { + InputText { + text: String, + }, + OutputText { + text: String, + }, + InputImage { + #[serde(skip_serializing_if = "Option::is_none")] + image_url: Option, + #[serde(default)] + detail: ResponseImageDetail, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ItemStatus { + InProgress, + Completed, + Incomplete, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum ResponseFunctionOutput { + Text(String), + Content(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseInputItem { + Message { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + FunctionCall { + call_id: String, + name: String, + arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + FunctionCallOutput { + call_id: String, + output: ResponseFunctionOutput, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + Reasoning { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + summary: Vec, + encrypted_content: String, + }, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum IncompleteReason { + #[serde(rename = "max_output_tokens")] + MaxOutputTokens, + #[serde(rename = "content_filter")] + ContentFilter, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct IncompleteDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResponseReasoningItem { + #[serde(rename = "type")] + pub kind: String, + pub text: String, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +pub enum StreamEvent { + #[serde(rename = "error")] + GenericError { error: ResponseError }, + + #[serde(rename = "response.created")] + Created { response: Response }, + + #[serde(rename = "response.output_item.added")] + OutputItemAdded { + output_index: usize, + #[serde(default)] + sequence_number: Option, + item: ResponseOutputItem, + }, + + #[serde(rename = "response.output_text.delta")] + OutputTextDelta { + item_id: String, + output_index: usize, + delta: String, + }, + + #[serde(rename = "response.output_item.done")] + OutputItemDone { + output_index: usize, + #[serde(default)] + sequence_number: Option, + item: ResponseOutputItem, + }, + + #[serde(rename = "response.incomplete")] + Incomplete { response: Response }, + + #[serde(rename = "response.completed")] + Completed { response: Response }, + + #[serde(rename = "response.failed")] + Failed { response: Response }, + + #[serde(other)] + Unknown, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ResponseError { + pub code: String, + pub message: String, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct Response { + pub id: Option, + pub status: Option, + pub usage: Option, + pub output: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub incomplete_details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct ResponseUsage { + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseOutputItem { + Message { + id: String, + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option>, + }, + FunctionCall { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + call_id: String, + name: String, + arguments: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + Reasoning { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + encrypted_content: Option, + }, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ResponseOutputContent { + OutputText { text: String }, + Refusal { refusal: String }, +} + +pub async fn stream_response( + client: Arc, + api_key: String, + api_url: String, + request: Request, + is_user_initiated: bool, +) -> Result>> { + let is_vision_request = request.input.iter().any(|item| match item { + ResponseInputItem::Message { + content: Some(parts), + .. + } => parts + .iter() + .any(|p| matches!(p, ResponseInputContent::InputImage { .. })), + _ => false, + }); + + let request_initiator = if is_user_initiated { "user" } else { "agent" }; + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(&api_url) + .header( + "Editor-Version", + format!( + "Zed/{}", + option_env!("CARGO_PKG_VERSION").unwrap_or("unknown") + ), + ) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .header("Copilot-Integration-Id", "vscode-chat") + .header("X-Initiator", request_initiator); + + let request_builder = if is_vision_request { + request_builder.header("Copilot-Vision-Request", "true") + } else { + request_builder + }; + + let is_streaming = request.stream; + let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; + let mut response = client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!("Failed to connect to API: {} {}", response.status(), body); + } + + if is_streaming { + let reader = BufReader::new(response.into_body()); + Ok(reader + .lines() + .filter_map(|line| async move { + match line { + Ok(line) => { + let line = line.strip_prefix("data: ")?; + if line.starts_with("[DONE]") || line.is_empty() { + return None; + } + + match serde_json::from_str::(line) { + Ok(event) => Some(Ok(event)), + Err(error) => { + log::error!( + "Failed to parse Copilot responses stream event: `{}`\nResponse: `{}`", + error, + line, + ); + Some(Err(anyhow!(error))) + } + } + } + Err(error) => Some(Err(anyhow!(error))), + } + }) + .boxed()) + } else { + // Simulate streaming this makes the mapping of this function return more straight-forward to handle if all callers assume it streams. + // Removes the need of having a method to map StreamEvent and another to map Response to a LanguageCompletionEvent + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + match serde_json::from_str::(&body) { + Ok(response) => { + let events = vec![StreamEvent::Created { + response: response.clone(), + }]; + + let mut all_events = events; + for (output_index, item) in response.output.iter().enumerate() { + all_events.push(StreamEvent::OutputItemAdded { + output_index, + sequence_number: None, + item: item.clone(), + }); + + if let ResponseOutputItem::Message { + id, + content: Some(content), + .. + } = item + { + for part in content { + if let ResponseOutputContent::OutputText { text } = part { + all_events.push(StreamEvent::OutputTextDelta { + item_id: id.clone(), + output_index, + delta: text.clone(), + }); + } + } + } + + all_events.push(StreamEvent::OutputItemDone { + output_index, + sequence_number: None, + item: item.clone(), + }); + } + + let final_event = if response.error.is_some() { + StreamEvent::Failed { response } + } else if response.incomplete_details.is_some() { + StreamEvent::Incomplete { response } + } else { + StreamEvent::Completed { response } + }; + all_events.push(final_event); + + Ok(futures::stream::iter(all_events.into_iter().map(Ok)).boxed()) + } + Err(error) => { + log::error!( + "Failed to parse Copilot non-streaming response: `{}`\nResponse: `{}`", + error, + body, + ); + Err(anyhow!(error)) + } + } + } +} diff --git a/crates/copilot/src/request.rs b/crates/copilot/src/request.rs index 0deabe16d15c4a502b278c4a631720094ad18af7..85d6254dc060824a9b2686e8f53090fccb39980e 100644 --- a/crates/copilot/src/request.rs +++ b/crates/copilot/src/request.rs @@ -102,7 +102,7 @@ pub struct GetCompletionsDocument { pub tab_size: u32, pub indent_size: u32, pub insert_spaces: bool, - pub uri: lsp::Url, + pub uri: lsp::Uri, pub relative_path: String, pub position: lsp::Position, pub version: usize, diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 2420b499f8fecb3d66f2cabbce57bbd39fd19a7c..3f85039e9ea3bce8e702991461adec4a931d3e4a 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -6,7 +6,10 @@ edition.workspace = true license = "GPL-3.0-or-later" [dependencies] +bincode.workspace = true +cfg-if.workspace = true crash-handler.workspace = true +extension_host.workspace = true log.workspace = true minidumper.workspace = true paths.workspace = true @@ -14,7 +17,11 @@ release_channel.workspace = true smol.workspace = true serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true +system_specs.workspace = true +zstd.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +mach2.workspace = true [lints] workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index ddf6468be817638d40cea3bfdd2a00e8a83e998f..3a2c9378535dd1ac5dead68b3191ba1699b4d920 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -1,16 +1,19 @@ -use crash_handler::CrashHandler; +use crash_handler::{CrashEventResult, CrashHandler}; use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; +use smol::process::Command; +#[cfg(target_os = "macos")] +use std::sync::atomic::AtomicU32; use std::{ env, fs::{self, File}, io, - panic::Location, + panic::{self, PanicHookInfo}, path::{Path, PathBuf}, - process::{self, Command}, + process::{self}, sync::{ Arc, OnceLock, atomic::{AtomicBool, Ordering}, @@ -26,9 +29,35 @@ pub static REQUESTED_MINIDUMP: AtomicBool = AtomicBool::new(false); const CRASH_HANDLER_PING_TIMEOUT: Duration = Duration::from_secs(60); const CRASH_HANDLER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(target_os = "macos")] +static PANIC_THREAD_ID: AtomicU32 = AtomicU32::new(0); + pub async fn init(crash_init: InitCrashHandler) { - if *RELEASE_CHANNEL == ReleaseChannel::Dev && env::var("ZED_GENERATE_MINIDUMPS").is_err() { - return; + let gen_var = match env::var("ZED_GENERATE_MINIDUMPS") { + Ok(v) => { + if v == "false" || v == "0" { + Some(false) + } else { + Some(true) + } + } + Err(_) => None, + }; + + match (gen_var, *RELEASE_CHANNEL) { + (Some(false), _) | (None, ReleaseChannel::Dev) => { + let old_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + unsafe { env::set_var("RUST_BACKTRACE", "1") }; + old_hook(info); + // prevent the macOS crash dialog from popping up + std::process::exit(1); + })); + return; + } + (Some(true), _) | (None, _) => { + panic::set_hook(Box::new(panic_hook)); + } } let exe = env::current_exe().expect("unable to find ourselves"); @@ -39,13 +68,13 @@ pub async fn init(crash_init: InitCrashHandler) { // used by the crash handler isn't destroyed correctly which causes it to stay on the file // system and block further attempts to initialize crash handlers with that socket path. let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}")); - #[allow(unused)] - let server_pid = Command::new(exe) + let _crash_handler = Command::new(exe) .arg("--crash-handler") .arg(&socket_name) .spawn() - .expect("unable to spawn server process") - .id(); + .expect("unable to spawn server process"); + #[cfg(target_os = "linux")] + let server_pid = _crash_handler.id(); info!("spawning crash handler process"); let mut elapsed = Duration::ZERO; @@ -66,7 +95,7 @@ pub async fn init(crash_init: InitCrashHandler) { .unwrap(); let client = Arc::new(client); - let handler = crash_handler::CrashHandler::attach(unsafe { + let handler = CrashHandler::attach(unsafe { let client = client.clone(); crash_handler::make_crash_event(move |crash_context: &crash_handler::CrashContext| { // only request a minidump once @@ -74,12 +103,18 @@ pub async fn init(crash_init: InitCrashHandler) { .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_ok() { - client.ping().unwrap(); + #[cfg(target_os = "macos")] + suspend_all_other_threads(); + + // on macos this "ping" is needed to ensure that all our + // `client.send_message` calls have been processed before we trigger the + // minidump request. + client.ping().ok(); client.request_dump(crash_context).is_ok() } else { true }; - crash_handler::CrashEventResult::Handled(res) + CrashEventResult::Handled(res) }) }) .expect("failed to attach signal handler"); @@ -98,9 +133,28 @@ pub async fn init(crash_init: InitCrashHandler) { } } +#[cfg(target_os = "macos")] +unsafe fn suspend_all_other_threads() { + let task = unsafe { mach2::traps::current_task() }; + let mut threads: mach2::mach_types::thread_act_array_t = std::ptr::null_mut(); + let mut count = 0; + unsafe { + mach2::task::task_threads(task, &raw mut threads, &raw mut count); + } + let current = unsafe { mach2::mach_init::mach_thread_self() }; + let panic_thread = PANIC_THREAD_ID.load(Ordering::SeqCst); + for i in 0..count { + let t = unsafe { *threads.add(i as usize) }; + if t != current && t != panic_thread { + unsafe { mach2::thread_act::thread_suspend(t) }; + } + } +} + pub struct CrashServer { initialization_params: OnceLock, panic_info: OnceLock, + active_gpu: OnceLock, has_connection: Arc, } @@ -108,15 +162,18 @@ pub struct CrashServer { pub struct CrashInfo { pub init: InitCrashHandler, pub panic: Option, + pub minidump_error: Option, + pub gpus: Vec, + pub active_gpu: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct InitCrashHandler { pub session_id: String, pub zed_version: String, + pub binary: String, pub release_channel: String, pub commit_sha: String, - // pub gpu: String, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -142,16 +199,33 @@ impl minidumper::ServerHandler for CrashServer { } fn on_minidump_created(&self, result: Result) -> LoopAction { - match result { - Ok(mut md_bin) => { + let minidump_error = match result { + Ok(MinidumpBinary { mut file, path, .. }) => { use io::Write; - let _ = md_bin.file.flush(); - info!("wrote minidump to disk {:?}", md_bin.path); + file.flush().ok(); + // TODO: clean this up once https://github.com/EmbarkStudios/crash-handling/issues/101 is addressed + drop(file); + let original_file = File::open(&path).unwrap(); + let compressed_path = path.with_extension("zstd"); + let compressed_file = File::create(&compressed_path).unwrap(); + zstd::stream::copy_encode(original_file, compressed_file, 0).ok(); + fs::rename(&compressed_path, path).unwrap(); + None } - Err(e) => { - info!("failed to write minidump: {:#}", e); + Err(e) => Some(format!("{e:?}")), + }; + + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let gpus = vec![]; + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let gpus = match system_specs::read_gpu_info_from_sys_class_drm() { + Ok(gpus) => gpus, + Err(err) => { + log::warn!("Failed to collect GPU information for crash report: {err}"); + vec![] } - } + }; let crash_info = CrashInfo { init: self @@ -160,6 +234,9 @@ impl minidumper::ServerHandler for CrashServer { .expect("not initialized") .clone(), panic: self.panic_info.get().cloned(), + minidump_error, + active_gpu: self.active_gpu.get().cloned(), + gpus, }; let crash_data_path = paths::logs_dir() @@ -185,6 +262,13 @@ impl minidumper::ServerHandler for CrashServer { serde_json::from_slice::(&buffer).expect("invalid panic data"); self.panic_info.set(panic_data).expect("already panicked"); } + 3 => { + let gpu_specs: system_specs::GpuSpecs = + bincode::deserialize(&buffer).expect("gpu specs"); + self.active_gpu + .set(gpu_specs) + .expect("already set active gpu"); + } _ => { panic!("invalid message kind"); } @@ -201,8 +285,21 @@ impl minidumper::ServerHandler for CrashServer { } } -pub fn handle_panic(message: String, span: Option<&Location>) { - let span = span +pub fn panic_hook(info: &PanicHookInfo) { + // Don't handle a panic on threads that are not relevant to the main execution. + if extension_host::wasm_host::IS_WASM_THREAD.with(|v| v.load(Ordering::Acquire)) { + return; + } + + let message = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "Box".to_string()); + + let span = info + .location() .map(|loc| format!("{}:{}", loc.file(), loc.line())) .unwrap_or_default(); @@ -218,11 +315,22 @@ pub fn handle_panic(message: String, span: Option<&Location>) { ) .ok(); log::error!("triggering a crash to generate a minidump..."); - #[cfg(target_os = "linux")] - CrashHandler.simulate_signal(crash_handler::Signal::Trap as u32); - #[cfg(not(target_os = "linux"))] - CrashHandler.simulate_exception(None); - break; + + #[cfg(target_os = "macos")] + PANIC_THREAD_ID.store( + unsafe { mach2::mach_init::mach_thread_self() }, + Ordering::SeqCst, + ); + + cfg_if::cfg_if! { + if #[cfg(target_os = "windows")] { + // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499- + CrashHandler.simulate_exception(Some(234)); // (MORE_DATA_AVAILABLE) + break; + } else { + std::process::abort(); + } + } } thread::sleep(retry_frequency); } @@ -237,16 +345,19 @@ pub fn crash_server(socket: &Path) { let shutdown = Arc::new(AtomicBool::new(false)); let has_connection = Arc::new(AtomicBool::new(false)); - std::thread::spawn({ - let shutdown = shutdown.clone(); - let has_connection = has_connection.clone(); - move || { - std::thread::sleep(CRASH_HANDLER_CONNECT_TIMEOUT); - if !has_connection.load(Ordering::SeqCst) { - shutdown.store(true, Ordering::SeqCst); + thread::Builder::new() + .name("CrashServerTimeout".to_owned()) + .spawn({ + let shutdown = shutdown.clone(); + let has_connection = has_connection.clone(); + move || { + std::thread::sleep(CRASH_HANDLER_CONNECT_TIMEOUT); + if !has_connection.load(Ordering::SeqCst) { + shutdown.store(true, Ordering::SeqCst); + } } - } - }); + }) + .unwrap(); server .run( @@ -254,6 +365,7 @@ pub fn crash_server(socket: &Path) { initialization_params: OnceLock::new(), panic_info: OnceLock::new(), has_connection, + active_gpu: OnceLock::new(), }), &shutdown, Some(CRASH_HANDLER_PING_TIMEOUT), diff --git a/crates/credentials_provider/Cargo.toml b/crates/credentials_provider/Cargo.toml index 3233b68c605e5273254366c62413172be3375ad5..bf47bb24b12b90d54bc04f766efe06489c730b43 100644 --- a/crates/credentials_provider/Cargo.toml +++ b/crates/credentials_provider/Cargo.toml @@ -19,4 +19,3 @@ paths.workspace = true release_channel.workspace = true serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/crates/credentials_provider/src/credentials_provider.rs b/crates/credentials_provider/src/credentials_provider.rs index f72fd6c39b12d5d46cfa1d4f3f30900f01471e64..2c8dd6fc812aaeffd6c06c88ee2adceabdbb27a3 100644 --- a/crates/credentials_provider/src/credentials_provider.rs +++ b/crates/credentials_provider/src/credentials_provider.rs @@ -19,7 +19,7 @@ use release_channel::ReleaseChannel; /// Only works in development. Setting this environment variable in other /// release channels is a no-op. static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock = LazyLock::new(|| { - std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").map_or(false, |value| !value.is_empty()) + std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty()) }); /// A provider for credentials. diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml index ee963a4f83a70775bcf103094cb04e09f1791998..d856ae0164ff35236f7a133361cdf28908f8b044 100644 --- a/crates/dap/Cargo.toml +++ b/crates/dap/Cargo.toml @@ -49,7 +49,6 @@ smol.workspace = true task.workspace = true telemetry.workspace = true util.workspace = true -workspace-hack.workspace = true [target.'cfg(not(windows))'.dependencies] libc.workspace = true diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs index 687305ae94da3bc1ddd72e9e9f4594f4f4a19ee4..b303a0c0268c7e7812e49d1ff3fbe827f6eac2aa 100644 --- a/crates/dap/src/adapters.rs +++ b/crates/dap/src/adapters.rs @@ -24,7 +24,7 @@ use std::{ sync::Arc, }; use task::{DebugScenario, TcpArgumentsTemplate, ZedDebugConfig}; -use util::archive::extract_zip; +use util::{archive::extract_zip, rel_path::RelPath}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum DapStatus { @@ -44,8 +44,9 @@ pub trait DapDelegate: Send + Sync + 'static { fn fs(&self) -> Arc; fn output_to_console(&self, msg: String); async fn which(&self, command: &OsStr) -> Option; - async fn read_text_file(&self, path: PathBuf) -> Result; + async fn read_text_file(&self, path: &RelPath) -> Result; async fn shell_env(&self) -> collections::HashMap; + fn is_headless(&self) -> bool; } #[derive( @@ -238,7 +239,7 @@ impl DebugAdapterBinary { cwd: self .cwd .as_ref() - .map(|cwd| cwd.to_string_lossy().to_string()), + .map(|cwd| cwd.to_string_lossy().into_owned()), connection: self.connection.as_ref().map(|c| c.to_proto()), launch_type: match self.request_args.request { StartDebuggingRequestArgumentsRequest::Launch => { @@ -285,7 +286,7 @@ pub async fn download_adapter_from_github( } if !adapter_path.exists() { - fs.create_dir(&adapter_path.as_path()) + fs.create_dir(adapter_path.as_path()) .await .context("Failed creating adapter path")?; } @@ -305,7 +306,7 @@ pub async fn download_adapter_from_github( anyhow::ensure!( response.status().is_success(), "download failed with status {}", - response.status().to_string() + response.status() ); delegate.output_to_console("Download complete".to_owned()); @@ -355,6 +356,7 @@ pub trait DebugAdapter: 'static + Send + Sync { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result; @@ -454,6 +456,7 @@ impl DebugAdapter for FakeAdapter { task_definition: &DebugTaskDefinition, _: Option, _: Option>, + _: Option>, _: &mut AsyncApp, ) -> Result { let connection = task_definition diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index 7b791450ecba3b09b6571ac84fbebdf92fff57b8..15801e989169677f6e42bdd7b9c5642d82ea644a 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -23,7 +23,7 @@ impl SessionId { Self(client_id as u32) } - pub fn to_proto(&self) -> u64 { + pub fn to_proto(self) -> u64 { self.0 as u64 } } @@ -118,6 +118,7 @@ impl DebugAdapterClient { R::COMMAND, sequence_id ); + log::debug!(" request: {request:?}"); self.send_message(Message::Request(request)).await?; @@ -130,6 +131,8 @@ impl DebugAdapterClient { command, sequence_id ); + log::debug!(" response: {response:?}"); + match response.success { true => { if let Some(json) = response.body { diff --git a/crates/dap/src/debugger_settings.rs b/crates/dap/src/debugger_settings.rs index e1176633e5403116c2789161d654912337150e9a..dc38c9a0616ff8d37bdfd33f269a4fec9a6395b2 100644 --- a/crates/dap/src/debugger_settings.rs +++ b/crates/dap/src/debugger_settings.rs @@ -1,19 +1,6 @@ use dap_types::SteppingGranularity; -use gpui::{App, Global}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use settings::{Settings, SettingsContent}; -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DebugPanelDockPosition { - Left, - Bottom, - Right, -} - -#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)] -#[serde(default)] pub struct DebuggerSettings { /// Determines the stepping granularity. /// @@ -42,33 +29,32 @@ pub struct DebuggerSettings { /// The dock position of the debug panel /// /// Default: Bottom - pub dock: DebugPanelDockPosition, + pub dock: settings::DockPosition, } -impl Default for DebuggerSettings { - fn default() -> Self { +impl Settings for DebuggerSettings { + fn from_settings(content: &SettingsContent) -> Self { + let content = content.debugger.clone().unwrap(); Self { - button: true, - save_breakpoints: true, - stepping_granularity: SteppingGranularity::Line, - timeout: 2000, - log_dap_communications: true, - format_dap_log_messages: true, - dock: DebugPanelDockPosition::Bottom, + stepping_granularity: dap_granularity_from_settings( + content.stepping_granularity.unwrap(), + ), + save_breakpoints: content.save_breakpoints.unwrap(), + button: content.button.unwrap(), + timeout: content.timeout.unwrap(), + log_dap_communications: content.log_dap_communications.unwrap(), + format_dap_log_messages: content.format_dap_log_messages.unwrap(), + dock: content.dock.unwrap(), } } } -impl Settings for DebuggerSettings { - const KEY: Option<&'static str> = Some("debugger"); - - type FileContent = Self; - - fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { - sources.json_merge() +fn dap_granularity_from_settings( + granularity: settings::SteppingGranularity, +) -> dap_types::SteppingGranularity { + match granularity { + settings::SteppingGranularity::Instruction => dap_types::SteppingGranularity::Instruction, + settings::SteppingGranularity::Line => dap_types::SteppingGranularity::Line, + settings::SteppingGranularity::Statement => dap_types::SteppingGranularity::Statement, } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } - -impl Global for DebuggerSettings {} diff --git a/crates/dap/src/registry.rs b/crates/dap/src/registry.rs index 212fa2bc239bb5180274ae482f3d39082a16dd3f..d578b41762ec51569ec4c853777b3a8daffa9531 100644 --- a/crates/dap/src/registry.rs +++ b/crates/dap/src/registry.rs @@ -64,19 +64,19 @@ impl DapRegistry { .and_then(|adapter| adapter.adapter_language_name()) } - pub async fn adapters_schema(&self) -> task::AdapterSchemas { - let mut schemas = AdapterSchemas(vec![]); + pub fn adapters_schema(&self) -> task::AdapterSchemas { + let mut schemas = vec![]; - let adapters = self.0.read().adapters.clone(); + let adapters = &self.0.read().adapters; for (name, adapter) in adapters.into_iter() { - schemas.0.push(AdapterSchema { - adapter: name.into(), + schemas.push(AdapterSchema { + adapter: name.clone().into(), schema: adapter.dap_schema(), }); } - schemas + AdapterSchemas(schemas) } pub fn locators(&self) -> FxHashMap> { diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs index f9fbbfc84295bfba946ad96b5eb701d13c6aa52c..e6f8d0bce1c28c9f1dfc8b7ad0c1ba4ffceeca36 100644 --- a/crates/dap/src/transport.rs +++ b/crates/dap/src/transport.rs @@ -263,9 +263,14 @@ impl TransportDelegate { } } + // Clean up logs by trimming unnecessary whitespace/newlines before inserting into log. + let line = line.trim(); + + log::debug!("stderr: {line}"); + for (kind, handler) in log_handlers.lock().iter_mut() { if matches!(kind, LogKind::Adapter) { - handler(iokind, None, line.as_str()); + handler(iokind, None, line); } } } @@ -648,7 +653,7 @@ impl Drop for TcpTransport { } pub struct StdioTransport { - process: Mutex>, + process: Mutex, _stderr_task: Option>, } @@ -673,15 +678,9 @@ impl StdioTransport { command.args(&binary.arguments); command.envs(&binary.envs); - let mut process = Child::spawn(command, Stdio::piped()).with_context(|| { - format!( - "failed to spawn command `{} {}`.", - binary_command, - binary.arguments.join(" ") - ) - })?; + let mut process = Child::spawn(command, Stdio::piped())?; - let err_task = process.stderr.take().map(|stderr| { + let _stderr_task = process.stderr.take().map(|stderr| { cx.background_spawn(TransportDelegate::handle_adapter_log( stderr, IoKind::StdErr, @@ -689,24 +688,22 @@ impl StdioTransport { )) }); - let process = Mutex::new(Some(process)); + let process = Mutex::new(process); Ok(Self { process, - _stderr_task: err_task, + _stderr_task, }) } } impl Transport for StdioTransport { fn has_adapter_logs(&self) -> bool { - false + true } fn kill(&mut self) { - if let Some(process) = &mut *self.process.lock() { - process.kill(); - } + self.process.lock().kill(); } fn connect( @@ -718,8 +715,7 @@ impl Transport for StdioTransport { )>, > { let result = util::maybe!({ - let mut guard = self.process.lock(); - let process = guard.as_mut().context("oops")?; + let mut process = self.process.lock(); Ok(( Box::new(process.stdin.take().context("Cannot reconnect")?) as _, Box::new(process.stdout.take().context("Cannot reconnect")?) as _, @@ -735,9 +731,7 @@ impl Transport for StdioTransport { impl Drop for StdioTransport { fn drop(&mut self) { - if let Some(process) = &mut *self.process.lock() { - process.kill(); - } + self.process.lock().kill(); } } @@ -1057,11 +1051,13 @@ impl Child { #[cfg(not(windows))] fn spawn(mut command: std::process::Command, stdin: Stdio) -> Result { util::set_pre_exec_to_start_new_session(&mut command); - let process = smol::process::Command::from(command) + let mut command = smol::process::Command::from(command); + let process = command .stdin(stdin) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn()?; + .spawn() + .with_context(|| format!("failed to spawn command `{command:?}`",))?; Ok(Self { process }) } @@ -1069,11 +1065,13 @@ impl Child { fn spawn(command: std::process::Command, stdin: Stdio) -> Result { // TODO(windows): create a job object and add the child process handle to it, // see https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects - let process = smol::process::Command::from(command) + let mut command = smol::process::Command::from(command); + let process = command .stdin(stdin) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn()?; + .spawn() + .with_context(|| format!("failed to spawn command `{command:?}`",))?; Ok(Self { process }) } diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml index e7366785c810077ef2bdc3669dd5b340859c97a6..253674c0f3da16574b4303faf679abeb310756d8 100644 --- a/crates/dap_adapters/Cargo.toml +++ b/crates/dap_adapters/Cargo.toml @@ -35,11 +35,9 @@ log.workspace = true paths.workspace = true serde.workspace = true serde_json.workspace = true -shlex.workspace = true smol.workspace = true task.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] dap = { workspace = true, features = ["test-support"] } diff --git a/crates/dap_adapters/src/codelldb.rs b/crates/dap_adapters/src/codelldb.rs index 842bb264a8469402fe73747356ab2e616ab08533..05aca2225aa9f0fd2a7fb4c5c1f213372f6ce899 100644 --- a/crates/dap_adapters/src/codelldb.rs +++ b/crates/dap_adapters/src/codelldb.rs @@ -1,7 +1,8 @@ -use std::{collections::HashMap, path::PathBuf, sync::OnceLock}; +use std::{path::PathBuf, sync::OnceLock}; use anyhow::{Context as _, Result}; use async_trait::async_trait; +use collections::HashMap; use dap::adapters::{DebugTaskDefinition, latest_github_release}; use futures::StreamExt; use gpui::AsyncApp; @@ -329,10 +330,11 @@ impl DebugAdapter for CodeLldbDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { let mut command = user_installed_path - .map(|p| p.to_string_lossy().to_string()) + .map(|p| p.to_string_lossy().into_owned()) .or(self.path_to_codelldb.get().cloned()); if command.is_none() { @@ -372,11 +374,12 @@ impl DebugAdapter for CodeLldbDebugAdapter { } }; let adapter_dir = version_path.join("extension").join("adapter"); - let path = adapter_dir.join("codelldb").to_string_lossy().to_string(); + let path = adapter_dir.join("codelldb").to_string_lossy().into_owned(); self.path_to_codelldb.set(path.clone()).ok(); command = Some(path); }; let mut json_config = config.config.clone(); + Ok(DebugAdapterBinary { command: Some(command.unwrap()), cwd: Some(delegate.worktree_root_path().to_path_buf()), @@ -385,7 +388,7 @@ impl DebugAdapter for CodeLldbDebugAdapter { && let Some(source_languages) = config.get("sourceLanguages").filter(|value| { value .as_array() - .map_or(false, |array| array.iter().all(Value::is_string)) + .is_some_and(|array| array.iter().all(Value::is_string)) }) { let ret = vec![ @@ -401,7 +404,7 @@ impl DebugAdapter for CodeLldbDebugAdapter { request_args: self .request_args(delegate, json_config, &config.label) .await?, - envs: HashMap::default(), + envs: user_env.unwrap_or_default(), connection: None, }) } diff --git a/crates/dap_adapters/src/gdb.rs b/crates/dap_adapters/src/gdb.rs index 17b7a659111532b5fa04f2b3424e50e7867df6d6..12489247c53322612ea7d7cd33fedce51bb68b26 100644 --- a/crates/dap_adapters/src/gdb.rs +++ b/crates/dap_adapters/src/gdb.rs @@ -1,7 +1,8 @@ -use std::{collections::HashMap, ffi::OsStr}; +use std::ffi::OsStr; use anyhow::{Context as _, Result, bail}; use async_trait::async_trait; +use collections::HashMap; use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use gpui::AsyncApp; use task::{DebugScenario, ZedDebugConfig}; @@ -160,6 +161,7 @@ impl DebugAdapter for GdbDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { let user_setting_path = user_installed_path @@ -188,7 +190,7 @@ impl DebugAdapter for GdbDebugAdapter { Ok(DebugAdapterBinary { command: Some(gdb_path), arguments: user_args.unwrap_or_else(|| vec!["-i=dap".into()]), - envs: HashMap::default(), + envs: user_env.unwrap_or_default(), cwd: Some(delegate.worktree_root_path().to_path_buf()), connection: None, request_args: StartDebuggingRequestArguments { diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs index 22d8262b93e36b17e548ae4dcc9bb725da8ca7cb..323ca094934fc93466451246f4bc69f34ded4891 100644 --- a/crates/dap_adapters/src/go.rs +++ b/crates/dap_adapters/src/go.rs @@ -36,7 +36,7 @@ impl GoDebugAdapter { delegate: &Arc, ) -> Result { let release = latest_github_release( - &"zed-industries/delve-shim-dap", + "zed-industries/delve-shim-dap", true, false, delegate.http_client(), @@ -409,17 +409,18 @@ impl DebugAdapter for GoDebugAdapter { task_definition: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _cx: &mut AsyncApp, ) -> Result { let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME); let dlv_path = adapter_path.join("dlv"); let delve_path = if let Some(path) = user_installed_path { - path.to_string_lossy().to_string() + path.to_string_lossy().into_owned() } else if let Some(path) = delegate.which(OsStr::new("dlv")).await { - path.to_string_lossy().to_string() + path.to_string_lossy().into_owned() } else if delegate.fs().is_file(&dlv_path).await { - dlv_path.to_string_lossy().to_string() + dlv_path.to_string_lossy().into_owned() } else { let go = delegate .which(OsStr::new("go")) @@ -443,7 +444,7 @@ impl DebugAdapter for GoDebugAdapter { ); } - adapter_path.join("dlv").to_string_lossy().to_string() + adapter_path.join("dlv").to_string_lossy().into_owned() }; let cwd = Some( @@ -460,7 +461,7 @@ impl DebugAdapter for GoDebugAdapter { let connection; let mut configuration = task_definition.config.clone(); - let mut envs = HashMap::default(); + let mut envs = user_env.unwrap_or_default(); if let Some(configuration) = configuration.as_object_mut() { configuration diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index 2d19921a0f0c979fe53ede5860ac0c4d26b510c3..68f5ca7e7976640c5b3e44ec5e2e2b880a6c2407 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -6,7 +6,7 @@ use gpui::AsyncApp; use serde_json::Value; use std::{path::PathBuf, sync::OnceLock}; use task::DebugRequest; -use util::{ResultExt, maybe}; +use util::{ResultExt, maybe, shell::ShellKind}; use crate::*; @@ -52,12 +52,13 @@ impl JsDebugAdapter { task_definition: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, _: &mut AsyncApp, ) -> Result { let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; - let mut envs = HashMap::default(); + let mut envs = user_env.unwrap_or_default(); let mut configuration = task_definition.config.clone(); if let Some(configuration) = configuration.as_object_mut() { @@ -66,7 +67,7 @@ impl JsDebugAdapter { .get("type") .filter(|value| value == &"node-terminal")?; let command = configuration.get("command")?.as_str()?.to_owned(); - let mut args = shlex::split(&command)?.into_iter(); + let mut args = ShellKind::Posix.split(&command)?.into_iter(); let program = args.next()?; configuration.insert("runtimeExecutable".to_owned(), program.into()); configuration.insert( @@ -99,10 +100,10 @@ impl JsDebugAdapter { } } - if let Some(env) = configuration.get("env").cloned() { - if let Ok(env) = serde_json::from_value(env) { - envs = env; - } + if let Some(env) = configuration.get("env").cloned() + && let Ok(env) = serde_json::from_value::>(env) + { + envs.extend(env.into_iter()); } configuration @@ -120,6 +121,13 @@ impl JsDebugAdapter { configuration .entry("sourceMapRenames") .or_insert(true.into()); + + // Set up remote browser debugging + if delegate.is_headless() { + configuration + .entry("browserLaunchLocation") + .or_insert("ui".into()); + } } let adapter_path = if let Some(user_installed_path) = user_installed_path { @@ -138,11 +146,11 @@ impl JsDebugAdapter { }; let arguments = if let Some(mut args) = user_args { - args.insert(0, adapter_path.to_string_lossy().to_string()); + args.insert(0, adapter_path.to_string_lossy().into_owned()); args } else { vec![ - adapter_path.to_string_lossy().to_string(), + adapter_path.to_string_lossy().into_owned(), port.to_string(), host.to_string(), ] @@ -497,6 +505,7 @@ impl DebugAdapter for JsDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result { if self.checked.set(()).is_ok() { @@ -514,8 +523,15 @@ impl DebugAdapter for JsDebugAdapter { } } - self.get_installed_binary(delegate, &config, user_installed_path, user_args, cx) - .await + self.get_installed_binary( + delegate, + config, + user_installed_path, + user_args, + user_env, + cx, + ) + .await } fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option { diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index a2bd934311ec21da13d08d23211e62718ec5bbc5..66005db77029bd28c66f458bef7f1d2a1ad7a685 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -1,12 +1,13 @@ use crate::*; -use anyhow::Context as _; +use anyhow::{Context as _, bail}; +use collections::HashMap; use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use fs::RemoveOptions; use futures::{StreamExt, TryStreamExt}; use gpui::http_client::AsyncBody; use gpui::{AsyncApp, SharedString}; use json_dotpath::DotPaths; -use language::LanguageName; +use language::{LanguageName, Toolchain}; use paths::debug_adapters_dir; use serde_json::Value; use smol::fs::File; @@ -16,14 +17,15 @@ use std::ffi::OsString; use std::net::Ipv4Addr; use std::str::FromStr; use std::{ - collections::HashMap, ffi::OsStr, path::{Path, PathBuf}, }; -use util::{ResultExt, maybe}; +use util::command::new_smol_command; +use util::{ResultExt, paths::PathStyle, rel_path::RelPath}; #[derive(Default)] pub(crate) struct PythonDebugAdapter { + base_venv_path: OnceCell, String>>, debugpy_whl_base_path: OnceCell, String>>, } @@ -45,7 +47,7 @@ impl PythonDebugAdapter { "Using user-installed debugpy adapter from: {}", user_installed_path.display() ); - vec![user_installed_path.to_string_lossy().to_string()] + vec![user_installed_path.to_string_lossy().into_owned()] } else { let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()); let path = adapter_path @@ -91,14 +93,16 @@ impl PythonDebugAdapter { }) } - async fn fetch_wheel(delegate: &Arc) -> Result, String> { - let system_python = Self::system_python_name(delegate) - .await - .ok_or_else(|| String::from("Could not find a Python installation"))?; - let command: &OsStr = system_python.as_ref(); + async fn fetch_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels"); - std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?; - let installation_succeeded = util::command::new_smol_command(command) + std::fs::create_dir_all(&download_dir)?; + let venv_python = self.base_venv_path(toolchain, delegate).await?; + + let installation_succeeded = util::command::new_smol_command(venv_python.as_ref()) .args([ "-m", "pip", @@ -110,36 +114,36 @@ impl PythonDebugAdapter { ]) .output() .await - .map_err(|e| format!("{e}"))? + .context("spawn system python")? .status .success(); if !installation_succeeded { - return Err("debugpy installation failed".into()); + bail!("debugpy installation failed (could not fetch Debugpy's wheel)"); } - let wheel_path = std::fs::read_dir(&download_dir) - .map_err(|e| e.to_string())? + let wheel_path = std::fs::read_dir(&download_dir)? .find_map(|entry| { entry.ok().filter(|e| { e.file_type().is_ok_and(|typ| typ.is_file()) && Path::new(&e.file_name()).extension() == Some("whl".as_ref()) }) }) - .ok_or_else(|| String::from("Did not find a .whl in {download_dir}"))?; + .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?; util::archive::extract_zip( &debug_adapters_dir().join(Self::ADAPTER_NAME), - File::open(&wheel_path.path()) - .await - .map_err(|e| e.to_string())?, + File::open(&wheel_path.path()).await?, ) - .await - .map_err(|e| e.to_string())?; + .await?; Ok(Arc::from(wheel_path.path())) } - async fn maybe_fetch_new_wheel(delegate: &Arc) { + async fn maybe_fetch_new_wheel( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result<()> { let latest_release = delegate .http_client() .get( @@ -149,62 +153,61 @@ impl PythonDebugAdapter { ) .await .log_err(); - maybe!(async move { - let response = latest_release.filter(|response| response.status().is_success())?; - - let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME); - std::fs::create_dir_all(&download_dir).ok()?; - - let mut output = String::new(); - response - .into_body() - .read_to_string(&mut output) - .await - .ok()?; - let as_json = serde_json::Value::from_str(&output).ok()?; - let latest_version = as_json.get("info").and_then(|info| { + let response = latest_release + .filter(|response| response.status().is_success()) + .context("getting latest release")?; + + let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME); + std::fs::create_dir_all(&download_dir)?; + + let mut output = String::new(); + response.into_body().read_to_string(&mut output).await?; + let as_json = serde_json::Value::from_str(&output)?; + let latest_version = as_json + .get("info") + .and_then(|info| { info.get("version") .and_then(|version| version.as_str()) .map(ToOwned::to_owned) - })?; - let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into(); - let is_up_to_date = delegate - .fs() - .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME)) - .await - .ok()? - .into_stream() - .any(async |entry| { - entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname)) - }) - .await; + }) + .context("parsing latest release information")?; + let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into(); + let is_up_to_date = delegate + .fs() + .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME)) + .await? + .into_stream() + .any(async |entry| { + entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname)) + }) + .await; - if !is_up_to_date { - delegate - .fs() - .remove_dir( - &debug_adapters_dir().join(Self::ADAPTER_NAME), - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - .ok()?; - Self::fetch_wheel(delegate).await.ok()?; - } - Some(()) - }) - .await; + if !is_up_to_date { + delegate + .fs() + .remove_dir( + &debug_adapters_dir().join(Self::ADAPTER_NAME), + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await?; + self.fetch_wheel(toolchain, delegate).await?; + } + anyhow::Ok(()) } async fn fetch_debugpy_whl( &self, + toolchain: Option, delegate: &Arc, ) -> Result, String> { self.debugpy_whl_base_path .get_or_init(|| async move { - Self::maybe_fetch_new_wheel(delegate).await; + self.maybe_fetch_new_wheel(toolchain, delegate) + .await + .map_err(|e| format!("{e}"))?; Ok(Arc::from( debug_adapters_dir() .join(Self::ADAPTER_NAME) @@ -217,18 +220,88 @@ impl PythonDebugAdapter { .clone() } + async fn base_venv_path( + &self, + toolchain: Option, + delegate: &Arc, + ) -> Result> { + let result = self.base_venv_path + .get_or_init(|| async { + let base_python = if let Some(toolchain) = toolchain { + toolchain.path.to_string() + } else { + Self::system_python_name(delegate).await.ok_or_else(|| { + let mut message = "Could not find a Python installation".to_owned(); + if cfg!(windows){ + message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.") + } + message + })? + }; + + let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()); + let output = util::command::new_smol_command(&base_python) + .args(["-m", "venv", "zed_base_venv"]) + .current_dir( + &debug_adapter_path, + ) + .spawn() + .map_err(|e| format!("{e:#?}"))? + .output() + .await + .map_err(|e| format!("{e:#?}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let debug_adapter_path = debug_adapter_path.display(); + return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n")); + } + + const PYTHON_PATH: &str = if cfg!(target_os = "windows") { + "Scripts/python.exe" + } else { + "bin/python3" + }; + Ok(Arc::from( + paths::debug_adapters_dir() + .join(Self::DEBUG_ADAPTER_NAME.as_ref()) + .join("zed_base_venv") + .join(PYTHON_PATH) + .as_ref(), + )) + }) + .await + .clone(); + match result { + Ok(path) => Ok(path), + Err(e) => Err(anyhow::anyhow!("{e}")), + } + } async fn system_python_name(delegate: &Arc) -> Option { const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"]; let mut name = None; for cmd in BINARY_NAMES { - name = delegate - .which(OsStr::new(cmd)) + let Some(path) = delegate.which(OsStr::new(cmd)).await else { + continue; + }; + // Try to detect situations where `python3` exists but is not a real Python interpreter. + // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app + // when run with no arguments, and just fails otherwise. + let Some(output) = new_smol_command(&path) + .args(["-c", "print(1 + 2)"]) + .output() .await - .map(|path| path.to_string_lossy().to_string()); - if name.is_some() { - break; + .ok() + else { + continue; + }; + if output.stdout.trim_ascii() != b"3" { + continue; } + name = Some(path.to_string_lossy().into_owned()); + break; } name } @@ -239,6 +312,7 @@ impl PythonDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, python_from_toolchain: Option, ) -> Result { let tcp_connection = config.tcp_connection.clone().unwrap_or_default(); @@ -276,7 +350,7 @@ impl PythonDebugAdapter { timeout, }), cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), + envs: user_env.unwrap_or_default(), request_args: self.request_args(delegate, config).await?, }) } @@ -671,6 +745,7 @@ impl DebugAdapter for PythonDebugAdapter { config: &DebugTaskDefinition, user_installed_path: Option, user_args: Option>, + user_env: Option>, cx: &mut AsyncApp, ) -> Result { if let Some(local_path) = &user_installed_path { @@ -679,7 +754,14 @@ impl DebugAdapter for PythonDebugAdapter { local_path.display() ); return self - .get_installed_binary(delegate, &config, Some(local_path.clone()), user_args, None) + .get_installed_binary( + delegate, + config, + Some(local_path.clone()), + user_args, + user_env, + None, + ) .await; } @@ -687,44 +769,43 @@ impl DebugAdapter for PythonDebugAdapter { .config .get("cwd") .and_then(|cwd| { - cwd.as_str() - .map(Path::new)? - .strip_prefix(delegate.worktree_root_path()) - .ok() + RelPath::new( + cwd.as_str() + .map(Path::new)? + .strip_prefix(delegate.worktree_root_path()) + .ok()?, + PathStyle::local(), + ) + .ok() }) - .unwrap_or_else(|| "".as_ref()) - .into(); + .unwrap_or_else(|| RelPath::empty().into()); let toolchain = delegate .toolchain_store() .active_toolchain( delegate.worktree_id(), - base_path, + base_path.into_arc(), language::LanguageName::new(Self::LANGUAGE_NAME), cx, ) .await; - let debugpy_path = self - .fetch_debugpy_whl(delegate) + self.fetch_debugpy_whl(toolchain.clone(), delegate) .await .map_err(|e| anyhow::anyhow!("{e}"))?; if let Some(toolchain) = &toolchain { - log::debug!( - "Found debugpy in toolchain environment: {}", - debugpy_path.display() - ); return self .get_installed_binary( delegate, - &config, + config, None, user_args, + user_env, Some(toolchain.path.to_string()), ) .await; } - self.get_installed_binary(delegate, &config, None, user_args, None) + self.get_installed_binary(delegate, config, None, user_args, user_env, None) .await } diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index c53b2988b94dd5b355e132024c2677b61a83d071..3bcfefec0315ad2d94f44946c754501f43999264 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -26,7 +26,7 @@ smol.workspace = true sqlez.workspace = true sqlez_macros.workspace = true util.workspace = true -workspace-hack.workspace = true +zed_env_vars.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index de55212cbadfdd3c66ede66b706a3d120dd765c5..eab2f115d8e5c3db51541544a8dbc95f34713741 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -17,9 +17,10 @@ use sqlez::thread_safe_connection::ThreadSafeConnection; use sqlez_macros::sql; use std::future::Future; use std::path::Path; +use std::sync::atomic::AtomicBool; use std::sync::{LazyLock, atomic::Ordering}; -use std::{env, sync::atomic::AtomicBool}; use util::{ResultExt, maybe}; +use zed_env_vars::ZED_STATELESS; const CONNECTION_INITIALIZE_QUERY: &str = sql!( PRAGMA foreign_keys=TRUE; @@ -36,9 +37,6 @@ const FALLBACK_DB_NAME: &str = "FALLBACK_MEMORY_DB"; const DB_FILE_NAME: &str = "db.sqlite"; -pub static ZED_STATELESS: LazyLock = - LazyLock::new(|| env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty())); - pub static ALL_FILE_DB_FAILED: LazyLock = LazyLock::new(|| AtomicBool::new(false)); /// Open or create a database at the given directory path. @@ -74,7 +72,7 @@ pub async fn open_db(db_dir: &Path, scope: &str) -> Threa } async fn open_main_db(db_path: &Path) -> Option { - log::info!("Opening database {}", db_path.display()); + log::trace!("Opening database {}", db_path.display()); ThreadSafeConnection::builder::(db_path.to_string_lossy().as_ref(), true) .with_db_initialization_query(DB_INITIALIZE_QUERY) .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY) @@ -110,11 +108,14 @@ pub async fn open_test_db(db_name: &str) -> ThreadSafeConnection { } /// Implements a basic DB wrapper for a given domain +/// +/// Arguments: +/// - static variable name for connection +/// - type of connection wrapper +/// - dependencies, whose migrations should be run prior to this domain's migrations #[macro_export] -macro_rules! define_connection { - (pub static ref $id:ident: $t:ident<()> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - +macro_rules! static_connection { + ($id:ident, $t:ident, [ $($d:ty),* ] $(, $global:ident)?) => { impl ::std::ops::Deref for $t { type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; @@ -123,16 +124,6 @@ macro_rules! define_connection { } } - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - impl $t { #[cfg(any(test, feature = "test-support"))] pub async fn open_test_db(name: &'static str) -> Self { @@ -142,7 +133,8 @@ macro_rules! define_connection { #[cfg(any(test, feature = "test-support"))] pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<$t>(stringify!($id)))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_test_db::<($($d,)* $t)>(stringify!($id)))) }); #[cfg(not(any(test, feature = "test-support")))] @@ -153,46 +145,10 @@ macro_rules! define_connection { } else { $crate::RELEASE_CHANNEL.dev_name() }; - $t($crate::smol::block_on($crate::open_db::<$t>(db_dir, scope))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_db::<($($d,)* $t)>(db_dir, scope))) }); - }; - (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - - impl ::std::ops::Deref for $t { - type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - - #[cfg(any(test, feature = "test-support"))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<($($d),+, $t)>(stringify!($id)))) - }); - - #[cfg(not(any(test, feature = "test-support")))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - let db_dir = $crate::database_dir(); - let scope = if false $(|| stringify!($global) == "global")? { - "global" - } else { - $crate::RELEASE_CHANNEL.dev_name() - }; - $t($crate::smol::block_on($crate::open_db::<($($d),+, $t)>(db_dir, scope))) - }); - }; + } } pub fn write_and_log(cx: &App, db_write: impl FnOnce() -> F + Send + 'static) @@ -219,17 +175,12 @@ mod tests { enum BadDB {} impl Domain for BadDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[ - sql!(CREATE TABLE test(value);), - // failure because test already exists - sql!(CREATE TABLE test(value);), - ] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[ + sql!(CREATE TABLE test(value);), + // failure because test already exists + sql!(CREATE TABLE test(value);), + ]; } let tempdir = tempfile::Builder::new() @@ -238,7 +189,7 @@ mod tests { .unwrap(); let _bad_db = open_db::( tempdir.path(), - &release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev.dev_name(), ) .await; } @@ -251,25 +202,15 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; } let tempdir = tempfile::Builder::new() @@ -279,7 +220,7 @@ mod tests { { let corrupt_db = open_db::( tempdir.path(), - &release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev.dev_name(), ) .await; assert!(corrupt_db.persistent()); @@ -287,7 +228,7 @@ mod tests { let good_db = open_db::( tempdir.path(), - &release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev.dev_name(), ) .await; assert!( @@ -305,25 +246,16 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } + const NAME: &str = "db_tests"; - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; // But different migration } let tempdir = tempfile::Builder::new() @@ -334,7 +266,7 @@ mod tests { // Setup the bad database let corrupt_db = open_db::( tempdir.path(), - &release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev.dev_name(), ) .await; assert!(corrupt_db.persistent()); @@ -347,7 +279,7 @@ mod tests { let guard = thread::spawn(move || { let good_db = smol::block_on(open_db::( tmp_path.as_path(), - &release_channel::ReleaseChannel::Dev.dev_name(), + release_channel::ReleaseChannel::Dev.dev_name(), )); assert!( good_db.select_row::("SELECT * FROM test2").unwrap()() diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index daf0b136fde5bd62411c70033e8bcfcb668a5e06..8ea877b35bfaf57bb258e7e179fa5b71f2b518ea 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -2,16 +2,26 @@ use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; -use crate::{define_connection, query, write_and_log}; +use crate::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + write_and_log, +}; -define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = - &[sql!( +pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for KeyValueStore { + const NAME: &str = stringify!(KeyValueStore); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; -); +} + +crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); pub trait Dismissable { const KEY: &'static str; @@ -20,7 +30,7 @@ pub trait Dismissable { KEY_VALUE_STORE .read_kvp(Self::KEY) .log_err() - .map_or(false, |s| s.is_some()) + .is_some_and(|s| s.is_some()) } fn set_dismissed(is_dismissed: bool, cx: &mut App) { @@ -91,15 +101,19 @@ mod tests { } } -define_connection!(pub static ref GLOBAL_KEY_VALUE_STORE: GlobalKeyValueStore<()> = - &[sql!( +pub struct GlobalKeyValueStore(ThreadSafeConnection); + +impl Domain for GlobalKeyValueStore { + const NAME: &str = stringify!(GlobalKeyValueStore); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; - global -); +} + +crate::static_connection!(GLOBAL_KEY_VALUE_STORE, GlobalKeyValueStore, [], global); impl GlobalKeyValueStore { query! { diff --git a/crates/debug_adapter_extension/Cargo.toml b/crates/debug_adapter_extension/Cargo.toml index 78d7cbaba3fbf92f4863228c532524cd0f0577ba..08f916eb9e7c2a26f598f75e46018b5fc76e37db 100644 --- a/crates/debug_adapter_extension/Cargo.toml +++ b/crates/debug_adapter_extension/Cargo.toml @@ -8,13 +8,13 @@ edition.workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true +collections.workspace = true dap.workspace = true extension.workspace = true gpui.workspace = true serde_json.workspace = true util.workspace = true task.workspace = true -workspace-hack = { version = "0.1", path = "../../tooling/workspace-hack" } [lints] workspace = true diff --git a/crates/debug_adapter_extension/src/extension_dap_adapter.rs b/crates/debug_adapter_extension/src/extension_dap_adapter.rs index b656bed9bc2ec972528c4b4c237e8ae0fceedc5a..abc0fbac19faa2be0f6c1ff8c93cadd2b6b96af9 100644 --- a/crates/debug_adapter_extension/src/extension_dap_adapter.rs +++ b/crates/debug_adapter_extension/src/extension_dap_adapter.rs @@ -6,6 +6,7 @@ use std::{ use anyhow::{Context, Result}; use async_trait::async_trait; +use collections::HashMap; use dap::{ StartDebuggingRequestArgumentsRequest, adapters::{ @@ -15,6 +16,7 @@ use dap::{ use extension::{Extension, WorktreeDelegate}; use gpui::AsyncApp; use task::{DebugScenario, ZedDebugConfig}; +use util::rel_path::RelPath; pub(crate) struct ExtensionDapAdapter { extension: Arc, @@ -54,10 +56,10 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { } fn root_path(&self) -> String { - self.0.worktree_root_path().to_string_lossy().to_string() + self.0.worktree_root_path().to_string_lossy().into_owned() } - async fn read_text_file(&self, path: PathBuf) -> Result { + async fn read_text_file(&self, path: &RelPath) -> Result { self.0.read_text_file(path).await } @@ -65,7 +67,7 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { self.0 .which(binary_name.as_ref()) .await - .map(|path| path.to_string_lossy().to_string()) + .map(|path| path.to_string_lossy().into_owned()) } async fn shell_env(&self) -> Vec<(String, String)> { @@ -90,6 +92,8 @@ impl DebugAdapter for ExtensionDapAdapter { user_installed_path: Option, // TODO support user args in the extension API _user_args: Option>, + // TODO support user env in the extension API + _user_env: Option>, _cx: &mut AsyncApp, ) -> Result { self.extension diff --git a/crates/debugger_tools/Cargo.toml b/crates/debugger_tools/Cargo.toml index d91f43182d1b1bb72dea02c612c60ee90e93ff84..c3f6dd9338ae87687680900380c96df53a5e9a6a 100644 --- a/crates/debugger_tools/Cargo.toml +++ b/crates/debugger_tools/Cargo.toml @@ -27,4 +27,3 @@ settings.workspace = true smol.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index b806381d251c6595a5dd12022dc3d1df8b71739f..4c994ad7eb749dcb5828daa83bad34a579f9f14c 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -392,7 +392,7 @@ impl LogStore { session.label(), session .adapter_client() - .map_or(false, |client| client.has_adapter_logs()), + .is_some_and(|client| client.has_adapter_logs()), ) }); @@ -485,7 +485,7 @@ impl LogStore { &mut self, id: &LogStoreEntryIdentifier<'_>, ) -> Option<&Vec> { - self.get_debug_adapter_state(&id) + self.get_debug_adapter_state(id) .map(|state| &state.rpc_messages.initialization_sequence) } } @@ -536,11 +536,11 @@ impl Render for DapLogToolbarItemView { }) .unwrap_or_else(|| "No adapter selected".into()), )) - .menu(move |mut window, cx| { + .menu(move |window, cx| { let log_view = log_view.clone(); let menu_rows = menu_rows.clone(); let project = project.clone(); - ContextMenu::build(&mut window, cx, move |mut menu, window, _cx| { + ContextMenu::build(window, cx, move |mut menu, window, _cx| { for row in menu_rows.into_iter() { menu = menu.custom_row(move |_window, _cx| { div() @@ -661,11 +661,11 @@ impl ToolbarItemView for DapLogToolbarItemView { _window: &mut Window, cx: &mut Context, ) -> workspace::ToolbarItemLocation { - if let Some(item) = active_pane_item { - if let Some(log_view) = item.downcast::() { - self.log_view = Some(log_view.clone()); - return workspace::ToolbarItemLocation::PrimaryLeft; - } + if let Some(item) = active_pane_item + && let Some(log_view) = item.downcast::() + { + self.log_view = Some(log_view); + return workspace::ToolbarItemLocation::PrimaryLeft; } self.log_view = None; @@ -963,26 +963,21 @@ pub fn init(cx: &mut App) { }; let project = workspace.project(); - if project.read(cx).is_local() { - log_store.update(cx, |store, cx| { - store.add_project(project, cx); - }); - } + log_store.update(cx, |store, cx| { + store.add_project(project, cx); + }); let log_store = log_store.clone(); workspace.register_action(move |workspace, _: &OpenDebugAdapterLogs, window, cx| { - let project = workspace.project().read(cx); - if project.is_local() { - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) - })), - None, - true, - window, - cx, - ); - } + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) + })), + None, + true, + window, + cx, + ); }); }) .detach(); @@ -1131,7 +1126,7 @@ impl LogStore { project: &WeakEntity, session_id: SessionId, ) -> Vec { - self.projects.get(&project).map_or(vec![], |state| { + self.projects.get(project).map_or(vec![], |state| { state .debug_sessions .get(&session_id) diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index df4125860f4ab79ce3a55d6b5b4fbb8f8fc64e5e..c1a0657c0ed93508acb330a98dc6d1c1ee91c570 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -60,7 +60,6 @@ serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true settings.workspace = true -shlex.workspace = true sysinfo.workspace = true task.workspace = true tasks_ui.workspace = true @@ -73,7 +72,6 @@ tree-sitter.workspace = true ui.workspace = true unindent = { workspace = true, optional = true } util.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 662a98c82075cd6e936988959c855eadb5138092..e39a842f63590375898c9870c345574e1932a788 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -1,13 +1,15 @@ use dap::{DapRegistry, DebugRequest}; use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render}; +use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task}; use gpui::{Subscription, WeakEntity}; use picker::{Picker, PickerDelegate}; +use project::Project; +use rpc::proto; use task::ZedDebugConfig; use util::debug_panic; use std::sync::Arc; -use sysinfo::System; +use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; use ui::{Context, Tooltip, prelude::*}; use ui::{ListItem, ListItemSpacing}; use workspace::{ModalView, Workspace}; @@ -56,29 +58,28 @@ impl AttachModal { pub fn new( definition: ZedDebugConfig, workspace: WeakEntity, + project: Entity, modal: bool, window: &mut Window, cx: &mut Context, ) -> Self { - let mut processes: Box<[_]> = System::new_all() - .processes() - .values() - .map(|process| { - let name = process.name().to_string_lossy().into_owned(); - Candidate { - name: name.into(), - pid: process.pid().as_u32(), - command: process - .cmd() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect::>(), - } - }) - .collect(); - processes.sort_by_key(|k| k.name.clone()); - let processes = processes.into_iter().collect(); - Self::with_processes(workspace, definition, processes, modal, window, cx) + let processes_task = get_processes_for_project(&project, cx); + + let modal = Self::with_processes(workspace, definition, Arc::new([]), modal, window, cx); + + cx.spawn_in(window, async move |this, cx| { + let processes = processes_task.await; + this.update_in(cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.candidates = processes; + picker.refresh(window, cx); + }); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + modal } pub(super) fn with_processes( @@ -288,7 +289,7 @@ impl PickerDelegate for AttachModalDelegate { _window: &mut Window, _: &mut Context>, ) -> Option { - let hit = &self.matches[ix]; + let hit = &self.matches.get(ix)?; let candidate = self.candidates.get(hit.candidate_id)?; Some( @@ -332,6 +333,62 @@ impl PickerDelegate for AttachModalDelegate { } } +fn get_processes_for_project(project: &Entity, cx: &mut App) -> Task> { + let project = project.read(cx); + + if let Some(remote_client) = project.remote_client() { + let proto_client = remote_client.read(cx).proto_client(); + cx.spawn(async move |_cx| { + let response = proto_client + .request(proto::GetProcesses { + project_id: proto::REMOTE_SERVER_PROJECT_ID, + }) + .await + .unwrap_or_else(|_| proto::GetProcessesResponse { + processes: Vec::new(), + }); + + let mut processes: Vec = response + .processes + .into_iter() + .map(|p| Candidate { + pid: p.pid, + name: p.name.into(), + command: p.command, + }) + .collect(); + + processes.sort_by_key(|k| k.name.clone()); + Arc::from(processes.into_boxed_slice()) + }) + } else { + let refresh_kind = RefreshKind::nothing().with_processes( + ProcessRefreshKind::nothing() + .without_tasks() + .with_cmd(UpdateKind::Always), + ); + let mut processes: Box<[_]> = System::new_with_specifics(refresh_kind) + .processes() + .values() + .map(|process| { + let name = process.name().to_string_lossy().into_owned(); + Candidate { + name: name.into(), + pid: process.pid().as_u32(), + command: process + .cmd() + .iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect::>(), + } + }) + .collect(); + processes.sort_by_key(|k| k.name.clone()); + let processes = processes.into_iter().collect(); + Task::ready(processes) + } +} + #[cfg(any(test, feature = "test-support"))] pub(crate) fn _process_names(modal: &AttachModal, cx: &mut Context) -> Vec { modal.picker.read_with(cx, |picker, _| { diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 1d44c5c2448afba50f682ea8ae96da8d3104945f..5379591f8ed256d2703a8e61b09925e9743ed341 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -12,12 +12,8 @@ use crate::{ use anyhow::{Context as _, Result, anyhow}; use collections::IndexMap; use dap::adapters::DebugAdapterName; -use dap::debugger_settings::DebugPanelDockPosition; -use dap::{ - ContinuedEvent, LoadedSourceEvent, ModuleEvent, OutputEvent, StoppedEvent, ThreadEvent, - client::SessionId, debugger_settings::DebuggerSettings, -}; use dap::{DapRegistry, StartDebuggingRequestArguments}; +use dap::{client::SessionId, debugger_settings::DebuggerSettings}; use editor::Editor; use gpui::{ Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, @@ -37,6 +33,7 @@ use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*}; +use util::rel_path::RelPath; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; use workspace::item::SaveOptions; @@ -46,22 +43,7 @@ use workspace::{ }; use zed_actions::ToggleFocus; -pub enum DebugPanelEvent { - Exited(SessionId), - Terminated(SessionId), - Stopped { - client_id: SessionId, - event: StoppedEvent, - go_to_stack_frame: bool, - }, - Thread((SessionId, ThreadEvent)), - Continued((SessionId, ContinuedEvent)), - Output((SessionId, OutputEvent)), - Module((SessionId, ModuleEvent)), - LoadedSource((SessionId, LoadedSourceEvent)), - ClientShutdown(SessionId), - CapabilitiesChanged(SessionId), -} +const DEBUG_PANEL_KEY: &str = "DebugPanel"; pub struct DebugPanel { size: Pixels, @@ -155,6 +137,10 @@ impl DebugPanel { .map(|session| session.read(cx).running_state().clone()) } + pub fn project(&self) -> &Entity { + &self.project + } + pub fn load( workspace: WeakEntity, cx: &mut AsyncWindowContext, @@ -257,7 +243,7 @@ impl DebugPanel { .as_ref() .map(|entity| entity.downgrade()), task_context: task_context.clone(), - worktree_id: worktree_id, + worktree_id, }); }; running.resolve_scenario( @@ -284,12 +270,12 @@ impl DebugPanel { async move |_, cx| { if let Err(error) = task.await { - log::error!("{error}"); + log::error!("{error:#}"); session .update(cx, |session, cx| { session .console_output(cx) - .unbounded_send(format!("error: {}", error)) + .unbounded_send(format!("error: {:#}", error)) .ok(); session.shutdown(cx) })? @@ -386,10 +372,10 @@ impl DebugPanel { return; }; - let dap_store_handle = self.project.read(cx).dap_store().clone(); + let dap_store_handle = self.project.read(cx).dap_store(); let label = curr_session.read(cx).label(); let quirks = curr_session.read(cx).quirks(); - let adapter = curr_session.read(cx).adapter().clone(); + let adapter = curr_session.read(cx).adapter(); let binary = curr_session.read(cx).binary().cloned().unwrap(); let task_context = curr_session.read(cx).task_context().clone(); @@ -447,9 +433,9 @@ impl DebugPanel { return; }; - let dap_store_handle = self.project.read(cx).dap_store().clone(); + let dap_store_handle = self.project.read(cx).dap_store(); let label = self.label_for_child_session(&parent_session, request, cx); - let adapter = parent_session.read(cx).adapter().clone(); + let adapter = parent_session.read(cx).adapter(); let quirks = parent_session.read(cx).quirks(); let Some(mut binary) = parent_session.read(cx).binary().cloned() else { log::error!("Attempted to start a child-session without a binary"); @@ -530,10 +516,9 @@ impl DebugPanel { .active_session .as_ref() .map(|session| session.entity_id()) + && active_session_id == entity_id { - if active_session_id == entity_id { - this.active_session = this.sessions_with_children.keys().next().cloned(); - } + this.active_session = this.sessions_with_children.keys().next().cloned(); } cx.notify() }) @@ -631,18 +616,26 @@ impl DebugPanel { }) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Start Debug Session", &crate::Start, &focus_handle, - window, cx, ) } }) }; + let edit_debug_json_button = || { + IconButton::new("debug-edit-debug-json", IconName::Code) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::OpenProjectDebugTasks.boxed_clone(), cx); + }) + .tooltip(Tooltip::text("Edit debug.json")) + }; + let documentation_button = || { IconButton::new("debug-open-documentation", IconName::CircleHelp) .icon_size(IconSize::Small) @@ -693,19 +686,18 @@ impl DebugPanel { ) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| { this.pause_thread(cx); }, )) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Pause Program", &Pause, &focus_handle, - window, cx, ) } @@ -719,18 +711,17 @@ impl DebugPanel { ) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| this.continue_thread(cx), )) .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Continue Program", &Continue, &focus_handle, - window, cx, ) } @@ -742,7 +733,7 @@ impl DebugPanel { IconButton::new("debug-step-over", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| { this.step_over(cx); }, @@ -750,12 +741,11 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Step Over", &StepOver, &focus_handle, - window, cx, ) } @@ -768,7 +758,7 @@ impl DebugPanel { ) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| { this.step_in(cx); }, @@ -776,12 +766,11 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Step In", &StepInto, &focus_handle, - window, cx, ) } @@ -791,7 +780,7 @@ impl DebugPanel { IconButton::new("debug-step-out", IconName::ArrowUpRight) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| { this.step_out(cx); }, @@ -799,12 +788,11 @@ impl DebugPanel { .disabled(thread_status != ThreadStatus::Stopped) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Step Out", &StepOut, &focus_handle, - window, cx, ) } @@ -815,19 +803,18 @@ impl DebugPanel { IconButton::new("debug-restart", IconName::RotateCcw) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, window, cx| { this.rerun_session(window, cx); }, )) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Rerun Session", &RerunSession, &focus_handle, - window, cx, ) } @@ -837,7 +824,7 @@ impl DebugPanel { IconButton::new("debug-stop", IconName::Power) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _window, cx| { if this.session().read(cx).is_building() { this.session().update(cx, |session, cx| { @@ -867,12 +854,11 @@ impl DebugPanel { } else { "Terminate All Threads" }; - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( label, &Stop, &focus_handle, - window, cx, ) } @@ -892,19 +878,18 @@ impl DebugPanel { ) .icon_size(IconSize::Small) .on_click(window.listener_for( - &running_state, + running_state, |this, _, _, cx| { this.detach_client(cx); }, )) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Detach", &Detach, &focus_handle, - window, cx, ) } @@ -917,8 +902,9 @@ impl DebugPanel { ) .when(is_side, |this| { this.child(new_session_button()) - .child(logs_button()) + .child(edit_debug_json_button()) .child(documentation_button()) + .child(logs_button()) }), ) .child( @@ -933,7 +919,6 @@ impl DebugPanel { .cloned(), |this, running_state| { this.children({ - let running_state = running_state.clone(); let threads = running_state.update(cx, |running_state, cx| { let session = running_state.session(); @@ -970,8 +955,9 @@ impl DebugPanel { )) .when(!is_side, |this| { this.child(new_session_button()) - .child(logs_button()) + .child(edit_debug_json_button()) .child(documentation_button()) + .child(logs_button()) }), ), ), @@ -1069,14 +1055,14 @@ impl DebugPanel { directory_in_worktree: dir, .. } => { - let relative_path = if dir.ends_with(".vscode") { - dir.join("launch.json") + let relative_path = if dir.ends_with(RelPath::unix(".vscode").unwrap()) { + dir.join(RelPath::unix("launch.json").unwrap()) } else { - dir.join("debug.json") + dir.join(RelPath::unix("debug.json").unwrap()) }; ProjectPath { worktree_id: id, - path: Arc::from(relative_path), + path: relative_path, } } _ => return self.save_scenario(scenario, worktree_id, window, cx), @@ -1137,13 +1123,13 @@ impl DebugPanel { let fs = workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?; - path.push(paths::local_settings_folder_relative_path()); + path.push(paths::local_settings_folder_name()); if !fs.is_dir(path.as_path()).await { fs.create_dir(path.as_path()).await?; } path.pop(); - path.push(paths::local_debug_file_relative_path()); + path.push(paths::local_debug_file_relative_path().as_std_path()); let path = path.as_path(); if !fs.is_file(path).await { @@ -1160,7 +1146,7 @@ impl DebugPanel { workspace .project() .read(cx) - .project_path_for_absolute_path(&path, cx) + .project_path_for_absolute_path(path, cx) .context( "Couldn't get project path for .zed/debug.json in active worktree", ) @@ -1302,10 +1288,10 @@ impl DebugPanel { cx: &mut Context<'_, Self>, ) -> Option { let adapter = parent_session.read(cx).adapter(); - if let Some(adapter) = DapRegistry::global(cx).adapter(&adapter) { - if let Some(label) = adapter.label_for_child_session(request) { - return Some(label.into()); - } + if let Some(adapter) = DapRegistry::global(cx).adapter(&adapter) + && let Some(label) = adapter.label_for_child_session(request) + { + return Some(label.into()); } None } @@ -1409,7 +1395,6 @@ async fn register_session_inner( } impl EventEmitter for DebugPanel {} -impl EventEmitter for DebugPanel {} impl Focusable for DebugPanel { fn focus_handle(&self, _: &App) -> FocusHandle { @@ -1422,12 +1407,12 @@ impl Panel for DebugPanel { "DebugPanel" } + fn panel_key() -> &'static str { + DEBUG_PANEL_KEY + } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { - match DebuggerSettings::get_global(cx).dock { - DebugPanelDockPosition::Left => DockPosition::Left, - DebugPanelDockPosition::Bottom => DockPosition::Bottom, - DebugPanelDockPosition::Right => DockPosition::Right, - } + DebuggerSettings::get_global(cx).dock.into() } fn position_is_valid(&self, _: DockPosition) -> bool { @@ -1449,18 +1434,9 @@ impl Panel for DebugPanel { }); } - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| { - let dock = match position { - DockPosition::Left => DebugPanelDockPosition::Left, - DockPosition::Bottom => DebugPanelDockPosition::Bottom, - DockPosition::Right => DebugPanelDockPosition::Right, - }; - settings.dock = dock; - }, - ); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.debugger.get_or_insert_default().dock = Some(position.into()); + }); } fn size(&self, _window: &Window, _: &App) -> Pixels { @@ -1646,7 +1622,6 @@ impl Render for DebugPanel { } }) .on_action({ - let this = this.clone(); move |_: &ToggleSessionPicker, window, cx| { this.update(cx, |this, cx| { this.toggle_session_picker(window, cx); @@ -1799,6 +1774,7 @@ impl Render for DebugPanel { this.child( v_flex() .size_full() + .overflow_hidden() .gap_1() .items_center() .justify_center() diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 5f5dfd1a1e6a543cdb7a4d87e1b8e9984c4ecba9..78cc9e9bd28beb31474c12662d7e118eae6f066e 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -85,6 +85,10 @@ actions!( Rerun, /// Toggles expansion of the selected item in the debugger UI. ToggleExpandItem, + /// Toggle the user frame filter in the stack frame list + /// When toggled on, only frames from the user's code are shown + /// When toggled off, all frames are shown + ToggleUserFrames, ] ); @@ -279,6 +283,18 @@ pub fn init(cx: &mut App) { .ok(); } }) + .on_action(move |_: &ToggleUserFrames, _, cx| { + if let Some((thread_status, stack_frame_list)) = active_item + .read_with(cx, |item, cx| { + (item.thread_status(cx), item.stack_frame_list().clone()) + }) + .ok() + { + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.toggle_frame_filter(thread_status, cx); + }) + } + }) }); }) .detach(); @@ -293,9 +309,8 @@ pub fn init(cx: &mut App) { let Some(debug_panel) = workspace.read(cx).panel::(cx) else { return; }; - let Some(active_session) = debug_panel - .clone() - .update(cx, |panel, _| panel.active_session()) + let Some(active_session) = + debug_panel.update(cx, |panel, _| panel.active_session()) else { return; }; @@ -326,8 +341,10 @@ pub fn init(cx: &mut App) { maybe!({ let (buffer, position, _) = editor .update(cx, |editor, cx| { - let cursor_point: language::Point = - editor.selections.newest(cx).head(); + let cursor_point: language::Point = editor + .selections + .newest(&editor.display_snapshot(cx)) + .head(); editor .buffer() @@ -377,7 +394,10 @@ pub fn init(cx: &mut App) { let text = editor .update(cx, |editor, cx| { editor.text_for_range( - editor.selections.newest(cx).range(), + editor + .selections + .newest(&editor.display_snapshot(cx)) + .range(), &mut None, window, cx, diff --git a/crates/debugger_ui/src/dropdown_menus.rs b/crates/debugger_ui/src/dropdown_menus.rs index dca15eb0527cfc78bd137889a1910e6b32abf98c..e0c3628f4fc0a927857adbe93549087f930145d6 100644 --- a/crates/debugger_ui/src/dropdown_menus.rs +++ b/crates/debugger_ui/src/dropdown_menus.rs @@ -1,9 +1,9 @@ -use std::{rc::Rc, time::Duration}; +use std::rc::Rc; use collections::HashMap; -use gpui::{Animation, AnimationExt as _, Entity, Transformation, WeakEntity, percentage}; +use gpui::{Corner, Entity, WeakEntity}; use project::debugger::session::{ThreadId, ThreadStatus}; -use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; +use ui::{CommonAnimationExt, ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*}; use util::{maybe, truncate_and_trailoff}; use crate::{ @@ -113,23 +113,6 @@ impl DebugPanel { } }; session_entries.push(root_entry); - - session_entries.extend( - sessions_with_children - .by_ref() - .take_while(|(session, _)| { - session - .read(cx) - .session(cx) - .read(cx) - .parent_id(cx) - .is_some() - }) - .map(|(session, _)| SessionListEntry { - leaf: session.clone(), - ancestors: vec![], - }), - ); } let weak = cx.weak_entity(); @@ -152,11 +135,7 @@ impl DebugPanel { Icon::new(IconName::ArrowCircle) .size(IconSize::Small) .color(Color::Muted) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) + .with_rotate_animation(2) .into_any_element() } else { match running_state.thread_status(cx).unwrap_or_default() { @@ -232,6 +211,7 @@ impl DebugPanel { this }), ) + .attach(Corner::BottomLeft) .style(DropdownStyle::Ghost) .handle(self.session_picker_menu_handle.clone()); @@ -272,10 +252,9 @@ impl DebugPanel { .child(session_entry.label_element(self_depth, cx)) .child( IconButton::new("close-debug-session", IconName::Close) - .visible_on_hover(id.clone()) + .visible_on_hover(id) .icon_size(IconSize::Small) .on_click({ - let weak = weak.clone(); move |_, window, cx| { weak.update(cx, |panel, cx| { panel.close_session(session_entity_id, window, cx); @@ -344,6 +323,7 @@ impl DebugPanel { this }), ) + .attach(Corner::BottomLeft) .disabled(session_terminated) .style(DropdownStyle::Ghost) .handle(self.thread_picker_menu_handle.clone()), diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 4ac8e371a15052a00ed962480a9f694a8802007c..e12c768e12b1e098e150027c89d05695c59c51f6 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, bail}; use collections::{FxHashMap, HashMap, HashSet}; -use language::LanguageRegistry; +use language::{LanguageName, LanguageRegistry}; use std::{ borrow::Cow, path::{Path, PathBuf}, @@ -20,9 +20,9 @@ use gpui::{ }; use itertools::Itertools as _; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; -use project::{DebugScenarioContext, TaskContexts, TaskSourceKind, task_store::TaskStore}; +use project::{DebugScenarioContext, Project, TaskContexts, TaskSourceKind, task_store::TaskStore}; use settings::Settings; -use task::{DebugScenario, RevealTarget, ZedDebugConfig}; +use task::{DebugScenario, RevealTarget, VariableName, ZedDebugConfig}; use theme::ThemeSettings; use ui::{ ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context, @@ -32,7 +32,7 @@ use ui::{ SharedString, Styled, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip, Window, div, h_flex, relative, rems, v_flex, }; -use util::ResultExt; +use util::{ResultExt, rel_path::RelPath, shell::ShellKind}; use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane}; use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel}; @@ -88,13 +88,17 @@ impl NewProcessModal { })?; workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = workspace.weak_handle(); + let project = workspace.project().clone(); workspace.toggle_modal(window, cx, |window, cx| { - let attach_mode = AttachMode::new(None, workspace_handle.clone(), window, cx); + let attach_mode = + AttachMode::new(None, workspace_handle.clone(), project, window, cx); let debug_picker = cx.new(|cx| { let delegate = DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); - Picker::uniform_list(delegate, window, cx).modal(false) + Picker::list(delegate, window, cx) + .modal(false) + .list_measure_all() }); let configure_mode = ConfigureMode::new(window, cx); @@ -343,10 +347,10 @@ impl NewProcessModal { return; } - if let NewProcessMode::Launch = &self.mode { - if self.configure_mode.read(cx).save_to_debug_json.selected() { - self.save_debug_scenario(window, cx); - } + if let NewProcessMode::Launch = &self.mode + && self.configure_mode.read(cx).save_to_debug_json.selected() + { + self.save_debug_scenario(window, cx); } let Some(debugger) = self.debugger.clone() else { @@ -413,7 +417,7 @@ impl NewProcessModal { let Some(adapter) = self.debugger.as_ref() else { return; }; - let scenario = self.debug_scenario(&adapter, cx); + let scenario = self.debug_scenario(adapter, cx); cx.spawn_in(window, async move |this, cx| { let scenario = scenario.await.context("no scenario to save")?; let worktree_id = task_contexts @@ -659,12 +663,7 @@ impl Render for NewProcessModal { this.mode = NewProcessMode::Attach; if let Some(debugger) = this.debugger.as_ref() { - Self::update_attach_picker( - &this.attach_mode, - &debugger, - window, - cx, - ); + Self::update_attach_picker(&this.attach_mode, debugger, window, cx); } this.mode_focus_handle(cx).focus(window); cx.notify(); @@ -748,22 +747,15 @@ impl Render for NewProcessModal { == 0; let secondary_action = menu::SecondaryConfirm.boxed_clone(); container - .child(div().children( - KeyBinding::for_action(&*secondary_action, window, cx).map( - |keybind| { - Button::new("edit-attach-task", "Edit in debug.json") - .label_size(LabelSize::Small) - .key_binding(keybind) - .on_click(move |_, window, cx| { - window.dispatch_action( - secondary_action.boxed_clone(), - cx, - ) - }) - .disabled(disabled) - }, - ), - )) + .child(div().child({ + Button::new("edit-attach-task", "Edit in debug.json") + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action(&*secondary_action, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(secondary_action.boxed_clone(), cx) + }) + .disabled(disabled) + })) .child( h_flex() .child(div().child(self.adapter_drop_down_menu(window, cx))), @@ -790,7 +782,7 @@ impl RenderOnce for AttachMode { v_flex() .w_full() .track_focus(&self.attach_picker.focus_handle(cx)) - .child(self.attach_picker.clone()) + .child(self.attach_picker) } } @@ -806,12 +798,12 @@ impl ConfigureMode { pub(super) fn new(window: &mut Window, cx: &mut App) -> Entity { let program = cx.new(|cx| Editor::single_line(window, cx)); program.update(cx, |this, cx| { - this.set_placeholder_text("ENV=Zed ~/bin/program --option", cx); + this.set_placeholder_text("ENV=Zed ~/bin/program --option", window, cx); }); let cwd = cx.new(|cx| Editor::single_line(window, cx)); cwd.update(cx, |this, cx| { - this.set_placeholder_text("Ex: $ZED_WORKTREE_ROOT", cx); + this.set_placeholder_text("Ex: $ZED_WORKTREE_ROOT", window, cx); }); cx.new(|_| Self { @@ -847,7 +839,11 @@ impl ConfigureMode { }; } let command = self.program.read(cx).text(cx); - let mut args = shlex::split(&command).into_iter().flatten().peekable(); + let mut args = ShellKind::Posix + .split(&command) + .into_iter() + .flatten() + .peekable(); let mut env = FxHashMap::default(); while args.peek().is_some_and(|arg| arg.contains('=')) { let arg = args.next().unwrap(); @@ -945,6 +941,7 @@ impl AttachMode { pub(super) fn new( debugger: Option, workspace: WeakEntity, + project: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -955,7 +952,7 @@ impl AttachMode { stop_on_entry: Some(false), }; let attach_picker = cx.new(|cx| { - let modal = AttachModal::new(definition.clone(), workspace, false, window, cx); + let modal = AttachModal::new(definition.clone(), workspace, project, false, window, cx); window.focus(&modal.focus_handle(cx)); modal @@ -980,6 +977,7 @@ pub(super) struct DebugDelegate { task_store: Entity, candidates: Vec<( Option, + Option, DebugScenario, Option, )>, @@ -1007,28 +1005,87 @@ impl DebugDelegate { } } - fn get_scenario_kind( + fn get_task_subtitle( + &self, + task_kind: &Option, + context: &Option, + cx: &mut App, + ) -> Option { + match task_kind { + Some(TaskSourceKind::Worktree { + id: worktree_id, + directory_in_worktree, + .. + }) => self + .debug_panel + .update(cx, |debug_panel, cx| { + let project = debug_panel.project().read(cx); + let worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + + let mut path = if worktrees.len() > 1 + && let Some(worktree) = project.worktree_for_id(*worktree_id, cx) + { + worktree + .read(cx) + .root_name() + .join(directory_in_worktree) + .to_rel_path_buf() + } else { + directory_in_worktree.to_rel_path_buf() + }; + + match path.components().next_back() { + Some(".zed") => { + path.push(RelPath::unix("debug.json").unwrap()); + } + Some(".vscode") => { + path.push(RelPath::unix("launch.json").unwrap()); + } + _ => {} + } + path.display(project.path_style(cx)).to_string() + }) + .ok(), + Some(TaskSourceKind::AbsPath { abs_path, .. }) => { + Some(abs_path.to_string_lossy().into_owned()) + } + Some(TaskSourceKind::Lsp { language_name, .. }) => { + Some(format!("LSP: {language_name}")) + } + Some(TaskSourceKind::Language { name }) => Some(format!("Lang: {name}")), + _ => context.clone().and_then(|ctx| { + ctx.task_context + .task_variables + .get(&VariableName::RelativeFile) + .map(|f| format!("in {f}")) + .or_else(|| { + ctx.task_context + .task_variables + .get(&VariableName::Dirname) + .map(|d| format!("in {d}/")) + }) + }), + } + } + + fn get_scenario_language( languages: &Arc, dap_registry: &DapRegistry, scenario: DebugScenario, - ) -> (Option, DebugScenario) { + ) -> (Option, DebugScenario) { let language_names = languages.language_names(); - let language = dap_registry - .adapter_language(&scenario.adapter) - .map(|language| TaskSourceKind::Language { name: language.0 }); + let language_name = dap_registry.adapter_language(&scenario.adapter); - let language = language.or_else(|| { + let language_name = language_name.or_else(|| { scenario.label.split_whitespace().find_map(|word| { language_names .iter() .find(|name| name.as_ref().eq_ignore_ascii_case(word)) - .map(|name| TaskSourceKind::Language { - name: name.to_owned().into(), - }) + .cloned() }) }); - (language, scenario) + (language_name, scenario) } pub fn tasks_loaded( @@ -1075,16 +1132,16 @@ impl DebugDelegate { id: _, directory_in_worktree: dir, id_base: _, - } => dir.ends_with(".zed"), + } => dir.ends_with(RelPath::unix(".zed").unwrap()), _ => false, }); this.delegate.candidates = recent .into_iter() .map(|(scenario, context)| { - let (kind, scenario) = - Self::get_scenario_kind(&languages, &dap_registry, scenario); - (kind, scenario, Some(context)) + let (language_name, scenario) = + Self::get_scenario_language(&languages, dap_registry, scenario); + (None, language_name, scenario, Some(context)) }) .chain( scenarios @@ -1094,14 +1151,17 @@ impl DebugDelegate { id: _, directory_in_worktree: dir, id_base: _, - } => !(hide_vscode && dir.ends_with(".vscode")), + } => { + !(hide_vscode + && dir.ends_with(RelPath::unix(".vscode").unwrap())) + } _ => true, }) .filter(|(_, scenario)| valid_adapters.contains(&scenario.adapter)) .map(|(kind, scenario)| { - let (language, scenario) = - Self::get_scenario_kind(&languages, &dap_registry, scenario); - (language.or(Some(kind)), scenario, None) + let (language_name, scenario) = + Self::get_scenario_language(&languages, dap_registry, scenario); + (Some(kind), language_name, scenario, None) }), ) .collect(); @@ -1147,7 +1207,7 @@ impl PickerDelegate for DebugDelegate { let candidates: Vec<_> = candidates .into_iter() .enumerate() - .map(|(index, (_, candidate, _))| { + .map(|(index, (_, _, candidate, _))| { StringMatchCandidate::new(index, candidate.label.as_ref()) }) .collect(); @@ -1209,7 +1269,11 @@ impl PickerDelegate for DebugDelegate { }) .unwrap_or_default(); - let mut args = shlex::split(&text).into_iter().flatten().peekable(); + let mut args = ShellKind::Posix + .split(&text) + .into_iter() + .flatten() + .peekable(); let mut env = HashMap::default(); while args.peek().is_some_and(|arg| arg.contains('=')) { let arg = args.next().unwrap(); @@ -1316,7 +1380,7 @@ impl PickerDelegate for DebugDelegate { .get(self.selected_index()) .and_then(|match_candidate| self.candidates.get(match_candidate.candidate_id).cloned()); - let Some((kind, debug_scenario, context)) = debug_scenario else { + let Some((kind, _, debug_scenario, context)) = debug_scenario else { return; }; @@ -1386,42 +1450,48 @@ impl PickerDelegate for DebugDelegate { .justify_between() .border_t_1() .border_color(cx.theme().colors().border_variant) - .children({ + .child({ let action = menu::SecondaryConfirm.boxed_clone(); - KeyBinding::for_action(&*action, window, cx).map(|keybind| { + if self.matches.is_empty() { + Button::new("edit-debug-json", "Edit debug.json") + .label_size(LabelSize::Small) + .on_click(cx.listener(|_picker, _, window, cx| { + window.dispatch_action( + zed_actions::OpenProjectDebugTasks.boxed_clone(), + cx, + ); + cx.emit(DismissEvent); + })) + } else { Button::new("edit-debug-task", "Edit in debug.json") .label_size(LabelSize::Small) - .key_binding(keybind) + .key_binding(KeyBinding::for_action(&*action, cx)) .on_click(move |_, window, cx| { window.dispatch_action(action.boxed_clone(), cx) }) - }) + } }) .map(|this| { if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() { let action = picker::ConfirmInput { secondary: false }.boxed_clone(); - this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| { + this.child({ Button::new("launch-custom", "Launch Custom") - .key_binding(keybind) + .key_binding(KeyBinding::for_action(&*action, cx)) .on_click(move |_, window, cx| { window.dispatch_action(action.boxed_clone(), cx) }) - })) + }) } else { - this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map( - |keybind| { - let is_recent_selected = - self.divider_index >= Some(self.selected_index); - let run_entry_label = - if is_recent_selected { "Rerun" } else { "Spawn" }; - - Button::new("spawn", run_entry_label) - .key_binding(keybind) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx); - }) - }, - )) + this.child({ + let is_recent_selected = self.divider_index >= Some(self.selected_index); + let run_entry_label = if is_recent_selected { "Rerun" } else { "Spawn" }; + + Button::new("spawn", run_entry_label) + .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + }) + }) } }); Some(footer.into_any_element()) @@ -1434,41 +1504,48 @@ impl PickerDelegate for DebugDelegate { window: &mut Window, cx: &mut Context>, ) -> Option { - let hit = &self.matches[ix]; + let hit = &self.matches.get(ix)?; + let (task_kind, language_name, _scenario, context) = &self.candidates[hit.candidate_id]; let highlighted_location = HighlightedMatch { text: hit.string.clone(), highlight_positions: hit.positions.clone(), - char_count: hit.string.chars().count(), color: Color::Default, }; - let task_kind = &self.candidates[hit.candidate_id].0; - - let icon = match task_kind { - Some(TaskSourceKind::UserInput) => Some(Icon::new(IconName::Terminal)), - Some(TaskSourceKind::AbsPath { .. }) => Some(Icon::new(IconName::Settings)), - Some(TaskSourceKind::Worktree { .. }) => Some(Icon::new(IconName::FileTree)), - Some(TaskSourceKind::Lsp { - language_name: name, - .. - }) - | Some(TaskSourceKind::Language { name }) => file_icons::FileIcons::get(cx) - .get_icon_for_type(&name.to_lowercase(), cx) - .map(Icon::from_path), - None => Some(Icon::new(IconName::HistoryRerun)), - } - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)); - let indicator = if matches!(task_kind, Some(TaskSourceKind::Lsp { .. })) { - Some(Indicator::icon( - Icon::new(IconName::BoltFilled) - .color(Color::Muted) - .size(IconSize::Small), - )) - } else { - None + + let subtitle = self.get_task_subtitle(task_kind, context, cx); + + let language_icon = language_name.as_ref().and_then(|lang| { + file_icons::FileIcons::get(cx) + .get_icon_for_type(&lang.0.to_lowercase(), cx) + .map(Icon::from_path) + }); + + let (icon, indicator) = match task_kind { + Some(TaskSourceKind::UserInput) => (Some(Icon::new(IconName::Terminal)), None), + Some(TaskSourceKind::AbsPath { .. }) => (Some(Icon::new(IconName::Settings)), None), + Some(TaskSourceKind::Worktree { .. }) => (Some(Icon::new(IconName::FileTree)), None), + Some(TaskSourceKind::Lsp { language_name, .. }) => ( + file_icons::FileIcons::get(cx) + .get_icon_for_type(&language_name.to_lowercase(), cx) + .map(Icon::from_path), + Some(Indicator::icon( + Icon::new(IconName::BoltFilled) + .color(Color::Muted) + .size(IconSize::Small), + )), + ), + Some(TaskSourceKind::Language { name }) => ( + file_icons::FileIcons::get(cx) + .get_icon_for_type(&name.to_lowercase(), cx) + .map(Icon::from_path), + None, + ), + None => (Some(Icon::new(IconName::HistoryRerun)), None), }; - let icon = icon.map(|icon| { - IconWithIndicator::new(icon, indicator) + + let icon = language_icon.or(icon).map(|icon| { + IconWithIndicator::new(icon.color(Color::Muted).size(IconSize::Small), indicator) .indicator_border_color(Some(cx.theme().colors().border_transparent)) }); @@ -1478,14 +1555,25 @@ impl PickerDelegate for DebugDelegate { .start_slot::(icon) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .child(highlighted_location.render(window, cx)), + .child( + v_flex() + .items_start() + .child(highlighted_location.render(window, cx)) + .when_some(subtitle, |this, subtitle_text| { + this.child( + Label::new(subtitle_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), ) } } pub(crate) fn resolve_path(path: &mut String) { if path.starts_with('~') { - let home = paths::home_dir().to_string_lossy().to_string(); + let home = paths::home_dir().to_string_lossy().into_owned(); let trimmed_path = path.trim().to_owned(); *path = trimmed_path.replacen('~', &home, 1); } else if let Some(strip_path) = path.strip_prefix(&format!(".{}", std::path::MAIN_SEPARATOR)) { @@ -1527,4 +1615,17 @@ impl NewProcessModal { } }) } + + pub(crate) fn debug_picker_candidate_subtitles(&self, cx: &mut App) -> Vec { + self.debug_picker.update(cx, |picker, cx| { + picker + .delegate + .candidates + .iter() + .filter_map(|(task_kind, _, _, context)| { + picker.delegate.get_task_subtitle(task_kind, context, cx) + }) + .collect() + }) + } } diff --git a/crates/debugger_ui/src/onboarding_modal.rs b/crates/debugger_ui/src/onboarding_modal.rs index 2a9f68d0c9e584e9674fce228c9597c3d8fe8dec..18205209983421691046e8a9d93eb6de32cd4563 100644 --- a/crates/debugger_ui/src/onboarding_modal.rs +++ b/crates/debugger_ui/src/onboarding_modal.rs @@ -40,7 +40,7 @@ impl DebuggerOnboardingModal { } fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { - cx.open_url("http://zed.dev/blog/debugger"); + cx.open_url("https://zed.dev/blog/debugger"); cx.notify(); debugger_onboarding_event!("Blog Link Clicked"); diff --git a/crates/debugger_ui/src/persistence.rs b/crates/debugger_ui/src/persistence.rs index 3a0ad7a40e60d4dc28f2086b94a0a43186978542..ab68fea1154182fe266bb150d762f8be0995d733 100644 --- a/crates/debugger_ui/src/persistence.rs +++ b/crates/debugger_ui/src/persistence.rs @@ -256,7 +256,7 @@ pub(crate) fn deserialize_pane_layout( Some(Member::Axis(PaneAxis::load( if should_invert { axis.invert() } else { axis }, members, - flexes.clone(), + flexes, ))) } SerializedPaneLayout::Pane(serialized_pane) => { @@ -270,12 +270,9 @@ pub(crate) fn deserialize_pane_layout( .children .iter() .map(|child| match child { - DebuggerPaneItem::Frames => Box::new(SubView::new( - stack_frame_list.focus_handle(cx), - stack_frame_list.clone().into(), - DebuggerPaneItem::Frames, - cx, - )), + DebuggerPaneItem::Frames => { + Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx)) + } DebuggerPaneItem::Variables => Box::new(SubView::new( variable_list.focus_handle(cx), variable_list.clone().into(), @@ -341,7 +338,7 @@ impl SerializedPaneLayout { pub(crate) fn in_order(&self) -> Vec { let mut panes = vec![]; - Self::inner_in_order(&self, &mut panes); + Self::inner_in_order(self, &mut panes); panes } diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs index 73cfef78cc6410196441ff974f09b5abe3d86916..40c9bd810f9c5c9691f51f3d38957a98c9f037a2 100644 --- a/crates/debugger_ui/src/session.rs +++ b/crates/debugger_ui/src/session.rs @@ -2,9 +2,7 @@ pub mod running; use crate::{StackTraceView, persistence::SerializedLayout, session::running::DebugTerminal}; use dap::client::SessionId; -use gpui::{ - App, Axis, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, -}; +use gpui::{App, Axis, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; use project::debugger::session::Session; use project::worktree_store::WorktreeStore; use project::{Project, debugger::session::SessionQuirks}; @@ -24,13 +22,6 @@ pub struct DebugSession { stack_trace_view: OnceCell>, _worktree_store: WeakEntity, workspace: WeakEntity, - _subscriptions: [Subscription; 1], -} - -#[derive(Debug)] -pub enum DebugPanelItemEvent { - Close, - Stopped { go_to_stack_frame: bool }, } impl DebugSession { @@ -59,9 +50,6 @@ impl DebugSession { let quirks = session.read(cx).quirks(); cx.new(|cx| Self { - _subscriptions: [cx.subscribe(&running_state, |_, _, _, cx| { - cx.notify(); - })], remote_id: None, running_state, quirks, @@ -87,7 +75,7 @@ impl DebugSession { self.stack_trace_view.get_or_init(|| { let stackframe_list = running_state.read(cx).stack_frame_list().clone(); - let stack_frame_view = cx.new(|cx| { + cx.new(|cx| { StackTraceView::new( workspace.clone(), project.clone(), @@ -95,9 +83,7 @@ impl DebugSession { window, cx, ) - }); - - stack_frame_view + }) }) } @@ -135,7 +121,7 @@ impl DebugSession { } } -impl EventEmitter for DebugSession {} +impl EventEmitter<()> for DebugSession {} impl Focusable for DebugSession { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -144,7 +130,7 @@ impl Focusable for DebugSession { } impl Item for DebugSession { - type Event = DebugPanelItemEvent; + type Event = (); fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { "Debugger".into() } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index f3117aee0797e2dd183a25a31bbe50ea560f21bc..0e21ef1268412418c381fc14617a917f9529834d 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -14,7 +14,6 @@ use crate::{ session::running::memory_view::MemoryView, }; -use super::DebugPanelItemEvent; use anyhow::{Context as _, Result, anyhow}; use breakpoint_list::BreakpointList; use collections::{HashMap, IndexMap}; @@ -36,15 +35,14 @@ use module_list::ModuleList; use project::{ DebugScenarioContext, Project, WorktreeId, debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus}, - terminals::TerminalKind, }; use rpc::proto::ViewId; use serde_json::Value; use settings::Settings; use stack_frame_list::StackFrameList; use task::{ - BuildTaskDefinition, DebugScenario, ShellBuilder, SpawnInTerminal, TaskContext, ZedDebugConfig, - substitute_variables_in_str, + BuildTaskDefinition, DebugScenario, Shell, ShellBuilder, SpawnInTerminal, TaskContext, + ZedDebugConfig, substitute_variables_in_str, }; use terminal_view::TerminalView; use ui::{ @@ -102,7 +100,7 @@ impl Render for RunningState { .find(|pane| pane.read(cx).is_zoomed()); let active = self.panes.panes().into_iter().next(); - let pane = if let Some(ref zoomed_pane) = zoomed_pane { + let pane = if let Some(zoomed_pane) = zoomed_pane { zoomed_pane.update(cx, |pane, cx| pane.render(window, cx).into_any_element()) } else if let Some(active) = active { self.panes @@ -158,6 +156,29 @@ impl SubView { }) } + pub(crate) fn stack_frame_list( + stack_frame_list: Entity, + cx: &mut App, + ) -> Entity { + let weak_list = stack_frame_list.downgrade(); + let this = Self::new( + stack_frame_list.focus_handle(cx), + stack_frame_list.into(), + DebuggerPaneItem::Frames, + cx, + ); + + this.update(cx, |this, _| { + this.with_actions(Box::new(move |_, cx| { + weak_list + .update(cx, |this, _| this.render_control_strip()) + .unwrap_or_else(|_| div().into_any_element()) + })); + }); + + this + } + pub(crate) fn console(console: Entity, cx: &mut App) -> Entity { let weak_console = console.downgrade(); let this = Self::new( @@ -180,7 +201,7 @@ impl SubView { let weak_list = list.downgrade(); let focus_handle = list.focus_handle(cx); let this = Self::new( - focus_handle.clone(), + focus_handle, list.into(), DebuggerPaneItem::BreakpointList, cx, @@ -358,13 +379,14 @@ pub(crate) fn new_debugger_pane( } }; - let ret = cx.new(move |cx| { + cx.new(move |cx| { let mut pane = Pane::new( workspace.clone(), project.clone(), Default::default(), None, NoAction.boxed_clone(), + true, window, cx, ); @@ -414,7 +436,7 @@ pub(crate) fn new_debugger_pane( .and_then(|item| item.downcast::()); let is_hovered = as_subview .as_ref() - .map_or(false, |item| item.read(cx).hovered); + .is_some_and(|item| item.read(cx).hovered); h_flex() .track_focus(&focus_handle) @@ -427,7 +449,6 @@ pub(crate) fn new_debugger_pane( .bg(cx.theme().colors().tab_bar_background) .on_action(|_: &menu::Cancel, window, cx| { if cx.stop_active_drag(window) { - return; } else { cx.propagate(); } @@ -449,7 +470,7 @@ pub(crate) fn new_debugger_pane( .children(pane.items().enumerate().map(|(ix, item)| { let selected = active_pane_item .as_ref() - .map_or(false, |active| active.item_id() == item.item_id()); + .is_some_and(|active| active.item_id() == item.item_id()); let deemphasized = !pane.has_focus(window, cx); let item_ = item.boxed_clone(); div() @@ -545,14 +566,13 @@ pub(crate) fn new_debugger_pane( })) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { let zoomed_text = if zoomed { "Minimize" } else { "Expand" }; Tooltip::for_action_in( zoomed_text, &ToggleExpandItem, &focus_handle, - window, cx, ) } @@ -563,9 +583,7 @@ pub(crate) fn new_debugger_pane( } }); pane - }); - - ret + }) } pub struct DebugTerminal { @@ -627,7 +645,7 @@ impl RunningState { if s.starts_with("\"$ZED_") && s.ends_with('"') { *s = s[1..s.len() - 1].to_string(); } - if let Some(substituted) = substitute_variables_in_str(&s, context) { + if let Some(substituted) = substitute_variables_in_str(s, context) { *s = substituted; } } @@ -657,7 +675,7 @@ impl RunningState { } resolve_path(s); - if let Some(substituted) = substitute_variables_in_str(&s, context) { + if let Some(substituted) = substitute_variables_in_str(s, context) { *s = substituted; } } @@ -919,7 +937,12 @@ impl RunningState { let task_store = project.read(cx).task_store().downgrade(); let weak_project = project.downgrade(); let weak_workspace = workspace.downgrade(); - let is_local = project.read(cx).is_local(); + let is_windows = project.read(cx).path_style(cx).is_windows(); + let remote_shell = project + .read(cx) + .remote_client() + .as_ref() + .and_then(|remote| remote.read(cx).shell()); cx.spawn_in(window, async move |this, cx| { let DebugScenario { @@ -954,7 +977,7 @@ impl RunningState { inventory.read(cx).task_template_by_label( buffer, worktree_id, - &label, + label, cx, ) }) @@ -966,7 +989,7 @@ impl RunningState { (task, None) } }; - let Some(task) = task_template.resolve_task("debug-build-task", &task_context) else { + let Some(mut task) = task_template.resolve_task("debug-build-task", &task_context) else { anyhow::bail!("Could not resolve task variables within a debug scenario"); }; @@ -1003,8 +1026,12 @@ impl RunningState { None }; - let builder = ShellBuilder::new(is_local, &task.resolved.shell); - let command_label = builder.command_label(&task.resolved.command_label); + if let Some(remote_shell) = remote_shell && task.resolved.shell == Shell::System { + task.resolved.shell = Shell::Program(remote_shell); + } + + let builder = ShellBuilder::new(&task.resolved.shell, is_windows); + let command_label = builder.command_label(task.resolved.command.as_deref().unwrap_or("")); let (command, args) = builder.build(task.resolved.command.clone(), &task.resolved.args); @@ -1016,12 +1043,11 @@ impl RunningState { }; let terminal = project .update(cx, |project, cx| { - project.create_terminal( - TerminalKind::Task(task_with_shell.clone()), + project.create_terminal_task( + task_with_shell.clone(), cx, ) - })? - .await?; + })?.await?; let terminal_view = cx.new_window_entity(|window, cx| { TerminalView::new( @@ -1116,9 +1142,8 @@ impl RunningState { }; let session = self.session.read(cx); - let cwd = Some(&request.cwd) - .filter(|cwd| cwd.len() > 0) - .map(PathBuf::from) + let cwd = (!request.cwd.is_empty()) + .then(|| PathBuf::from(&request.cwd)) .or_else(|| session.binary().unwrap().cwd.clone()); let mut envs: HashMap = @@ -1153,7 +1178,7 @@ impl RunningState { } else { None } - } else if args.len() > 0 { + } else if !args.is_empty() { Some(args.remove(0)) } else { None @@ -1166,13 +1191,13 @@ impl RunningState { .filter(|title| !title.is_empty()) .or_else(|| command.clone()) .unwrap_or_else(|| "Debug terminal".to_string()); - let kind = TerminalKind::Task(task::SpawnInTerminal { + let kind = task::SpawnInTerminal { id: task::TaskId("debug".to_string()), full_label: title.clone(), label: title.clone(), - command: command.clone(), + command, args, - command_label: title.clone(), + command_label: title, cwd, env: envs, use_new_terminal: true, @@ -1184,12 +1209,13 @@ impl RunningState { show_summary: false, show_command: false, show_rerun: false, - }); + }; let workspace = self.workspace.clone(); let weak_project = project.downgrade(); - let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx)); + let terminal_task = + project.update(cx, |project, cx| project.create_terminal_task(kind, cx)); let terminal_task = cx.spawn_in(window, async move |_, cx| { let terminal = terminal_task.await?; @@ -1207,7 +1233,6 @@ impl RunningState { terminal.read_with(cx, |terminal, _| { terminal - .pty_info .pid() .map(|pid| pid.as_u32()) .context("Terminal was spawned but PID was not available") @@ -1310,7 +1335,7 @@ impl RunningState { let mut pane_item_status = IndexMap::from_iter( DebuggerPaneItem::all() .iter() - .filter(|kind| kind.is_supported(&caps)) + .filter(|kind| kind.is_supported(caps)) .map(|kind| (*kind, false)), ); self.panes.panes().iter().for_each(|pane| { @@ -1371,7 +1396,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(&source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1759,7 +1784,7 @@ impl RunningState { this.activate_item(0, false, false, window, cx); }); - let rightmost_pane = new_debugger_pane(workspace.clone(), project.clone(), window, cx); + let rightmost_pane = new_debugger_pane(workspace.clone(), project, window, cx); rightmost_pane.update(cx, |this, cx| { this.add_item( Box::new(SubView::new( @@ -1804,8 +1829,6 @@ impl RunningState { } } -impl EventEmitter for RunningState {} - impl Focusable for RunningState { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 38108dbfbcc62e777ea9ee9aa9f1ab1f7d2c2f3d..0a02a5a8e4197bf6b959a592b6e3d3da92c00846 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -10,8 +10,9 @@ use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, - Stateful, Task, UniformListScrollHandle, WeakEntity, actions, uniform_list, + Task, UniformListScrollHandle, WeakEntity, actions, uniform_list, }; +use itertools::Itertools; use language::Point; use project::{ Project, @@ -23,9 +24,10 @@ use project::{ worktree_store::WorktreeStore, }; use ui::{ - Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, Scrollbar, - ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*, + Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, + ScrollAxes, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, }; +use util::rel_path::RelPath; use workspace::Workspace; use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint}; @@ -49,12 +51,12 @@ pub(crate) struct BreakpointList { breakpoint_store: Entity, dap_store: Entity, worktree_store: Entity, - scrollbar_state: ScrollbarState, breakpoints: Vec, session: Option>, focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, selected_ix: Option, + max_width_index: Option, input: Entity, strip_mode: Option, serialize_exception_breakpoints_task: Option>>, @@ -87,7 +89,6 @@ impl BreakpointList { let dap_store = project.dap_store(); let focus_handle = cx.focus_handle(); let scroll_handle = UniformListScrollHandle::new(); - let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let adapter_name = session.as_ref().map(|session| session.read(cx).adapter()); cx.new(|cx| { @@ -95,8 +96,8 @@ impl BreakpointList { breakpoint_store, dap_store, worktree_store, - scrollbar_state, breakpoints: Default::default(), + max_width_index: None, workspace, session, focus_handle, @@ -219,7 +220,7 @@ impl BreakpointList { }); self.input.update(cx, |this, cx| { - this.set_placeholder_text(placeholder, cx); + this.set_placeholder_text(placeholder, window, cx); this.set_read_only(is_exception_breakpoint); this.set_text(active_value.as_deref().unwrap_or(""), window, cx); }); @@ -239,14 +240,12 @@ impl BreakpointList { } fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { - if self.strip_mode.is_some() { - if self.input.focus_handle(cx).contains_focused(window, cx) { - cx.propagate(); - return; - } + if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) { + cx.propagate(); + return; } let ix = match self.selected_ix { - _ if self.breakpoints.len() == 0 => None, + _ if self.breakpoints.is_empty() => None, None => Some(0), Some(ix) => { if ix == self.breakpoints.len() - 1 { @@ -265,14 +264,12 @@ impl BreakpointList { window: &mut Window, cx: &mut Context, ) { - if self.strip_mode.is_some() { - if self.input.focus_handle(cx).contains_focused(window, cx) { - cx.propagate(); - return; - } + if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) { + cx.propagate(); + return; } let ix = match self.selected_ix { - _ if self.breakpoints.len() == 0 => None, + _ if self.breakpoints.is_empty() => None, None => Some(self.breakpoints.len() - 1), Some(ix) => { if ix == 0 { @@ -286,13 +283,11 @@ impl BreakpointList { } fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context) { - if self.strip_mode.is_some() { - if self.input.focus_handle(cx).contains_focused(window, cx) { - cx.propagate(); - return; - } + if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) { + cx.propagate(); + return; } - let ix = if self.breakpoints.len() > 0 { + let ix = if !self.breakpoints.is_empty() { Some(0) } else { None @@ -301,13 +296,11 @@ impl BreakpointList { } fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context) { - if self.strip_mode.is_some() { - if self.input.focus_handle(cx).contains_focused(window, cx) { - cx.propagate(); - return; - } + if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) { + cx.propagate(); + return; } - let ix = if self.breakpoints.len() > 0 { + let ix = if !self.breakpoints.is_empty() { Some(self.breakpoints.len() - 1) } else { None @@ -337,8 +330,8 @@ impl BreakpointList { let text = self.input.read(cx).text(cx); match mode { - ActiveBreakpointStripMode::Log => match &entry.kind { - BreakpointEntryKind::LineBreakpoint(line_breakpoint) => { + ActiveBreakpointStripMode::Log => { + if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind { Self::edit_line_breakpoint_inner( &self.breakpoint_store, line_breakpoint.breakpoint.path.clone(), @@ -347,10 +340,9 @@ impl BreakpointList { cx, ); } - _ => {} - }, - ActiveBreakpointStripMode::Condition => match &entry.kind { - BreakpointEntryKind::LineBreakpoint(line_breakpoint) => { + } + ActiveBreakpointStripMode::Condition => { + if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind { Self::edit_line_breakpoint_inner( &self.breakpoint_store, line_breakpoint.breakpoint.path.clone(), @@ -359,10 +351,9 @@ impl BreakpointList { cx, ); } - _ => {} - }, - ActiveBreakpointStripMode::HitCondition => match &entry.kind { - BreakpointEntryKind::LineBreakpoint(line_breakpoint) => { + } + ActiveBreakpointStripMode::HitCondition => { + if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &entry.kind { Self::edit_line_breakpoint_inner( &self.breakpoint_store, line_breakpoint.breakpoint.path.clone(), @@ -371,8 +362,7 @@ impl BreakpointList { cx, ); } - _ => {} - }, + } } self.focus_handle.focus(window); } else { @@ -401,11 +391,9 @@ impl BreakpointList { let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else { return; }; - if self.strip_mode.is_some() { - if self.input.focus_handle(cx).contains_focused(window, cx) { - cx.propagate(); - return; - } + if self.strip_mode.is_some() && self.input.focus_handle(cx).contains_focused(window, cx) { + cx.propagate(); + return; } match &mut entry.kind { @@ -436,13 +424,10 @@ impl BreakpointList { return; }; - match &mut entry.kind { - BreakpointEntryKind::LineBreakpoint(line_breakpoint) => { - let path = line_breakpoint.breakpoint.path.clone(); - let row = line_breakpoint.breakpoint.row; - self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx); - } - _ => {} + if let BreakpointEntryKind::LineBreakpoint(line_breakpoint) = &mut entry.kind { + let path = line_breakpoint.breakpoint.path.clone(); + let row = line_breakpoint.breakpoint.row; + self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx); } cx.notify(); } @@ -494,7 +479,7 @@ impl BreakpointList { fn toggle_data_breakpoint(&mut self, id: &str, cx: &mut Context) { if let Some(session) = &self.session { session.update(cx, |this, cx| { - this.toggle_data_breakpoint(&id, cx); + this.toggle_data_breakpoint(id, cx); }); } } @@ -502,7 +487,7 @@ impl BreakpointList { fn toggle_exception_breakpoint(&mut self, id: &str, cx: &mut Context) { if let Some(session) = &self.session { session.update(cx, |this, cx| { - this.toggle_exception_breakpoint(&id, cx); + this.toggle_exception_breakpoint(id, cx); }); cx.notify(); const EXCEPTION_SERIALIZATION_INTERVAL: Duration = Duration::from_secs(1); @@ -538,7 +523,7 @@ impl BreakpointList { cx.background_executor() .spawn(async move { KEY_VALUE_STORE.write_kvp(key, value?).await }) } else { - return Task::ready(Result::Ok(())); + Task::ready(Result::Ok(())) } } @@ -564,7 +549,7 @@ impl BreakpointList { .session .as_ref() .map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities())) - .unwrap_or_else(SupportedBreakpointProperties::empty); + .unwrap_or_else(SupportedBreakpointProperties::all); let strip_mode = self.strip_mode; uniform_list( @@ -588,43 +573,12 @@ impl BreakpointList { .collect() }), ) + .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) + .with_width_from_item(self.max_width_index) .track_scroll(self.scroll_handle.clone()) .flex_1() } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("breakpoint-list-vertical-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) - } - pub(crate) fn render_control_strip(&self) -> AnyElement { let selection_kind = self.selection_kind(); let focus_handle = self.focus_handle.clone(); @@ -658,13 +612,12 @@ impl BreakpointList { .when_some(toggle_label, |this, (label, meta)| { this.tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta_in( label, Some(&ToggleEnableBreakpoint), meta, &focus_handle, - window, cx, ) } @@ -685,13 +638,12 @@ impl BreakpointList { .when_some(remove_breakpoint_tooltip, |this, tooltip| { this.tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta_in( "Remove Breakpoint", Some(&UnsetBreakpoint), tooltip, &focus_handle, - window, cx, ) } @@ -701,7 +653,6 @@ impl BreakpointList { selection_kind.map(|kind| kind.0) != Some(SelectedBreakpointKind::Source), ) .on_click({ - let focus_handle = focus_handle.clone(); move |_, window, cx| { focus_handle.focus(window); window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx) @@ -716,6 +667,7 @@ impl Render for BreakpointList { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { let breakpoints = self.breakpoint_store.read(cx).all_source_breakpoints(cx); self.breakpoints.clear(); + let path_style = self.worktree_store.read(cx).path_style(); let weak = cx.weak_entity(); let breakpoints = breakpoints.into_iter().flat_map(|(path, mut breakpoints)| { let relative_worktree_path = self @@ -726,24 +678,20 @@ impl Render for BreakpointList { worktree .read(cx) .is_visible() - .then(|| Path::new(worktree.read(cx).root_name()).join(relative_path)) + .then(|| worktree.read(cx).root_name().join(&relative_path)) }); breakpoints.sort_by_key(|breakpoint| breakpoint.row); let weak = weak.clone(); breakpoints.into_iter().filter_map(move |breakpoint| { debug_assert_eq!(&path, &breakpoint.path); let file_name = breakpoint.path.file_name()?; + let breakpoint_path = RelPath::new(&breakpoint.path, path_style).ok(); let dir = relative_worktree_path - .clone() - .unwrap_or_else(|| PathBuf::from(&*breakpoint.path)) + .as_deref() + .or(breakpoint_path.as_deref())? .parent() - .and_then(|parent| { - parent - .to_str() - .map(ToOwned::to_owned) - .map(SharedString::from) - }); + .map(|parent| SharedString::from(parent.display(path_style).to_string())); let name = file_name .to_str() .map(ToOwned::to_owned) @@ -789,6 +737,26 @@ impl Render for BreakpointList { .chain(exception_breakpoints), ); + let text_pixels = ui::TextSize::Default.pixels(cx).to_f64() as f32; + + self.max_width_index = self + .breakpoints + .iter() + .map(|entry| match &entry.kind { + BreakpointEntryKind::LineBreakpoint(line_bp) => { + let name_and_line = format!("{}:{}", line_bp.name, line_bp.line); + let dir_len = line_bp.dir.as_ref().map(|d| d.len()).unwrap_or(0); + (name_and_line.len() + dir_len) as f32 * text_pixels + } + BreakpointEntryKind::ExceptionBreakpoint(exc_bp) => { + exc_bp.data.label.len() as f32 * text_pixels + } + BreakpointEntryKind::DataBreakpoint(data_bp) => { + data_bp.0.context.human_readable_label().len() as f32 * text_pixels + } + }) + .position_max_by(|left, right| left.total_cmp(right)); + v_flex() .id("breakpoint-list") .key_context("BreakpointList") @@ -806,7 +774,14 @@ impl Render for BreakpointList { .size_full() .pt_1() .child(self.render_list(cx)) - .child(self.render_vertical_scrollbar(cx)) + .custom_scrollbars( + ui::Scrollbars::new(ScrollAxes::Both) + .tracked_scroll_handle(self.scroll_handle.clone()) + .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) + .tracked_entity(cx.entity_id()), + window, + cx, + ) .when_some(self.strip_mode, |this, _| { this.child(Divider::horizontal().color(DividerColor::Border)) .child( @@ -874,7 +849,7 @@ impl LineBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Breakpoint" @@ -883,7 +858,6 @@ impl LineBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } @@ -977,7 +951,7 @@ impl LineBreakpoint { props, breakpoint: BreakpointEntry { kind: BreakpointEntryKind::LineBreakpoint(self.clone()), - weak: weak, + weak, }, is_selected, focus_handle, @@ -1035,7 +1009,7 @@ impl DataBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Data Breakpoint" @@ -1044,7 +1018,6 @@ impl DataBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } @@ -1140,7 +1113,7 @@ impl ExceptionBreakpoint { ) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( if is_enabled { "Disable Exception Breakpoint" @@ -1149,13 +1122,11 @@ impl ExceptionBreakpoint { }, &ToggleEnableBreakpoint, &focus_handle, - window, cx, ) } }) .on_click({ - let list = list.clone(); move |_, _, cx| { list.update(cx, |this, cx| { this.toggle_exception_breakpoint(&id, cx); @@ -1189,7 +1160,7 @@ impl ExceptionBreakpoint { props, breakpoint: BreakpointEntry { kind: BreakpointEntryKind::ExceptionBreakpoint(self.clone()), - weak: weak, + weak, }, is_selected, focus_handle, @@ -1437,8 +1408,10 @@ impl RenderOnce for BreakpointOptionsStrip { h_flex() .gap_px() .mr_3() // Space to avoid overlapping with the scrollbar - .child( - div() + .justify_end() + .when(has_logs || self.is_selected, |this| { + this.child( + div() .map(self.add_focus_styles( ActiveBreakpointStripMode::Log, supports_logs, @@ -1458,56 +1431,55 @@ impl RenderOnce for BreakpointOptionsStrip { .disabled(!supports_logs) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Log)) .on_click(self.on_click_callback(ActiveBreakpointStripMode::Log)) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Set Log Message", None, "Set log message to display (instead of stopping) when a breakpoint is hit.", - window, cx, ) }), ) - .when(!has_logs && !self.is_selected, |this| this.invisible()), - ) - .child( - div() - .map(self.add_focus_styles( - ActiveBreakpointStripMode::Condition, - supports_condition, - window, - cx, - )) - .child( - IconButton::new( - SharedString::from(format!("{id}-condition-toggle")), - IconName::SplitAlt, - ) - .shape(ui::IconButtonShape::Square) - .style(style_for_toggle( + ) + }) + .when(has_condition || self.is_selected, |this| { + this.child( + div() + .map(self.add_focus_styles( ActiveBreakpointStripMode::Condition, - has_condition, + supports_condition, + window, + cx, )) - .icon_size(IconSize::Small) - .icon_color(color_for_toggle(has_condition)) - .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) - .disabled(!supports_condition) - .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition)) - .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition)) - .tooltip(|window, cx| { - Tooltip::with_meta( - "Set Condition", - None, - "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.", - window, - cx, + .child( + IconButton::new( + SharedString::from(format!("{id}-condition-toggle")), + IconName::SplitAlt, ) - }), - ) - .when(!has_condition && !self.is_selected, |this| this.invisible()), - ) - .child( - div() + .shape(ui::IconButtonShape::Square) + .style(style_for_toggle( + ActiveBreakpointStripMode::Condition, + has_condition, + )) + .icon_size(IconSize::Small) + .icon_color(color_for_toggle(has_condition)) + .when(has_condition, |this| this.indicator(Indicator::dot().color(Color::Info))) + .disabled(!supports_condition) + .toggle_state(self.is_toggled(ActiveBreakpointStripMode::Condition)) + .on_click(self.on_click_callback(ActiveBreakpointStripMode::Condition)) + .tooltip(|_window, cx| { + Tooltip::with_meta( + "Set Condition", + None, + "Set condition to evaluate when a breakpoint is hit. Program execution will stop only when the condition is met.", + cx, + ) + }), + ) + ) + }) + .when(has_hit_condition || self.is_selected, |this| { + this.child(div() .map(self.add_focus_styles( ActiveBreakpointStripMode::HitCondition, supports_hit_condition, @@ -1530,19 +1502,16 @@ impl RenderOnce for BreakpointOptionsStrip { .disabled(!supports_hit_condition) .toggle_state(self.is_toggled(ActiveBreakpointStripMode::HitCondition)) .on_click(self.on_click_callback(ActiveBreakpointStripMode::HitCondition)) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Set Hit Condition", None, "Set expression that controls how many hits of the breakpoint are ignored.", - window, cx, ) }), - ) - .when(!has_hit_condition && !self.is_selected, |this| { - this.invisible() - }), - ) + )) + + }) } } diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index e6308518e4dea66e6ef155e3dbf6ccfa74c18f55..2d01a325a2b0056bfbf42e519a79a4ec199c4a9d 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -12,10 +12,10 @@ use gpui::{ Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, Render, Subscription, Task, TextStyle, WeakEntity, actions, }; -use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset}; +use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset}; use menu::{Confirm, SelectNext, SelectPrevious}; use project::{ - Completion, CompletionResponse, + Completion, CompletionDisplayOptions, CompletionResponse, debugger::session::{CompletionsQuery, OutputToken, Session}, lsp_store::CompletionDocumentation, search_history::{SearchHistory, SearchHistoryCursor}, @@ -83,7 +83,7 @@ impl Console { let this = cx.weak_entity(); let query_bar = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Evaluate an expression", cx); + editor.set_placeholder_text("Evaluate an expression", window, cx); editor.set_use_autoclose(false); editor.set_show_gutter(false, cx); editor.set_show_wrap_guides(false, cx); @@ -365,7 +365,7 @@ impl Console { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu .when_some(keybinding_target.clone(), |el, keybinding_target| { - el.context(keybinding_target.clone()) + el.context(keybinding_target) }) .action("Watch Expression", WatchExpression.boxed_clone()) })) @@ -484,12 +484,11 @@ impl Render for Console { .tooltip({ let query_focus_handle = query_focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Evaluate", &Confirm, &query_focus_handle, - window, cx, ) } @@ -575,7 +574,9 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { return false; } - let classifier = snapshot.char_classifier_at(position).for_completion(true); + let classifier = snapshot + .char_classifier_at(position) + .scope_context(Some(CharScopeContext::Completion)); if trigger_in_words && classifier.is_word(char) { return true; } @@ -611,17 +612,16 @@ impl ConsoleQueryBarCompletionProvider { for variable in console.variable_list.update(cx, |variable_list, cx| { variable_list.completion_variables(cx) }) { - if let Some(evaluate_name) = &variable.evaluate_name { - if variables + if let Some(evaluate_name) = &variable.evaluate_name + && variables .insert(evaluate_name.clone(), variable.value.clone()) .is_none() - { - string_matches.push(StringMatchCandidate { - id: 0, - string: evaluate_name.clone(), - char_bag: evaluate_name.chars().collect(), - }); - } + { + string_matches.push(StringMatchCandidate { + id: 0, + string: evaluate_name.clone(), + char_bag: evaluate_name.chars().collect(), + }); } if variables @@ -668,11 +668,7 @@ impl ConsoleQueryBarCompletionProvider { &snapshot, ), new_text: string_match.string.clone(), - label: CodeLabel { - filter_range: 0..string_match.string.len(), - text: string_match.string.clone(), - runs: Vec::new(), - }, + label: CodeLabel::plain(string_match.string.clone(), None), icon_path: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), @@ -686,6 +682,7 @@ impl ConsoleQueryBarCompletionProvider { Ok(vec![project::CompletionResponse { is_incomplete: completions.len() >= LIMIT, + display_options: CompletionDisplayOptions::default(), completions, }]) }) @@ -697,7 +694,7 @@ impl ConsoleQueryBarCompletionProvider { new_bytes: &[u8], snapshot: &TextBufferSnapshot, ) -> Range { - let buffer_offset = buffer_position.to_offset(&snapshot); + let buffer_offset = buffer_position.to_offset(snapshot); let buffer_bytes = &buffer_text.as_bytes()[0..buffer_offset]; let mut prefix_len = 0; @@ -780,11 +777,7 @@ impl ConsoleQueryBarCompletionProvider { &snapshot, ), new_text, - label: CodeLabel { - filter_range: 0..completion.label.len(), - text: completion.label, - runs: Vec::new(), - }, + label: CodeLabel::plain(completion.label, None), icon_path: None, documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) @@ -798,6 +791,7 @@ impl ConsoleQueryBarCompletionProvider { Ok(vec![project::CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), is_incomplete: false, }]) }) @@ -968,8 +962,12 @@ mod tests { ) { cx.set_state(input); - let buffer_position = - cx.editor(|editor, _, cx| editor.selections.newest::(cx).start); + let buffer_position = cx.editor(|editor, _, cx| { + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .start + }); let snapshot = &cx.buffer_snapshot(); @@ -977,7 +975,7 @@ mod tests { &cx.buffer_text(), snapshot.anchor_before(buffer_position), replacement.as_bytes(), - &snapshot, + snapshot, ); cx.update_editor(|editor, _, cx| { diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs index 6b376bb892e1ea5aae64a1d5873b91487e65f3c2..921ebd8b5f5bdfe8a3c8a8f7bb1625bd1ffad7fb 100644 --- a/crates/debugger_ui/src/session/running/loaded_source_list.rs +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -57,7 +57,7 @@ impl LoadedSourceList { h_flex() .text_ui_xs(cx) .text_color(cx.theme().colors().text_muted) - .when_some(source.path.clone(), |this, path| this.child(path)), + .when_some(source.path, |this, path| this.child(path)), ) .into_any() } diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index f936d908b157ae2631a20b78bfe9fcea26b47b96..8670beb0f5f93f68a6052b868a866e22b82c92fd 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -1,17 +1,18 @@ use std::{ - cell::LazyCell, + cell::{LazyCell, RefCell, RefMut}, fmt::Write, ops::RangeInclusive, + rc::Rc, sync::{Arc, LazyLock}, time::Duration, }; use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - Action, AppContext, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, Focusable, - MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, TextStyle, - UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, - uniform_list, + Action, Along, AppContext, Axis, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, + Focusable, ListHorizontalSizingBehavior, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, + Subscription, Task, TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, + anchored, deferred, uniform_list, }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; @@ -19,7 +20,7 @@ use settings::Settings; use theme::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, - Scrollbar, ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*, + ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, }; use workspace::Workspace; @@ -29,11 +30,9 @@ actions!(debugger, [GoToSelectedAddress]); pub(crate) struct MemoryView { workspace: WeakEntity, - scroll_handle: UniformListScrollHandle, - scroll_state: ScrollbarState, stack_frame_list: WeakEntity, focus_handle: FocusHandle, - view_state: ViewState, + view_state_handle: ViewStateHandle, query_editor: Entity, session: Entity, width_picker_handle: PopoverMenuHandle, @@ -90,18 +89,29 @@ impl SelectedMemoryRange { } } +#[derive(Clone)] +struct ViewStateHandle(Rc>); + +impl ViewStateHandle { + fn new(base_row: u64, line_width: ViewWidth) -> Self { + Self(Rc::new(RefCell::new(ViewState::new(base_row, line_width)))) + } +} + #[derive(Clone)] struct ViewState { /// Uppermost row index base_row: u64, /// How many cells per row do we have? line_width: ViewWidth, + scroll_handle: UniformListScrollHandle, selection: Option, } impl ViewState { fn new(base_row: u64, line_width: ViewWidth) -> Self { Self { + scroll_handle: UniformListScrollHandle::new(), base_row, line_width, selection: None, @@ -119,13 +129,39 @@ impl ViewState { fn schedule_scroll_up(&mut self) { self.base_row = self.base_row.saturating_sub(1); } + + fn set_offset(&mut self, point: Point) { + if point.y >= -Pixels::ZERO { + self.schedule_scroll_up(); + } else if point.y <= -self.scroll_handle.max_offset().height { + self.schedule_scroll_down(); + } + self.scroll_handle.set_offset(point); + } } -struct ScrollbarDragging; +impl ScrollableHandle for ViewStateHandle { + fn max_offset(&self) -> gpui::Size { + self.0.borrow().scroll_handle.max_offset() + } + + fn set_offset(&self, point: Point) { + self.0.borrow_mut().set_offset(point); + } + + fn offset(&self) -> Point { + self.0.borrow().scroll_handle.offset() + } + + fn viewport(&self) -> gpui::Bounds { + self.0.borrow().scroll_handle.viewport() + } +} static HEX_BYTES_MEMOIZED: LazyLock<[SharedString; 256]> = LazyLock::new(|| std::array::from_fn(|byte| SharedString::from(format!("{byte:02X}")))); static UNKNOWN_BYTE: SharedString = SharedString::new_static("??"); + impl MemoryView { pub(crate) fn new( session: Entity, @@ -134,19 +170,15 @@ impl MemoryView { window: &mut Window, cx: &mut Context, ) -> Self { - let view_state = ViewState::new(0, WIDTHS[4].clone()); - let scroll_handle = UniformListScrollHandle::default(); + let view_state_handle = ViewStateHandle::new(0, WIDTHS[4].clone()); let query_editor = cx.new(|cx| Editor::single_line(window, cx)); - let scroll_state = ScrollbarState::new(scroll_handle.clone()); let mut this = Self { workspace, - scroll_state, - scroll_handle, stack_frame_list, focus_handle: cx.focus_handle(), - view_state, + view_state_handle, query_editor, session, width_picker_handle: Default::default(), @@ -162,50 +194,17 @@ impl MemoryView { this } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("memory-view-vertical-scrollbar") - .on_drag_move(cx.listener(|this, evt, _, cx| { - let did_handle = this.handle_scroll_drag(evt); - cx.notify(); - if did_handle { - cx.stop_propagation() - } - })) - .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty)) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scroll_state.clone()).map(|s| s.auto_hide(cx))) + fn view_state(&self) -> RefMut<'_, ViewState> { + self.view_state_handle.0.borrow_mut() } fn render_memory(&self, cx: &mut Context) -> UniformList { let weak = cx.weak_entity(); let session = self.session.clone(); - let view_state = self.view_state.clone(); + let view_state = self.view_state_handle.0.borrow().clone(); uniform_list( "debugger-memory-view", - self.view_state.row_count() as usize, + view_state.row_count() as usize, move |range, _, cx| { let mut line_buffer = Vec::with_capacity(view_state.line_width.width as usize); let memory_start = @@ -230,22 +229,14 @@ impl MemoryView { rows }, ) - .track_scroll(self.scroll_handle.clone()) + .track_scroll(view_state.scroll_handle) + .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| { + let mut view_state = this.view_state(); let delta = evt.delta.pixel_delta(window.line_height()); - let scroll_handle = this.scroll_state.scroll_handle(); - let size = scroll_handle.content_size(); - let viewport = scroll_handle.viewport(); - let current_offset = scroll_handle.offset(); - let first_entry_offset_boundary = size.height / this.view_state.row_count() as f32; - let last_entry_offset_boundary = size.height - first_entry_offset_boundary; - if first_entry_offset_boundary + viewport.size.height > current_offset.y.abs() { - // The topmost entry is visible, hence if we're scrolling up, we need to load extra lines. - this.view_state.schedule_scroll_up(); - } else if last_entry_offset_boundary < current_offset.y.abs() + viewport.size.height { - this.view_state.schedule_scroll_down(); - } - scroll_handle.set_offset(current_offset + point(px(0.), delta.y)); + let current_offset = view_state.scroll_handle.offset(); + view_state + .set_offset(current_offset.apply_along(Axis::Vertical, |offset| offset + delta.y)); })) } fn render_query_bar(&self, cx: &Context) -> impl IntoElement { @@ -262,7 +253,7 @@ impl MemoryView { cx: &mut Context, ) { use parse_int::parse; - let Ok(as_address) = parse::(&memory_reference) else { + let Ok(as_address) = parse::(memory_reference) else { return; }; let access_size = evaluate_name @@ -275,7 +266,7 @@ impl MemoryView { cx.spawn(async move |this, cx| { let access_size = access_size.await.unwrap_or(1); this.update(cx, |this, cx| { - this.view_state.selection = Some(SelectedMemoryRange::DragComplete(Drag { + this.view_state().selection = Some(SelectedMemoryRange::DragComplete(Drag { start_address: as_address, end_address: as_address + access_size - 1, })); @@ -287,43 +278,23 @@ impl MemoryView { } fn handle_memory_drag(&mut self, evt: &DragMoveEvent) { - if !self - .view_state + let mut view_state = self.view_state(); + if !view_state .selection .as_ref() .is_some_and(|selection| selection.is_dragging()) { return; } - let row_count = self.view_state.row_count(); + let row_count = view_state.row_count(); debug_assert!(row_count > 1); - let scroll_handle = self.scroll_state.scroll_handle(); + let scroll_handle = &view_state.scroll_handle; let viewport = scroll_handle.viewport(); if viewport.bottom() < evt.event.position.y { - self.view_state.schedule_scroll_down(); + view_state.schedule_scroll_down(); } else if viewport.top() > evt.event.position.y { - self.view_state.schedule_scroll_up(); - } - } - - fn handle_scroll_drag(&mut self, evt: &DragMoveEvent) -> bool { - if !self.scroll_state.is_dragging() { - return false; - } - let row_count = self.view_state.row_count(); - debug_assert!(row_count > 1); - let scroll_handle = self.scroll_state.scroll_handle(); - let viewport = scroll_handle.viewport(); - - if viewport.bottom() < evt.event.position.y { - self.view_state.schedule_scroll_down(); - true - } else if viewport.top() > evt.event.position.y { - self.view_state.schedule_scroll_up(); - true - } else { - false + view_state.schedule_scroll_up(); } } @@ -354,7 +325,7 @@ impl MemoryView { fn render_width_picker(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { let weak = cx.weak_entity(); - let selected_width = self.view_state.line_width.clone(); + let selected_width = self.view_state().line_width.clone(); DropdownMenu::new( "memory-view-width-picker", selected_width.label.clone(), @@ -364,24 +335,25 @@ impl MemoryView { let width = width.clone(); this = this.entry(width.label.clone(), None, move |_, cx| { _ = weak.update(cx, |this, _| { + let mut view_state = this.view_state(); // Convert base ix between 2 line widths to keep the shown memory address roughly the same. // All widths are powers of 2, so the conversion should be lossless. - match this.view_state.line_width.width.cmp(&width.width) { + match view_state.line_width.width.cmp(&width.width) { std::cmp::Ordering::Less => { // We're converting up. let shift = width.width.trailing_zeros() - - this.view_state.line_width.width.trailing_zeros(); - this.view_state.base_row >>= shift; + - view_state.line_width.width.trailing_zeros(); + view_state.base_row >>= shift; } std::cmp::Ordering::Greater => { // We're converting down. - let shift = this.view_state.line_width.width.trailing_zeros() + let shift = view_state.line_width.width.trailing_zeros() - width.width.trailing_zeros(); - this.view_state.base_row <<= shift; + view_state.base_row <<= shift; } _ => {} } - this.view_state.line_width = width.clone(); + view_state.line_width = width.clone(); }); }); } @@ -400,18 +372,18 @@ impl MemoryView { } fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { - self.view_state.base_row = self - .view_state + let mut view_state = self.view_state(); + view_state.base_row = view_state .base_row - .overflowing_add(self.view_state.row_count()) + .overflowing_add(view_state.row_count()) .0; cx.notify(); } fn page_up(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { - self.view_state.base_row = self - .view_state + let mut view_state = self.view_state(); + view_state.base_row = view_state .base_row - .overflowing_sub(self.view_state.row_count()) + .overflowing_sub(view_state.row_count()) .0; cx.notify(); } @@ -428,14 +400,14 @@ impl MemoryView { if !self.is_writing_memory { self.query_editor.update(cx, |this, cx| { this.clear(window, cx); - this.set_placeholder_text("Write to Selected Memory Range", cx); + this.set_placeholder_text("Write to Selected Memory Range", window, cx); }); self.is_writing_memory = true; self.query_editor.focus_handle(cx).focus(window); } else { self.query_editor.update(cx, |this, cx| { this.clear(window, cx); - this.set_placeholder_text("Go to Memory Address / Expression", cx); + this.set_placeholder_text("Go to Memory Address / Expression", window, cx); }); self.is_writing_memory = false; } @@ -447,7 +419,8 @@ impl MemoryView { _: &mut Window, cx: &mut Context, ) { - let Some(SelectedMemoryRange::DragComplete(selection)) = self.view_state.selection.clone() + let Some(SelectedMemoryRange::DragComplete(selection)) = + self.view_state().selection.clone() else { return; }; @@ -461,7 +434,7 @@ impl MemoryView { let data_breakpoint_info = this.data_breakpoint_info(context.clone(), None, cx); cx.spawn(async move |this, cx| { if let Some(info) = data_breakpoint_info.await { - let Some(data_id) = info.data_id.clone() else { + let Some(data_id) = info.data_id else { return; }; _ = this.update(cx, |this, cx| { @@ -484,7 +457,8 @@ impl MemoryView { } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - if let Some(SelectedMemoryRange::DragComplete(drag)) = &self.view_state.selection { + let selection = self.view_state().selection.clone(); + if let Some(SelectedMemoryRange::DragComplete(drag)) = selection { // Go into memory writing mode. if !self.is_writing_memory { let should_return = self.session.update(cx, |session, cx| { @@ -558,9 +532,11 @@ impl MemoryView { } fn jump_to_address(&mut self, address: u64, cx: &mut Context) { - self.view_state.base_row = (address & !0xfff) / self.view_state.line_width.width as u64; - let line_ix = (address & 0xfff) / self.view_state.line_width.width as u64; - self.scroll_handle + let mut view_state = self.view_state(); + view_state.base_row = (address & !0xfff) / view_state.line_width.width as u64; + let line_ix = (address & 0xfff) / view_state.line_width.width as u64; + view_state + .scroll_handle .scroll_to_item(line_ix as usize, ScrollStrategy::Center); cx.notify(); } @@ -595,7 +571,7 @@ impl MemoryView { } fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - self.view_state.selection = None; + self.view_state().selection = None; cx.notify(); } @@ -606,7 +582,7 @@ impl MemoryView { window: &mut Window, cx: &mut Context, ) { - let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state.selection.clone() + let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state().selection.clone() else { return; }; @@ -718,7 +694,7 @@ fn render_single_memory_view_line( weak: gpui::WeakEntity, cx: &mut App, ) -> AnyElement { - let Ok(view_state) = weak.update(cx, |this, _| this.view_state.clone()) else { + let Ok(view_state) = weak.update(cx, |this, _| this.view_state().clone()) else { return div().into_any(); }; let base_address = (view_state.base_row + ix) * view_state.line_width.width as u64; @@ -799,7 +775,7 @@ fn render_single_memory_view_line( let weak = weak.clone(); move |drag, _, _, cx| { _ = weak.update(cx, |this, _| { - this.view_state.selection = + this.view_state().selection = Some(SelectedMemoryRange::DragUnderway(drag.clone())); }); @@ -811,7 +787,7 @@ fn render_single_memory_view_line( let weak = weak.clone(); move |drag: &Drag, _, cx| { _ = weak.update(cx, |this, _| { - this.view_state.selection = + this.view_state().selection = Some(SelectedMemoryRange::DragComplete(Drag { start_address: drag.start_address, end_address: base_address + cell_ix as u64, @@ -821,7 +797,7 @@ fn render_single_memory_view_line( }) .drag_over(move |style, drag: &Drag, _, cx| { _ = weak.update(cx, |this, _| { - this.view_state.selection = + this.view_state().selection = Some(SelectedMemoryRange::DragUnderway(Drag { start_address: drag.start_address, end_address: base_address + cell_ix as u64, @@ -931,7 +907,7 @@ impl Render for MemoryView { v_flex() .size_full() .on_drag_move(cx.listener(|this, evt, _, _| { - this.handle_memory_drag(&evt); + this.handle_memory_drag(evt); })) .child(self.render_memory(cx).size_full()) .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { @@ -943,7 +919,17 @@ impl Render for MemoryView { ) .with_priority(1) })) - .child(self.render_vertical_scrollbar(cx)), + .custom_scrollbars( + ui::Scrollbars::new(ui::ScrollAxes::Both) + .tracked_scroll_handle(self.view_state_handle.clone()) + .with_track_along( + ui::ScrollAxes::Both, + cx.theme().colors().panel_background, + ) + .tracked_entity(cx.entity_id()), + window, + cx, + ), ) } } diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs index 74a9fb457a57cf2e70af694ed586af2227ee4a0a..545d8392745c636b805cfc1e0743170635ef8abe 100644 --- a/crates/debugger_ui/src/session/running/module_list.rs +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -1,15 +1,15 @@ use anyhow::anyhow; use dap::Module; use gpui::{ - AnyElement, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, Stateful, - Subscription, Task, UniformListScrollHandle, WeakEntity, uniform_list, + AnyElement, Entity, FocusHandle, Focusable, ScrollStrategy, Subscription, Task, + UniformListScrollHandle, WeakEntity, uniform_list, }; use project::{ ProjectItem as _, ProjectPath, debugger::session::{Session, SessionEvent}, }; use std::{ops::Range, path::Path, sync::Arc}; -use ui::{Scrollbar, ScrollbarState, prelude::*}; +use ui::{WithScrollbar, prelude::*}; use workspace::Workspace; pub struct ModuleList { @@ -18,7 +18,6 @@ pub struct ModuleList { session: Entity, workspace: WeakEntity, focus_handle: FocusHandle, - scrollbar_state: ScrollbarState, entries: Vec, _rebuild_task: Option>, _subscription: Subscription, @@ -44,7 +43,6 @@ impl ModuleList { let scroll_handle = UniformListScrollHandle::new(); Self { - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), scroll_handle, session, workspace, @@ -89,7 +87,7 @@ impl ModuleList { this.open_buffer( ProjectPath { worktree_id, - path: relative_path.into(), + path: relative_path, }, cx, ) @@ -157,7 +155,7 @@ impl ModuleList { h_flex() .text_ui_xs(cx) .text_color(cx.theme().colors().text_muted) - .when_some(module.path.clone(), |this, path| this.child(path)), + .when_some(module.path, |this, path| this.child(path)), ) .into_any() } @@ -167,38 +165,6 @@ impl ModuleList { self.session .update(cx, |session, cx| session.modules(cx).to_vec()) } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("module-list-vertical-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { let Some(ix) = self.selected_ix else { return }; @@ -223,7 +189,7 @@ impl ModuleList { fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { let ix = match self.selected_ix { - _ if self.entries.len() == 0 => None, + _ if self.entries.is_empty() => None, None => Some(0), Some(ix) => { if ix == self.entries.len() - 1 { @@ -243,7 +209,7 @@ impl ModuleList { cx: &mut Context, ) { let ix = match self.selected_ix { - _ if self.entries.len() == 0 => None, + _ if self.entries.is_empty() => None, None => Some(self.entries.len() - 1), Some(ix) => { if ix == 0 { @@ -262,7 +228,7 @@ impl ModuleList { _window: &mut Window, cx: &mut Context, ) { - let ix = if self.entries.len() > 0 { + let ix = if !self.entries.is_empty() { Some(0) } else { None @@ -271,7 +237,7 @@ impl ModuleList { } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - let ix = if self.entries.len() > 0 { + let ix = if !self.entries.is_empty() { Some(self.entries.len() - 1) } else { None @@ -313,6 +279,6 @@ impl Render for ModuleList { .size_full() .p_1() .child(self.render_list(window, cx)) - .child(self.render_vertical_scrollbar(cx)) + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) } } diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 8b44c231c37dfe9bc3deb9905699e2c80df8897f..a8fabd327a3de630ff884899fe7af1167932618c 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -4,18 +4,22 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use dap::StackFrameId; +use db::kvp::KEY_VALUE_STORE; use gpui::{ - AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, MouseButton, - Stateful, Subscription, Task, WeakEntity, list, + Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, + Subscription, Task, WeakEntity, list, +}; +use util::{ + debug_panic, + paths::{PathStyle, is_absolute}, }; -use util::debug_panic; -use crate::StackTraceView; +use crate::{StackTraceView, ToggleUserFrames}; use language::PointUtf16; use project::debugger::breakpoint_store::ActiveStackFrame; -use project::debugger::session::{Session, SessionEvent, StackFrame}; +use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus}; use project::{ProjectItem, ProjectPath}; -use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*}; +use ui::{Tooltip, WithScrollbar, prelude::*}; use workspace::{ItemHandle, Workspace}; use super::RunningState; @@ -26,6 +30,34 @@ pub enum StackFrameListEvent { BuiltEntries, } +/// Represents the filter applied to the stack frame list +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +pub(crate) enum StackFrameFilter { + /// Show all frames + All, + /// Show only frames from the user's code + OnlyUserFrames, +} + +impl StackFrameFilter { + fn from_str_or_default(s: impl AsRef) -> Self { + match s.as_ref() { + "user" => StackFrameFilter::OnlyUserFrames, + "all" => StackFrameFilter::All, + _ => StackFrameFilter::All, + } + } +} + +impl From for String { + fn from(filter: StackFrameFilter) -> Self { + match filter { + StackFrameFilter::All => "all".to_string(), + StackFrameFilter::OnlyUserFrames => "user".to_string(), + } + } +} + pub struct StackFrameList { focus_handle: FocusHandle, _subscription: Subscription, @@ -35,8 +67,9 @@ pub struct StackFrameList { workspace: WeakEntity, selected_ix: Option, opened_stack_frame_id: Option, - scrollbar_state: ScrollbarState, list_state: ListState, + list_filter: StackFrameFilter, + filter_entries_indices: Vec, error: Option, _refresh_task: Task<()>, } @@ -71,7 +104,16 @@ impl StackFrameList { }); let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - let scrollbar_state = ScrollbarState::new(list_state.clone()); + + let list_filter = KEY_VALUE_STORE + .read_kvp(&format!( + "stack-frame-list-filter-{}", + session.read(cx).adapter().0 + )) + .ok() + .flatten() + .map(StackFrameFilter::from_str_or_default) + .unwrap_or(StackFrameFilter::All); let mut this = Self { session, @@ -80,11 +122,12 @@ impl StackFrameList { state, _subscription, entries: Default::default(), + filter_entries_indices: Vec::default(), error: None, selected_ix: None, opened_stack_frame_id: None, + list_filter, list_state, - scrollbar_state, _refresh_task: Task::ready(()), }; this.schedule_refresh(true, window, cx); @@ -103,7 +146,15 @@ impl StackFrameList { ) -> Vec { self.entries .iter() - .flat_map(|frame| match frame { + .enumerate() + .filter(|(ix, _)| { + self.list_filter == StackFrameFilter::All + || self + .filter_entries_indices + .binary_search_by_key(&ix, |ix| ix) + .is_ok() + }) + .flat_map(|(_, frame)| match frame { StackFrameEntry::Normal(frame) => vec![frame.clone()], StackFrameEntry::Label(frame) if show_labels => vec![frame.clone()], StackFrameEntry::Collapsed(frames) if show_collapsed => frames.clone(), @@ -123,11 +174,29 @@ impl StackFrameList { #[cfg(test)] pub(crate) fn dap_stack_frames(&self, cx: &mut App) -> Vec { - self.stack_frames(cx) - .unwrap_or_default() - .into_iter() - .map(|stack_frame| stack_frame.dap.clone()) - .collect() + match self.list_filter { + StackFrameFilter::All => self + .stack_frames(cx) + .unwrap_or_default() + .into_iter() + .map(|stack_frame| stack_frame.dap) + .collect(), + StackFrameFilter::OnlyUserFrames => self + .filter_entries_indices + .iter() + .map(|ix| match &self.entries[*ix] { + StackFrameEntry::Label(label) => label, + StackFrameEntry::Collapsed(_) => panic!("Collapsed tabs should not be visible"), + StackFrameEntry::Normal(frame) => frame, + }) + .cloned() + .collect(), + } + } + + #[cfg(test)] + pub(crate) fn list_filter(&self) -> StackFrameFilter { + self.list_filter } pub fn opened_stack_frame_id(&self) -> Option { @@ -187,12 +256,34 @@ impl StackFrameList { self.entries.clear(); self.selected_ix = None; self.list_state.reset(0); + self.filter_entries_indices.clear(); cx.emit(StackFrameListEvent::BuiltEntries); cx.notify(); return; } }; - for stack_frame in &stack_frames { + + let worktree_prefixes: Vec<_> = self + .workspace + .read_with(cx, |workspace, cx| { + workspace + .visible_worktrees(cx) + .map(|tree| tree.read(cx).abs_path()) + .collect() + }) + .unwrap_or_default(); + + let mut filter_entries_indices = Vec::default(); + for stack_frame in stack_frames.iter() { + let frame_in_visible_worktree = stack_frame.dap.source.as_ref().is_some_and(|source| { + source.path.as_ref().is_some_and(|path| { + worktree_prefixes + .iter() + .filter_map(|tree| tree.to_str()) + .any(|tree| path.starts_with(tree)) + }) + }); + match stack_frame.dap.presentation_hint { Some(dap::StackFramePresentationHint::Deemphasize) | Some(dap::StackFramePresentationHint::Subtle) => { @@ -218,15 +309,19 @@ impl StackFrameList { first_stack_frame_with_path.get_or_insert(entries.len()); } entries.push(StackFrameEntry::Normal(stack_frame.dap.clone())); + if frame_in_visible_worktree { + filter_entries_indices.push(entries.len() - 1); + } } } } let collapsed_entries = std::mem::take(&mut collapsed_entries); if !collapsed_entries.is_empty() { - entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone())); + entries.push(StackFrameEntry::Collapsed(collapsed_entries)); } self.entries = entries; + self.filter_entries_indices = filter_entries_indices; if let Some(ix) = first_stack_frame_with_path .or(first_stack_frame) @@ -242,7 +337,14 @@ impl StackFrameList { self.selected_ix = ix; } - self.list_state.reset(self.entries.len()); + match self.list_filter { + StackFrameFilter::All => { + self.list_state.reset(self.entries.len()); + } + StackFrameFilter::OnlyUserFrames => { + self.list_state.reset(self.filter_entries_indices.len()); + } + } cx.emit(StackFrameListEvent::BuiltEntries); cx.notify(); } @@ -302,7 +404,7 @@ impl StackFrameList { this.open_buffer( ProjectPath { worktree_id, - path: relative_path.into(), + path: relative_path, }, cx, ) @@ -371,8 +473,12 @@ impl StackFrameList { stack_frame.source.as_ref().and_then(|s| { s.path .as_deref() + .filter(|path| { + // Since we do not know if we are debugging on the host or (a remote/WSL) target, + // we need to check if either the path is absolute as Posix or Windows. + is_absolute(path, PathStyle::Posix) || is_absolute(path, PathStyle::Windows) + }) .map(|path| Arc::::from(Path::new(path))) - .filter(|path| path.is_absolute()) }) } @@ -418,7 +524,7 @@ impl StackFrameList { let source = stack_frame.source.clone(); let is_selected_frame = Some(ix) == self.selected_ix; - let path = source.clone().and_then(|s| s.path.or(s.name)); + let path = source.and_then(|s| s.path.or(s.name)); let formatted_path = path.map(|path| format!("{}:{}", path, stack_frame.line,)); let formatted_path = formatted_path.map(|path| { Label::new(path) @@ -460,6 +566,7 @@ impl StackFrameList { this.activate_selected_entry(window, cx); })) .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .overflow_x_scroll() .child( v_flex() .gap_0p5() @@ -519,7 +626,16 @@ impl StackFrameList { let entries = std::mem::take(stack_frames) .into_iter() .map(StackFrameEntry::Normal); + // HERE + let entries_len = entries.len(); self.entries.splice(ix..ix + 1, entries); + let (Ok(filtered_indices_start) | Err(filtered_indices_start)) = + self.filter_entries_indices.binary_search(&ix); + + for idx in &mut self.filter_entries_indices[filtered_indices_start..] { + *idx += entries_len - 1; + } + self.selected_ix = Some(ix); self.list_state.reset(self.entries.len()); cx.emit(StackFrameListEvent::BuiltEntries); @@ -572,6 +688,11 @@ impl StackFrameList { } fn render_entry(&self, ix: usize, cx: &mut Context) -> AnyElement { + let ix = match self.list_filter { + StackFrameFilter::All => ix, + StackFrameFilter::OnlyUserFrames => self.filter_entries_indices[ix], + }; + match &self.entries[ix] { StackFrameEntry::Label(stack_frame) => self.render_label_entry(stack_frame, cx), StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(ix, stack_frame, cx), @@ -581,39 +702,6 @@ impl StackFrameList { } } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("stack-frame-list-vertical-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } - fn select_ix(&mut self, ix: Option, cx: &mut Context) { self.selected_ix = ix; cx.notify(); @@ -621,7 +709,7 @@ impl StackFrameList { fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { let ix = match self.selected_ix { - _ if self.entries.len() == 0 => None, + _ if self.entries.is_empty() => None, None => Some(0), Some(ix) => { if ix == self.entries.len() - 1 { @@ -641,7 +729,7 @@ impl StackFrameList { cx: &mut Context, ) { let ix = match self.selected_ix { - _ if self.entries.len() == 0 => None, + _ if self.entries.is_empty() => None, None => Some(self.entries.len() - 1), Some(ix) => { if ix == 0 { @@ -660,7 +748,7 @@ impl StackFrameList { _window: &mut Window, cx: &mut Context, ) { - let ix = if self.entries.len() > 0 { + let ix = if !self.entries.is_empty() { Some(0) } else { None @@ -669,7 +757,7 @@ impl StackFrameList { } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - let ix = if self.entries.len() > 0 { + let ix = if !self.entries.is_empty() { Some(self.entries.len() - 1) } else { None @@ -702,6 +790,67 @@ impl StackFrameList { self.activate_selected_entry(window, cx); } + pub(crate) fn toggle_frame_filter( + &mut self, + thread_status: Option, + cx: &mut Context, + ) { + self.list_filter = match self.list_filter { + StackFrameFilter::All => StackFrameFilter::OnlyUserFrames, + StackFrameFilter::OnlyUserFrames => StackFrameFilter::All, + }; + + if let Some(database_id) = self + .workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten() + { + let database_id: i64 = database_id.into(); + let save_task = KEY_VALUE_STORE.write_kvp( + format!( + "stack-frame-list-filter-{}-{}", + self.session.read(cx).adapter().0, + database_id, + ), + self.list_filter.into(), + ); + cx.background_spawn(save_task).detach(); + } + + if let Some(ThreadStatus::Stopped) = thread_status { + match self.list_filter { + StackFrameFilter::All => { + self.list_state.reset(self.entries.len()); + } + StackFrameFilter::OnlyUserFrames => { + self.list_state.reset(self.filter_entries_indices.len()); + if !self + .selected_ix + .map(|ix| self.filter_entries_indices.contains(&ix)) + .unwrap_or_default() + { + self.selected_ix = None; + } + } + } + + if let Some(ix) = self.selected_ix { + let scroll_to = match self.list_filter { + StackFrameFilter::All => ix, + StackFrameFilter::OnlyUserFrames => self + .filter_entries_indices + .binary_search_by_key(&ix, |ix| *ix) + .expect("This index will always exist"), + }; + self.list_state.scroll_to_reveal_item(scroll_to); + } + + cx.emit(StackFrameListEvent::BuiltEntries); + cx.notify(); + } + } + fn render_list(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div().p_1().size_full().child( list( @@ -711,6 +860,30 @@ impl StackFrameList { .size_full(), ) } + + pub(crate) fn render_control_strip(&self) -> AnyElement { + let tooltip_title = match self.list_filter { + StackFrameFilter::All => "Show stack frames from your project", + StackFrameFilter::OnlyUserFrames => "Show all stack frames", + }; + + h_flex() + .child( + IconButton::new( + "filter-by-visible-worktree-stack-frame-list", + IconName::ListFilter, + ) + .tooltip(move |_window, cx| { + Tooltip::for_action(tooltip_title, &ToggleUserFrames, cx) + }) + .toggle_state(self.list_filter == StackFrameFilter::OnlyUserFrames) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleUserFrames.boxed_clone(), cx) + }), + ) + .into_any_element() + } } impl Render for StackFrameList { @@ -740,7 +913,7 @@ impl Render for StackFrameList { ) }) .child(self.render_list(window, cx)) - .child(self.render_vertical_scrollbar(cx)) + .vertical_scrollbar_for(self.list_state.clone(), window, cx) } } diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index efbc72e8cfc9099a5d699493898440d17fbf615b..3da1bd33c4a6de3d161a78b5ff5188f655d019c7 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -8,19 +8,21 @@ use dap::{ use editor::Editor; use gpui::{ Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity, - FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription, - TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, - uniform_list, + FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TextStyleRefinement, + UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, }; +use itertools::Itertools; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::debugger::{ dap_command::DataBreakpointContext, session::{Session, SessionEvent, Watcher}, }; use std::{collections::HashMap, ops::Range, sync::Arc}; -use ui::{ContextMenu, ListItem, ScrollableHandle, Scrollbar, ScrollbarState, Tooltip, prelude::*}; +use ui::{ContextMenu, ListItem, ScrollAxes, ScrollableHandle, Tooltip, WithScrollbar, prelude::*}; use util::{debug_panic, maybe}; +static INDENT_STEP_SIZE: Pixels = px(10.0); + actions!( variable_list, [ @@ -186,10 +188,10 @@ struct VariableColor { pub struct VariableList { entries: Vec, + max_width_index: Option, entry_states: HashMap, selected_stack_frame_id: Option, list_handle: UniformListScrollHandle, - scrollbar_state: ScrollbarState, session: Entity, selection: Option, open_context_menu: Option<(Entity, Point, Subscription)>, @@ -235,7 +237,6 @@ impl VariableList { let list_state = UniformListScrollHandle::default(); Self { - scrollbar_state: ScrollbarState::new(list_state.clone()), list_handle: list_state, session, focus_handle, @@ -246,6 +247,7 @@ impl VariableList { disabled: false, edited_path: None, entries: Default::default(), + max_width_index: None, entry_states: Default::default(), weak_running, memory_view, @@ -272,7 +274,7 @@ impl VariableList { let mut entries = vec![]; let scopes: Vec<_> = self.session.update(cx, |session, cx| { - session.scopes(stack_frame_id, cx).iter().cloned().collect() + session.scopes(stack_frame_id, cx).to_vec() }); let mut contains_local_scope = false; @@ -291,7 +293,7 @@ impl VariableList { } self.session.update(cx, |session, cx| { - session.variables(scope.variables_reference, cx).len() > 0 + !session.variables(scope.variables_reference, cx).is_empty() }) }) .map(|scope| { @@ -313,7 +315,7 @@ impl VariableList { watcher.variables_reference, watcher.variables_reference, EntryPath::for_watcher(watcher.expression.clone()), - DapEntry::Watcher(watcher.clone()), + DapEntry::Watcher(watcher), ) }) .collect::>(), @@ -371,6 +373,26 @@ impl VariableList { } self.entries = entries; + + let text_pixels = ui::TextSize::Default.pixels(cx).to_f64() as f32; + let indent_size = INDENT_STEP_SIZE.to_f64() as f32; + + self.max_width_index = self + .entries + .iter() + .map(|entry| match &entry.entry { + DapEntry::Scope(scope) => scope.name.len() as f32 * text_pixels, + DapEntry::Variable(variable) => { + (variable.value.len() + variable.name.len()) as f32 * text_pixels + + (entry.path.indices.len() as f32 * indent_size) + } + DapEntry::Watcher(watcher) => { + (watcher.value.len() + watcher.expression.len()) as f32 * text_pixels + + (entry.path.indices.len() as f32 * indent_size) + } + }) + .position_max_by(|left, right| left.total_cmp(right)); + cx.notify(); } @@ -947,7 +969,7 @@ impl VariableList { #[track_caller] #[cfg(test)] pub(crate) fn assert_visual_entries(&self, expected: Vec<&str>) { - const INDENT: &'static str = " "; + const INDENT: &str = " "; let entries = &self.entries; let mut visual_entries = Vec::with_capacity(entries.len()); @@ -997,7 +1019,7 @@ impl VariableList { DapEntry::Watcher { .. } => continue, DapEntry::Variable(dap) => scopes[idx].1.push(dap.clone()), DapEntry::Scope(scope) => { - if scopes.len() > 0 { + if !scopes.is_empty() { idx += 1; } @@ -1132,6 +1154,7 @@ impl VariableList { this.color(Color::from(color)) }), ) + .tooltip(Tooltip::text(value)) } }) .into_any_element() @@ -1216,7 +1239,7 @@ impl VariableList { let weak = cx.weak_entity(); let focus_handle = self.focus_handle.clone(); - let watcher_len = (self.list_handle.content_size().width.0 / 12.0).floor() - 3.0; + let watcher_len = (f32::from(self.list_handle.content_size().width / 12.0).floor()) - 3.0; let watcher_len = watcher_len as usize; div() @@ -1246,7 +1269,7 @@ impl VariableList { .disabled(self.disabled) .selectable(false) .indent_level(state.depth) - .indent_step_size(px(10.)) + .indent_step_size(INDENT_STEP_SIZE) .always_show_disclosure_icon(true) .when(var_ref > 0, |list_item| { list_item.toggle(state.is_expanded).on_toggle(cx.listener({ @@ -1289,7 +1312,7 @@ impl VariableList { }), ) .child(self.render_variable_value( - &entry, + entry, &variable_color, watcher.value.to_string(), cx, @@ -1301,8 +1324,6 @@ impl VariableList { IconName::Close, ) .on_click({ - let weak = weak.clone(); - let path = path.clone(); move |_, window, cx| { weak.update(cx, |variable_list, cx| { variable_list.selection = Some(path.clone()); @@ -1311,14 +1332,8 @@ impl VariableList { .ok(); } }) - .tooltip(move |window, cx| { - Tooltip::for_action_in( - "Remove Watch", - &RemoveWatch, - &focus_handle, - window, - cx, - ) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Remove Watch", &RemoveWatch, &focus_handle, cx) }) .icon_size(ui::IconSize::Indicator), ), @@ -1455,7 +1470,7 @@ impl VariableList { .disabled(self.disabled) .selectable(false) .indent_level(state.depth) - .indent_step_size(px(10.)) + .indent_step_size(INDENT_STEP_SIZE) .always_show_disclosure_icon(true) .when(var_ref > 0, |list_item| { list_item.toggle(state.is_expanded).on_toggle(cx.listener({ @@ -1470,7 +1485,6 @@ impl VariableList { })) }) .on_secondary_mouse_down(cx.listener({ - let path = path.clone(); let entry = variable.clone(); move |this, event: &MouseDownEvent, window, cx| { this.selection = Some(path.clone()); @@ -1494,7 +1508,7 @@ impl VariableList { }), ) .child(self.render_variable_value( - &variable, + variable, &variable_color, dap.value.clone(), cx, @@ -1503,39 +1517,6 @@ impl VariableList { ) .into_any() } - - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("variable-list-vertical-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } } impl Focusable for VariableList { @@ -1545,13 +1526,12 @@ impl Focusable for VariableList { } impl Render for VariableList { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .track_focus(&self.focus_handle) .key_context("VariableList") .id("variable-list") .group("variable-list") - .overflow_y_scroll() .size_full() .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) @@ -1577,6 +1557,9 @@ impl Render for VariableList { }), ) .track_scroll(self.list_handle.clone()) + .with_width_from_item(self.max_width_index) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) .gap_1_5() .size_full() .flex_grow(), @@ -1590,7 +1573,15 @@ impl Render for VariableList { ) .with_priority(1) })) - .child(self.render_vertical_scrollbar(cx)) + // .vertical_scrollbar_for(self.list_handle.clone(), window, cx) + .custom_scrollbars( + ui::Scrollbars::new(ScrollAxes::Both) + .tracked_scroll_handle(self.list_handle.clone()) + .with_track_along(ScrollAxes::Both, cx.theme().colors().panel_background) + .tracked_entity(cx.entity_id()), + window, + cx, + ) } } diff --git a/crates/debugger_ui/src/stack_trace_view.rs b/crates/debugger_ui/src/stack_trace_view.rs index aef053df4a1ea930fb09a779e08afecfa08ddde9..07caabaacaf00d2752a04c5ba68be07a5678c40a 100644 --- a/crates/debugger_ui/src/stack_trace_view.rs +++ b/crates/debugger_ui/src/stack_trace_view.rs @@ -55,11 +55,14 @@ impl StackTraceView { cx.subscribe_in(&editor, window, |this, editor, event, window, cx| { if let EditorEvent::SelectionsChanged { local: true } = event { let excerpt_id = editor.update(cx, |editor, cx| { - let position: Point = editor.selections.newest(cx).head(); + let position: Point = editor + .selections + .newest(&editor.display_snapshot(cx)) + .head(); editor .snapshot(window, cx) - .buffer_snapshot + .buffer_snapshot() .excerpt_containing(position..position) .map(|excerpt| excerpt.id()) }); @@ -181,7 +184,7 @@ impl StackTraceView { let project_path = ProjectPath { worktree_id: worktree.read_with(cx, |tree, _| tree.id())?, - path: relative_path.into(), + path: relative_path, }; if let Some(buffer) = this @@ -259,7 +262,7 @@ impl StackTraceView { let mut is_first = true; for (_, highlight) in self.highlights.iter().skip(active_idx) { - let position = highlight.to_point(&snapshot.buffer_snapshot); + let position = highlight.to_point(&snapshot.buffer_snapshot()); let color = if is_first { is_first = false; first_color @@ -268,11 +271,11 @@ impl StackTraceView { }; let start = snapshot - .buffer_snapshot + .buffer_snapshot() .clip_point(Point::new(position.row, 0), Bias::Left); let end = start + Point::new(1, 0); - let start = snapshot.buffer_snapshot.anchor_before(start); - let end = snapshot.buffer_snapshot.anchor_before(end); + let start = snapshot.buffer_snapshot().anchor_before(start); + let end = snapshot.buffer_snapshot().anchor_before(end); editor.highlight_rows::( start..end, color, @@ -354,10 +357,6 @@ impl Item for StackTraceView { self.editor.for_each_project_item(cx, f) } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index 906a7a0d4bd76f0451d6b5d5cfa5beff0136c613..80e2b73d5a100bbd21462f0ad80def1997e184de 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -139,7 +139,7 @@ async fn test_show_attach_modal_and_select_process( workspace .update(cx, |_, window, cx| { let names = - attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx)); + attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx)); // Initially all processes are visible. assert_eq!(3, names.len()); attach_modal.update(cx, |this, cx| { @@ -154,7 +154,7 @@ async fn test_show_attach_modal_and_select_process( workspace .update(cx, |_, _, cx| { let names = - attach_modal.update(cx, |modal, cx| attach_modal::_process_names(&modal, cx)); + attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx)); // Initially all processes are visible. assert_eq!(2, names.len()); }) diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 6180831ea9dccfb3c1ee861daac099e54b2242c3..c0f8c0a065be3ec8da1801caae8f75a4d08dc226 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -32,7 +32,7 @@ use std::{ }; use terminal_view::terminal_panel::TerminalPanel; use tests::{active_debug_session_panel, init_test, init_test_workspace}; -use util::path; +use util::{path, rel_path::rel_path}; use workspace::item::SaveOptions; use workspace::{Item, dock::Panel}; @@ -351,7 +351,7 @@ async fn test_handle_successful_run_in_terminal_reverse_request( .fake_reverse_request::(RunInTerminalRequestArguments { kind: None, title: None, - cwd: std::env::temp_dir().to_string_lossy().to_string(), + cwd: std::env::temp_dir().to_string_lossy().into_owned(), args: vec![], env: None, args_can_be_interpreted_by_shell: None, @@ -1114,7 +1114,7 @@ async fn test_send_breakpoints_when_editor_has_been_saved( let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -1276,14 +1276,14 @@ async fn test_unsetting_breakpoints_on_clear_breakpoint_action( let first = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let second = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "second.rs"), cx) + project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); @@ -1330,7 +1330,6 @@ async fn test_unsetting_breakpoints_on_clear_breakpoint_action( let called_set_breakpoints = Arc::new(AtomicBool::new(false)); client.on_request::({ - let called_set_breakpoints = called_set_breakpoints.clone(); move |_, args| { assert!( args.breakpoints.is_none_or(|bps| bps.is_empty()), @@ -1445,7 +1444,6 @@ async fn test_we_send_arguments_from_user_config( let launch_handler_called = Arc::new(AtomicBool::new(false)); start_debug_session_with(&workspace, cx, debug_definition.clone(), { - let debug_definition = debug_definition.clone(); let launch_handler_called = launch_handler_called.clone(); move |client| { @@ -1501,14 +1499,14 @@ async fn test_active_debug_line_setting(executor: BackgroundExecutor, cx: &mut T let main_buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let second_buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "second.rs"), cx) + project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); @@ -1606,7 +1604,7 @@ async fn test_active_debug_line_setting(executor: BackgroundExecutor, cx: &mut T let point = editor .snapshot(window, cx) - .buffer_snapshot + .buffer_snapshot() .summary_for_anchor::(&active_debug_lines.first().unwrap().0.start); assert_eq!(point.row, 1); @@ -1681,7 +1679,7 @@ async fn test_active_debug_line_setting(executor: BackgroundExecutor, cx: &mut T let point = editor .snapshot(window, cx) - .buffer_snapshot + .buffer_snapshot() .summary_for_anchor::(&active_debug_lines.first().unwrap().0.start); assert_eq!(point.row, 2); @@ -1783,9 +1781,8 @@ async fn test_debug_adapters_shutdown_on_app_quit( let disconnect_request_received = Arc::new(AtomicBool::new(false)); let disconnect_clone = disconnect_request_received.clone(); - let disconnect_clone_for_handler = disconnect_clone.clone(); client.on_request::(move |_, _| { - disconnect_clone_for_handler.store(true, Ordering::SeqCst); + disconnect_clone.store(true, Ordering::SeqCst); Ok(()) }); diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 9f921ec969debc5247d531469c5132e8485c163b..801e6d43623b50d69ea3ce297c274c2d7e5a8b14 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -3,11 +3,14 @@ use std::{path::Path, sync::Arc}; use dap::{Scope, StackFrame, Variable, requests::Variables}; use editor::{Editor, EditorMode, MultiBuffer}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; -use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust}; +use language::{ + Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust, + tree_sitter_typescript, +}; use project::{FakeFs, Project}; use serde_json::json; use unindent::Unindent as _; -use util::path; +use util::{path, rel_path::rel_path}; use crate::{ debugger_panel::DebugPanel, @@ -215,7 +218,7 @@ fn main() { let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -1584,7 +1587,7 @@ def process_data(untyped_param, typed_param: int, another_typed: str): let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.py"), cx) + project.open_buffer((worktree_id, rel_path("main.py")), cx) }) .await .unwrap(); @@ -2082,7 +2085,7 @@ async fn test_inline_values_util( let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -2272,3 +2275,257 @@ fn main() { ) .await; } + +fn javascript_lang() -> Language { + let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); + Language::new( + LanguageConfig { + name: "JavaScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["js".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap() +} + +fn typescript_lang() -> Language { + let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); + Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap() +} + +fn tsx_lang() -> Language { + let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); + Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap() +} + +#[gpui::test] +async fn test_javascript_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [ + ("x", "10"), + ("y", "20"), + ("sum", "30"), + ("message", "Hello"), + ]; + + let before = r#" +function calculate() { + const x = 10; + const y = 20; + const sum = x + y; + const message = "Hello"; + console.log(message, "Sum:", sum); +} +"# + .unindent(); + + let after = r#" +function calculate() { + const x: 10 = 10; + const y: 20 = 20; + const sum: 30 = x: 10 + y: 20; + const message: Hello = "Hello"; + console.log(message, "Sum:", sum); +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + javascript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_typescript_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [ + ("count", "42"), + ("name", "Alice"), + ("result", "84"), + ("i", "3"), + ]; + + let before = r#" +function processData(count: number, name: string): number { + let result = count * 2; + for (let i = 0; i < 5; i++) { + console.log(i); + } + return result; +} +"# + .unindent(); + + let after = r#" +function processData(count: number, name: string): number { + let result: 84 = count: 42 * 2; + for (let i: 3 = 0; i: 3 < 5; i: 3++) { + console.log(i); + } + return result: 84; +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + typescript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_tsx_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("count", "5"), ("message", "Hello React")]; + + let before = r#" +const Counter = () => { + const count = 5; + const message = "Hello React"; + return ( +
+

{message}

+ {count} +
+ ); +}; +"# + .unindent(); + + let after = r#" +const Counter = () => { + const count: 5 = 5; + const message: Hello React = "Hello React"; + return ( +
+

{message: Hello React}

+ {count} +
+ ); +}; +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + tsx_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_javascript_arrow_functions(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("x", "42"), ("result", "84")]; + + let before = r#" +const double = (x) => { + const result = x * 2; + return result; +}; +"# + .unindent(); + + let after = r#" +const double = (x) => { + const result: 84 = x: 42 * 2; + return result: 84; +}; +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + javascript_lang(), + executor, + cx, + ) + .await; +} + +#[gpui::test] +async fn test_typescript_for_in_loop(executor: BackgroundExecutor, cx: &mut TestAppContext) { + let variables = [("key", "name"), ("obj", "{name: 'test'}")]; + + let before = r#" +function iterate() { + const obj = {name: 'test'}; + for (const key in obj) { + console.log(key); + } +} +"# + .unindent(); + + let after = r#" +function iterate() { + const obj: {name: 'test'} = {name: 'test'}; + for (const key: name in obj) { + console.log(key); + } +} +"# + .unindent(); + + test_inline_values_util( + &variables, + &[], + &before, + &after, + None, + typescript_lang(), + executor, + cx, + ) + .await; +} diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index d6b0dfa00429f9487eafbe38dca5f072ed547779..2f470560d5a58a1ed9e56ebe89257572d195689e 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -10,6 +10,7 @@ use text::Point; use util::path; use crate::NewProcessMode; +use crate::new_process_modal::NewProcessModal; use crate::tests::{init_test, init_test_workspace}; #[gpui::test] @@ -106,9 +107,7 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths( ); let expected_other_field = if input_path.contains("$ZED_WORKTREE_ROOT") { - input_path - .replace("$ZED_WORKTREE_ROOT", &path!("/test/worktree/path")) - .to_owned() + input_path.replace("$ZED_WORKTREE_ROOT", path!("/test/worktree/path")) } else { input_path.to_string() }; @@ -180,13 +179,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut workspace .update(cx, |workspace, window, cx| { - crate::new_process_modal::NewProcessModal::show( - workspace, - window, - NewProcessMode::Debug, - None, - cx, - ); + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); }) .unwrap(); @@ -194,7 +187,7 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut let modal = workspace .update(cx, |workspace, _, cx| { - workspace.active_modal::(cx) + workspace.active_modal::(cx) }) .unwrap() .expect("Modal should be active"); @@ -238,7 +231,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut editor.update(cx, |editor, cx| { assert_eq!( - editor.selections.newest::(cx).head(), + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(), Point::new(5, 2) ) }); @@ -283,6 +279,73 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut pretty_assertions::assert_eq!(expected_content, debug_json_content); } +#[gpui::test] +async fn test_debug_modal_subtitles_with_multiple_worktrees( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/workspace1"), + json!({ + ".zed": { + "debug.json": r#"[ + { + "adapter": "fake-adapter", + "label": "Debug App 1", + "request": "launch", + "program": "./app1", + "cwd": "." + }, + { + "adapter": "fake-adapter", + "label": "Debug Tests 1", + "request": "launch", + "program": "./test1", + "cwd": "." + } + ]"# + }, + "main.rs": "fn main() {}" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/workspace1").as_ref()], cx).await; + + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + workspace + .update(cx, |workspace, window, cx| { + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + }) + .unwrap(); + + cx.run_until_parked(); + + let modal = workspace + .update(cx, |workspace, _, cx| { + workspace.active_modal::(cx) + }) + .unwrap() + .expect("Modal should be active"); + + cx.executor().run_until_parked(); + + let subtitles = modal.update_in(cx, |modal, _, cx| { + modal.debug_picker_candidate_subtitles(cx) + }); + + assert_eq!( + subtitles.as_slice(), + [path!(".zed/debug.json"), path!(".zed/debug.json")] + ); +} + #[gpui::test] async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 95a6903c14a1cbd5f750d6e11437cb0bf92887c7..05e638e2321bb6fcb4504a8bc8c81123f1b09a33 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1,6 +1,6 @@ use crate::{ debugger_panel::DebugPanel, - session::running::stack_frame_list::StackFrameEntry, + session::running::stack_frame_list::{StackFrameEntry, StackFrameFilter}, tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session}, }; use dap::{ @@ -13,7 +13,7 @@ use project::{FakeFs, Project}; use serde_json::json; use std::sync::Arc; use unindent::Unindent as _; -use util::path; +use util::{path, rel_path::rel_path}; #[gpui::test] async fn test_fetch_initial_stack_frames_and_go_to_stack_frame( @@ -331,12 +331,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC let project_path = editors[0] .update(cx, |editor, cx| editor.project_path(cx)) .unwrap(); - let expected = if cfg!(target_os = "windows") { - "src\\test.js" - } else { - "src/test.js" - }; - assert_eq!(expected, project_path.path.to_string_lossy()); + assert_eq!(rel_path("src/test.js"), project_path.path.as_ref()); assert_eq!(test_file_content, editors[0].read(cx).text(cx)); assert_eq!( vec![2..3], @@ -346,8 +341,8 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC editor .highlighted_rows::() .map(|(range, _)| { - let start = range.start.to_point(&snapshot.buffer_snapshot); - let end = range.end.to_point(&snapshot.buffer_snapshot); + let start = range.start.to_point(&snapshot.buffer_snapshot()); + let end = range.end.to_point(&snapshot.buffer_snapshot()); start.row..end.row }) .collect::>() @@ -399,12 +394,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC let project_path = editors[0] .update(cx, |editor, cx| editor.project_path(cx)) .unwrap(); - let expected = if cfg!(target_os = "windows") { - "src\\module.js" - } else { - "src/module.js" - }; - assert_eq!(expected, project_path.path.to_string_lossy()); + assert_eq!(rel_path("src/module.js"), project_path.path.as_ref()); assert_eq!(module_file_content, editors[0].read(cx).text(cx)); assert_eq!( vec![0..1], @@ -414,8 +404,8 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC editor .highlighted_rows::() .map(|(range, _)| { - let start = range.start.to_point(&snapshot.buffer_snapshot); - let end = range.end.to_point(&snapshot.buffer_snapshot); + let start = range.start.to_point(&snapshot.buffer_snapshot()); + let end = range.end.to_point(&snapshot.buffer_snapshot()); start.row..end.row }) .collect::>() @@ -752,3 +742,346 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo }); }); } + +#[gpui::test] +async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + function main() { + doSomething(); + } + + function doSomething() { + console.log('doing something'); + } + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client.on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }); + + client.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "main".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 2, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "node:internal/modules/cjs/loader".into(), + source: Some(dap::Source { + name: Some("loader.js".into()), + path: Some(path!("/usr/lib/node/internal/modules/cjs/loader.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 100, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 3, + name: "node:internal/modules/run_main".into(), + source: Some(dap::Source { + name: Some("run_main.js".into()), + path: Some(path!("/usr/lib/node/internal/modules/run_main.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 50, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 4, + name: "node:internal/modules/run_main2".into(), + source: Some(dap::Source { + name: Some("run_main.js".into()), + path: Some(path!("/usr/lib/node/internal/modules/run_main2.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 50, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 5, + name: "doSomething".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + // Store a copy for assertions + let stack_frames_for_assertions = stack_frames.clone(); + + client.on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // trigger threads to load + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + session.running_state().update(cx, |running_state, cx| { + running_state + .session() + .update(cx, |session, cx| session.threads(cx)); + }); + }); + + cx.run_until_parked(); + + // select first thread + active_debug_session_panel(workspace, cx).update_in(cx, |session, window, cx| { + session.running_state().update(cx, |running_state, cx| { + running_state.select_current_thread( + &running_state + .session() + .update(cx, |session, cx| session.threads(cx)), + window, + cx, + ); + }); + }); + + cx.run_until_parked(); + + // trigger stack frames to load + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + let stack_frame_list = debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.dap_stack_frames(cx); + }); + }); + + cx.run_until_parked(); + + let stack_frame_list = + active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { + let stack_frame_list = debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.build_entries(true, window, cx); + + // Verify we have the expected collapsed structure + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames_for_assertions[1].clone(), + stack_frames_for_assertions[2].clone(), + stack_frames_for_assertions[3].clone() + ]), + StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()), + ] + ); + }); + + stack_frame_list + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + let all_frames = stack_frame_list.flatten_entries(true, false); + assert_eq!(all_frames.len(), 5, "Should see all 5 frames initially"); + + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames + ); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + let user_frames = stack_frame_list.dap_stack_frames(cx); + assert_eq!(user_frames.len(), 2, "Should only see 2 user frames"); + assert_eq!(user_frames[0].name, "main"); + assert_eq!(user_frames[1].name, "doSomething"); + + // Toggle back to all frames + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!(stack_frame_list.list_filter(), StackFrameFilter::All); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + let all_frames_again = stack_frame_list.flatten_entries(true, false); + assert_eq!( + all_frames_again.len(), + 5, + "Should see all 5 frames after toggling back" + ); + + // Test 3: Verify collapsed entries stay expanded + stack_frame_list.expand_collapsed_entry(1, cx); + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()), + ] + ); + + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames + ); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!(stack_frame_list.list_filter(), StackFrameFilter::All); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames + ); + + assert_eq!( + stack_frame_list.dap_stack_frames(cx).as_slice(), + &[ + stack_frames_for_assertions[0].clone(), + stack_frames_for_assertions[4].clone() + ] + ); + + // Verify entries remain expanded + assert_eq!( + stack_frame_list.entries(), + &vec![ + StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()), + StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()), + ], + "Expanded entries should remain expanded after toggling filter" + ); + }); +} diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index fbbd52964105659c2cae645cec494824069f5529..4cfdae093f6a1464b178c053e629a6ebe6d76d02 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -1445,11 +1445,8 @@ async fn test_variable_list_only_sends_requests_when_rendering( cx.run_until_parked(); - let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, _| { - let state = item.running_state().clone(); - - state - }); + let running_state = active_debug_session_panel(workspace, cx) + .update_in(cx, |item, _, _| item.running_state().clone()); client .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { diff --git a/crates/deepseek/Cargo.toml b/crates/deepseek/Cargo.toml index f294e946d805245649c4dedf07df36bfae4972e1..25e8f2f25c8f6cb8505f7975a93f02f12937f3b5 100644 --- a/crates/deepseek/Cargo.toml +++ b/crates/deepseek/Cargo.toml @@ -22,4 +22,3 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index c49270febe3b2b3702b808e2219f6e45d7252267..64a1cbe5d96354260c2bf84a43ed70be7336aa7a 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::convert::TryFrom; -pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com"; +pub const DEEPSEEK_API_URL: &str = "https://api.deepseek.com/v1"; #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] @@ -96,7 +96,7 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Chat | Self::Reasoner => 64_000, + Self::Chat | Self::Reasoner => 128_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -104,7 +104,7 @@ impl Model { pub fn max_output_tokens(&self) -> Option { match self { Self::Chat => Some(8_192), - Self::Reasoner => Some(8_192), + Self::Reasoner => Some(64_000), Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -263,12 +263,12 @@ pub async fn stream_completion( api_key: &str, request: Request, ) -> Result>> { - let uri = format!("{api_url}/v1/chat/completions"); + let uri = format!("{api_url}/chat/completions"); let request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; diff --git a/crates/denoise/Cargo.toml b/crates/denoise/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7d4644a610c854c63a11a8d92e8ac89eace0a6dc --- /dev/null +++ b/crates/denoise/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "denoise" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[dependencies] +candle-core = { version = "0.9.1", git ="https://github.com/zed-industries/candle", branch = "9.1-patched" } +candle-onnx = { version = "0.9.1", git ="https://github.com/zed-industries/candle", branch = "9.1-patched" } +log.workspace = true + +rodio = { workspace = true, features = ["wav_output"] } + +rustfft = { version = "6.2.0", features = ["avx"] } +realfft = "3.4.0" +thiserror.workspace = true diff --git a/crates/jj/LICENSE-GPL b/crates/denoise/LICENSE-GPL similarity index 100% rename from crates/jj/LICENSE-GPL rename to crates/denoise/LICENSE-GPL diff --git a/crates/denoise/README.md b/crates/denoise/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d7486da36e9078f2f99c2a6a8226dbce499cae8b --- /dev/null +++ b/crates/denoise/README.md @@ -0,0 +1,20 @@ +Real time streaming audio denoising using a [Dual-Signal Transformation LSTM Network for Real-Time Noise Suppression](https://arxiv.org/abs/2005.07551). + +Trivial to build as it uses the native rust Candle crate for inference. Easy to integrate into any Rodio pipeline. + +```rust + # use rodio::{nz, source::UniformSourceIterator, wav_to_file}; + let file = std::fs::File::open("clips_airconditioning.wav")?; + let decoder = rodio::Decoder::try_from(file)?; + let resampled = UniformSourceIterator::new(decoder, nz!(1), nz!(16_000)); + + let mut denoised = denoise::Denoiser::try_new(resampled)?; + wav_to_file(&mut denoised, "denoised.wav")?; + Result::Ok<(), Box> +``` + +## Acknowledgements & License + +The trained models in this repo are optimized versions of the models in the [breizhn/DTLN](https://github.com/breizhn/DTLN?tab=readme-ov-file#model-conversion-and-real-time-processing-with-onnx). These are licensed under MIT. + +The FFT code was adapted from Datadog's [dtln-rs Repo](https://github.com/DataDog/dtln-rs/tree/main) also licensed under MIT. diff --git a/crates/denoise/examples/denoise.rs b/crates/denoise/examples/denoise.rs new file mode 100644 index 0000000000000000000000000000000000000000..a4d89d7e517e7b35d0f87adbd218cd34b75a4789 --- /dev/null +++ b/crates/denoise/examples/denoise.rs @@ -0,0 +1,11 @@ +use rodio::{nz, source::UniformSourceIterator, wav_to_file}; + +fn main() -> Result<(), Box> { + let file = std::fs::File::open("airconditioning.wav")?; + let decoder = rodio::Decoder::try_from(file)?; + let resampled = UniformSourceIterator::new(decoder, nz!(1), nz!(16_000)); + + let mut denoised = denoise::Denoiser::try_new(resampled)?; + wav_to_file(&mut denoised, "denoised.wav")?; + Ok(()) +} diff --git a/crates/denoise/examples/enable_disable.rs b/crates/denoise/examples/enable_disable.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cffadbce2b0e58cdf56b291cb68a13fc6556b22 --- /dev/null +++ b/crates/denoise/examples/enable_disable.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use rodio::Source; +use rodio::wav_to_file; +use rodio::{nz, source::UniformSourceIterator}; + +fn main() -> Result<(), Box> { + let file = std::fs::File::open("clips_airconditioning.wav")?; + let decoder = rodio::Decoder::try_from(file)?; + let resampled = UniformSourceIterator::new(decoder, nz!(1), nz!(16_000)); + + let mut enabled = true; + let denoised = denoise::Denoiser::try_new(resampled)?.periodic_access( + Duration::from_secs(2), + |denoised| { + enabled = !enabled; + denoised.set_enabled(enabled); + }, + ); + + wav_to_file(denoised, "processed.wav")?; + Ok(()) +} diff --git a/crates/denoise/models/model_1_converted_simplified.onnx b/crates/denoise/models/model_1_converted_simplified.onnx new file mode 100644 index 0000000000000000000000000000000000000000..821cb73bd76b1470c0ee814d07bd03c47a613643 Binary files /dev/null and b/crates/denoise/models/model_1_converted_simplified.onnx differ diff --git a/crates/denoise/models/model_2_converted_simplified.onnx b/crates/denoise/models/model_2_converted_simplified.onnx new file mode 100644 index 0000000000000000000000000000000000000000..a83023ab22748fb60f8186c3c5b0161531337082 Binary files /dev/null and b/crates/denoise/models/model_2_converted_simplified.onnx differ diff --git a/crates/denoise/src/engine.rs b/crates/denoise/src/engine.rs new file mode 100644 index 0000000000000000000000000000000000000000..be0548c689e3b902342cd1cb6d6d8e29351e8be4 --- /dev/null +++ b/crates/denoise/src/engine.rs @@ -0,0 +1,204 @@ +/// use something like https://netron.app/ to inspect the models and understand +/// the flow +use std::collections::HashMap; + +use candle_core::{Device, IndexOp, Tensor}; +use candle_onnx::onnx::ModelProto; +use candle_onnx::prost::Message; +use realfft::RealFftPlanner; +use rustfft::num_complex::Complex; + +pub struct Engine { + spectral_model: ModelProto, + signal_model: ModelProto, + + fft_planner: RealFftPlanner, + fft_scratch: Vec>, + spectrum: [Complex; FFT_OUT_SIZE], + signal: [f32; BLOCK_LEN], + + in_magnitude: [f32; FFT_OUT_SIZE], + in_phase: [f32; FFT_OUT_SIZE], + + spectral_memory: Tensor, + signal_memory: Tensor, + + in_buffer: [f32; BLOCK_LEN], + out_buffer: [f32; BLOCK_LEN], +} + +// 32 ms @ 16khz per DTLN docs: https://github.com/breizhn/DTLN +pub const BLOCK_LEN: usize = 512; +// 8 ms @ 16khz per DTLN docs. +pub const BLOCK_SHIFT: usize = 128; +pub const FFT_OUT_SIZE: usize = BLOCK_LEN / 2 + 1; + +impl Engine { + pub fn new() -> Self { + let mut fft_planner = RealFftPlanner::new(); + let fft_planned = fft_planner.plan_fft_forward(BLOCK_LEN); + let scratch_len = fft_planned.get_scratch_len(); + Self { + // Models are 1.5MB and 2.5MB respectively. Its worth the binary + // size increase not to have to distribute the models separately. + spectral_model: ModelProto::decode( + include_bytes!("../models/model_1_converted_simplified.onnx").as_slice(), + ) + .expect("The model should decode"), + signal_model: ModelProto::decode( + include_bytes!("../models/model_2_converted_simplified.onnx").as_slice(), + ) + .expect("The model should decode"), + fft_planner, + fft_scratch: vec![Complex::ZERO; scratch_len], + spectrum: [Complex::ZERO; FFT_OUT_SIZE], + signal: [0f32; BLOCK_LEN], + + in_magnitude: [0f32; FFT_OUT_SIZE], + in_phase: [0f32; FFT_OUT_SIZE], + + spectral_memory: Tensor::from_slice::<_, f32>( + &[0f32; 512], + (1, 2, BLOCK_SHIFT, 2), + &Device::Cpu, + ) + .expect("Tensor has the correct dimensions"), + signal_memory: Tensor::from_slice::<_, f32>( + &[0f32; 512], + (1, 2, BLOCK_SHIFT, 2), + &Device::Cpu, + ) + .expect("Tensor has the correct dimensions"), + out_buffer: [0f32; BLOCK_LEN], + in_buffer: [0f32; BLOCK_LEN], + } + } + + /// Add a clunk of samples and get the denoised chunk 4 feeds later + pub fn feed(&mut self, samples: &[f32]) -> [f32; BLOCK_SHIFT] { + /// The name of the output node of the onnx network + /// [Dual-Signal Transformation LSTM Network for Real-Time Noise Suppression](https://arxiv.org/abs/2005.07551). + const MEMORY_OUTPUT: &'static str = "Identity_1"; + + debug_assert_eq!(samples.len(), BLOCK_SHIFT); + + // place new samples at the end of the `in_buffer` + self.in_buffer.copy_within(BLOCK_SHIFT.., 0); + self.in_buffer[(BLOCK_LEN - BLOCK_SHIFT)..].copy_from_slice(&samples); + + // run inference + let inputs = self.spectral_inputs(); + let mut spectral_outputs = candle_onnx::simple_eval(&self.spectral_model, inputs) + .expect("The embedded file must be valid"); + self.spectral_memory = spectral_outputs + .remove(MEMORY_OUTPUT) + .expect("The model has an output named Identity_1"); + let inputs = self.signal_inputs(spectral_outputs); + let mut signal_outputs = candle_onnx::simple_eval(&self.signal_model, inputs) + .expect("The embedded file must be valid"); + self.signal_memory = signal_outputs + .remove(MEMORY_OUTPUT) + .expect("The model has an output named Identity_1"); + let model_output = model_outputs(signal_outputs); + + // place processed samples at the start of the `out_buffer` + // shift the rest left, fill the end with zeros. Zeros are needed as + // the out buffer is part of the input of the network + self.out_buffer.copy_within(BLOCK_SHIFT.., 0); + self.out_buffer[BLOCK_LEN - BLOCK_SHIFT..].fill(0f32); + for (a, b) in self.out_buffer.iter_mut().zip(model_output) { + *a += b; + } + + // samples at the front of the `out_buffer` are now denoised + self.out_buffer[..BLOCK_SHIFT] + .try_into() + .expect("len is correct") + } + + fn spectral_inputs(&mut self) -> HashMap { + // Prepare FFT input + let fft = self.fft_planner.plan_fft_forward(BLOCK_LEN); + + // Perform real-to-complex FFT + let mut fft_in = self.in_buffer; + fft.process_with_scratch(&mut fft_in, &mut self.spectrum, &mut self.fft_scratch) + .expect("The fft should run, there is enough scratch space"); + + // Generate magnitude and phase + for ((magnitude, phase), complex) in self + .in_magnitude + .iter_mut() + .zip(self.in_phase.iter_mut()) + .zip(self.spectrum) + { + *magnitude = complex.norm(); + *phase = complex.arg(); + } + + const SPECTRUM_INPUT: &str = "input_2"; + const MEMORY_INPUT: &str = "input_3"; + let spectrum = + Tensor::from_slice::<_, f32>(&self.in_magnitude, (1, 1, FFT_OUT_SIZE), &Device::Cpu) + .expect("the in magnitude has enough elements to fill the Tensor"); + + let inputs = HashMap::from([ + (SPECTRUM_INPUT.to_string(), spectrum), + (MEMORY_INPUT.to_string(), self.spectral_memory.clone()), + ]); + inputs + } + + fn signal_inputs(&mut self, outputs: HashMap) -> HashMap { + let magnitude_weight = model_outputs(outputs); + + // Apply mask and reconstruct complex spectrum + let mut spectrum = [Complex::I; FFT_OUT_SIZE]; + for i in 0..FFT_OUT_SIZE { + let magnitude = self.in_magnitude[i] * magnitude_weight[i]; + let phase = self.in_phase[i]; + let real = magnitude * phase.cos(); + let imag = magnitude * phase.sin(); + spectrum[i] = Complex::new(real, imag); + } + + // Handle DC component (i = 0) + let magnitude = self.in_magnitude[0] * magnitude_weight[0]; + spectrum[0] = Complex::new(magnitude, 0.0); + + // Handle Nyquist component (i = N/2) + let magnitude = self.in_magnitude[FFT_OUT_SIZE - 1] * magnitude_weight[FFT_OUT_SIZE - 1]; + spectrum[FFT_OUT_SIZE - 1] = Complex::new(magnitude, 0.0); + + // Perform complex-to-real IFFT + let ifft = self.fft_planner.plan_fft_inverse(BLOCK_LEN); + ifft.process_with_scratch(&mut spectrum, &mut self.signal, &mut self.fft_scratch) + .expect("The fft should run, there is enough scratch space"); + + // Normalize the IFFT output + for real in &mut self.signal { + *real /= BLOCK_LEN as f32; + } + + const SIGNAL_INPUT: &str = "input_4"; + const SIGNAL_MEMORY: &str = "input_5"; + let signal_input = + Tensor::from_slice::<_, f32>(&self.signal, (1, 1, BLOCK_LEN), &Device::Cpu).unwrap(); + + HashMap::from([ + (SIGNAL_INPUT.to_string(), signal_input), + (SIGNAL_MEMORY.to_string(), self.signal_memory.clone()), + ]) + } +} + +// Both models put their outputs in the same location +fn model_outputs(mut outputs: HashMap) -> Vec { + const NON_MEMORY_OUTPUT: &str = "Identity"; + outputs + .remove(NON_MEMORY_OUTPUT) + .expect("The model has this output") + .i((0, 0)) + .and_then(|tensor| tensor.to_vec1()) + .expect("The tensor has the correct dimensions") +} diff --git a/crates/denoise/src/lib.rs b/crates/denoise/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..f6cbf0fadf1f216cc6168c2b249f807b557869af --- /dev/null +++ b/crates/denoise/src/lib.rs @@ -0,0 +1,273 @@ +mod engine; + +use core::fmt; +use std::{collections::VecDeque, sync::mpsc, thread}; + +pub use engine::Engine; +use rodio::{ChannelCount, Sample, SampleRate, Source, nz}; + +use crate::engine::BLOCK_SHIFT; + +const SUPPORTED_SAMPLE_RATE: SampleRate = nz!(16_000); +const SUPPORTED_CHANNEL_COUNT: ChannelCount = nz!(1); + +pub struct Denoiser { + inner: S, + input_tx: mpsc::Sender<[Sample; BLOCK_SHIFT]>, + denoised_rx: mpsc::Receiver<[Sample; BLOCK_SHIFT]>, + ready: [Sample; BLOCK_SHIFT], + next: usize, + state: IterState, + // When disabled instead of reading denoised sub-blocks from the engine through + // `denoised_rx` we read unprocessed from this queue. This maintains the same + // latency so we can 'trivially' re-enable + queued: Queue, +} + +impl fmt::Debug for Denoiser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Denoiser") + .field("state", &self.state) + .finish_non_exhaustive() + } +} + +struct Queue(VecDeque<[Sample; BLOCK_SHIFT]>); + +impl Queue { + fn new() -> Self { + Self(VecDeque::new()) + } + fn push(&mut self, block: [Sample; BLOCK_SHIFT]) { + self.0.push_back(block); + self.0.resize(4, [0f32; BLOCK_SHIFT]); + } + fn pop(&mut self) -> [Sample; BLOCK_SHIFT] { + debug_assert!(self.0.len() == 4); + self.0.pop_front().expect( + "There is no State where the queue is popped while there are less then 4 entries", + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum IterState { + Enabled, + StartingMidAudio { fed_to_denoiser: usize }, + Disabled, + Startup { enabled: bool }, +} + +#[derive(Debug, thiserror::Error)] +pub enum DenoiserError { + #[error("This denoiser only works on sources with samplerate 16000")] + UnsupportedSampleRate, + #[error("This denoiser only works on mono sources (1 channel)")] + UnsupportedChannelCount, +} + +// todo dvdsk needs constant source upstream in rodio +impl Denoiser { + pub fn try_new(source: S) -> Result { + if source.sample_rate() != SUPPORTED_SAMPLE_RATE { + return Err(DenoiserError::UnsupportedSampleRate); + } + if source.channels() != SUPPORTED_CHANNEL_COUNT { + return Err(DenoiserError::UnsupportedChannelCount); + } + + let (input_tx, input_rx) = mpsc::channel(); + let (denoised_tx, denoised_rx) = mpsc::channel(); + + thread::Builder::new() + .name("NeuralDenoiser".to_owned()) + .spawn(move || { + run_neural_denoiser(denoised_tx, input_rx); + }) + .expect("Should be ablet to spawn threads"); + + Ok(Self { + inner: source, + input_tx, + denoised_rx, + ready: [0.0; BLOCK_SHIFT], + state: IterState::Startup { enabled: true }, + next: BLOCK_SHIFT, + queued: Queue::new(), + }) + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.state = match (enabled, self.state) { + (false, IterState::StartingMidAudio { .. }) | (false, IterState::Enabled) => { + IterState::Disabled + } + (false, IterState::Startup { enabled: true }) => IterState::Startup { enabled: false }, + (true, IterState::Disabled) => IterState::StartingMidAudio { fed_to_denoiser: 0 }, + (_, state) => state, + }; + } + + fn feed(&self, sub_block: [f32; BLOCK_SHIFT]) { + self.input_tx.send(sub_block).unwrap(); + } +} + +fn run_neural_denoiser( + denoised_tx: mpsc::Sender<[f32; BLOCK_SHIFT]>, + input_rx: mpsc::Receiver<[f32; BLOCK_SHIFT]>, +) { + let mut engine = Engine::new(); + loop { + let Ok(sub_block) = input_rx.recv() else { + // tx must have dropped, stop thread + break; + }; + + let denoised_sub_block = engine.feed(&sub_block); + if denoised_tx.send(denoised_sub_block).is_err() { + break; + } + } +} + +impl Source for Denoiser { + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + + fn channels(&self) -> rodio::ChannelCount { + self.inner.channels() + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.inner.sample_rate() + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} + +impl Iterator for Denoiser { + type Item = Sample; + + #[inline] + fn next(&mut self) -> Option { + self.next += 1; + if self.next < self.ready.len() { + let sample = self.ready[self.next]; + return Some(sample); + } + + // This is a separate function to prevent it from being inlined + // as this code only runs once every 128 samples + self.prepare_next_ready() + .inspect_err(|_| { + log::error!("Denoise engine crashed"); + }) + .ok() + .flatten() + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Could not send or receive from denoise thread. It must have crashed")] +struct DenoiseEngineCrashed; + +impl Denoiser { + #[cold] + fn prepare_next_ready(&mut self) -> Result, DenoiseEngineCrashed> { + self.state = match self.state { + IterState::Startup { enabled } => { + // guaranteed to be coming from silence + for _ in 0..3 { + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + self.input_tx + .send(sub_block) + .map_err(|_| DenoiseEngineCrashed)?; + } + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + self.input_tx + .send(sub_block) + .map_err(|_| DenoiseEngineCrashed)?; + // throw out old blocks that are denoised silence + let _ = self.denoised_rx.iter().take(3).count(); + self.ready = self.denoised_rx.recv().map_err(|_| DenoiseEngineCrashed)?; + + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + self.feed(sub_block); + + if enabled { + IterState::Enabled + } else { + IterState::Disabled + } + } + IterState::Enabled => { + self.ready = self.denoised_rx.recv().map_err(|_| DenoiseEngineCrashed)?; + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + self.input_tx + .send(sub_block) + .map_err(|_| DenoiseEngineCrashed)?; + IterState::Enabled + } + IterState::Disabled => { + // Need to maintain the same 512 samples delay such that + // we can re-enable at any point. + self.ready = self.queued.pop(); + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + IterState::Disabled + } + IterState::StartingMidAudio { + fed_to_denoiser: mut sub_blocks_fed, + } => { + self.ready = self.queued.pop(); + let Some(sub_block) = read_sub_block(&mut self.inner) else { + return Ok(None); + }; + self.queued.push(sub_block); + self.input_tx + .send(sub_block) + .map_err(|_| DenoiseEngineCrashed)?; + sub_blocks_fed += 1; + if sub_blocks_fed > 4 { + // throw out partially denoised blocks, + // next will be correctly denoised + let _ = self.denoised_rx.iter().take(3).count(); + IterState::Enabled + } else { + IterState::StartingMidAudio { + fed_to_denoiser: sub_blocks_fed, + } + } + } + }; + + self.next = 0; + Ok(Some(self.ready[0])) + } +} + +fn read_sub_block(s: &mut impl Source) -> Option<[f32; BLOCK_SHIFT]> { + let mut res = [0f32; BLOCK_SHIFT]; + for sample in &mut res { + *sample = s.next()?; + } + Some(res) +} diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 53b5792e10e73d1629a104e345965547b6f2b25e..5bb6892f0cea9500fd66671f8e8e86ab9a6d901a 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -18,7 +18,6 @@ collections.workspace = true component.workspace = true ctor.workspace = true editor.workspace = true -futures.workspace = true gpui.workspace = true indoc.workspace = true language.workspace = true @@ -35,7 +34,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs new file mode 100644 index 0000000000000000000000000000000000000000..1205cef385fdd91af8e3f986b432b9fff4ad3ac6 --- /dev/null +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -0,0 +1,987 @@ +use crate::{ + DIAGNOSTICS_UPDATE_DELAY, IncludeWarnings, ToggleWarnings, context_range_for_entry, + diagnostic_renderer::{DiagnosticBlock, DiagnosticRenderer}, + toolbar_controls::DiagnosticsToolbarEditor, +}; +use anyhow::Result; +use collections::HashMap; +use editor::{ + Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, + multibuffer_context_lines, +}; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + Task, WeakEntity, Window, actions, div, +}; +use language::{Buffer, DiagnosticEntry, DiagnosticEntryRef, Point}; +use project::{ + DiagnosticSummary, Event, Project, ProjectItem, ProjectPath, + project_settings::{DiagnosticSeverity, ProjectSettings}, +}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + sync::Arc, +}; +use text::{Anchor, BufferSnapshot, OffsetRangeExt}; +use ui::{Button, ButtonStyle, Icon, IconName, Label, Tooltip, h_flex, prelude::*}; +use workspace::{ + ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace, + item::{BreadcrumbText, Item, ItemEvent, TabContentParams}, +}; + +actions!( + diagnostics, + [ + /// Opens the project diagnostics view for the currently focused file. + DeployCurrentFile, + ] +); + +/// The `BufferDiagnosticsEditor` is meant to be used when dealing specifically +/// with diagnostics for a single buffer, as only the excerpts of the buffer +/// where diagnostics are available are displayed. +pub(crate) struct BufferDiagnosticsEditor { + pub project: Entity, + focus_handle: FocusHandle, + editor: Entity, + /// The current diagnostic entries in the `BufferDiagnosticsEditor`. Used to + /// allow quick comparison of updated diagnostics, to confirm if anything + /// has changed. + pub(crate) diagnostics: Vec>, + /// The blocks used to display the diagnostics' content in the editor, next + /// to the excerpts where the diagnostic originated. + blocks: Vec, + /// Multibuffer to contain all excerpts that contain diagnostics, which are + /// to be rendered in the editor. + multibuffer: Entity, + /// The buffer for which the editor is displaying diagnostics and excerpts + /// for. + buffer: Option>, + /// The path for which the editor is displaying diagnostics for. + project_path: ProjectPath, + /// Summary of the number of warnings and errors for the path. Used to + /// display the number of warnings and errors in the tab's content. + summary: DiagnosticSummary, + /// Whether to include warnings in the list of diagnostics shown in the + /// editor. + pub(crate) include_warnings: bool, + /// Keeps track of whether there's a background task already running to + /// update the excerpts, in order to avoid firing multiple tasks for this purpose. + pub(crate) update_excerpts_task: Option>>, + /// The project's subscription, responsible for processing events related to + /// diagnostics. + _subscription: Subscription, +} + +impl BufferDiagnosticsEditor { + /// Creates new instance of the `BufferDiagnosticsEditor` which can then be + /// displayed by adding it to a pane. + pub fn new( + project_path: ProjectPath, + project_handle: Entity, + buffer: Option>, + include_warnings: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // Subscribe to project events related to diagnostics so the + // `BufferDiagnosticsEditor` can update its state accordingly. + let project_event_subscription = cx.subscribe_in( + &project_handle, + window, + |buffer_diagnostics_editor, _project, event, window, cx| match event { + Event::DiskBasedDiagnosticsStarted { .. } => { + cx.notify(); + } + Event::DiskBasedDiagnosticsFinished { .. } => { + buffer_diagnostics_editor.update_all_excerpts(window, cx); + } + Event::DiagnosticsUpdated { + paths, + language_server_id, + } => { + // When diagnostics have been updated, the + // `BufferDiagnosticsEditor` should update its state only if + // one of the paths matches its `project_path`, otherwise + // the event should be ignored. + if paths.contains(&buffer_diagnostics_editor.project_path) { + buffer_diagnostics_editor.update_diagnostic_summary(cx); + + if buffer_diagnostics_editor.editor.focus_handle(cx).contains_focused(window, cx) || buffer_diagnostics_editor.focus_handle.contains_focused(window, cx) { + log::debug!("diagnostics updated for server {language_server_id}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}. updating excerpts"); + buffer_diagnostics_editor.update_all_excerpts(window, cx); + } + } + } + _ => {} + }, + ); + + let focus_handle = cx.focus_handle(); + + cx.on_focus_in( + &focus_handle, + window, + |buffer_diagnostics_editor, window, cx| buffer_diagnostics_editor.focus_in(window, cx), + ) + .detach(); + + cx.on_focus_out( + &focus_handle, + window, + |buffer_diagnostics_editor, _event, window, cx| { + buffer_diagnostics_editor.focus_out(window, cx) + }, + ) + .detach(); + + let summary = project_handle + .read(cx) + .diagnostic_summary_for_path(&project_path, cx); + + let multibuffer = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability())); + let max_severity = Self::max_diagnostics_severity(include_warnings); + let editor = cx.new(|cx| { + let mut editor = Editor::for_multibuffer( + multibuffer.clone(), + Some(project_handle.clone()), + window, + cx, + ); + editor.set_vertical_scroll_margin(5, cx); + editor.disable_inline_diagnostics(); + editor.set_max_diagnostics_severity(max_severity, cx); + editor.set_all_diagnostics_active(cx); + editor + }); + + // Subscribe to events triggered by the editor in order to correctly + // update the buffer's excerpts. + cx.subscribe_in( + &editor, + window, + |buffer_diagnostics_editor, _editor, event: &EditorEvent, window, cx| { + cx.emit(event.clone()); + + match event { + // If the user tries to focus on the editor but there's actually + // no excerpts for the buffer, focus back on the + // `BufferDiagnosticsEditor` instance. + EditorEvent::Focused => { + if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() { + window.focus(&buffer_diagnostics_editor.focus_handle); + } + } + EditorEvent::Blurred => { + buffer_diagnostics_editor.update_all_excerpts(window, cx) + } + _ => {} + } + }, + ) + .detach(); + + let diagnostics = vec![]; + let update_excerpts_task = None; + let mut buffer_diagnostics_editor = Self { + project: project_handle, + focus_handle, + editor, + diagnostics, + blocks: Default::default(), + multibuffer, + buffer, + project_path, + summary, + include_warnings, + update_excerpts_task, + _subscription: project_event_subscription, + }; + + buffer_diagnostics_editor.update_all_diagnostics(window, cx); + buffer_diagnostics_editor + } + + fn deploy( + workspace: &mut Workspace, + _: &DeployCurrentFile, + window: &mut Window, + cx: &mut Context, + ) { + // Determine the currently opened path by finding the active editor and + // finding the project path for the buffer. + // If there's no active editor with a project path, avoiding deploying + // the buffer diagnostics view. + if let Some(editor) = workspace.active_item_as::(cx) + && let Some(project_path) = editor.project_path(cx) + { + // Check if there's already a `BufferDiagnosticsEditor` tab for this + // same path, and if so, focus on that one instead of creating a new + // one. + let existing_editor = workspace + .items_of_type::(cx) + .find(|editor| editor.read(cx).project_path == project_path); + + if let Some(editor) = existing_editor { + workspace.activate_item(&editor, true, true, window, cx); + } else { + let include_warnings = match cx.try_global::() { + Some(include_warnings) => include_warnings.0, + None => ProjectSettings::get_global(cx).diagnostics.include_warnings, + }; + + let item = cx.new(|cx| { + Self::new( + project_path, + workspace.project().clone(), + editor.read(cx).buffer().read(cx).as_singleton(), + include_warnings, + window, + cx, + ) + }); + + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + } + } + } + + pub fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + _: &mut Context, + ) { + workspace.register_action(Self::deploy); + } + + fn update_all_diagnostics(&mut self, window: &mut Window, cx: &mut Context) { + self.update_all_excerpts(window, cx); + } + + fn update_diagnostic_summary(&mut self, cx: &mut Context) { + let project = self.project.read(cx); + + self.summary = project.diagnostic_summary_for_path(&self.project_path, cx); + } + + /// Enqueue an update to the excerpts and diagnostic blocks being shown in + /// the editor. + pub(crate) fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context) { + // If there's already a task updating the excerpts, early return and let + // the other task finish. + if self.update_excerpts_task.is_some() { + return; + } + + let buffer = self.buffer.clone(); + + self.update_excerpts_task = Some(cx.spawn_in(window, async move |editor, cx| { + cx.background_executor() + .timer(DIAGNOSTICS_UPDATE_DELAY) + .await; + + if let Some(buffer) = buffer { + editor + .update_in(cx, |editor, window, cx| { + editor.update_excerpts(buffer, window, cx) + })? + .await?; + }; + + let _ = editor.update(cx, |editor, cx| { + editor.update_excerpts_task = None; + cx.notify(); + }); + + Ok(()) + })); + } + + /// Updates the excerpts in the `BufferDiagnosticsEditor` for a single + /// buffer. + fn update_excerpts( + &mut self, + buffer: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let was_empty = self.multibuffer.read(cx).is_empty(); + let multibuffer_context = multibuffer_context_lines(cx); + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_snapshot_max = buffer_snapshot.max_point(); + let max_severity = Self::max_diagnostics_severity(self.include_warnings) + .into_lsp() + .unwrap_or(lsp::DiagnosticSeverity::WARNING); + + cx.spawn_in(window, async move |buffer_diagnostics_editor, mut cx| { + // Fetch the diagnostics for the whole of the buffer + // (`Point::zero()..buffer_snapshot.max_point()`) so we can confirm + // if the diagnostics changed, if it didn't, early return as there's + // nothing to update. + let diagnostics = buffer_snapshot + .diagnostics_in_range::<_, Anchor>(Point::zero()..buffer_snapshot_max, false) + .collect::>(); + + let unchanged = + buffer_diagnostics_editor.update(cx, |buffer_diagnostics_editor, _cx| { + if buffer_diagnostics_editor + .diagnostics_are_unchanged(&diagnostics, &buffer_snapshot) + { + return true; + } + + buffer_diagnostics_editor.set_diagnostics(&diagnostics); + return false; + })?; + + if unchanged { + return Ok(()); + } + + // Mapping between the Group ID and a vector of DiagnosticEntry. + let mut grouped: HashMap> = HashMap::default(); + for entry in diagnostics { + grouped + .entry(entry.diagnostic.group_id) + .or_default() + .push(DiagnosticEntryRef { + range: entry.range.to_point(&buffer_snapshot), + diagnostic: entry.diagnostic, + }) + } + + let mut blocks: Vec = Vec::new(); + for (_, group) in grouped { + // If the minimum severity of the group is higher than the + // maximum severity, or it doesn't even have severity, skip this + // group. + if group + .iter() + .map(|d| d.diagnostic.severity) + .min() + .is_none_or(|severity| severity > max_severity) + { + continue; + } + + let diagnostic_blocks = cx.update(|_window, cx| { + DiagnosticRenderer::diagnostic_blocks_for_group( + group, + buffer_snapshot.remote_id(), + Some(Arc::new(buffer_diagnostics_editor.clone())), + cx, + ) + })?; + + // For each of the diagnostic blocks to be displayed in the + // editor, figure out its index in the list of blocks. + // + // The following rules are used to determine the order: + // 1. Blocks with a lower start position should come first. + // 2. If two blocks have the same start position, the one with + // the higher end position should come first. + for diagnostic_block in diagnostic_blocks { + let index = blocks.partition_point(|probe| { + match probe + .initial_range + .start + .cmp(&diagnostic_block.initial_range.start) + { + Ordering::Less => true, + Ordering::Greater => false, + Ordering::Equal => { + probe.initial_range.end > diagnostic_block.initial_range.end + } + } + }); + + blocks.insert(index, diagnostic_block); + } + } + + // Build the excerpt ranges for this specific buffer's diagnostics, + // so those excerpts can later be used to update the excerpts shown + // in the editor. + // This is done by iterating over the list of diagnostic blocks and + // determine what range does the diagnostic block span. + let mut excerpt_ranges: Vec> = Vec::new(); + + for diagnostic_block in blocks.iter() { + let excerpt_range = context_range_for_entry( + diagnostic_block.initial_range.clone(), + multibuffer_context, + buffer_snapshot.clone(), + &mut cx, + ) + .await; + + let index = excerpt_ranges + .binary_search_by(|probe| { + probe + .context + .start + .cmp(&excerpt_range.start) + .then(probe.context.end.cmp(&excerpt_range.end)) + .then( + probe + .primary + .start + .cmp(&diagnostic_block.initial_range.start), + ) + .then(probe.primary.end.cmp(&diagnostic_block.initial_range.end)) + .then(Ordering::Greater) + }) + .unwrap_or_else(|index| index); + + excerpt_ranges.insert( + index, + ExcerptRange { + context: excerpt_range, + primary: diagnostic_block.initial_range.clone(), + }, + ) + } + + // Finally, update the editor's content with the new excerpt ranges + // for this editor, as well as the diagnostic blocks. + buffer_diagnostics_editor.update_in(cx, |buffer_diagnostics_editor, window, cx| { + // Remove the list of `CustomBlockId` from the editor's display + // map, ensuring that if any diagnostics have been solved, the + // associated block stops being shown. + let block_ids = buffer_diagnostics_editor.blocks.clone(); + + buffer_diagnostics_editor.editor.update(cx, |editor, cx| { + editor.display_map.update(cx, |display_map, cx| { + display_map.remove_blocks(block_ids.into_iter().collect(), cx); + }) + }); + + let (anchor_ranges, _) = + buffer_diagnostics_editor + .multibuffer + .update(cx, |multibuffer, cx| { + multibuffer.set_excerpt_ranges_for_path( + PathKey::for_buffer(&buffer, cx), + buffer.clone(), + &buffer_snapshot, + excerpt_ranges, + cx, + ) + }); + + if was_empty { + if let Some(anchor_range) = anchor_ranges.first() { + let range_to_select = anchor_range.start..anchor_range.start; + + buffer_diagnostics_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selection| { + selection.select_anchor_ranges([range_to_select]) + }) + }); + + // If the `BufferDiagnosticsEditor` is currently + // focused, move focus to its editor. + if buffer_diagnostics_editor.focus_handle.is_focused(window) { + buffer_diagnostics_editor + .editor + .read(cx) + .focus_handle(cx) + .focus(window); + } + } + } + + // Cloning the blocks before moving ownership so these can later + // be used to set the block contents for testing purposes. + #[cfg(test)] + let cloned_blocks = blocks.clone(); + + // Build new diagnostic blocks to be added to the editor's + // display map for the new diagnostics. Update the `blocks` + // property before finishing, to ensure the blocks are removed + // on the next execution. + let editor_blocks = + anchor_ranges + .into_iter() + .zip(blocks.into_iter()) + .map(|(anchor, block)| { + let editor = buffer_diagnostics_editor.editor.downgrade(); + + BlockProperties { + placement: BlockPlacement::Near(anchor.start), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(move |block_context| { + block.render_block(editor.clone(), block_context) + }), + priority: 1, + } + }); + + let block_ids = buffer_diagnostics_editor.editor.update(cx, |editor, cx| { + editor.display_map.update(cx, |display_map, cx| { + display_map.insert_blocks(editor_blocks, cx) + }) + }); + + // In order to be able to verify which diagnostic blocks are + // rendered in the editor, the `set_block_content_for_tests` + // function must be used, so that the + // `editor::test::editor_content_with_blocks` function can then + // be called to fetch these blocks. + #[cfg(test)] + { + for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) { + let markdown = block.markdown.clone(); + editor::test::set_block_content_for_tests( + &buffer_diagnostics_editor.editor, + *block_id, + cx, + move |cx| { + markdown::MarkdownElement::rendered_text( + markdown.clone(), + cx, + editor::hover_popover::diagnostics_markdown_style, + ) + }, + ); + } + } + + buffer_diagnostics_editor.blocks = block_ids; + cx.notify() + }) + }) + } + + fn set_diagnostics(&mut self, diagnostics: &[DiagnosticEntryRef<'_, Anchor>]) { + self.diagnostics = diagnostics + .iter() + .map(DiagnosticEntryRef::to_owned) + .collect(); + } + + fn diagnostics_are_unchanged( + &self, + diagnostics: &Vec>, + snapshot: &BufferSnapshot, + ) -> bool { + if self.diagnostics.len() != diagnostics.len() { + return false; + } + + self.diagnostics + .iter() + .zip(diagnostics.iter()) + .all(|(existing, new)| { + existing.diagnostic.message == new.diagnostic.message + && existing.diagnostic.severity == new.diagnostic.severity + && existing.diagnostic.is_primary == new.diagnostic.is_primary + && existing.range.to_offset(snapshot) == new.range.to_offset(snapshot) + }) + } + + fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { + // If the `BufferDiagnosticsEditor` is focused and the multibuffer is + // not empty, focus on the editor instead, which will allow the user to + // start interacting and editing the buffer's contents. + if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { + self.editor.focus_handle(cx).focus(window) + } + } + + fn focus_out(&mut self, window: &mut Window, cx: &mut Context) { + if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window) + { + self.update_all_excerpts(window, cx); + } + } + + pub fn toggle_warnings( + &mut self, + _: &ToggleWarnings, + window: &mut Window, + cx: &mut Context, + ) { + let include_warnings = !self.include_warnings; + let max_severity = Self::max_diagnostics_severity(include_warnings); + + self.editor.update(cx, |editor, cx| { + editor.set_max_diagnostics_severity(max_severity, cx); + }); + + self.include_warnings = include_warnings; + self.diagnostics.clear(); + self.update_all_diagnostics(window, cx); + } + + fn max_diagnostics_severity(include_warnings: bool) -> DiagnosticSeverity { + match include_warnings { + true => DiagnosticSeverity::Warning, + false => DiagnosticSeverity::Error, + } + } + + #[cfg(test)] + pub fn editor(&self) -> &Entity { + &self.editor + } + + #[cfg(test)] + pub fn summary(&self) -> &DiagnosticSummary { + &self.summary + } +} + +impl Focusable for BufferDiagnosticsEditor { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for BufferDiagnosticsEditor {} + +impl Item for BufferDiagnosticsEditor { + type Event = EditorEvent; + + fn act_as_type<'a>( + &'a self, + type_id: std::any::TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }); + } + + fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn can_save(&self, _cx: &App) -> bool { + true + } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + Task::ready(Some(cx.new(|cx| { + BufferDiagnosticsEditor::new( + self.project_path.clone(), + self.project.clone(), + self.buffer.clone(), + self.include_warnings, + window, + cx, + ) + }))) + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| editor.deactivated(window, cx)); + } + + fn for_each_project_item(&self, cx: &App, f: &mut dyn FnMut(EntityId, &dyn ProjectItem)) { + self.editor.for_each_project_item(cx, f); + } + + fn has_conflict(&self, cx: &App) -> bool { + self.multibuffer.read(cx).has_conflict(cx) + } + + fn has_deleted_file(&self, cx: &App) -> bool { + self.multibuffer.read(cx).has_deleted_file(cx) + } + + fn is_dirty(&self, cx: &App) -> bool { + self.multibuffer.read(cx).is_dirty(cx) + } + + fn navigate( + &mut self, + data: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + } + + fn reload( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.reload(project, window, cx) + } + + fn save( + &mut self, + options: workspace::item::SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.save(options, project, window, cx) + } + + fn save_as( + &mut self, + _project: Entity, + _path: ProjectPath, + _window: &mut Window, + _cx: &mut Context, + ) -> Task> { + unreachable!() + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }) + } + + // Builds the content to be displayed in the tab. + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + let path_style = self.project.read(cx).path_style(cx); + let error_count = self.summary.error_count; + let warning_count = self.summary.warning_count; + let label = Label::new( + self.project_path + .path + .file_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| self.project_path.path.display(path_style).to_string()), + ); + + h_flex() + .gap_1() + .child(label) + .when(error_count == 0 && warning_count == 0, |parent| { + parent.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)), + ) + }) + .when(error_count > 0, |parent| { + parent.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new(error_count.to_string()).color(params.text_color())), + ) + }) + .when(warning_count > 0, |parent| { + parent.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Warning).color(Color::Warning)) + .child(Label::new(warning_count.to_string()).color(params.text_color())), + ) + }) + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _app: &App) -> SharedString { + "Buffer Diagnostics".into() + } + + fn tab_tooltip_text(&self, cx: &App) -> Option { + let path_style = self.project.read(cx).path_style(cx); + Some( + format!( + "Buffer Diagnostics - {}", + self.project_path.path.display(path_style) + ) + .into(), + ) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Buffer Diagnostics Opened") + } + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } +} + +impl Render for BufferDiagnosticsEditor { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let path_style = self.project.read(cx).path_style(cx); + let filename = self.project_path.path.display(path_style).to_string(); + let error_count = self.summary.error_count; + let warning_count = match self.include_warnings { + true => self.summary.warning_count, + false => 0, + }; + + let child = if error_count + warning_count == 0 { + let label = match warning_count { + 0 => "No problems in", + _ => "No errors in", + }; + + v_flex() + .key_context("EmptyPane") + .size_full() + .gap_1() + .justify_center() + .items_center() + .text_center() + .bg(cx.theme().colors().editor_background) + .child( + div() + .h_flex() + .child(Label::new(label).color(Color::Muted)) + .child( + Button::new("open-file", filename) + .style(ButtonStyle::Transparent) + .tooltip(Tooltip::text("Open File")) + .on_click(cx.listener(|buffer_diagnostics, _, window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace + .open_path( + buffer_diagnostics.project_path.clone(), + None, + true, + window, + cx, + ) + .detach_and_log_err(cx); + }) + } + })), + ), + ) + .when(self.summary.warning_count > 0, |div| { + let label = match self.summary.warning_count { + 1 => "Show 1 warning".into(), + warning_count => format!("Show {} warnings", warning_count), + }; + + div.child( + Button::new("diagnostics-show-warning-label", label).on_click(cx.listener( + |buffer_diagnostics_editor, _, window, cx| { + buffer_diagnostics_editor.toggle_warnings( + &Default::default(), + window, + cx, + ); + cx.notify(); + }, + )), + ) + }) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .key_context("Diagnostics") + .track_focus(&self.focus_handle(cx)) + .size_full() + .child(child) + } +} + +impl DiagnosticsToolbarEditor for WeakEntity { + fn include_warnings(&self, cx: &App) -> bool { + self.read_with(cx, |buffer_diagnostics_editor, _cx| { + buffer_diagnostics_editor.include_warnings + }) + .unwrap_or(false) + } + + fn has_stale_excerpts(&self, _cx: &App) -> bool { + false + } + + fn is_updating(&self, cx: &App) -> bool { + self.read_with(cx, |buffer_diagnostics_editor, cx| { + buffer_diagnostics_editor.update_excerpts_task.is_some() + || buffer_diagnostics_editor + .project + .read(cx) + .language_servers_running_disk_based_diagnostics(cx) + .next() + .is_some() + }) + .unwrap_or(false) + } + + fn stop_updating(&self, cx: &mut App) { + let _ = self.update(cx, |buffer_diagnostics_editor, cx| { + buffer_diagnostics_editor.update_excerpts_task = None; + cx.notify(); + }); + } + + fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) { + let _ = self.update(cx, |buffer_diagnostics_editor, cx| { + buffer_diagnostics_editor.update_all_excerpts(window, cx); + }); + } + + fn toggle_warnings(&self, window: &mut Window, cx: &mut App) { + let _ = self.update(cx, |buffer_diagnostics_editor, cx| { + buffer_diagnostics_editor.toggle_warnings(&Default::default(), window, cx); + }); + } + + fn get_diagnostics_for_buffer( + &self, + _buffer_id: text::BufferId, + cx: &App, + ) -> Vec> { + self.read_with(cx, |buffer_diagnostics_editor, _cx| { + buffer_diagnostics_editor.diagnostics.clone() + }) + .unwrap_or_default() + } +} diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index ce7b253702a01e24e7f4a457ac418572e0fa2729..5eda81faf97878605142a8bf0832b9082dbc414c 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -6,7 +6,7 @@ use editor::{ hover_popover::diagnostics_markdown_style, }; use gpui::{AppContext, Entity, Focusable, WeakEntity}; -use language::{BufferId, Diagnostic, DiagnosticEntry}; +use language::{BufferId, Diagnostic, DiagnosticEntryRef}; use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement}; use settings::Settings; @@ -18,15 +18,15 @@ use ui::{ }; use util::maybe; -use crate::ProjectDiagnosticsEditor; +use crate::toolbar_controls::DiagnosticsToolbarEditor; pub struct DiagnosticRenderer; impl DiagnosticRenderer { pub fn diagnostic_blocks_for_group( - diagnostic_group: Vec>, + diagnostic_group: Vec>, buffer_id: BufferId, - diagnostics_editor: Option>, + diagnostics_editor: Option>, cx: &mut App, ) -> Vec { let Some(primary_ix) = diagnostic_group @@ -35,7 +35,7 @@ impl DiagnosticRenderer { else { return Vec::new(); }; - let primary = diagnostic_group[primary_ix].clone(); + let primary = &diagnostic_group[primary_ix]; let group_id = primary.diagnostic.group_id; let mut results = vec![]; for entry in diagnostic_group.iter() { @@ -46,7 +46,7 @@ impl DiagnosticRenderer { markdown.push_str(" ("); } if let Some(source) = diagnostic.source.as_ref() { - markdown.push_str(&Markdown::escape(&source)); + markdown.push_str(&Markdown::escape(source)); } if diagnostic.source.is_some() && diagnostic.code.is_some() { markdown.push(' '); @@ -123,13 +123,14 @@ impl DiagnosticRenderer { impl editor::DiagnosticRenderer for DiagnosticRenderer { fn render_group( &self, - diagnostic_group: Vec>, + diagnostic_group: Vec>, buffer_id: BufferId, snapshot: EditorSnapshot, editor: WeakEntity, cx: &mut App, ) -> Vec> { let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx); + blocks .into_iter() .map(|block| { @@ -137,7 +138,7 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer { BlockProperties { placement: BlockPlacement::Near( snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(block.initial_range.start), ), height: Some(1), @@ -151,19 +152,15 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer { fn render_hover( &self, - diagnostic_group: Vec>, + diagnostic_group: Vec>, range: Range, buffer_id: BufferId, cx: &mut App, ) -> Option> { let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx); - blocks.into_iter().find_map(|block| { - if block.initial_range == range { - Some(block.markdown) - } else { - None - } - }) + blocks + .into_iter() + .find_map(|block| (block.initial_range == range).then(|| block.markdown)) } fn open_link( @@ -182,13 +179,13 @@ pub(crate) struct DiagnosticBlock { pub(crate) initial_range: Range, pub(crate) severity: DiagnosticSeverity, pub(crate) markdown: Entity, - pub(crate) diagnostics_editor: Option>, + pub(crate) diagnostics_editor: Option>, } impl DiagnosticBlock { pub fn render_block(&self, editor: WeakEntity, bcx: &BlockContext) -> AnyElement { let cx = &bcx.app; - let status_colors = bcx.app.theme().status(); + let status_colors = cx.theme().status(); let max_width = bcx.em_width * 120.; @@ -233,7 +230,7 @@ impl DiagnosticBlock { pub fn open_link( editor: &mut Editor, - diagnostics_editor: &Option>, + diagnostics_editor: &Option>, link: SharedString, window: &mut Window, cx: &mut Context, @@ -254,18 +251,10 @@ impl DiagnosticBlock { if let Some(diagnostics_editor) = diagnostics_editor { if let Some(diagnostic) = diagnostics_editor - .read_with(cx, |diagnostics, _| { - diagnostics - .diagnostics - .get(&buffer_id) - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|d| d.diagnostic.group_id == group_id) - .nth(ix) - }) - .ok() - .flatten() + .get_diagnostics_for_buffer(buffer_id, cx) + .into_iter() + .filter(|d| d.diagnostic.group_id == group_id) + .nth(ix) { let multibuffer = editor.buffer().read(cx); let Some(snapshot) = multibuffer @@ -287,26 +276,24 @@ impl DiagnosticBlock { } } } - } else { - if let Some(diagnostic) = editor - .snapshot(window, cx) - .buffer_snapshot - .diagnostic_group(buffer_id, group_id) - .nth(ix) - { - Self::jump_to(editor, diagnostic.range, window, cx) - } + } else if let Some(diagnostic) = editor + .snapshot(window, cx) + .buffer_snapshot() + .diagnostic_group(buffer_id, group_id) + .nth(ix) + { + Self::jump_to(editor, diagnostic.range, window, cx) }; } - fn jump_to( + fn jump_to( editor: &mut Editor, - range: Range, + range: Range, window: &mut Window, cx: &mut Context, ) { let snapshot = &editor.buffer().read(cx).snapshot(cx); - let range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + let range = range.start.to_offset(snapshot)..range.end.to_offset(snapshot); editor.unfold_ranges(&[range.start..range.end], true, false, cx); editor.change_selections(Default::default(), window, cx, |s| { diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index e7660920da30ddcc088c2bbee6bfb1cf05d51d58..5a43fd135391a5e3d97d5c65e6d3be826210f102 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -1,30 +1,32 @@ pub mod items; mod toolbar_controls; +mod buffer_diagnostics; mod diagnostic_renderer; #[cfg(test)] mod diagnostics_tests; use anyhow::Result; +use buffer_diagnostics::BufferDiagnosticsEditor; use collections::{BTreeSet, HashMap}; use diagnostic_renderer::DiagnosticBlock; use editor::{ - DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, + multibuffer_context_lines, }; -use futures::future::join_all; use gpui::{ AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, div, }; use language::{ - Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, Point, ToTreeSitterPoint, + Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, DiagnosticEntryRef, Point, + ToTreeSitterPoint, }; use project::{ DiagnosticSummary, Project, ProjectPath, - lsp_store::rust_analyzer_ext::{cancel_flycheck, run_flycheck}, project_settings::{DiagnosticSeverity, ProjectSettings}, }; use settings::Settings; @@ -37,6 +39,7 @@ use std::{ }; use text::{BufferId, OffsetRangeExt}; use theme::ActiveTheme; +use toolbar_controls::DiagnosticsToolbarEditor; pub use toolbar_controls::ToolbarControls; use ui::{Icon, IconName, Label, h_flex, prelude::*}; use util::ResultExt; @@ -65,6 +68,7 @@ impl Global for IncludeWarnings {} pub fn init(cx: &mut App) { editor::set_diagnostic_renderer(diagnostic_renderer::DiagnosticRenderer {}, cx); cx.observe_new(ProjectDiagnosticsEditor::register).detach(); + cx.observe_new(BufferDiagnosticsEditor::register).detach(); } pub(crate) struct ProjectDiagnosticsEditor { @@ -79,20 +83,14 @@ pub(crate) struct ProjectDiagnosticsEditor { paths_to_update: BTreeSet, include_warnings: bool, update_excerpts_task: Option>>, - cargo_diagnostics_fetch: CargoDiagnosticsFetchState, diagnostic_summary_update: Task<()>, _subscription: Subscription, } -struct CargoDiagnosticsFetchState { - fetch_task: Option>, - cancel_task: Option>, - diagnostic_sources: Arc>, -} - impl EventEmitter for ProjectDiagnosticsEditor {} const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50); +const DIAGNOSTICS_SUMMARY_UPDATE_DELAY: Duration = Duration::from_millis(30); impl Render for ProjectDiagnosticsEditor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -102,43 +100,44 @@ impl Render for ProjectDiagnosticsEditor { 0 }; - let child = if warning_count + self.summary.error_count == 0 { - let label = if self.summary.warning_count == 0 { - SharedString::new_static("No problems in workspace") + let child = + if warning_count + self.summary.error_count == 0 && self.editor.read(cx).is_empty(cx) { + let label = if self.summary.warning_count == 0 { + SharedString::new_static("No problems in workspace") + } else { + SharedString::new_static("No errors in workspace") + }; + v_flex() + .key_context("EmptyPane") + .size_full() + .gap_1() + .justify_center() + .items_center() + .text_center() + .bg(cx.theme().colors().editor_background) + .child(Label::new(label).color(Color::Muted)) + .when(self.summary.warning_count > 0, |this| { + let plural_suffix = if self.summary.warning_count > 1 { + "s" + } else { + "" + }; + let label = format!( + "Show {} warning{}", + self.summary.warning_count, plural_suffix + ); + this.child( + Button::new("diagnostics-show-warning-label", label).on_click( + cx.listener(|this, _, window, cx| { + this.toggle_warnings(&Default::default(), window, cx); + cx.notify(); + }), + ), + ) + }) } else { - SharedString::new_static("No errors in workspace") + div().size_full().child(self.editor.clone()) }; - v_flex() - .key_context("EmptyPane") - .size_full() - .gap_1() - .justify_center() - .items_center() - .text_center() - .bg(cx.theme().colors().editor_background) - .child(Label::new(label).color(Color::Muted)) - .when(self.summary.warning_count > 0, |this| { - let plural_suffix = if self.summary.warning_count > 1 { - "s" - } else { - "" - }; - let label = format!( - "Show {} warning{}", - self.summary.warning_count, plural_suffix - ); - this.child( - Button::new("diagnostics-show-warning-label", label).on_click(cx.listener( - |this, _, window, cx| { - this.toggle_warnings(&Default::default(), window, cx); - cx.notify(); - }, - )), - ) - }) - } else { - div().size_full().child(self.editor.clone()) - }; div() .key_context("Diagnostics") @@ -151,7 +150,7 @@ impl Render for ProjectDiagnosticsEditor { } impl ProjectDiagnosticsEditor { - fn register( + pub fn register( workspace: &mut Workspace, _window: Option<&mut Window>, _: &mut Context, @@ -167,7 +166,7 @@ impl ProjectDiagnosticsEditor { cx: &mut Context, ) -> Self { let project_event_subscription = - cx.subscribe_in(&project_handle, window, |this, project, event, window, cx| match event { + cx.subscribe_in(&project_handle, window, |this, _project, event, window, cx| match event { project::Event::DiskBasedDiagnosticsStarted { .. } => { cx.notify(); } @@ -180,13 +179,12 @@ impl ProjectDiagnosticsEditor { paths, } => { this.paths_to_update.extend(paths.clone()); - let project = project.clone(); this.diagnostic_summary_update = cx.spawn(async move |this, cx| { cx.background_executor() - .timer(Duration::from_millis(30)) + .timer(DIAGNOSTICS_SUMMARY_UPDATE_DELAY) .await; this.update(cx, |this, cx| { - this.summary = project.read(cx).diagnostic_summary(false, cx); + this.update_diagnostic_summary(cx); }) .log_err(); }); @@ -241,6 +239,7 @@ impl ProjectDiagnosticsEditor { } } EditorEvent::Blurred => this.update_stale_excerpts(window, cx), + EditorEvent::Saved => this.update_stale_excerpts(window, cx), _ => {} } }, @@ -260,11 +259,7 @@ impl ProjectDiagnosticsEditor { ) }); this.diagnostics.clear(); - this.update_all_diagnostics(false, window, cx); - }) - .detach(); - cx.observe_release(&cx.entity(), |editor, _, cx| { - editor.stop_cargo_diagnostics_fetch(cx); + this.update_all_excerpts(window, cx); }) .detach(); @@ -281,20 +276,15 @@ impl ProjectDiagnosticsEditor { editor, paths_to_update: Default::default(), update_excerpts_task: None, - cargo_diagnostics_fetch: CargoDiagnosticsFetchState { - fetch_task: None, - cancel_task: None, - diagnostic_sources: Arc::new(Vec::new()), - }, diagnostic_summary_update: Task::ready(()), _subscription: project_event_subscription, }; - this.update_all_diagnostics(true, window, cx); + this.update_all_excerpts(window, cx); this } fn update_stale_excerpts(&mut self, window: &mut Window, cx: &mut Context) { - if self.update_excerpts_task.is_some() { + if self.update_excerpts_task.is_some() || self.multibuffer.read(cx).is_dirty(cx) { return; } @@ -341,6 +331,7 @@ impl ProjectDiagnosticsEditor { let is_active = workspace .active_item(cx) .is_some_and(|item| item.item_id() == existing.item_id()); + workspace.activate_item(&existing, true, !is_active, window, cx); } else { let workspace_handle = cx.entity().downgrade(); @@ -373,22 +364,10 @@ impl ProjectDiagnosticsEditor { window: &mut Window, cx: &mut Context, ) { - let fetch_cargo_diagnostics = ProjectSettings::get_global(cx) - .diagnostics - .fetch_cargo_diagnostics(); - - if fetch_cargo_diagnostics { - if self.cargo_diagnostics_fetch.fetch_task.is_some() { - self.stop_cargo_diagnostics_fetch(cx); - } else { - self.update_all_diagnostics(false, window, cx); - } + if self.update_excerpts_task.is_some() { + self.update_excerpts_task = None; } else { - if self.update_excerpts_task.is_some() { - self.update_excerpts_task = None; - } else { - self.update_all_diagnostics(false, window, cx); - } + self.update_all_excerpts(window, cx); } cx.notify(); } @@ -406,100 +385,36 @@ impl ProjectDiagnosticsEditor { } } - fn update_all_diagnostics( - &mut self, - first_launch: bool, - window: &mut Window, - cx: &mut Context, - ) { - let cargo_diagnostics_sources = self.cargo_diagnostics_sources(cx); - if cargo_diagnostics_sources.is_empty() { - self.update_all_excerpts(window, cx); - } else if first_launch && !self.summary.is_empty() { - self.update_all_excerpts(window, cx); - } else { - self.fetch_cargo_diagnostics(Arc::new(cargo_diagnostics_sources), cx); - } - } - - fn fetch_cargo_diagnostics( - &mut self, - diagnostics_sources: Arc>, - cx: &mut Context, - ) { - let project = self.project.clone(); - self.cargo_diagnostics_fetch.cancel_task = None; - self.cargo_diagnostics_fetch.fetch_task = None; - self.cargo_diagnostics_fetch.diagnostic_sources = diagnostics_sources.clone(); - if self.cargo_diagnostics_fetch.diagnostic_sources.is_empty() { - return; - } - - self.cargo_diagnostics_fetch.fetch_task = Some(cx.spawn(async move |editor, cx| { - let mut fetch_tasks = Vec::new(); - for buffer_path in diagnostics_sources.iter().cloned() { - if cx - .update(|cx| { - fetch_tasks.push(run_flycheck(project.clone(), buffer_path, cx)); - }) - .is_err() - { - break; - } - } - - let _ = join_all(fetch_tasks).await; - editor - .update(cx, |editor, _| { - editor.cargo_diagnostics_fetch.fetch_task = None; - }) - .ok(); - })); - } - - fn stop_cargo_diagnostics_fetch(&mut self, cx: &mut App) { - self.cargo_diagnostics_fetch.fetch_task = None; - let mut cancel_gasks = Vec::new(); - for buffer_path in std::mem::take(&mut self.cargo_diagnostics_fetch.diagnostic_sources) - .iter() - .cloned() - { - cancel_gasks.push(cancel_flycheck(self.project.clone(), buffer_path, cx)); - } - - self.cargo_diagnostics_fetch.cancel_task = Some(cx.background_spawn(async move { - let _ = join_all(cancel_gasks).await; - log::info!("Finished fetching cargo diagnostics"); - })); - } - /// Enqueue an update of all excerpts. Updates all paths that either /// currently have diagnostics or are currently present in this view. fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context) { self.project.update(cx, |project, cx| { - let mut paths = project + let mut project_paths = project .diagnostic_summaries(false, cx) - .map(|(path, _, _)| path) + .map(|(project_path, _, _)| project_path) .collect::>(); + self.multibuffer.update(cx, |multibuffer, cx| { for buffer in multibuffer.all_buffers() { if let Some(file) = buffer.read(cx).file() { - paths.insert(ProjectPath { + project_paths.insert(ProjectPath { path: file.path().clone(), worktree_id: file.worktree_id(cx), }); } } }); - self.paths_to_update = paths; + + self.paths_to_update = project_paths; }); + self.update_stale_excerpts(window, cx); } fn diagnostics_are_unchanged( &self, - existing: &Vec>, - new: &Vec>, + existing: &[DiagnosticEntry], + new: &[DiagnosticEntryRef<'_, text::Anchor>], snapshot: &BufferSnapshot, ) -> bool { if existing.len() != new.len() { @@ -522,27 +437,35 @@ impl ProjectDiagnosticsEditor { let was_empty = self.multibuffer.read(cx).is_empty(); let buffer_snapshot = buffer.read(cx).snapshot(); let buffer_id = buffer_snapshot.remote_id(); + let max_severity = if self.include_warnings { lsp::DiagnosticSeverity::WARNING } else { lsp::DiagnosticSeverity::ERROR }; - cx.spawn_in(window, async move |this, mut cx| { + cx.spawn_in(window, async move |this, cx| { let diagnostics = buffer_snapshot .diagnostics_in_range::<_, text::Anchor>( Point::zero()..buffer_snapshot.max_point(), false, ) .collect::>(); + let unchanged = this.update(cx, |this, _| { if this.diagnostics.get(&buffer_id).is_some_and(|existing| { this.diagnostics_are_unchanged(existing, &diagnostics, &buffer_snapshot) }) { return true; } - this.diagnostics.insert(buffer_id, diagnostics.clone()); - return false; + this.diagnostics.insert( + buffer_id, + diagnostics + .iter() + .map(DiagnosticEntryRef::to_owned) + .collect(), + ); + false })?; if unchanged { return Ok(()); @@ -553,7 +476,7 @@ impl ProjectDiagnosticsEditor { grouped .entry(entry.diagnostic.group_id) .or_default() - .push(DiagnosticEntry { + .push(DiagnosticEntryRef { range: entry.range.to_point(&buffer_snapshot), diagnostic: entry.diagnostic, }) @@ -569,7 +492,7 @@ impl ProjectDiagnosticsEditor { crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( group, buffer_snapshot.remote_id(), - Some(this.clone()), + Some(Arc::new(this.clone())), cx, ) })?; @@ -590,14 +513,16 @@ impl ProjectDiagnosticsEditor { } let mut excerpt_ranges: Vec> = Vec::new(); + let context_lines = cx.update(|_, cx| multibuffer_context_lines(cx))?; for b in blocks.iter() { let excerpt_range = context_range_for_entry( b.initial_range.clone(), - DEFAULT_MULTIBUFFER_CONTEXT, + context_lines, buffer_snapshot.clone(), - &mut cx, + cx, ) .await; + let i = excerpt_ranges .binary_search_by(|probe| { probe @@ -639,17 +564,15 @@ impl ProjectDiagnosticsEditor { #[cfg(test)] let cloned_blocks = blocks.clone(); - if was_empty { - if let Some(anchor_range) = anchor_ranges.first() { - let range_to_select = anchor_range.start..anchor_range.start; - this.editor.update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |s| { - s.select_anchor_ranges([range_to_select]); - }) - }); - if this.focus_handle.is_focused(window) { - this.editor.read(cx).focus_handle(cx).focus(window); - } + if was_empty && let Some(anchor_range) = anchor_ranges.first() { + let range_to_select = anchor_range.start..anchor_range.start; + this.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_anchor_ranges([range_to_select]); + }) + }); + if this.focus_handle.is_focused(window) { + this.editor.read(cx).focus_handle(cx).focus(window); } } @@ -669,6 +592,7 @@ impl ProjectDiagnosticsEditor { priority: 1, } }); + let block_ids = this.editor.update(cx, |editor, cx| { editor.display_map.update(cx, |display_map, cx| { display_map.insert_blocks(editor_blocks, cx) @@ -700,28 +624,8 @@ impl ProjectDiagnosticsEditor { }) } - pub fn cargo_diagnostics_sources(&self, cx: &App) -> Vec { - let fetch_cargo_diagnostics = ProjectSettings::get_global(cx) - .diagnostics - .fetch_cargo_diagnostics(); - if !fetch_cargo_diagnostics { - return Vec::new(); - } - self.project - .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - let _cargo_toml_entry = worktree.read(cx).entry_for_path("Cargo.toml")?; - let rust_file_entry = worktree.read(cx).entries(false, 0).find(|entry| { - entry - .path - .extension() - .and_then(|extension| extension.to_str()) - == Some("rs") - })?; - self.project.read(cx).path_for_entry(rust_file_entry.id, cx) - }) - .collect() + fn update_diagnostic_summary(&mut self, cx: &mut Context) { + self.summary = self.project.read(cx).diagnostic_summary(false, cx); } } @@ -812,10 +716,6 @@ impl Item for ProjectDiagnosticsEditor { self.editor.for_each_project_item(cx, f) } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn set_nav_history( &mut self, nav_history: ItemNavHistory, @@ -827,16 +727,20 @@ impl Item for ProjectDiagnosticsEditor { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { ProjectDiagnosticsEditor::new( self.include_warnings, self.project.clone(), @@ -844,7 +748,7 @@ impl Item for ProjectDiagnosticsEditor { window, cx, ) - })) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -931,6 +835,68 @@ impl Item for ProjectDiagnosticsEditor { } } +impl DiagnosticsToolbarEditor for WeakEntity { + fn include_warnings(&self, cx: &App) -> bool { + self.read_with(cx, |project_diagnostics_editor, _cx| { + project_diagnostics_editor.include_warnings + }) + .unwrap_or(false) + } + + fn has_stale_excerpts(&self, cx: &App) -> bool { + self.read_with(cx, |project_diagnostics_editor, _cx| { + !project_diagnostics_editor.paths_to_update.is_empty() + }) + .unwrap_or(false) + } + + fn is_updating(&self, cx: &App) -> bool { + self.read_with(cx, |project_diagnostics_editor, cx| { + project_diagnostics_editor.update_excerpts_task.is_some() + || project_diagnostics_editor + .project + .read(cx) + .language_servers_running_disk_based_diagnostics(cx) + .next() + .is_some() + }) + .unwrap_or(false) + } + + fn stop_updating(&self, cx: &mut App) { + let _ = self.update(cx, |project_diagnostics_editor, cx| { + project_diagnostics_editor.update_excerpts_task = None; + cx.notify(); + }); + } + + fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) { + let _ = self.update(cx, |project_diagnostics_editor, cx| { + project_diagnostics_editor.update_all_excerpts(window, cx); + }); + } + + fn toggle_warnings(&self, window: &mut Window, cx: &mut App) { + let _ = self.update(cx, |project_diagnostics_editor, cx| { + project_diagnostics_editor.toggle_warnings(&Default::default(), window, cx); + }); + } + + fn get_diagnostics_for_buffer( + &self, + buffer_id: text::BufferId, + cx: &App, + ) -> Vec> { + self.read_with(cx, |project_diagnostics_editor, _cx| { + project_diagnostics_editor + .diagnostics + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }) + .unwrap_or_default() + } +} const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32; async fn context_range_for_entry( @@ -980,18 +946,16 @@ async fn heuristic_syntactic_expand( // Remove blank lines from start and end if let Some(start_row) = (outline_range.start.row..outline_range.end.row) .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank()) - { - if let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1) + && let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1) .rev() .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank()) - { - let row_count = end_row.saturating_sub(start_row); - if row_count <= max_row_count { - return Some(RangeInclusive::new( - outline_range.start.row, - outline_range.end.row, - )); - } + { + let row_count = end_row.saturating_sub(start_row); + if row_count <= max_row_count { + return Some(RangeInclusive::new( + outline_range.start.row, + outline_range.end.row, + )); } } } @@ -1005,10 +969,11 @@ async fn heuristic_syntactic_expand( let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; let reached_outline_node = cx.background_executor().scoped({ - let node_range = node_range.clone(); - let outline_range = outline_range.clone(); - let ancestor_range = &mut ancestor_range; - |scope| {scope.spawn(async move { + let node_range = node_range.clone(); + let outline_range = outline_range.clone(); + let ancestor_range = &mut ancestor_range; + |scope| { + scope.spawn(async move { // Stop if we've exceeded the row count or reached an outline node. Then, find the interval // of node children which contains the query range. For example, this allows just returning // the header of a declaration rather than the entire declaration. @@ -1020,8 +985,11 @@ async fn heuristic_syntactic_expand( if cursor.goto_first_child() { loop { let child_node = cursor.node(); - let child_range = previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() && child_range.contains(&input_range.start) { + let child_range = + previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() + && child_range.contains(&input_range.start) + { included_child_start = Some(child_range.start); } if child_range.contains(&input_range.end) { @@ -1037,19 +1005,22 @@ async fn heuristic_syntactic_expand( if let Some(start) = included_child_start { let row_count = end.row - start.row; if row_count < max_row_count { - *ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + *ancestor_range = + Some(Some(RangeInclusive::new(start.row, end.row))); return; } } log::info!( - "Expanding to ancestor started on {} node exceeding row limit of {max_row_count}.", + "Expanding to ancestor started on {} node\ + exceeding row limit of {max_row_count}.", node.grammar_name() ); *ancestor_range = Some(None); } }) - }}); + } + }); reached_outline_node.await; if let Some(node) = ancestor_range { return node; diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 5df1b1389701d28477dc1fa1c435f41bd6079ccb..d97a5ab65aab4bb238182040821ecf9fdf828bc3 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -1,9 +1,9 @@ use super::*; use collections::{HashMap, HashSet}; use editor::{ - DisplayPoint, EditorSettings, + DisplayPoint, EditorSettings, Inlay, actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning}, - display_map::{DisplayRow, Inlay}, + display_map::DisplayRow, test::{ editor_content_with_blocks, editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, @@ -24,9 +24,10 @@ use settings::SettingsStore; use std::{ env, path::{Path, PathBuf}, + str::FromStr, }; use unindent::Unindent as _; -use util::{RandomCharIter, path, post_inc}; +use util::{RandomCharIter, path, post_inc, rel_path::rel_path}; #[ctor::ctor] fn init_logger() { @@ -70,7 +71,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); - let uri = lsp::Url::from_file_path(path!("/test/main.rs")).unwrap(); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); // Create some diagnostics lsp_store.update(cx, |lsp_store, cx| { @@ -167,7 +168,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { .update_diagnostics( language_server_id, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/consts.rs")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/consts.rs")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 15), @@ -243,7 +244,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) { .update_diagnostics( language_server_id, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/consts.rs")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/consts.rs")).unwrap(), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( @@ -356,14 +357,14 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(4, 0), lsp::Position::new(4, 4)), severity: Some(lsp::DiagnosticSeverity::WARNING), message: "no method `tset`".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location::new( - lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), lsp::Range::new( lsp::Position::new(0, 9), lsp::Position::new(0, 13), @@ -465,7 +466,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), @@ -509,7 +510,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 1)), severity: Some(lsp::DiagnosticSeverity::ERROR), @@ -552,7 +553,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 0), lsp::Position::new(2, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), @@ -571,7 +572,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.rs")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(), diagnostics: vec![], version: None, }, @@ -608,7 +609,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(3, 0), lsp::Position::new(3, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), @@ -681,7 +682,7 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng Default::default(); for _ in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { // language server completes its diagnostic check 0..=20 if !updated_language_servers.is_empty() => { let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap(); @@ -690,7 +691,7 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng lsp_store.disk_based_diagnostics_finished(server_id, cx) }); - if rng.gen_bool(0.5) { + if rng.random_bool(0.5) { cx.run_until_parked(); } } @@ -700,7 +701,7 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng let (path, server_id, diagnostics) = match current_diagnostics.iter_mut().choose(&mut rng) { // update existing set of diagnostics - Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => { + Some(((path, server_id), diagnostics)) if rng.random_bool(0.5) => { (path.clone(), *server_id, diagnostics) } @@ -708,13 +709,13 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng _ => { let path: PathBuf = format!(path!("/test/{}.rs"), post_inc(&mut next_filename)).into(); - let len = rng.gen_range(128..256); + let len = rng.random_range(128..256); let content = RandomCharIter::new(&mut rng).take(len).collect::(); fs.insert_file(&path, content.into_bytes()).await; let server_id = match language_server_ids.iter().choose(&mut rng) { - Some(server_id) if rng.gen_bool(0.5) => *server_id, + Some(server_id) if rng.random_bool(0.5) => *server_id, _ => { let id = LanguageServerId(language_server_ids.len()); language_server_ids.push(id); @@ -745,8 +746,8 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(&path).unwrap_or_else(|_| { - lsp::Url::parse("file:///test/fallback.rs").unwrap() + uri: lsp::Uri::from_file_path(&path).unwrap_or_else(|_| { + lsp::Uri::from_str("file:///test/fallback.rs").unwrap() }), diagnostics: diagnostics.clone(), version: None, @@ -845,7 +846,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S let mut next_inlay_id = 0; for _ in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { // language server completes its diagnostic check 0..=20 if !updated_language_servers.is_empty() => { let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap(); @@ -854,7 +855,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S lsp_store.disk_based_diagnostics_finished(server_id, cx) }); - if rng.gen_bool(0.5) { + if rng.random_bool(0.5) { cx.run_until_parked(); } } @@ -862,20 +863,20 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S 21..=50 => mutated_diagnostics.update_in(cx, |diagnostics, window, cx| { diagnostics.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); - if snapshot.buffer_snapshot.len() > 0 { - let position = rng.gen_range(0..snapshot.buffer_snapshot.len()); - let position = snapshot.buffer_snapshot.clip_offset(position, Bias::Left); + if !snapshot.buffer_snapshot().is_empty() { + let position = rng.random_range(0..snapshot.buffer_snapshot().len()); + let position = snapshot.buffer_snapshot().clip_offset(position, Bias::Left); log::info!( "adding inlay at {position}/{}: {:?}", - snapshot.buffer_snapshot.len(), - snapshot.buffer_snapshot.text(), + snapshot.buffer_snapshot().len(), + snapshot.buffer_snapshot().text(), ); editor.splice_inlays( &[], vec![Inlay::edit_prediction( post_inc(&mut next_inlay_id), - snapshot.buffer_snapshot.anchor_before(position), + snapshot.buffer_snapshot().anchor_before(position), Rope::from_iter(["Test inlay ", "next_inlay_id"]), )], cx, @@ -889,7 +890,7 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S let (path, server_id, diagnostics) = match current_diagnostics.iter_mut().choose(&mut rng) { // update existing set of diagnostics - Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => { + Some(((path, server_id), diagnostics)) if rng.random_bool(0.5) => { (path.clone(), *server_id, diagnostics) } @@ -897,13 +898,13 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S _ => { let path: PathBuf = format!(path!("/test/{}.rs"), post_inc(&mut next_filename)).into(); - let len = rng.gen_range(128..256); + let len = rng.random_range(128..256); let content = RandomCharIter::new(&mut rng).take(len).collect::(); fs.insert_file(&path, content.into_bytes()).await; let server_id = match language_server_ids.iter().choose(&mut rng) { - Some(server_id) if rng.gen_bool(0.5) => *server_id, + Some(server_id) if rng.random_bool(0.5) => *server_id, _ => { let id = LanguageServerId(language_server_ids.len()); language_server_ids.push(id); @@ -934,8 +935,8 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(&path).unwrap_or_else(|_| { - lsp::Url::parse("file:///test/fallback.rs").unwrap() + uri: lsp::Uri::from_file_path(&path).unwrap_or_else(|_| { + lsp::Uri::from_str("file:///test/fallback.rs").unwrap() }), diagnostics: diagnostics.clone(), version: None, @@ -985,7 +986,7 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext) .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new( @@ -1028,7 +1029,7 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext) .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: Vec::new(), }, @@ -1078,7 +1079,7 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { @@ -1246,7 +1247,7 @@ async fn test_diagnostics_with_links(cx: &mut TestAppContext) { lsp_store.update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 12)), @@ -1299,7 +1300,7 @@ async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) lsp_store.update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/dir/file.rs")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range, @@ -1340,7 +1341,7 @@ async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) range: Some(range), })) }); - let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay + 1); + let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0 + 1); cx.background_executor .advance_clock(Duration::from_millis(delay)); @@ -1376,7 +1377,7 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) { let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); - let uri = lsp::Url::from_file_path(path!("/root/main.js")).unwrap(); + let uri = lsp::Uri::from_file_path(path!("/root/main.js")).unwrap(); // Create diagnostics with code fields lsp_store.update(cx, |lsp_store, cx| { @@ -1460,7 +1461,7 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) { .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { @@ -1566,6 +1567,440 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); } +#[gpui::test] +async fn test_buffer_diagnostics(cx: &mut TestAppContext) { + init_test(cx); + + // We'll be creating two different files, both with diagnostics, so we can + // later verify that, since the `BufferDiagnosticsEditor` only shows + // diagnostics for the provided path, the diagnostics for the other file + // will not be shown, contrary to what happens with + // `ProjectDiagnosticsEditor`. + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + "other.rs": " + fn other() { + let unused = 42; + undefined_function(); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + // Create the diagnostics for `main.rs`. + let language_server_id = LanguageServerId(0); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![ + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: Some(vec![ + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 9))), + message: "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait".to_string() + }, + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(4, 6), lsp::Position::new(4, 7))), + message: "value moved here".to_string() + }, + ]), + ..Default::default() + }, + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: Some(vec![ + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9))), + message: "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait".to_string() + }, + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(3, 6), lsp::Position::new(3, 7))), + message: "value moved here".to_string() + }, + ]), + ..Default::default() + } + ], + version: None + }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap(); + + // Create diagnostics for other.rs to ensure that the file and + // diagnostics are not included in `BufferDiagnosticsEditor` when it is + // deployed for main.rs. + lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/test/other.rs")).unwrap(), + diagnostics: vec![ + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 14)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "unused variable: `unused`".to_string(), + ..Default::default() + }, + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 22)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "cannot find function `undefined_function` in this scope".to_string(), + ..Default::default() + } + ], + version: None + }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap(); + }); + + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + true, + window, + cx, + ) + }); + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + + pretty_assertions::assert_eq!( + editor_content_with_blocks(&editor, cx), + indoc::indoc! { + "§ main.rs + § ----- + fn main() { + let x = vec![]; + § move occurs because `x` has type `Vec`, which does not implement + § the `Copy` trait (back) + let y = vec![]; + § move occurs because `y` has type `Vec`, which does not implement + § the `Copy` trait + a(x); § value moved here + b(y); § value moved here + c(y); + § use of moved value + § value used here after move + d(x); + § use of moved value + § value used here after move + § hint: move occurs because `x` has type `Vec`, which does not + § implement the `Copy` trait + }" + } + ); +} + +#[gpui::test] +async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + let language_server_id = LanguageServerId(0); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![ + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: Some(vec![ + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 9))), + message: "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait".to_string() + }, + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(4, 6), lsp::Position::new(4, 7))), + message: "value moved here".to_string() + }, + ]), + ..Default::default() + }, + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: Some(vec![ + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9))), + message: "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait".to_string() + }, + lsp::DiagnosticRelatedInformation { + location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(3, 6), lsp::Position::new(3, 7))), + message: "value moved here".to_string() + }, + ]), + ..Default::default() + } + ], + version: None + }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap(); + }); + + let include_warnings = false; + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + include_warnings, + window, + cx, + ) + }); + + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + + pretty_assertions::assert_eq!( + editor_content_with_blocks(&editor, cx), + indoc::indoc! { + "§ main.rs + § ----- + fn main() { + let x = vec![]; + § move occurs because `x` has type `Vec`, which does not implement + § the `Copy` trait (back) + let y = vec![]; + a(x); § value moved here + b(y); + c(y); + d(x); + § use of moved value + § value used here after move + § hint: move occurs because `x` has type `Vec`, which does not + § implement the `Copy` trait + }" + } + ); +} + +#[gpui::test] +async fn test_buffer_diagnostics_multiple_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + // Create the diagnostics for `main.rs`. + // Two warnings are being created, one for each language server, in order to + // assert that both warnings are rendered in the editor. + let language_server_id_a = LanguageServerId(0); + let language_server_id_b = LanguageServerId(1); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + language_server_id_a, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: None, + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + + lsp_store + .update_diagnostics( + language_server_id_b, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "use of moved value\nvalue used here after move".to_string(), + related_information: None, + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + true, + window, + cx, + ) + }); + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); + + pretty_assertions::assert_eq!( + editor_content_with_blocks(&editor, cx), + indoc::indoc! { + "§ main.rs + § ----- + a(x); + b(y); + c(y); + § use of moved value + § value used here after move + d(x); + § use of moved value + § value used here after move + }" + } + ); + + buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| { + assert_eq!( + *buffer_diagnostics.summary(), + DiagnosticSummary { + warning_count: 2, + error_count: 0 + } + ); + }) +} + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { zlog::init_test(); @@ -1588,10 +2023,10 @@ fn randomly_update_diagnostics_for_path( next_id: &mut usize, rng: &mut impl Rng, ) { - let mutation_count = rng.gen_range(1..=3); + let mutation_count = rng.random_range(1..=3); for _ in 0..mutation_count { - if rng.gen_bool(0.3) && !diagnostics.is_empty() { - let idx = rng.gen_range(0..diagnostics.len()); + if rng.random_bool(0.3) && !diagnostics.is_empty() { + let idx = rng.random_range(0..diagnostics.len()); log::info!(" removing diagnostic at index {idx}"); diagnostics.remove(idx); } else { @@ -1600,7 +2035,7 @@ fn randomly_update_diagnostics_for_path( let new_diagnostic = random_lsp_diagnostic(rng, fs, path, unique_id); - let ix = rng.gen_range(0..=diagnostics.len()); + let ix = rng.random_range(0..=diagnostics.len()); log::info!( " inserting {} at index {ix}. {},{}..{},{}", new_diagnostic.message, @@ -1637,8 +2072,8 @@ fn random_lsp_diagnostic( let file_content = fs.read_file_sync(path).unwrap(); let file_text = Rope::from(String::from_utf8_lossy(&file_content).as_ref()); - let start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN)); - let end = rng.gen_range(start..file_text.len().saturating_add(ERROR_MARGIN)); + let start = rng.random_range(0..file_text.len().saturating_add(ERROR_MARGIN)); + let end = rng.random_range(start..file_text.len().saturating_add(ERROR_MARGIN)); let start_point = file_text.offset_to_point_utf16(start); let end_point = file_text.offset_to_point_utf16(end); @@ -1648,7 +2083,7 @@ fn random_lsp_diagnostic( lsp::Position::new(end_point.row, end_point.column), ); - let severity = if rng.gen_bool(0.5) { + let severity = if rng.random_bool(0.5) { Some(lsp::DiagnosticSeverity::ERROR) } else { Some(lsp::DiagnosticSeverity::WARNING) @@ -1656,13 +2091,14 @@ fn random_lsp_diagnostic( let message = format!("diagnostic {unique_id}"); - let related_information = if rng.gen_bool(0.3) { - let info_count = rng.gen_range(1..=3); + let related_information = if rng.random_bool(0.3) { + let info_count = rng.random_range(1..=3); let mut related_info = Vec::with_capacity(info_count); for i in 0..info_count { - let info_start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN)); - let info_end = rng.gen_range(info_start..file_text.len().saturating_add(ERROR_MARGIN)); + let info_start = rng.random_range(0..file_text.len().saturating_add(ERROR_MARGIN)); + let info_end = + rng.random_range(info_start..file_text.len().saturating_add(ERROR_MARGIN)); let info_start_point = file_text.offset_to_point_utf16(info_start); let info_end_point = file_text.offset_to_point_utf16(info_end); @@ -1673,7 +2109,7 @@ fn random_lsp_diagnostic( ); related_info.push(lsp::DiagnosticRelatedInformation { - location: lsp::Location::new(lsp::Url::from_file_path(path).unwrap(), info_range), + location: lsp::Location::new(lsp::Uri::from_file_path(path).unwrap(), info_range), message: format!("related info {i} for diagnostic {unique_id}"), }); } diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 7ac6d101f315674cec4fd07f4ad2df0830284124..413bad5c0d696bfcba92a1127789c9e7c31edc30 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -14,12 +14,14 @@ use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle}; use crate::{Deploy, IncludeWarnings, ProjectDiagnosticsEditor}; +/// The status bar item that displays diagnostic counts. pub struct DiagnosticIndicator { summary: project::DiagnosticSummary, - active_editor: Option>, workspace: WeakEntity, current_diagnostic: Option, + active_editor: Option>, _observe_active_editor: Option, + diagnostics_update: Task<()>, diagnostic_summary_update: Task<()>, } @@ -28,66 +30,53 @@ impl Render for DiagnosticIndicator { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let indicator = h_flex().gap_2(); if !ProjectSettings::get_global(cx).diagnostics.button { - return indicator; + return indicator.hidden(); } let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { - (0, 0) => h_flex().map(|this| { - this.child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Default), - ) - }), - (0, warning_count) => h_flex() - .gap_1() - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child(Label::new(warning_count.to_string()).size(LabelSize::Small)), - (error_count, 0) => h_flex() - .gap_1() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error), - ) - .child(Label::new(error_count.to_string()).size(LabelSize::Small)), + (0, 0) => h_flex().child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Default), + ), (error_count, warning_count) => h_flex() .gap_1() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::Small) - .color(Color::Error), - ) - .child(Label::new(error_count.to_string()).size(LabelSize::Small)) - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child(Label::new(warning_count.to_string()).size(LabelSize::Small)), + .when(error_count > 0, |this| { + this.child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new(error_count.to_string()).size(LabelSize::Small)) + }) + .when(warning_count > 0, |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child(Label::new(warning_count.to_string()).size(LabelSize::Small)) + }), }; let status = if let Some(diagnostic) = &self.current_diagnostic { - let message = diagnostic.message.split('\n').next().unwrap().to_string(); + let message = diagnostic + .message + .split_once('\n') + .map_or(&*diagnostic.message, |(first, _)| first); Some( - Button::new("diagnostic_message", message) + Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::for_action( "Next Diagnostic", &editor::actions::GoToDiagnostic::default(), - window, cx, ) }) - .on_click(cx.listener(|this, _, window, cx| { - this.go_to_next_diagnostic(window, cx); - })) - .into_any_element(), + .on_click( + cx.listener(|this, _, window, cx| this.go_to_next_diagnostic(window, cx)), + ), ) } else { None @@ -97,8 +86,8 @@ impl Render for DiagnosticIndicator { .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) - .tooltip(|window, cx| { - Tooltip::for_action("Project Diagnostics", &Deploy, window, cx) + .tooltip(move |_window, cx| { + Tooltip::for_action("Project Diagnostics", &Deploy, cx) }) .on_click(cx.listener(|this, _, window, cx| { if let Some(workspace) = this.workspace.upgrade() { @@ -180,7 +169,10 @@ impl DiagnosticIndicator { fn update(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { let (buffer, cursor_position) = editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); - let cursor_position = editor.selections.newest::(cx).head(); + let cursor_position = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); (buffer, cursor_position) }); let new_diagnostic = buffer @@ -188,7 +180,8 @@ impl DiagnosticIndicator { .filter(|entry| !entry.range.is_empty()) .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) .map(|entry| entry.diagnostic); - if new_diagnostic != self.current_diagnostic { + if new_diagnostic != self.current_diagnostic.as_ref() { + let new_diagnostic = new_diagnostic.cloned(); self.diagnostics_update = cx.spawn_in(window, async move |diagnostics_indicator, cx| { cx.background_executor() diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index e77b80115f2ffe6de512743d3eb00311052d7937..b55fa5783dc96965a7d1ce7f52c5e4336b674ed2 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -1,43 +1,56 @@ -use std::sync::Arc; - -use crate::{ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh}; -use gpui::{Context, Entity, EventEmitter, ParentElement, Render, WeakEntity, Window}; +use crate::{BufferDiagnosticsEditor, ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh}; +use gpui::{Context, EventEmitter, ParentElement, Render, Window}; +use language::DiagnosticEntry; +use text::{Anchor, BufferId}; use ui::prelude::*; use ui::{IconButton, IconButtonShape, IconName, Tooltip}; use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, item::ItemHandle}; pub struct ToolbarControls { - editor: Option>, + editor: Option>, +} + +pub(crate) trait DiagnosticsToolbarEditor: Send + Sync { + /// Informs the toolbar whether warnings are included in the diagnostics. + fn include_warnings(&self, cx: &App) -> bool; + /// Toggles whether warning diagnostics should be displayed by the + /// diagnostics editor. + fn toggle_warnings(&self, window: &mut Window, cx: &mut App); + /// Indicates whether any of the excerpts displayed by the diagnostics + /// editor are stale. + fn has_stale_excerpts(&self, cx: &App) -> bool; + /// Indicates whether the diagnostics editor is currently updating the + /// diagnostics. + fn is_updating(&self, cx: &App) -> bool; + /// Requests that the diagnostics editor stop updating the diagnostics. + fn stop_updating(&self, cx: &mut App); + /// Requests that the diagnostics editor updates the displayed diagnostics + /// with the latest information. + fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App); + /// Returns a list of diagnostics for the provided buffer id. + fn get_diagnostics_for_buffer( + &self, + buffer_id: BufferId, + cx: &App, + ) -> Vec>; } impl Render for ToolbarControls { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut include_warnings = false; let mut has_stale_excerpts = false; + let mut include_warnings = false; let mut is_updating = false; - let cargo_diagnostics_sources = Arc::new(self.diagnostics().map_or(Vec::new(), |editor| { - editor.read(cx).cargo_diagnostics_sources(cx) - })); - let fetch_cargo_diagnostics = !cargo_diagnostics_sources.is_empty(); - if let Some(editor) = self.diagnostics() { - let diagnostics = editor.read(cx); - include_warnings = diagnostics.include_warnings; - has_stale_excerpts = !diagnostics.paths_to_update.is_empty(); - is_updating = if fetch_cargo_diagnostics { - diagnostics.cargo_diagnostics_fetch.fetch_task.is_some() - } else { - diagnostics.update_excerpts_task.is_some() - || diagnostics - .project - .read(cx) - .language_servers_running_disk_based_diagnostics(cx) - .next() - .is_some() - }; + match &self.editor { + Some(editor) => { + include_warnings = editor.include_warnings(cx); + has_stale_excerpts = editor.has_stale_excerpts(cx); + is_updating = editor.is_updating(cx); + } + None => {} } - let tooltip = if include_warnings { + let warning_tooltip = if include_warnings { "Exclude Warnings" } else { "Include Warnings" @@ -62,12 +75,9 @@ impl Render for ToolbarControls { &ToggleDiagnosticsRefresh, )) .on_click(cx.listener(move |toolbar_controls, _, _, cx| { - if let Some(diagnostics) = toolbar_controls.diagnostics() { - diagnostics.update(cx, |diagnostics, cx| { - diagnostics.stop_cargo_diagnostics_fetch(cx); - diagnostics.update_excerpts_task = None; - cx.notify(); - }); + if let Some(editor) = toolbar_controls.editor() { + editor.stop_updating(cx); + cx.notify(); } })), ) @@ -76,26 +86,15 @@ impl Render for ToolbarControls { IconButton::new("refresh-diagnostics", IconName::ArrowCircle) .icon_color(Color::Info) .shape(IconButtonShape::Square) - .disabled(!has_stale_excerpts && !fetch_cargo_diagnostics) + .disabled(!has_stale_excerpts) .tooltip(Tooltip::for_action_title( "Refresh diagnostics", &ToggleDiagnosticsRefresh, )) .on_click(cx.listener({ move |toolbar_controls, _, window, cx| { - if let Some(diagnostics) = toolbar_controls.diagnostics() { - let cargo_diagnostics_sources = - Arc::clone(&cargo_diagnostics_sources); - diagnostics.update(cx, move |diagnostics, cx| { - if fetch_cargo_diagnostics { - diagnostics.fetch_cargo_diagnostics( - cargo_diagnostics_sources, - cx, - ); - } else { - diagnostics.update_all_excerpts(window, cx); - } - }); + if let Some(editor) = toolbar_controls.editor() { + editor.refresh_diagnostics(window, cx) } } })), @@ -106,12 +105,10 @@ impl Render for ToolbarControls { IconButton::new("toggle-warnings", IconName::Warning) .icon_color(warning_color) .shape(IconButtonShape::Square) - .tooltip(Tooltip::text(tooltip)) + .tooltip(Tooltip::text(warning_tooltip)) .on_click(cx.listener(|this, _, window, cx| { - if let Some(editor) = this.diagnostics() { - editor.update(cx, |editor, cx| { - editor.toggle_warnings(&Default::default(), window, cx); - }); + if let Some(editor) = &this.editor { + editor.toggle_warnings(window, cx) } })), ) @@ -129,7 +126,10 @@ impl ToolbarItemView for ToolbarControls { ) -> ToolbarItemLocation { if let Some(pane_item) = active_pane_item.as_ref() { if let Some(editor) = pane_item.downcast::() { - self.editor = Some(editor.downgrade()); + self.editor = Some(Box::new(editor.downgrade())); + ToolbarItemLocation::PrimaryRight + } else if let Some(editor) = pane_item.downcast::() { + self.editor = Some(Box::new(editor.downgrade())); ToolbarItemLocation::PrimaryRight } else { ToolbarItemLocation::Hidden @@ -151,7 +151,7 @@ impl ToolbarControls { ToolbarControls { editor: None } } - fn diagnostics(&self) -> Option> { - self.editor.as_ref()?.upgrade() + fn editor(&self) -> Option<&dyn DiagnosticsToolbarEditor> { + self.editor.as_deref() } } diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index e46ceb18db7e75f0f946da1d112509a18a68d4aa..e71f9ae3f3f6fcff790db27fb1e377f0d1c20e40 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -17,9 +17,10 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true util.workspace = true -workspace-hack.workspace = true zed.workspace = true zlog.workspace = true +task.workspace = true +theme.workspace = true [lints] workspace = true diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 17804b428145ed49d6bb274ab5f13d5b46e5f7f4..b614a8251139413f4b316937db1d4e3c0d551df6 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -9,7 +9,6 @@ use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; use std::process; use std::sync::{LazyLock, OnceLock}; -use util::paths::PathExt; static KEYMAP_MACOS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap") @@ -19,9 +18,13 @@ static KEYMAP_LINUX: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap") }); +static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") +}); + static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); -const FRONT_MATTER_COMMENT: &'static str = ""; +const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { zlog::init(); @@ -50,9 +53,20 @@ fn main() -> Result<()> { #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum PreprocessorError { - ActionNotFound { action_name: String }, - DeprecatedActionUsed { used: String, should_be: String }, + ActionNotFound { + action_name: String, + }, + DeprecatedActionUsed { + used: String, + should_be: String, + }, InvalidFrontmatterLine(String), + InvalidSettingsJson { + file: std::path::PathBuf, + line: usize, + snippet: String, + error: String, + }, } impl PreprocessorError { @@ -61,14 +75,26 @@ impl PreprocessorError { for alias in action.deprecated_aliases { if alias == &action_name { return PreprocessorError::DeprecatedActionUsed { - used: action_name.clone(), + used: action_name, should_be: action.name.to_string(), }; } } } - PreprocessorError::ActionNotFound { - action_name: action_name.to_string(), + PreprocessorError::ActionNotFound { action_name } + } + + fn new_for_invalid_settings_json( + chapter: &Chapter, + location: usize, + snippet: String, + error: String, + ) -> Self { + PreprocessorError::InvalidSettingsJson { + file: chapter.path.clone().expect("chapter has path"), + line: chapter.content[..location].lines().count() + 1, + snippet, + error, } } } @@ -87,6 +113,21 @@ impl std::fmt::Display for PreprocessorError { "Deprecated action used: {} should be {}", used, should_be ), + PreprocessorError::InvalidSettingsJson { + file, + line, + snippet, + error, + } => { + write!( + f, + "Invalid settings JSON at {}:{}\nError: {}\n\n{}", + file.display(), + line, + error, + snippet + ) + } } } } @@ -99,14 +140,15 @@ fn handle_preprocessing() -> Result<()> { let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?; let mut errors = HashSet::::new(); - handle_frontmatter(&mut book, &mut errors); + template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors); if !errors.is_empty() { - const ANSI_RED: &'static str = "\x1b[31m"; - const ANSI_RESET: &'static str = "\x1b[0m"; + const ANSI_RED: &str = "\x1b[31m"; + const ANSI_RESET: &str = "\x1b[0m"; for error in &errors { eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error); } @@ -129,7 +171,7 @@ fn handle_frontmatter(book: &mut Book, errors: &mut HashSet) let Some((name, value)) = line.split_once(':') else { errors.insert(PreprocessorError::InvalidFrontmatterLine(format!( "{}: {}", - chapter_breadcrumbs(&chapter), + chapter_breadcrumbs(chapter), line ))); continue; @@ -143,15 +185,28 @@ fn handle_frontmatter(book: &mut Book, errors: &mut HashSet) &serde_json::to_string(&metadata).expect("Failed to serialize metadata"), ) }); - match new_content { - Cow::Owned(content) => { - chapter.content = content; - } - Cow::Borrowed(_) => {} + if let Cow::Owned(content) = new_content { + chapter.content = content; + } + }); +} + +fn template_big_table_of_actions(book: &mut Book) { + for_each_chapter_mut(book, |chapter| { + let needle = "{#ACTIONS_TABLE#}"; + if let Some(start) = chapter.content.rfind(needle) { + chapter.content.replace_range( + start..start + needle.len(), + &generate_big_table_of_actions(), + ); } }); } +fn format_binding(binding: String) -> String { + binding.replace("\\", "\\\\") +} + fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); @@ -172,7 +227,10 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSetNo default binding
".to_string(); } - format!("{macos_binding}|{linux_binding}") + let formatted_macos_binding = format_binding(macos_binding); + let formatted_linux_binding = format_binding(linux_binding); + + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -208,6 +266,7 @@ fn find_binding(os: &str, action: &str) -> Option { let keymap = match os { "macos" => &KEYMAP_MACOS, "linux" | "freebsd" => &KEYMAP_LINUX, + "windows" => &KEYMAP_WINDOWS, _ => unreachable!("Not a valid OS: {}", os), }; @@ -223,6 +282,161 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { + fn for_each_labeled_code_block_mut( + book: &mut Book, + errors: &mut HashSet, + f: impl Fn(&str, &str) -> anyhow::Result<()>, + ) { + const TAGGED_JSON_BLOCK_START: &'static str = "```json ["; + const JSON_BLOCK_END: &'static str = "```"; + + for_each_chapter_mut(book, |chapter| { + let mut offset = 0; + while let Some(loc) = chapter.content[offset..].find(TAGGED_JSON_BLOCK_START) { + let loc = loc + offset; + let tag_start = loc + TAGGED_JSON_BLOCK_START.len(); + offset = tag_start; + let Some(tag_end) = chapter.content[tag_start..].find(']') else { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_start].to_string(), + "Unclosed JSON block tag".to_string(), + )); + continue; + }; + let tag_end = tag_end + tag_start; + + let tag = &chapter.content[tag_start..tag_end]; + + if tag.contains('\n') { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_start].to_string(), + "Unclosed JSON block tag".to_string(), + )); + continue; + } + + let snippet_start = tag_end + 1; + offset = snippet_start; + + let Some(snippet_end) = chapter.content[snippet_start..].find(JSON_BLOCK_END) + else { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..tag_end + 1].to_string(), + "Missing closing code block".to_string(), + )); + continue; + }; + let snippet_end = snippet_start + snippet_end; + let snippet_json = &chapter.content[snippet_start..snippet_end]; + offset = snippet_end + 3; + + if let Err(err) = f(tag, snippet_json) { + errors.insert(PreprocessorError::new_for_invalid_settings_json( + chapter, + loc, + chapter.content[loc..snippet_end + 3].to_string(), + err.to_string(), + )); + continue; + }; + let tag_range_complete = tag_start - 1..tag_end + 1; + offset -= tag_range_complete.len(); + chapter.content.replace_range(tag_range_complete, ""); + } + }); + } + + for_each_labeled_code_block_mut(book, errors, |label, snippet_json| { + let mut snippet_json_fixed = snippet_json + .to_string() + .replace("\n>", "\n") + .trim() + .to_string(); + while snippet_json_fixed.starts_with("//") { + if let Some(line_end) = snippet_json_fixed.find('\n') { + snippet_json_fixed.replace_range(0..line_end, ""); + snippet_json_fixed = snippet_json_fixed.trim().to_string(); + } + } + match label { + "settings" => { + if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') { + snippet_json_fixed.insert(0, '{'); + snippet_json_fixed.push_str("\n}"); + } + settings::parse_json_with_comments::( + &snippet_json_fixed, + )?; + } + "keymap" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + let keymap = settings::KeymapFile::parse(&snippet_json_fixed) + .context("Failed to parse keymap JSON")?; + for section in keymap.sections() { + for (keystrokes, action) in section.bindings() { + keystrokes + .split_whitespace() + .map(|source| gpui::Keystroke::parse(source)) + .collect::, _>>() + .context("Failed to parse keystroke")?; + if let Some((action_name, _)) = settings::KeymapFile::parse_action(action) + .map_err(|err| anyhow::format_err!(err)) + .context("Failed to parse action")? + { + anyhow::ensure!( + find_action_by_name(action_name).is_some(), + "Action not found: {}", + action_name + ); + } + } + } + } + "debug" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + settings::parse_json_with_comments::(&snippet_json_fixed)?; + } + "tasks" => { + if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') { + snippet_json_fixed.insert(0, '['); + snippet_json_fixed.push_str("\n]"); + } + + settings::parse_json_with_comments::(&snippet_json_fixed)?; + } + "icon-theme" => { + if !snippet_json_fixed.starts_with('{') || !snippet_json_fixed.ends_with('}') { + snippet_json_fixed.insert(0, '{'); + snippet_json_fixed.push_str("\n}"); + } + + settings::parse_json_with_comments::( + &snippet_json_fixed, + )?; + } + label => { + anyhow::bail!("Unexpected JSON code block tag: {}", label) + } + }; + Ok(()) + }); +} + /// Removes any configurable options from the stringified action if existing, /// ensuring that only the actual action name is returned. If the action consists /// only of a string and nothing else, the string is returned as-is. @@ -282,6 +496,7 @@ struct ActionDef { name: &'static str, human_name: String, deprecated_aliases: &'static [&'static str], + docs: Option<&'static str>, } fn dump_all_gpui_actions() -> Vec { @@ -290,12 +505,13 @@ fn dump_all_gpui_actions() -> Vec { name: action.name, human_name: command_palette::humanize_action_name(action.name), deprecated_aliases: action.deprecated_aliases, + docs: action.documentation, }) .collect::>(); actions.sort_by_key(|a| a.name); - return actions; + actions } fn handle_postprocessing() -> Result<()> { @@ -320,6 +536,7 @@ fn handle_postprocessing() -> Result<()> { .as_str() .expect("Default title not a string") .to_string(); + let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default(); output.insert("html".to_string(), zed_html); mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?; @@ -330,7 +547,7 @@ fn handle_postprocessing() -> Result<()> { let mut queue = Vec::with_capacity(64); queue.push(root_dir.clone()); while let Some(dir) = queue.pop() { - for entry in std::fs::read_dir(&dir).context(dir.to_sanitized_string())? { + for entry in std::fs::read_dir(&dir).context("failed to read docs dir")? { let Ok(entry) = entry else { continue; }; @@ -388,6 +605,7 @@ fn handle_postprocessing() -> Result<()> { let meta_title = format!("{} | {}", page_title, meta_title); zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir)); let contents = contents.replace("#description#", meta_description); + let contents = contents.replace("#amplitude_key#", &litude_key); let contents = title_regex() .replace(&contents, |_: ®ex::Captures| { format!("{}", meta_title) @@ -402,20 +620,20 @@ fn handle_postprocessing() -> Result<()> { path: &'a std::path::PathBuf, root: &'a std::path::PathBuf, ) -> &'a std::path::Path { - &path.strip_prefix(&root).unwrap_or(&path) + path.strip_prefix(&root).unwrap_or(path) } fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String { let title_tag_contents = &title_regex() - .captures(&contents) + .captures(contents) .with_context(|| format!("Failed to find title in {:?}", pretty_path)) .expect("Page has element")[1]; - let title = title_tag_contents + + title_tag_contents .trim() .strip_suffix("- Zed") .unwrap_or(title_tag_contents) .trim() - .to_string(); - title + .to_string() } } @@ -423,3 +641,54 @@ fn title_regex() -> &'static Regex { static TITLE_REGEX: OnceLock<Regex> = OnceLock::new(); TITLE_REGEX.get_or_init(|| Regex::new(r"<title>\s*(.*?)\s*").unwrap()) } + +fn generate_big_table_of_actions() -> String { + let actions = &*ALL_ACTIONS; + let mut output = String::new(); + + let mut actions_sorted = actions.iter().collect::>(); + actions_sorted.sort_by_key(|a| a.name); + + // Start the definition list with custom styling for better spacing + output.push_str("
\n"); + + for action in actions_sorted.into_iter() { + // Add the humanized action name as the term with margin + output.push_str( + "
", + ); + output.push_str(&action.human_name); + output.push_str("
\n"); + + // Add the definition with keymap name and description + output.push_str("
\n"); + + // Add the description, escaping HTML if needed + if let Some(description) = action.docs { + output.push_str( + &description + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"), + ); + output.push_str("
\n"); + } + output.push_str("Keymap Name: "); + output.push_str(action.name); + output.push_str("
\n"); + if !action.deprecated_aliases.is_empty() { + output.push_str("Deprecated Alias(es): "); + for alias in action.deprecated_aliases.iter() { + output.push_str(""); + output.push_str(alias); + output.push_str(", "); + } + } + output.push_str("\n
\n"); + } + + // Close the definition list + output.push_str("
\n"); + + output +} diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 81c1e5dec20ce9032c4e1422f330b11da56fabe7..2c6888d14be49c857e7805fb63f9f9335ac32c8e 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -15,5 +15,3 @@ path = "src/edit_prediction.rs" client.workspace = true gpui.workspace = true language.workspace = true -project.workspace = true -workspace-hack.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index c8502f75de5adac0a1bfdcb8cd8fe4444bb70f84..22cb1047d1dda93b639990e549f9b76b3ff385f5 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -2,8 +2,7 @@ use std::ops::Range; use client::EditPredictionUsage; use gpui::{App, Context, Entity, SharedString}; -use language::Buffer; -use project::Project; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt}; // TODO: Find a better home for `Direction`. // @@ -16,11 +15,19 @@ pub enum Direction { } #[derive(Clone)] -pub struct EditPrediction { - /// The ID of the completion, if it has one. - pub id: Option, - pub edits: Vec<(Range, String)>, - pub edit_preview: Option, +pub enum EditPrediction { + /// Edits within the buffer that requested the prediction + Local { + id: Option, + edits: Vec<(Range, String)>, + edit_preview: Option, + }, + /// Jump to a different file from the one that requested the prediction + Jump { + id: Option, + snapshot: language::BufferSnapshot, + target: language::Anchor, + }, } pub enum DataCollectionState { @@ -34,7 +41,7 @@ pub enum DataCollectionState { impl DataCollectionState { pub fn is_supported(&self) -> bool { - !matches!(self, DataCollectionState::Unsupported { .. }) + !matches!(self, DataCollectionState::Unsupported) } pub fn is_enabled(&self) -> bool { @@ -83,15 +90,11 @@ pub trait EditPredictionProvider: 'static + Sized { fn is_refreshing(&self) -> bool; fn refresh( &mut self, - project: Option>, buffer: Entity, cursor_position: language::Anchor, debounce: bool, cx: &mut Context, ); - fn needs_terms_acceptance(&self, _cx: &App) -> bool { - false - } fn cycle( &mut self, buffer: Entity, @@ -124,11 +127,9 @@ pub trait EditPredictionProviderHandle { fn data_collection_state(&self, cx: &App) -> DataCollectionState; fn usage(&self, cx: &App) -> Option; fn toggle_data_collection(&self, cx: &mut App); - fn needs_terms_acceptance(&self, cx: &App) -> bool; fn is_refreshing(&self, cx: &App) -> bool; fn refresh( &self, - project: Option>, buffer: Entity, cursor_position: language::Anchor, debounce: bool, @@ -196,24 +197,19 @@ where self.read(cx).is_enabled(buffer, cursor_position, cx) } - fn needs_terms_acceptance(&self, cx: &App) -> bool { - self.read(cx).needs_terms_acceptance(cx) - } - fn is_refreshing(&self, cx: &App) -> bool { self.read(cx).is_refreshing() } fn refresh( &self, - project: Option>, buffer: Entity, cursor_position: language::Anchor, debounce: bool, cx: &mut App, ) { self.update(cx, |this, cx| { - this.refresh(project, buffer, cursor_position, debounce, cx) + this.refresh(buffer, cursor_position, debounce, cx) }) } @@ -246,3 +242,51 @@ where self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) } } + +/// Returns edits updated based on user edits since the old snapshot. None is returned if any user +/// edit is not a prefix of a predicted insertion. +pub fn interpolate_edits( + old_snapshot: &BufferSnapshot, + new_snapshot: &BufferSnapshot, + current_edits: &[(Range, String)], +) -> Option, String)>> { + let mut edits = Vec::new(); + + let mut model_edits = current_edits.iter().peekable(); + for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { + while let Some((model_old_range, _)) = model_edits.peek() { + let model_old_range = model_old_range.to_offset(old_snapshot); + if model_old_range.end < user_edit.old.start { + let (model_old_range, model_new_text) = model_edits.next().unwrap(); + edits.push((model_old_range.clone(), model_new_text.clone())); + } else { + break; + } + } + + if let Some((model_old_range, model_new_text)) = model_edits.peek() { + let model_old_offset_range = model_old_range.to_offset(old_snapshot); + if user_edit.old == model_old_offset_range { + let user_new_text = new_snapshot + .text_for_range(user_edit.new.clone()) + .collect::(); + + if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { + if !model_suffix.is_empty() { + let anchor = old_snapshot.anchor_after(user_edit.old.end); + edits.push((anchor..anchor, model_suffix.to_string())); + } + + model_edits.next(); + continue; + } + } + } + + return None; + } + + edits.extend(model_edits.cloned()); + + if edits.is_empty() { None } else { Some(edits) } +} diff --git a/crates/edit_prediction_button/Cargo.toml b/crates/edit_prediction_button/Cargo.toml index 07447280fa0d3b8041f1d35eba9c368288322c25..189db7f7bac3eaea36a154424c4e7702f1387d24 100644 --- a/crates/edit_prediction_button/Cargo.toml +++ b/crates/edit_prediction_button/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true client.workspace = true cloud_llm_client.workspace = true +codestral.workspace = true copilot.workspace = true editor.workspace = true feature_flags.workspace = true @@ -31,7 +32,6 @@ settings.workspace = true supermaven.workspace = true telemetry.workspace = true ui.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true zeta.workspace = true diff --git a/crates/edit_prediction_button/src/edit_prediction_button.rs b/crates/edit_prediction_button/src/edit_prediction_button.rs index 4632a03daf53460cc0f674c0bca425f6bc689f24..70c861ab1112630c2e3293cb54a4e96c6754b3bd 100644 --- a/crates/edit_prediction_button/src/edit_prediction_button.rs +++ b/crates/edit_prediction_button/src/edit_prediction_button.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use client::{UserStore, zed_urls}; +use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; +use codestral::CodestralCompletionProvider; use copilot::{Copilot, Status}; use editor::{Editor, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll}; use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag}; @@ -24,8 +25,8 @@ use std::{ }; use supermaven::{AccountStatus, Supermaven}; use ui::{ - Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape, - Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*, + Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton, + IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*, }; use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, @@ -71,17 +72,17 @@ impl Render for EditPredictionButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { // Return empty div if AI is disabled if DisableAiSettings::get_global(cx).disable_ai { - return div(); + return div().hidden(); } let all_language_settings = all_language_settings(None, cx); match all_language_settings.edit_predictions.provider { - EditPredictionProvider::None => div(), + EditPredictionProvider::None => div().hidden(), EditPredictionProvider::Copilot => { let Some(copilot) = Copilot::global(cx) else { - return div(); + return div().hidden(); }; let status = copilot.read(cx).status(); @@ -122,8 +123,8 @@ impl Render for EditPredictionButton { }); } })) - .tooltip(|window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) + .tooltip(|_window, cx| { + Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx) }), ); } @@ -132,7 +133,8 @@ impl Render for EditPredictionButton { div().child( PopoverMenu::new("copilot") .menu(move |window, cx| { - Some(match status { + let current_status = Copilot::global(cx)?.read(cx).status(); + Some(match current_status { Status::Authorized => this.update(cx, |this, cx| { this.build_copilot_context_menu(window, cx) }), @@ -144,9 +146,7 @@ impl Render for EditPredictionButton { .anchor(Corner::BottomRight) .trigger_with_tooltip( IconButton::new("copilot-icon", icon), - |window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx) - }, + |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx), ) .with_handle(self.popover_menu_handle.clone()), ) @@ -168,7 +168,7 @@ impl Render for EditPredictionButton { let account_status = agent.account_status.clone(); match account_status { AccountStatus::NeedsActivation { activate_url } => { - SupermavenButtonStatus::NeedsActivation(activate_url.clone()) + SupermavenButtonStatus::NeedsActivation(activate_url) } AccountStatus::Unknown => SupermavenButtonStatus::Initializing, AccountStatus::Ready => SupermavenButtonStatus::Ready, @@ -185,13 +185,14 @@ impl Render for EditPredictionButton { let this = cx.entity(); let fs = self.fs.clone(); - return div().child( + div().child( PopoverMenu::new("supermaven") .menu(move |window, cx| match &status { SupermavenButtonStatus::NeedsActivation(activate_url) => { Some(ContextMenu::build(window, cx, |menu, _, _| { let fs = fs.clone(); let activate_url = activate_url.clone(); + menu.entry("Sign In", None, move |_, cx| { cx.open_url(activate_url.as_str()) }) @@ -218,19 +219,78 @@ impl Render for EditPredictionButton { IconButton::new("supermaven-icon", icon), move |window, cx| { if has_menu { - Tooltip::for_action( - tooltip_text.clone(), - &ToggleMenu, - window, - cx, - ) + Tooltip::for_action(tooltip_text.clone(), &ToggleMenu, cx) } else { Tooltip::text(tooltip_text.clone())(window, cx) } }, ) .with_handle(self.popover_menu_handle.clone()), - ); + ) + } + + EditPredictionProvider::Codestral => { + let enabled = self.editor_enabled.unwrap_or(true); + let has_api_key = CodestralCompletionProvider::has_api_key(cx); + let fs = self.fs.clone(); + let this = cx.entity(); + + div().child( + PopoverMenu::new("codestral") + .menu(move |window, cx| { + if has_api_key { + Some(this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + })) + } else { + Some(ContextMenu::build(window, cx, |menu, _, _| { + let fs = fs.clone(); + + menu.entry( + "Configure Codestral API Key", + None, + move |window, cx| { + window.dispatch_action( + zed_actions::agent::OpenSettings.boxed_clone(), + cx, + ); + }, + ) + .separator() + .entry( + "Use Zed AI instead", + None, + move |_, cx| { + set_completion_provider( + fs.clone(), + cx, + EditPredictionProvider::Zed, + ) + }, + ) + })) + } + }) + .anchor(Corner::BottomRight) + .trigger_with_tooltip( + IconButton::new("codestral-icon", IconName::AiMistral) + .shape(IconButtonShape::Square) + .when(!has_api_key, |this| { + this.indicator(Indicator::dot().color(Color::Error)) + .indicator_border_color(Some( + cx.theme().colors().status_bar_background, + )) + }) + .when(has_api_key && !enabled, |this| { + this.indicator(Indicator::dot().color(Color::Ignored)) + .indicator_border_color(Some( + cx.theme().colors().status_bar_background, + )) + }), + move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx), + ) + .with_handle(self.popover_menu_handle.clone()), + ) } EditPredictionProvider::Zed => { @@ -242,13 +302,9 @@ impl Render for EditPredictionButton { IconName::ZedPredictDisabled }; - if zeta::should_show_upsell_modal(&self.user_store, cx) { + if zeta::should_show_upsell_modal() { let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { - if self.user_store.read(cx).has_accepted_terms_of_service() { - "Choose a Plan" - } else { - "Accept the Terms of Service" - } + "Choose a Plan" } else { "Sign In" }; @@ -258,14 +314,8 @@ impl Render for EditPredictionButton { .shape(IconButtonShape::Square) .indicator(Indicator::dot().color(Color::Muted)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) - .tooltip(move |window, cx| { - Tooltip::with_meta( - "Edit Predictions", - None, - tooltip_meta, - window, - cx, - ) + .tooltip(move |_window, cx| { + Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx) }) .on_click(cx.listener(move |_, _, window, cx| { telemetry::event!( @@ -306,16 +356,15 @@ impl Render for EditPredictionButton { }, ) .when(!self.popover_menu_handle.is_deployed(), |element| { - element.tooltip(move |window, cx| { + element.tooltip(move |_window, cx| { if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx) + Tooltip::for_action("Edit Prediction", &ToggleMenu, cx) } else { Tooltip::with_meta( "Edit Prediction", Some(&ToggleMenu), "Hidden For This File", - window, cx, ) } @@ -324,7 +373,6 @@ impl Render for EditPredictionButton { "Edit Prediction", Some(&ToggleMenu), "Disabled For This File", - window, cx, ) } @@ -343,7 +391,7 @@ impl Render for EditPredictionButton { let is_refreshing = self .edit_prediction_provider .as_ref() - .map_or(false, |provider| provider.is_refreshing(cx)); + .is_some_and(|provider| provider.is_refreshing(cx)); if is_refreshing { popover_menu = popover_menu.trigger( @@ -370,6 +418,7 @@ impl EditPredictionButton { fs: Arc, user_store: Entity, popover_menu_handle: PopoverMenuHandle, + client: Arc, cx: &mut Context, ) -> Self { if let Some(copilot) = Copilot::global(cx) { @@ -379,6 +428,8 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); + CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx); + Self { editor_subscription: None, editor_enabled: None, @@ -393,6 +444,89 @@ impl EditPredictionButton { } } + fn get_available_providers(&self, cx: &App) -> Vec { + let mut providers = Vec::new(); + + providers.push(EditPredictionProvider::Zed); + + if let Some(copilot) = Copilot::global(cx) { + if matches!(copilot.read(cx).status(), Status::Authorized) { + providers.push(EditPredictionProvider::Copilot); + } + } + + if let Some(supermaven) = Supermaven::global(cx) { + if let Supermaven::Spawned(agent) = supermaven.read(cx) { + if matches!(agent.account_status, AccountStatus::Ready) { + providers.push(EditPredictionProvider::Supermaven); + } + } + } + + if CodestralCompletionProvider::has_api_key(cx) { + providers.push(EditPredictionProvider::Codestral); + } + + providers + } + + fn add_provider_switching_section( + &self, + mut menu: ContextMenu, + current_provider: EditPredictionProvider, + cx: &App, + ) -> ContextMenu { + let available_providers = self.get_available_providers(cx); + + let other_providers: Vec<_> = available_providers + .into_iter() + .filter(|p| *p != current_provider && *p != EditPredictionProvider::None) + .collect(); + + if !other_providers.is_empty() { + menu = menu.separator().header("Switch Providers"); + + for provider in other_providers { + let fs = self.fs.clone(); + + menu = match provider { + EditPredictionProvider::Zed => menu.item( + ContextMenuEntry::new("Zed AI") + .documentation_aside( + DocumentationSide::Left, + DocumentationEdge::Top, + |_| { + Label::new("Zed's edit prediction is powered by Zeta, an open-source, dataset mode.") + .into_any_element() + }, + ) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ), + EditPredictionProvider::Copilot => { + menu.entry("GitHub Copilot", None, move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }) + } + EditPredictionProvider::Supermaven => { + menu.entry("Supermaven", None, move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }) + } + EditPredictionProvider::Codestral => { + menu.entry("Codestral", None, move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }) + } + EditPredictionProvider::None => continue, + }; + } + } + + menu + } + pub fn build_copilot_start_menu( &mut self, window: &mut Window, @@ -451,7 +585,7 @@ impl EditPredictionButton { menu = menu.item( entry .disabled(true) - .documentation_aside(DocumentationSide::Left, move |_cx| { + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| { Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name())) .into_any_element() }) @@ -496,6 +630,7 @@ impl EditPredictionButton { EditPredictionProvider::Zed | EditPredictionProvider::Copilot | EditPredictionProvider::Supermaven + | EditPredictionProvider::Codestral ) { menu = menu .separator() @@ -503,7 +638,7 @@ impl EditPredictionButton { .item( ContextMenuEntry::new("Eager") .toggleable(IconPosition::Start, eager_mode) - .documentation_aside(DocumentationSide::Left, move |_| { + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| { Label::new("Display predictions inline when there are no language server completions available.").into_any_element() }) .handler({ @@ -516,7 +651,7 @@ impl EditPredictionButton { .item( ContextMenuEntry::new("Subtle") .toggleable(IconPosition::Start, subtle_mode) - .documentation_aside(DocumentationSide::Left, move |_| { + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| { Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element() }) .handler({ @@ -529,8 +664,10 @@ impl EditPredictionButton { } menu = menu.separator().header("Privacy"); + if let Some(provider) = &self.edit_prediction_provider { let data_collection = provider.data_collection_state(cx); + if data_collection.is_supported() { let provider = provider.clone(); let enabled = data_collection.is_enabled(); @@ -547,7 +684,7 @@ impl EditPredictionButton { .toggleable(IconPosition::Start, data_collection.is_enabled()) .icon(icon_name) .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, move |cx| { + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { (true, true) => ( "Project identified as open source, and you're sharing data.", @@ -630,7 +767,7 @@ impl EditPredictionButton { ContextMenuEntry::new("Configure Excluded Files") .icon(IconName::LockOutlined) .icon_color(Color::Muted) - .documentation_aside(DocumentationSide::Left, |_| { + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| { Label::new(indoc!{" Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element() }) @@ -648,7 +785,7 @@ impl EditPredictionButton { } }), ).item( - ContextMenuEntry::new("View Documentation") + ContextMenuEntry::new("View Docs") .icon(IconName::FileGeneric) .icon_color(Color::Muted) .handler(move |_, cx| { @@ -668,6 +805,7 @@ impl EditPredictionButton { if let Some(editor_focus_handle) = self.editor_focus_handle.clone() { menu = menu .separator() + .header("Actions") .entry( "Predict Edit at Cursor", Some(Box::new(ShowEditPrediction)), @@ -678,7 +816,11 @@ impl EditPredictionButton { } }, ) - .context(editor_focus_handle); + .context(editor_focus_handle) + .when( + cx.has_flag::(), + |this| this.action("Rate Completions", RateCompletions.boxed_clone()), + ); } menu @@ -690,15 +832,11 @@ impl EditPredictionButton { cx: &mut Context, ) -> Entity { ContextMenu::build(window, cx, |menu, window, cx| { - self.build_language_settings_menu(menu, window, cx) - .separator() - .entry("Use Zed AI instead", None, { - let fs = self.fs.clone(); - move |_window, cx| { - set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed) - } - }) - .separator() + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx); + + menu.separator() .link( "Go to Copilot Settings", OpenBrowser { @@ -716,12 +854,32 @@ impl EditPredictionButton { cx: &mut Context, ) -> Entity { ContextMenu::build(window, cx, |menu, window, cx| { - self.build_language_settings_menu(menu, window, cx) - .separator() + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx); + + menu.separator() .action("Sign Out", supermaven::SignOut.boxed_clone()) }) } + fn build_codestral_context_menu( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + ContextMenu::build(window, cx, |menu, window, cx| { + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = + self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); + + menu.separator() + .entry("Configure Codestral API Key", None, move |window, cx| { + window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx); + }) + }) + } + fn build_zeta_context_menu( &self, window: &mut Window, @@ -810,10 +968,10 @@ impl EditPredictionButton { .separator(); } - self.build_language_settings_menu(menu, window, cx).when( - cx.has_flag::(), - |this| this.action("Rate Completions", RateCompletions.boxed_clone()), - ) + let menu = self.build_language_settings_menu(menu, window, cx); + let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Zed, cx); + + menu }) } @@ -914,8 +1072,10 @@ async fn open_disabled_globs_setting_in_editor( let settings = cx.global::(); // Ensure that we always have "edit_predictions { "disabled_globs": [] }" - let edits = settings.edits_for_update::(&text, |file| { - file.edit_predictions + let edits = settings.edits_for_update(&text, |file| { + file.project + .all_languages + .edit_predictions .get_or_insert_with(Default::default) .disabled_globs .get_or_insert_with(Vec::new); @@ -952,9 +1112,12 @@ async fn open_disabled_globs_setting_in_editor( } fn set_completion_provider(fs: Arc, cx: &mut App, provider: EditPredictionProvider) { - update_settings_file::(fs, cx, move |file, _| { - file.features - .get_or_insert(Default::default()) + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() .edit_prediction_provider = Some(provider); }); } @@ -966,18 +1129,24 @@ fn toggle_show_edit_predictions_for_language( ) { let show_edit_predictions = all_language_settings(None, cx).show_edit_predictions(Some(&language), cx); - update_settings_file::(fs, cx, move |file, _| { - file.languages + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .all_languages + .languages .0 - .entry(language.name()) + .entry(language.name().0) .or_default() .show_edit_predictions = Some(!show_edit_predictions); }); } fn hide_copilot(fs: Arc, cx: &mut App) { - update_settings_file::(fs, cx, move |file, _| { - file.features + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .all_languages + .features .get_or_insert(Default::default()) .edit_prediction_provider = Some(EditPredictionProvider::None); }); @@ -988,13 +1157,14 @@ fn toggle_edit_prediction_mode(fs: Arc, mode: EditPredictionsMode, cx: & let current_mode = settings.edit_predictions_mode(); if current_mode != mode { - update_settings_file::(fs, cx, move |settings, _cx| { - if let Some(edit_predictions) = settings.edit_predictions.as_mut() { - edit_predictions.mode = mode; + update_settings_file(fs, cx, move |settings, _cx| { + if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut() + { + edit_predictions.mode = Some(mode); } else { - settings.edit_predictions = - Some(language_settings::EditPredictionSettingsContent { - mode, + settings.project.all_languages.edit_predictions = + Some(settings::EditPredictionSettingsContent { + mode: Some(mode), ..Default::default() }); } diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..6976831b8cbbe2b998f713ff65f1585f28fc3005 --- /dev/null +++ b/crates/edit_prediction_context/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "edit_prediction_context" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/edit_prediction_context.rs" + +[dependencies] +anyhow.workspace = true +arrayvec.workspace = true +cloud_llm_client.workspace = true +collections.workspace = true +futures.workspace = true +gpui.workspace = true +hashbrown.workspace = true +indoc.workspace = true +itertools.workspace = true +language.workspace = true +log.workspace = true +ordered-float.workspace = true +postage.workspace = true +project.workspace = true +regex.workspace = true +serde.workspace = true +slotmap.workspace = true +strum.workspace = true +text.workspace = true +tree-sitter.workspace = true +util.workspace = true + +[dev-dependencies] +clap.workspace = true +futures.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true +project = {workspace= true, features = ["test-support"]} +serde_json.workspace = true +settings = {workspace= true, features = ["test-support"]} +text = { workspace = true, features = ["test-support"] } +tree-sitter-c.workspace = true +tree-sitter-cpp.workspace = true +tree-sitter-go.workspace = true +util = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/jj_ui/LICENSE-GPL b/crates/edit_prediction_context/LICENSE-GPL similarity index 100% rename from crates/jj_ui/LICENSE-GPL rename to crates/edit_prediction_context/LICENSE-GPL diff --git a/crates/edit_prediction_context/src/declaration.rs b/crates/edit_prediction_context/src/declaration.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc32640425ecc563b1f24a6c695be1c13199cd73 --- /dev/null +++ b/crates/edit_prediction_context/src/declaration.rs @@ -0,0 +1,350 @@ +use cloud_llm_client::predict_edits_v3::{self, Line}; +use language::{Language, LanguageId}; +use project::ProjectEntryId; +use std::ops::Range; +use std::sync::Arc; +use std::{borrow::Cow, path::Path}; +use text::{Bias, BufferId, Rope}; +use util::paths::{path_ends_with, strip_path_suffix}; +use util::rel_path::RelPath; + +use crate::outline::OutlineDeclaration; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Identifier { + pub name: Arc, + pub language_id: LanguageId, +} + +slotmap::new_key_type! { + pub struct DeclarationId; +} + +#[derive(Debug, Clone)] +pub enum Declaration { + File { + project_entry_id: ProjectEntryId, + declaration: FileDeclaration, + cached_path: CachedDeclarationPath, + }, + Buffer { + project_entry_id: ProjectEntryId, + buffer_id: BufferId, + rope: Rope, + declaration: BufferDeclaration, + cached_path: CachedDeclarationPath, + }, +} + +const ITEM_TEXT_TRUNCATION_LENGTH: usize = 1024; + +impl Declaration { + pub fn identifier(&self) -> &Identifier { + match self { + Declaration::File { declaration, .. } => &declaration.identifier, + Declaration::Buffer { declaration, .. } => &declaration.identifier, + } + } + + pub fn parent(&self) -> Option { + match self { + Declaration::File { declaration, .. } => declaration.parent, + Declaration::Buffer { declaration, .. } => declaration.parent, + } + } + + pub fn as_buffer(&self) -> Option<&BufferDeclaration> { + match self { + Declaration::File { .. } => None, + Declaration::Buffer { declaration, .. } => Some(declaration), + } + } + + pub fn as_file(&self) -> Option<&FileDeclaration> { + match self { + Declaration::Buffer { .. } => None, + Declaration::File { declaration, .. } => Some(declaration), + } + } + + pub fn project_entry_id(&self) -> ProjectEntryId { + match self { + Declaration::File { + project_entry_id, .. + } => *project_entry_id, + Declaration::Buffer { + project_entry_id, .. + } => *project_entry_id, + } + } + + pub fn cached_path(&self) -> &CachedDeclarationPath { + match self { + Declaration::File { cached_path, .. } => cached_path, + Declaration::Buffer { cached_path, .. } => cached_path, + } + } + + pub fn item_range(&self) -> Range { + match self { + Declaration::File { declaration, .. } => declaration.item_range.clone(), + Declaration::Buffer { declaration, .. } => declaration.item_range.clone(), + } + } + + pub fn item_line_range(&self) -> Range { + match self { + Declaration::File { declaration, .. } => declaration.item_line_range.clone(), + Declaration::Buffer { + declaration, rope, .. + } => { + Line(rope.offset_to_point(declaration.item_range.start).row) + ..Line(rope.offset_to_point(declaration.item_range.end).row) + } + } + } + + pub fn item_text(&self) -> (Cow<'_, str>, bool) { + match self { + Declaration::File { declaration, .. } => ( + declaration.text.as_ref().into(), + declaration.text_is_truncated, + ), + Declaration::Buffer { + rope, declaration, .. + } => ( + rope.chunks_in_range(declaration.item_range.clone()) + .collect::>(), + declaration.item_range_is_truncated, + ), + } + } + + pub fn signature_text(&self) -> (Cow<'_, str>, bool) { + match self { + Declaration::File { declaration, .. } => ( + declaration.text[self.signature_range_in_item_text()].into(), + declaration.signature_is_truncated, + ), + Declaration::Buffer { + rope, declaration, .. + } => ( + rope.chunks_in_range(declaration.signature_range.clone()) + .collect::>(), + declaration.signature_range_is_truncated, + ), + } + } + + pub fn signature_range(&self) -> Range { + match self { + Declaration::File { declaration, .. } => declaration.signature_range.clone(), + Declaration::Buffer { declaration, .. } => declaration.signature_range.clone(), + } + } + + pub fn signature_line_range(&self) -> Range { + match self { + Declaration::File { declaration, .. } => declaration.signature_line_range.clone(), + Declaration::Buffer { + declaration, rope, .. + } => { + Line(rope.offset_to_point(declaration.signature_range.start).row) + ..Line(rope.offset_to_point(declaration.signature_range.end).row) + } + } + } + + pub fn signature_range_in_item_text(&self) -> Range { + let signature_range = self.signature_range(); + let item_range = self.item_range(); + signature_range.start.saturating_sub(item_range.start) + ..(signature_range.end.saturating_sub(item_range.start)).min(item_range.len()) + } +} + +fn expand_range_to_line_boundaries_and_truncate( + range: &Range, + limit: usize, + rope: &Rope, +) -> (Range, Range, bool) { + let mut point_range = rope.offset_to_point(range.start)..rope.offset_to_point(range.end); + point_range.start.column = 0; + point_range.end.row += 1; + point_range.end.column = 0; + + let mut item_range = + rope.point_to_offset(point_range.start)..rope.point_to_offset(point_range.end); + let is_truncated = item_range.len() > limit; + if is_truncated { + item_range.end = item_range.start + limit; + } + item_range.end = rope.clip_offset(item_range.end, Bias::Left); + + let line_range = + predict_edits_v3::Line(point_range.start.row)..predict_edits_v3::Line(point_range.end.row); + (item_range, line_range, is_truncated) +} + +#[derive(Debug, Clone)] +pub struct FileDeclaration { + pub parent: Option, + pub identifier: Identifier, + /// offset range of the declaration in the file, expanded to line boundaries and truncated + pub item_range: Range, + /// line range of the declaration in the file, potentially truncated + pub item_line_range: Range, + /// text of `item_range` + pub text: Arc, + /// whether `text` was truncated + pub text_is_truncated: bool, + /// offset range of the signature in the file, expanded to line boundaries and truncated + pub signature_range: Range, + /// line range of the signature in the file, truncated + pub signature_line_range: Range, + /// whether `signature` was truncated + pub signature_is_truncated: bool, +} + +impl FileDeclaration { + pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> FileDeclaration { + let (item_range_in_file, item_line_range_in_file, text_is_truncated) = + expand_range_to_line_boundaries_and_truncate( + &declaration.item_range, + ITEM_TEXT_TRUNCATION_LENGTH, + rope, + ); + + let (mut signature_range_in_file, signature_line_range, mut signature_is_truncated) = + expand_range_to_line_boundaries_and_truncate( + &declaration.signature_range, + ITEM_TEXT_TRUNCATION_LENGTH, + rope, + ); + + if signature_range_in_file.start < item_range_in_file.start { + signature_range_in_file.start = item_range_in_file.start; + signature_is_truncated = true; + } + if signature_range_in_file.end > item_range_in_file.end { + signature_range_in_file.end = item_range_in_file.end; + signature_is_truncated = true; + } + + FileDeclaration { + parent: None, + identifier: declaration.identifier, + signature_range: signature_range_in_file, + signature_line_range, + signature_is_truncated, + text: rope + .chunks_in_range(item_range_in_file.clone()) + .collect::() + .into(), + text_is_truncated, + item_range: item_range_in_file, + item_line_range: item_line_range_in_file, + } + } +} + +#[derive(Debug, Clone)] +pub struct BufferDeclaration { + pub parent: Option, + pub identifier: Identifier, + pub item_range: Range, + pub item_range_is_truncated: bool, + pub signature_range: Range, + pub signature_range_is_truncated: bool, +} + +impl BufferDeclaration { + pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> Self { + let (item_range, _item_line_range, item_range_is_truncated) = + expand_range_to_line_boundaries_and_truncate( + &declaration.item_range, + ITEM_TEXT_TRUNCATION_LENGTH, + rope, + ); + let (signature_range, _signature_line_range, signature_range_is_truncated) = + expand_range_to_line_boundaries_and_truncate( + &declaration.signature_range, + ITEM_TEXT_TRUNCATION_LENGTH, + rope, + ); + Self { + parent: None, + identifier: declaration.identifier, + item_range, + item_range_is_truncated, + signature_range, + signature_range_is_truncated, + } + } +} + +#[derive(Debug, Clone)] +pub struct CachedDeclarationPath { + pub worktree_abs_path: Arc, + pub rel_path: Arc, + /// The relative path of the file, possibly stripped according to `import_path_strip_regex`. + pub rel_path_after_regex_stripping: Arc, +} + +impl CachedDeclarationPath { + pub fn new( + worktree_abs_path: Arc, + path: &Arc, + language: Option<&Arc>, + ) -> Self { + let rel_path = path.clone(); + let rel_path_after_regex_stripping = if let Some(language) = language + && let Some(strip_regex) = language.config().import_path_strip_regex.as_ref() + && let Ok(stripped) = RelPath::unix(&Path::new( + strip_regex.replace_all(rel_path.as_unix_str(), "").as_ref(), + )) { + Arc::from(stripped) + } else { + rel_path.clone() + }; + CachedDeclarationPath { + worktree_abs_path, + rel_path, + rel_path_after_regex_stripping, + } + } + + #[cfg(test)] + pub fn new_for_test(worktree_abs_path: &str, rel_path: &str) -> Self { + let rel_path: Arc = util::rel_path::rel_path(rel_path).into(); + CachedDeclarationPath { + worktree_abs_path: std::path::PathBuf::from(worktree_abs_path).into(), + rel_path_after_regex_stripping: rel_path.clone(), + rel_path, + } + } + + pub fn ends_with_posix_path(&self, path: &Path) -> bool { + if path.as_os_str().len() <= self.rel_path_after_regex_stripping.as_unix_str().len() { + path_ends_with(self.rel_path_after_regex_stripping.as_std_path(), path) + } else { + if let Some(remaining) = + strip_path_suffix(path, self.rel_path_after_regex_stripping.as_std_path()) + { + path_ends_with(&self.worktree_abs_path, remaining) + } else { + false + } + } + } + + pub fn equals_absolute_path(&self, path: &Path) -> bool { + if let Some(remaining) = + strip_path_suffix(path, &self.rel_path_after_regex_stripping.as_std_path()) + { + self.worktree_abs_path.as_ref() == remaining + } else { + false + } + } +} diff --git a/crates/edit_prediction_context/src/declaration_scoring.rs b/crates/edit_prediction_context/src/declaration_scoring.rs new file mode 100644 index 0000000000000000000000000000000000000000..48a823362769770c836b44e7d8a6c1942d3a1196 --- /dev/null +++ b/crates/edit_prediction_context/src/declaration_scoring.rs @@ -0,0 +1,539 @@ +use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents; +use collections::HashMap; +use language::BufferSnapshot; +use ordered_float::OrderedFloat; +use project::ProjectEntryId; +use serde::Serialize; +use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc}; +use strum::EnumIter; +use text::{Point, ToPoint}; +use util::RangeExt as _; + +use crate::{ + CachedDeclarationPath, Declaration, EditPredictionExcerpt, Identifier, + imports::{Import, Imports, Module}, + reference::{Reference, ReferenceRegion}, + syntax_index::SyntaxIndexState, + text_similarity::{Occurrences, jaccard_similarity, weighted_overlap_coefficient}, +}; + +const MAX_IDENTIFIER_DECLARATION_COUNT: usize = 16; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditPredictionScoreOptions { + pub omit_excerpt_overlaps: bool, +} + +#[derive(Clone, Debug)] +pub struct ScoredDeclaration { + /// identifier used by the local reference + pub identifier: Identifier, + pub declaration: Declaration, + pub components: DeclarationScoreComponents, +} + +#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum DeclarationStyle { + Signature, + Declaration, +} + +#[derive(Clone, Debug, Serialize, Default)] +pub struct DeclarationScores { + pub signature: f32, + pub declaration: f32, + pub retrieval: f32, +} + +impl ScoredDeclaration { + /// Returns the score for this declaration with the specified style. + pub fn score(&self, style: DeclarationStyle) -> f32 { + // TODO: handle truncation + + // Score related to how likely this is the correct declaration, range 0 to 1 + let retrieval = self.retrieval_score(); + + // Score related to the distance between the reference and cursor, range 0 to 1 + let distance_score = if self.components.is_referenced_nearby { + 1.0 / (1.0 + self.components.reference_line_distance as f32 / 10.0).powf(2.0) + } else { + // same score as ~14 lines away, rationale is to not overly penalize references from parent signatures + 0.5 + }; + + // For now instead of linear combination, the scores are just multiplied together. + let combined_score = 10.0 * retrieval * distance_score; + + match style { + DeclarationStyle::Signature => { + combined_score * self.components.excerpt_vs_signature_weighted_overlap + } + DeclarationStyle::Declaration => { + 2.0 * combined_score * self.components.excerpt_vs_item_weighted_overlap + } + } + } + + pub fn retrieval_score(&self) -> f32 { + let mut score = if self.components.is_same_file { + 10.0 / self.components.same_file_declaration_count as f32 + } else if self.components.path_import_match_count > 0 { + 3.0 + } else if self.components.wildcard_path_import_match_count > 0 { + 1.0 + } else if self.components.normalized_import_similarity > 0.0 { + self.components.normalized_import_similarity + } else if self.components.normalized_wildcard_import_similarity > 0.0 { + 0.5 * self.components.normalized_wildcard_import_similarity + } else { + 1.0 / self.components.declaration_count as f32 + }; + score *= 1. + self.components.included_by_others as f32 / 2.; + score *= 1. + self.components.includes_others as f32 / 4.; + score + } + + pub fn size(&self, style: DeclarationStyle) -> usize { + match &self.declaration { + Declaration::File { declaration, .. } => match style { + DeclarationStyle::Signature => declaration.signature_range.len(), + DeclarationStyle::Declaration => declaration.text.len(), + }, + Declaration::Buffer { declaration, .. } => match style { + DeclarationStyle::Signature => declaration.signature_range.len(), + DeclarationStyle::Declaration => declaration.item_range.len(), + }, + } + } + + pub fn score_density(&self, style: DeclarationStyle) -> f32 { + self.score(style) / self.size(style) as f32 + } +} + +pub fn scored_declarations( + options: &EditPredictionScoreOptions, + index: &SyntaxIndexState, + excerpt: &EditPredictionExcerpt, + excerpt_occurrences: &Occurrences, + adjacent_occurrences: &Occurrences, + imports: &Imports, + identifier_to_references: HashMap>, + cursor_offset: usize, + current_buffer: &BufferSnapshot, +) -> Vec { + let cursor_point = cursor_offset.to_point(¤t_buffer); + + let mut wildcard_import_occurrences = Vec::new(); + let mut wildcard_import_paths = Vec::new(); + for wildcard_import in imports.wildcard_modules.iter() { + match wildcard_import { + Module::Namespace(namespace) => { + wildcard_import_occurrences.push(namespace.occurrences()) + } + Module::SourceExact(path) => wildcard_import_paths.push(path), + Module::SourceFuzzy(path) => { + wildcard_import_occurrences.push(Occurrences::from_path(&path)) + } + } + } + + let mut scored_declarations = Vec::new(); + let mut project_entry_id_to_outline_ranges: HashMap>> = + HashMap::default(); + for (identifier, references) in identifier_to_references { + let mut import_occurrences = Vec::new(); + let mut import_paths = Vec::new(); + let mut found_external_identifier: Option<&Identifier> = None; + + if let Some(imports) = imports.identifier_to_imports.get(&identifier) { + // only use alias when it's the only import, could be generalized if some language + // has overlapping aliases + // + // TODO: when an aliased declaration is included in the prompt, should include the + // aliasing in the prompt. + // + // TODO: For SourceFuzzy consider having componentwise comparison that pays + // attention to ordering. + if let [ + Import::Alias { + module, + external_identifier, + }, + ] = imports.as_slice() + { + match module { + Module::Namespace(namespace) => { + import_occurrences.push(namespace.occurrences()) + } + Module::SourceExact(path) => import_paths.push(path), + Module::SourceFuzzy(path) => { + import_occurrences.push(Occurrences::from_path(&path)) + } + } + found_external_identifier = Some(&external_identifier); + } else { + for import in imports { + match import { + Import::Direct { module } => match module { + Module::Namespace(namespace) => { + import_occurrences.push(namespace.occurrences()) + } + Module::SourceExact(path) => import_paths.push(path), + Module::SourceFuzzy(path) => { + import_occurrences.push(Occurrences::from_path(&path)) + } + }, + Import::Alias { .. } => {} + } + } + } + } + + let identifier_to_lookup = found_external_identifier.unwrap_or(&identifier); + // TODO: update this to be able to return more declarations? Especially if there is the + // ability to quickly filter a large list (based on imports) + let identifier_declarations = index + .declarations_for_identifier::(&identifier_to_lookup); + let declaration_count = identifier_declarations.len(); + + if declaration_count == 0 { + continue; + } + + // TODO: option to filter out other candidates when same file / import match + let mut checked_declarations = Vec::with_capacity(declaration_count); + for (declaration_id, declaration) in identifier_declarations { + match declaration { + Declaration::Buffer { + buffer_id, + declaration: buffer_declaration, + .. + } => { + if buffer_id == ¤t_buffer.remote_id() { + let already_included_in_prompt = + range_intersection(&buffer_declaration.item_range, &excerpt.range) + .is_some() + || excerpt + .parent_declarations + .iter() + .any(|(excerpt_parent, _)| excerpt_parent == &declaration_id); + if !options.omit_excerpt_overlaps || !already_included_in_prompt { + let declaration_line = buffer_declaration + .item_range + .start + .to_point(current_buffer) + .row; + let declaration_line_distance = + (cursor_point.row as i32 - declaration_line as i32).unsigned_abs(); + checked_declarations.push(CheckedDeclaration { + declaration, + same_file_line_distance: Some(declaration_line_distance), + path_import_match_count: 0, + wildcard_path_import_match_count: 0, + }); + } + continue; + } else { + } + } + Declaration::File { .. } => {} + } + let declaration_path = declaration.cached_path(); + let path_import_match_count = import_paths + .iter() + .filter(|import_path| { + declaration_path_matches_import(&declaration_path, import_path) + }) + .count(); + let wildcard_path_import_match_count = wildcard_import_paths + .iter() + .filter(|import_path| { + declaration_path_matches_import(&declaration_path, import_path) + }) + .count(); + checked_declarations.push(CheckedDeclaration { + declaration, + same_file_line_distance: None, + path_import_match_count, + wildcard_path_import_match_count, + }); + } + + let mut max_import_similarity = 0.0; + let mut max_wildcard_import_similarity = 0.0; + + let mut scored_declarations_for_identifier = Vec::with_capacity(checked_declarations.len()); + for checked_declaration in checked_declarations { + let same_file_declaration_count = + index.file_declaration_count(checked_declaration.declaration); + + let declaration = score_declaration( + &identifier, + &references, + checked_declaration, + same_file_declaration_count, + declaration_count, + &excerpt_occurrences, + &adjacent_occurrences, + &import_occurrences, + &wildcard_import_occurrences, + cursor_point, + current_buffer, + ); + + if declaration.components.import_similarity > max_import_similarity { + max_import_similarity = declaration.components.import_similarity; + } + + if declaration.components.wildcard_import_similarity > max_wildcard_import_similarity { + max_wildcard_import_similarity = declaration.components.wildcard_import_similarity; + } + + project_entry_id_to_outline_ranges + .entry(declaration.declaration.project_entry_id()) + .or_default() + .push(declaration.declaration.item_range()); + scored_declarations_for_identifier.push(declaration); + } + + if max_import_similarity > 0.0 || max_wildcard_import_similarity > 0.0 { + for declaration in scored_declarations_for_identifier.iter_mut() { + if max_import_similarity > 0.0 { + declaration.components.max_import_similarity = max_import_similarity; + declaration.components.normalized_import_similarity = + declaration.components.import_similarity / max_import_similarity; + } + if max_wildcard_import_similarity > 0.0 { + declaration.components.normalized_wildcard_import_similarity = + declaration.components.wildcard_import_similarity + / max_wildcard_import_similarity; + } + } + } + + scored_declarations.extend(scored_declarations_for_identifier); + } + + // TODO: Inform this via import / retrieval scores of outline items + // TODO: Consider using a sweepline + for scored_declaration in scored_declarations.iter_mut() { + let project_entry_id = scored_declaration.declaration.project_entry_id(); + let Some(ranges) = project_entry_id_to_outline_ranges.get(&project_entry_id) else { + continue; + }; + for range in ranges { + if range.contains_inclusive(&scored_declaration.declaration.item_range()) { + scored_declaration.components.included_by_others += 1 + } else if scored_declaration + .declaration + .item_range() + .contains_inclusive(range) + { + scored_declaration.components.includes_others += 1 + } + } + } + + scored_declarations.sort_unstable_by_key(|declaration| { + Reverse(OrderedFloat( + declaration.score(DeclarationStyle::Declaration), + )) + }); + + scored_declarations +} + +struct CheckedDeclaration<'a> { + declaration: &'a Declaration, + same_file_line_distance: Option, + path_import_match_count: usize, + wildcard_path_import_match_count: usize, +} + +fn declaration_path_matches_import( + declaration_path: &CachedDeclarationPath, + import_path: &Arc, +) -> bool { + if import_path.is_absolute() { + declaration_path.equals_absolute_path(import_path) + } else { + declaration_path.ends_with_posix_path(import_path) + } +} + +fn range_intersection(a: &Range, b: &Range) -> Option> { + let start = a.start.clone().max(b.start.clone()); + let end = a.end.clone().min(b.end.clone()); + if start < end { + Some(Range { start, end }) + } else { + None + } +} + +fn score_declaration( + identifier: &Identifier, + references: &[Reference], + checked_declaration: CheckedDeclaration, + same_file_declaration_count: usize, + declaration_count: usize, + excerpt_occurrences: &Occurrences, + adjacent_occurrences: &Occurrences, + import_occurrences: &[Occurrences], + wildcard_import_occurrences: &[Occurrences], + cursor: Point, + current_buffer: &BufferSnapshot, +) -> ScoredDeclaration { + let CheckedDeclaration { + declaration, + same_file_line_distance, + path_import_match_count, + wildcard_path_import_match_count, + } = checked_declaration; + + let is_referenced_nearby = references + .iter() + .any(|r| r.region == ReferenceRegion::Nearby); + let is_referenced_in_breadcrumb = references + .iter() + .any(|r| r.region == ReferenceRegion::Breadcrumb); + let reference_count = references.len(); + let reference_line_distance = references + .iter() + .map(|r| { + let reference_line = r.range.start.to_point(current_buffer).row as i32; + (cursor.row as i32 - reference_line).unsigned_abs() + }) + .min() + .unwrap(); + + let is_same_file = same_file_line_distance.is_some(); + let declaration_line_distance = same_file_line_distance.unwrap_or(u32::MAX); + + let item_source_occurrences = Occurrences::within_string(&declaration.item_text().0); + let item_signature_occurrences = Occurrences::within_string(&declaration.signature_text().0); + let excerpt_vs_item_jaccard = jaccard_similarity(excerpt_occurrences, &item_source_occurrences); + let excerpt_vs_signature_jaccard = + jaccard_similarity(excerpt_occurrences, &item_signature_occurrences); + let adjacent_vs_item_jaccard = + jaccard_similarity(adjacent_occurrences, &item_source_occurrences); + let adjacent_vs_signature_jaccard = + jaccard_similarity(adjacent_occurrences, &item_signature_occurrences); + + let excerpt_vs_item_weighted_overlap = + weighted_overlap_coefficient(excerpt_occurrences, &item_source_occurrences); + let excerpt_vs_signature_weighted_overlap = + weighted_overlap_coefficient(excerpt_occurrences, &item_signature_occurrences); + let adjacent_vs_item_weighted_overlap = + weighted_overlap_coefficient(adjacent_occurrences, &item_source_occurrences); + let adjacent_vs_signature_weighted_overlap = + weighted_overlap_coefficient(adjacent_occurrences, &item_signature_occurrences); + + let mut import_similarity = 0f32; + let mut wildcard_import_similarity = 0f32; + if !import_occurrences.is_empty() || !wildcard_import_occurrences.is_empty() { + let cached_path = declaration.cached_path(); + let path_occurrences = Occurrences::from_worktree_path( + cached_path + .worktree_abs_path + .file_name() + .map(|f| f.to_string_lossy()), + &cached_path.rel_path, + ); + import_similarity = import_occurrences + .iter() + .map(|namespace_occurrences| { + OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) + }) + .max() + .map(|similarity| similarity.into_inner()) + .unwrap_or_default(); + + // TODO: Consider something other than max + wildcard_import_similarity = wildcard_import_occurrences + .iter() + .map(|namespace_occurrences| { + OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) + }) + .max() + .map(|similarity| similarity.into_inner()) + .unwrap_or_default(); + } + + // TODO: Consider adding declaration_file_count + let score_components = DeclarationScoreComponents { + is_same_file, + is_referenced_nearby, + is_referenced_in_breadcrumb, + reference_line_distance, + declaration_line_distance, + reference_count, + same_file_declaration_count, + declaration_count, + excerpt_vs_item_jaccard, + excerpt_vs_signature_jaccard, + adjacent_vs_item_jaccard, + adjacent_vs_signature_jaccard, + excerpt_vs_item_weighted_overlap, + excerpt_vs_signature_weighted_overlap, + adjacent_vs_item_weighted_overlap, + adjacent_vs_signature_weighted_overlap, + path_import_match_count, + wildcard_path_import_match_count, + import_similarity, + max_import_similarity: 0.0, + normalized_import_similarity: 0.0, + wildcard_import_similarity, + normalized_wildcard_import_similarity: 0.0, + included_by_others: 0, + includes_others: 0, + }; + + ScoredDeclaration { + identifier: identifier.clone(), + declaration: declaration.clone(), + components: score_components, + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_declaration_path_matches() { + let declaration_path = + CachedDeclarationPath::new_for_test("/home/user/project", "src/maths.ts"); + + assert!(declaration_path_matches_import( + &declaration_path, + &Path::new("maths.ts").into() + )); + + assert!(declaration_path_matches_import( + &declaration_path, + &Path::new("project/src/maths.ts").into() + )); + + assert!(declaration_path_matches_import( + &declaration_path, + &Path::new("user/project/src/maths.ts").into() + )); + + assert!(declaration_path_matches_import( + &declaration_path, + &Path::new("/home/user/project/src/maths.ts").into() + )); + + assert!(!declaration_path_matches_import( + &declaration_path, + &Path::new("other.ts").into() + )); + + assert!(!declaration_path_matches_import( + &declaration_path, + &Path::new("/home/user/project/src/other.ts").into() + )); + } +} diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..f52a2259cf83ff992a904b6b5c9b3ceea7c0a71e --- /dev/null +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -0,0 +1,337 @@ +mod declaration; +mod declaration_scoring; +mod excerpt; +mod imports; +mod outline; +mod reference; +mod syntax_index; +pub mod text_similarity; + +use std::{path::Path, sync::Arc}; + +use cloud_llm_client::predict_edits_v3; +use collections::HashMap; +use gpui::{App, AppContext as _, Entity, Task}; +use language::BufferSnapshot; +use text::{Point, ToOffset as _}; + +pub use declaration::*; +pub use declaration_scoring::*; +pub use excerpt::*; +pub use imports::*; +pub use reference::*; +pub use syntax_index::*; + +pub use predict_edits_v3::Line; + +#[derive(Clone, Debug, PartialEq)] +pub struct EditPredictionContextOptions { + pub use_imports: bool, + pub excerpt: EditPredictionExcerptOptions, + pub score: EditPredictionScoreOptions, + pub max_retrieved_declarations: u8, +} + +#[derive(Clone, Debug)] +pub struct EditPredictionContext { + pub excerpt: EditPredictionExcerpt, + pub excerpt_text: EditPredictionExcerptText, + pub cursor_point: Point, + pub declarations: Vec, +} + +impl EditPredictionContext { + pub fn gather_context_in_background( + cursor_point: Point, + buffer: BufferSnapshot, + options: EditPredictionContextOptions, + syntax_index: Option>, + cx: &mut App, + ) -> Task> { + let parent_abs_path = project::File::from_dyn(buffer.file()).and_then(|f| { + let mut path = f.worktree.read(cx).absolutize(&f.path); + if path.pop() { Some(path) } else { None } + }); + + if let Some(syntax_index) = syntax_index { + let index_state = + syntax_index.read_with(cx, |index, _cx| Arc::downgrade(index.state())); + cx.background_spawn(async move { + let parent_abs_path = parent_abs_path.as_deref(); + let index_state = index_state.upgrade()?; + let index_state = index_state.lock().await; + Self::gather_context( + cursor_point, + &buffer, + parent_abs_path, + &options, + Some(&index_state), + ) + }) + } else { + cx.background_spawn(async move { + let parent_abs_path = parent_abs_path.as_deref(); + Self::gather_context(cursor_point, &buffer, parent_abs_path, &options, None) + }) + } + } + + pub fn gather_context( + cursor_point: Point, + buffer: &BufferSnapshot, + parent_abs_path: Option<&Path>, + options: &EditPredictionContextOptions, + index_state: Option<&SyntaxIndexState>, + ) -> Option { + let imports = if options.use_imports { + Imports::gather(&buffer, parent_abs_path) + } else { + Imports::default() + }; + Self::gather_context_with_references_fn( + cursor_point, + buffer, + &imports, + options, + index_state, + references_in_excerpt, + ) + } + + pub fn gather_context_with_references_fn( + cursor_point: Point, + buffer: &BufferSnapshot, + imports: &Imports, + options: &EditPredictionContextOptions, + index_state: Option<&SyntaxIndexState>, + get_references: impl FnOnce( + &EditPredictionExcerpt, + &EditPredictionExcerptText, + &BufferSnapshot, + ) -> HashMap>, + ) -> Option { + let excerpt = EditPredictionExcerpt::select_from_buffer( + cursor_point, + buffer, + &options.excerpt, + index_state, + )?; + let excerpt_text = excerpt.text(buffer); + + let declarations = if options.max_retrieved_declarations > 0 + && let Some(index_state) = index_state + { + let excerpt_occurrences = + text_similarity::Occurrences::within_string(&excerpt_text.body); + + let adjacent_start = Point::new(cursor_point.row.saturating_sub(2), 0); + let adjacent_end = Point::new(cursor_point.row + 1, 0); + let adjacent_occurrences = text_similarity::Occurrences::within_string( + &buffer + .text_for_range(adjacent_start..adjacent_end) + .collect::(), + ); + + let cursor_offset_in_file = cursor_point.to_offset(buffer); + + let references = get_references(&excerpt, &excerpt_text, buffer); + + let mut declarations = scored_declarations( + &options.score, + &index_state, + &excerpt, + &excerpt_occurrences, + &adjacent_occurrences, + &imports, + references, + cursor_offset_in_file, + buffer, + ); + // TODO [zeta2] if we need this when we ship, we should probably do it in a smarter way + declarations.truncate(options.max_retrieved_declarations as usize); + declarations + } else { + vec![] + }; + + Some(Self { + excerpt, + excerpt_text, + cursor_point, + declarations, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use gpui::{Entity, TestAppContext}; + use indoc::indoc; + use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + use crate::{EditPredictionExcerptOptions, SyntaxIndex}; + + #[gpui::test] + async fn test_call_site(cx: &mut TestAppContext) { + let (project, index, _rust_lang_id) = init_test(cx).await; + + let buffer = project + .update(cx, |project, cx| { + let project_path = project.find_project_path("c.rs", cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // first process_data call site + let cursor_point = language::Point::new(8, 21); + let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let context = cx + .update(|cx| { + EditPredictionContext::gather_context_in_background( + cursor_point, + buffer_snapshot, + EditPredictionContextOptions { + use_imports: true, + excerpt: EditPredictionExcerptOptions { + max_bytes: 60, + min_bytes: 10, + target_before_cursor_over_total_bytes: 0.5, + }, + score: EditPredictionScoreOptions { + omit_excerpt_overlaps: true, + }, + max_retrieved_declarations: u8::MAX, + }, + Some(index.clone()), + cx, + ) + }) + .await + .unwrap(); + + let mut snippet_identifiers = context + .declarations + .iter() + .map(|snippet| snippet.identifier.name.as_ref()) + .collect::>(); + snippet_identifiers.sort(); + assert_eq!(snippet_identifiers, vec!["main", "process_data"]); + drop(buffer); + } + + async fn init_test( + cx: &mut TestAppContext, + ) -> (Entity, Entity, LanguageId) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "a.rs": indoc! {r#" + fn main() { + let x = 1; + let y = 2; + let z = add(x, y); + println!("Result: {}", z); + } + + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + "b.rs": indoc! {" + pub struct Config { + pub name: String, + pub value: i32, + } + + impl Config { + pub fn new(name: String, value: i32) -> Self { + Config { name, value } + } + } + "}, + "c.rs": indoc! {r#" + use std::collections::HashMap; + + fn main() { + let args: Vec = std::env::args().collect(); + let data: Vec = args[1..] + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + let result = process_data(data); + println!("{:?}", result); + } + + fn process_data(data: Vec) -> HashMap { + let mut counts = HashMap::new(); + for value in data { + *counts.entry(value).or_insert(0) += 1; + } + counts + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_process_data() { + let data = vec![1, 2, 2, 3]; + let result = process_data(data); + assert_eq!(result.get(&2), Some(&2)); + } + } + "#} + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let lang = rust_lang(); + let lang_id = lang.id(); + language_registry.add(Arc::new(lang)); + + let file_indexing_parallelism = 2; + let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); + cx.run_until_parked(); + + (project, index, lang_id) + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) + .unwrap() + .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) + .unwrap() + } +} diff --git a/crates/edit_prediction_context/src/excerpt.rs b/crates/edit_prediction_context/src/excerpt.rs new file mode 100644 index 0000000000000000000000000000000000000000..7a4bb73edfa131b620a930d7f0e1c0da77e0afe6 --- /dev/null +++ b/crates/edit_prediction_context/src/excerpt.rs @@ -0,0 +1,625 @@ +use language::{BufferSnapshot, LanguageId}; +use std::ops::Range; +use text::{Point, ToOffset as _, ToPoint as _}; +use tree_sitter::{Node, TreeCursor}; +use util::RangeExt; + +use crate::{BufferDeclaration, Line, declaration::DeclarationId, syntax_index::SyntaxIndexState}; + +// TODO: +// +// - Test parent signatures +// +// - Decide whether to count signatures against the excerpt size. Could instead defer this to prompt +// planning. +// +// - Still return an excerpt even if the line around the cursor doesn't fit (e.g. for a markdown +// paragraph). +// +// - Truncation of long lines. +// +// - Filter outer syntax layers that don't support edit prediction. + +#[derive(Debug, Clone, PartialEq)] +pub struct EditPredictionExcerptOptions { + /// Limit for the number of bytes in the window around the cursor. + pub max_bytes: usize, + /// Minimum number of bytes in the window around the cursor. When syntax tree selection results + /// in an excerpt smaller than this, it will fall back on line-based selection. + pub min_bytes: usize, + /// Target ratio of bytes before the cursor divided by total bytes in the window. + pub target_before_cursor_over_total_bytes: f32, +} + +// TODO: consider merging these +#[derive(Debug, Clone)] +pub struct EditPredictionExcerpt { + pub range: Range, + pub line_range: Range, + pub parent_declarations: Vec<(DeclarationId, Range)>, + pub size: usize, +} + +#[derive(Debug, Clone)] +pub struct EditPredictionExcerptText { + pub body: String, + pub parent_signatures: Vec, + pub language_id: Option, +} + +impl EditPredictionExcerpt { + pub fn text(&self, buffer: &BufferSnapshot) -> EditPredictionExcerptText { + let body = buffer + .text_for_range(self.range.clone()) + .collect::(); + let parent_signatures = self + .parent_declarations + .iter() + .map(|(_, range)| buffer.text_for_range(range.clone()).collect::()) + .collect(); + let language_id = buffer.language().map(|l| l.id()); + EditPredictionExcerptText { + body, + parent_signatures, + language_id, + } + } + + /// Selects an excerpt around a buffer position, attempting to choose logical boundaries based + /// on TreeSitter structure and approximately targeting a goal ratio of bytesbefore vs after the + /// cursor. + /// + /// When `index` is provided, the excerpt will include the signatures of parent outline items. + /// + /// First tries to use AST node boundaries to select the excerpt, and falls back on line-based + /// expansion. + /// + /// Returns `None` if the line around the cursor doesn't fit. + pub fn select_from_buffer( + query_point: Point, + buffer: &BufferSnapshot, + options: &EditPredictionExcerptOptions, + syntax_index: Option<&SyntaxIndexState>, + ) -> Option { + if buffer.len() <= options.max_bytes { + log::debug!( + "using entire file for excerpt since source length ({}) <= window max bytes ({})", + buffer.len(), + options.max_bytes + ); + let offset_range = 0..buffer.len(); + let line_range = Line(0)..Line(buffer.max_point().row); + return Some(EditPredictionExcerpt::new( + offset_range, + line_range, + Vec::new(), + )); + } + + let query_offset = query_point.to_offset(buffer); + let query_line_range = query_point.row..query_point.row + 1; + let query_range = Point::new(query_line_range.start, 0).to_offset(buffer) + ..Point::new(query_line_range.end, 0).to_offset(buffer); + if query_range.len() >= options.max_bytes { + return None; + } + + let parent_declarations = if let Some(syntax_index) = syntax_index { + syntax_index + .buffer_declarations_containing_range(buffer.remote_id(), query_range.clone()) + .collect() + } else { + Vec::new() + }; + + let excerpt_selector = ExcerptSelector { + query_offset, + query_range, + query_line_range: Line(query_line_range.start)..Line(query_line_range.end), + parent_declarations: &parent_declarations, + buffer, + options, + }; + + if let Some(excerpt) = excerpt_selector.select_tree_sitter_nodes() { + if excerpt.size >= options.min_bytes { + return Some(excerpt); + } + log::debug!( + "tree-sitter excerpt was {} bytes, smaller than min of {}, falling back on line-based selection", + excerpt.size, + options.min_bytes + ); + } else { + log::debug!( + "couldn't find excerpt via tree-sitter, falling back on line-based selection" + ); + } + + excerpt_selector.select_lines() + } + + fn new( + range: Range, + line_range: Range, + parent_declarations: Vec<(DeclarationId, Range)>, + ) -> Self { + let size = range.len() + + parent_declarations + .iter() + .map(|(_, range)| range.len()) + .sum::(); + Self { + range, + parent_declarations, + size, + line_range, + } + } + + fn with_expanded_range(&self, new_range: Range, new_line_range: Range) -> Self { + if !new_range.contains_inclusive(&self.range) { + // this is an issue because parent_signature_ranges may be incorrect + log::error!("bug: with_expanded_range called with disjoint range"); + } + let mut parent_declarations = Vec::with_capacity(self.parent_declarations.len()); + for (declaration_id, range) in &self.parent_declarations { + if !range.contains_inclusive(&new_range) { + break; + } + parent_declarations.push((*declaration_id, range.clone())); + } + Self::new(new_range, new_line_range, parent_declarations) + } + + fn parent_signatures_size(&self) -> usize { + self.size - self.range.len() + } +} + +struct ExcerptSelector<'a> { + query_offset: usize, + query_range: Range, + query_line_range: Range, + parent_declarations: &'a [(DeclarationId, &'a BufferDeclaration)], + buffer: &'a BufferSnapshot, + options: &'a EditPredictionExcerptOptions, +} + +impl<'a> ExcerptSelector<'a> { + /// Finds the largest node that is smaller than the window size and contains `query_range`. + fn select_tree_sitter_nodes(&self) -> Option { + let selected_layer_root = self.select_syntax_layer()?; + let mut cursor = selected_layer_root.walk(); + + loop { + let line_start = node_line_start(cursor.node()); + let line_end = node_line_end(cursor.node()); + let line_range = Line(line_start.row)..Line(line_end.row); + let excerpt_range = + line_start.to_offset(&self.buffer)..line_end.to_offset(&self.buffer); + if excerpt_range.contains_inclusive(&self.query_range) { + let excerpt = self.make_excerpt(excerpt_range, line_range); + if excerpt.size <= self.options.max_bytes { + return Some(self.expand_to_siblings(&mut cursor, excerpt)); + } + } else { + // TODO: Should still be able to handle this case via AST nodes. For example, this + // can happen if the cursor is between two methods in a large class file. + return None; + } + + if cursor + .goto_first_child_for_byte(self.query_range.start) + .is_none() + { + return None; + } + } + } + + /// Select the smallest syntax layer that exceeds max_len, or the largest if none exceed max_len. + fn select_syntax_layer(&self) -> Option> { + let mut smallest_exceeding_max_len: Option> = None; + let mut largest: Option> = None; + for layer in self + .buffer + .syntax_layers_for_range(self.query_range.start..self.query_range.start, true) + { + let layer_range = layer.node().byte_range(); + if !layer_range.contains_inclusive(&self.query_range) { + continue; + } + + if layer_range.len() > self.options.max_bytes { + match &smallest_exceeding_max_len { + None => smallest_exceeding_max_len = Some(layer.node()), + Some(existing) => { + if layer_range.len() < existing.byte_range().len() { + smallest_exceeding_max_len = Some(layer.node()); + } + } + } + } else { + match &largest { + None => largest = Some(layer.node()), + Some(existing) if layer_range.len() > existing.byte_range().len() => { + largest = Some(layer.node()) + } + _ => {} + } + } + } + + smallest_exceeding_max_len.or(largest) + } + + // motivation for this and `goto_previous_named_sibling` is to avoid including things like + // trailing unnamed "}" in body nodes + fn goto_next_named_sibling(cursor: &mut TreeCursor) -> bool { + while cursor.goto_next_sibling() { + if cursor.node().is_named() { + return true; + } + } + false + } + + fn goto_previous_named_sibling(cursor: &mut TreeCursor) -> bool { + while cursor.goto_previous_sibling() { + if cursor.node().is_named() { + return true; + } + } + false + } + + fn expand_to_siblings( + &self, + cursor: &mut TreeCursor, + mut excerpt: EditPredictionExcerpt, + ) -> EditPredictionExcerpt { + let mut forward_cursor = cursor.clone(); + let backward_cursor = cursor; + let mut forward_done = !Self::goto_next_named_sibling(&mut forward_cursor); + let mut backward_done = !Self::goto_previous_named_sibling(backward_cursor); + loop { + if backward_done && forward_done { + break; + } + + let mut forward = None; + while !forward_done { + let new_end_point = node_line_end(forward_cursor.node()); + let new_end = new_end_point.to_offset(&self.buffer); + if new_end > excerpt.range.end { + let new_excerpt = excerpt.with_expanded_range( + excerpt.range.start..new_end, + excerpt.line_range.start..Line(new_end_point.row), + ); + if new_excerpt.size <= self.options.max_bytes { + forward = Some(new_excerpt); + break; + } else { + log::debug!("halting forward expansion, as it doesn't fit"); + forward_done = true; + break; + } + } + forward_done = !Self::goto_next_named_sibling(&mut forward_cursor); + } + + let mut backward = None; + while !backward_done { + let new_start_point = node_line_start(backward_cursor.node()); + let new_start = new_start_point.to_offset(&self.buffer); + if new_start < excerpt.range.start { + let new_excerpt = excerpt.with_expanded_range( + new_start..excerpt.range.end, + Line(new_start_point.row)..excerpt.line_range.end, + ); + if new_excerpt.size <= self.options.max_bytes { + backward = Some(new_excerpt); + break; + } else { + log::debug!("halting backward expansion, as it doesn't fit"); + backward_done = true; + break; + } + } + backward_done = !Self::goto_previous_named_sibling(backward_cursor); + } + + let go_forward = match (forward, backward) { + (Some(forward), Some(backward)) => { + let go_forward = self.is_better_excerpt(&forward, &backward); + if go_forward { + excerpt = forward; + } else { + excerpt = backward; + } + go_forward + } + (Some(forward), None) => { + log::debug!("expanding forward, since backward expansion has halted"); + excerpt = forward; + true + } + (None, Some(backward)) => { + log::debug!("expanding backward, since forward expansion has halted"); + excerpt = backward; + false + } + (None, None) => break, + }; + + if go_forward { + forward_done = !Self::goto_next_named_sibling(&mut forward_cursor); + } else { + backward_done = !Self::goto_previous_named_sibling(backward_cursor); + } + } + + excerpt + } + + fn select_lines(&self) -> Option { + // early return if line containing query_offset is already too large + let excerpt = self.make_excerpt(self.query_range.clone(), self.query_line_range.clone()); + if excerpt.size > self.options.max_bytes { + log::debug!( + "excerpt for cursor line is {} bytes, which exceeds the window", + excerpt.size + ); + return None; + } + let signatures_size = excerpt.parent_signatures_size(); + let bytes_remaining = self.options.max_bytes.saturating_sub(signatures_size); + + let before_bytes = + (self.options.target_before_cursor_over_total_bytes * bytes_remaining as f32) as usize; + + let start_line = { + let offset = self.query_offset.saturating_sub(before_bytes); + let point = offset.to_point(self.buffer); + Line(point.row + 1) + }; + let start_offset = Point::new(start_line.0, 0).to_offset(&self.buffer); + let end_line = { + let offset = start_offset + bytes_remaining; + let point = offset.to_point(self.buffer); + Line(point.row) + }; + let end_offset = Point::new(end_line.0, 0).to_offset(&self.buffer); + + // this could be expanded further since recalculated `signature_size` may be smaller, but + // skipping that for now for simplicity + // + // TODO: could also consider checking if lines immediately before / after fit. + let excerpt = self.make_excerpt(start_offset..end_offset, start_line..end_line); + if excerpt.size > self.options.max_bytes { + log::error!( + "bug: line-based excerpt selection has size {}, \ + which is {} bytes larger than the max size", + excerpt.size, + excerpt.size - self.options.max_bytes + ); + } + return Some(excerpt); + } + + fn make_excerpt(&self, range: Range, line_range: Range) -> EditPredictionExcerpt { + let parent_declarations = self + .parent_declarations + .iter() + .filter(|(_, declaration)| declaration.item_range.contains_inclusive(&range)) + .map(|(id, declaration)| (*id, declaration.signature_range.clone())) + .collect(); + EditPredictionExcerpt::new(range, line_range, parent_declarations) + } + + /// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt. + fn is_better_excerpt( + &self, + forward: &EditPredictionExcerpt, + backward: &EditPredictionExcerpt, + ) -> bool { + let forward_ratio = self.excerpt_range_ratio(forward); + let backward_ratio = self.excerpt_range_ratio(backward); + let forward_delta = + (forward_ratio - self.options.target_before_cursor_over_total_bytes).abs(); + let backward_delta = + (backward_ratio - self.options.target_before_cursor_over_total_bytes).abs(); + let forward_is_better = forward_delta <= backward_delta; + if forward_is_better { + log::debug!( + "expanding forward since {} is closer than {} to {}", + forward_ratio, + backward_ratio, + self.options.target_before_cursor_over_total_bytes + ); + } else { + log::debug!( + "expanding backward since {} is closer than {} to {}", + backward_ratio, + forward_ratio, + self.options.target_before_cursor_over_total_bytes + ); + } + forward_is_better + } + + /// Returns the ratio of bytes before the cursor over bytes within the range. + fn excerpt_range_ratio(&self, excerpt: &EditPredictionExcerpt) -> f32 { + let Some(bytes_before_cursor) = self.query_offset.checked_sub(excerpt.range.start) else { + log::error!("bug: edit prediction cursor offset is not outside the excerpt"); + return 0.0; + }; + bytes_before_cursor as f32 / excerpt.range.len() as f32 + } +} + +fn node_line_start(node: Node) -> Point { + Point::new(node.start_position().row as u32, 0) +} + +fn node_line_end(node: Node) -> Point { + Point::new(node.end_position().row as u32 + 1, 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use util::test::{generate_marked_text, marked_text_offsets_by}; + + fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang().into(), cx)); + buffer.read_with(cx, |buffer, _| buffer.snapshot()) + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) + .unwrap() + } + + fn cursor_and_excerpt_range(text: &str) -> (String, usize, Range) { + let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']); + (text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0]) + } + + fn check_example(options: EditPredictionExcerptOptions, text: &str, cx: &mut TestAppContext) { + let (text, cursor, expected_excerpt) = cursor_and_excerpt_range(text); + + let buffer = create_buffer(&text, cx); + let cursor_point = cursor.to_point(&buffer); + + let excerpt = + EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options, None) + .expect("Should select an excerpt"); + pretty_assertions::assert_eq!( + generate_marked_text(&text, std::slice::from_ref(&excerpt.range), false), + generate_marked_text(&text, &[expected_excerpt], false) + ); + assert!(excerpt.size <= options.max_bytes); + assert!(excerpt.range.contains(&cursor)); + } + + #[gpui::test] + fn test_ast_based_selection_current_node(cx: &mut TestAppContext) { + zlog::init_test(); + let text = r#" +fn main() { + let x = 1; +« let ˇy = 2; +» let z = 3; +}"#; + + let options = EditPredictionExcerptOptions { + max_bytes: 20, + min_bytes: 10, + target_before_cursor_over_total_bytes: 0.5, + }; + + check_example(options, text, cx); + } + + #[gpui::test] + fn test_ast_based_selection_parent_node(cx: &mut TestAppContext) { + zlog::init_test(); + let text = r#" +fn foo() {} + +«fn main() { + let x = 1; + let ˇy = 2; + let z = 3; +} +» +fn bar() {}"#; + + let options = EditPredictionExcerptOptions { + max_bytes: 65, + min_bytes: 10, + target_before_cursor_over_total_bytes: 0.5, + }; + + check_example(options, text, cx); + } + + #[gpui::test] + fn test_ast_based_selection_expands_to_siblings(cx: &mut TestAppContext) { + zlog::init_test(); + let text = r#" +fn main() { +« let x = 1; + let ˇy = 2; + let z = 3; +»}"#; + + let options = EditPredictionExcerptOptions { + max_bytes: 50, + min_bytes: 10, + target_before_cursor_over_total_bytes: 0.5, + }; + + check_example(options, text, cx); + } + + #[gpui::test] + fn test_line_based_selection(cx: &mut TestAppContext) { + zlog::init_test(); + let text = r#" +fn main() { + let x = 1; +« if true { + let ˇy = 2; + } + let z = 3; +»}"#; + + let options = EditPredictionExcerptOptions { + max_bytes: 60, + min_bytes: 45, + target_before_cursor_over_total_bytes: 0.5, + }; + + check_example(options, text, cx); + } + + #[gpui::test] + fn test_line_based_selection_with_before_cursor_ratio(cx: &mut TestAppContext) { + zlog::init_test(); + let text = r#" + fn main() { +« let a = 1; + let b = 2; + let c = 3; + let ˇd = 4; + let e = 5; + let f = 6; +» + let g = 7; + }"#; + + let options = EditPredictionExcerptOptions { + max_bytes: 120, + min_bytes: 10, + target_before_cursor_over_total_bytes: 0.6, + }; + + check_example(options, text, cx); + } +} diff --git a/crates/edit_prediction_context/src/imports.rs b/crates/edit_prediction_context/src/imports.rs new file mode 100644 index 0000000000000000000000000000000000000000..70f175159340ddb9a6f26f23db0c1b3c843e7b96 --- /dev/null +++ b/crates/edit_prediction_context/src/imports.rs @@ -0,0 +1,1319 @@ +use collections::HashMap; +use language::BufferSnapshot; +use language::ImportsConfig; +use language::Language; +use std::ops::Deref; +use std::path::Path; +use std::sync::Arc; +use std::{borrow::Cow, ops::Range}; +use text::OffsetRangeExt as _; +use util::RangeExt; +use util::paths::PathStyle; + +use crate::Identifier; +use crate::text_similarity::Occurrences; + +// TODO: Write documentation for extension authors. The @import capture must match before or in the +// same pattern as all all captures it contains + +// Future improvements to consider: +// +// * Distinguish absolute vs relative paths in captures. `#include "maths.h"` is relative whereas +// `#include ` is not. +// +// * Provide the name used when importing whole modules (see tests with "named_module" in the name). +// To be useful, will require parsing of identifier qualification. +// +// * Scoping for imports that aren't at the top level +// +// * Only scan a prefix of the file, when possible. This could look like having query matches that +// indicate it reached a declaration that is not allowed in the import section. +// +// * Support directly parsing to occurrences instead of storing namespaces / paths. Types should be +// generic on this, so that tests etc can still use strings. Could do similar in syntax index. +// +// * Distinguish different types of namespaces when known. E.g. "name.type" capture. Once capture +// names are more open-ended like this may make sense to build and cache a jump table (direct +// dispatch from capture index). +// +// * There are a few "Language specific:" comments on behavior that gets applied to all languages. +// Would be cleaner to be conditional on the language or otherwise configured. + +#[derive(Debug, Clone, Default)] +pub struct Imports { + pub identifier_to_imports: HashMap>, + pub wildcard_modules: Vec, +} + +#[derive(Debug, Clone)] +pub enum Import { + Direct { + module: Module, + }, + Alias { + module: Module, + external_identifier: Identifier, + }, +} + +#[derive(Debug, Clone)] +pub enum Module { + SourceExact(Arc), + SourceFuzzy(Arc), + Namespace(Namespace), +} + +impl Module { + fn empty() -> Self { + Module::Namespace(Namespace::default()) + } + + fn push_range( + &mut self, + range: &ModuleRange, + snapshot: &BufferSnapshot, + language: &Language, + parent_abs_path: Option<&Path>, + ) -> usize { + if range.is_empty() { + return 0; + } + + match range { + ModuleRange::Source(range) => { + if let Self::Namespace(namespace) = self + && namespace.0.is_empty() + { + let path = snapshot.text_for_range(range.clone()).collect::>(); + + let path = if let Some(strip_regex) = + language.config().import_path_strip_regex.as_ref() + { + strip_regex.replace_all(&path, "") + } else { + path + }; + + let path = Path::new(path.as_ref()); + if (path.starts_with(".") || path.starts_with("..")) + && let Some(parent_abs_path) = parent_abs_path + && let Ok(abs_path) = + util::paths::normalize_lexically(&parent_abs_path.join(path)) + { + *self = Self::SourceExact(abs_path.into()); + } else { + *self = Self::SourceFuzzy(path.into()); + }; + } else if matches!(self, Self::SourceExact(_)) + || matches!(self, Self::SourceFuzzy(_)) + { + log::warn!("bug in imports query: encountered multiple @source matches"); + } else { + log::warn!( + "bug in imports query: encountered both @namespace and @source match" + ); + } + } + ModuleRange::Namespace(range) => { + if let Self::Namespace(namespace) = self { + let segment = range_text(snapshot, range); + if language.config().ignored_import_segments.contains(&segment) { + return 0; + } else { + namespace.0.push(segment); + return 1; + } + } else { + log::warn!( + "bug in imports query: encountered both @namespace and @source match" + ); + } + } + } + 0 + } +} + +#[derive(Debug, Clone)] +enum ModuleRange { + Source(Range), + Namespace(Range), +} + +impl Deref for ModuleRange { + type Target = Range; + + fn deref(&self) -> &Self::Target { + match self { + ModuleRange::Source(range) => range, + ModuleRange::Namespace(range) => range, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Namespace(pub Vec>); + +impl Namespace { + pub fn occurrences(&self) -> Occurrences { + Occurrences::from_identifiers(&self.0) + } +} + +impl Imports { + pub fn gather(snapshot: &BufferSnapshot, parent_abs_path: Option<&Path>) -> Self { + // Query to match different import patterns + let mut matches = snapshot + .syntax + .matches(0..snapshot.len(), &snapshot.text, |grammar| { + grammar.imports_config().map(|imports| &imports.query) + }); + + let mut detached_nodes: Vec = Vec::new(); + let mut identifier_to_imports = HashMap::default(); + let mut wildcard_modules = Vec::new(); + let mut import_range = None; + + while let Some(query_match) = matches.peek() { + let ImportsConfig { + query: _, + import_ix, + name_ix, + namespace_ix, + source_ix, + list_ix, + wildcard_ix, + alias_ix, + } = matches.grammars()[query_match.grammar_index] + .imports_config() + .unwrap(); + + let mut new_import_range = None; + let mut alias_range = None; + let mut modules = Vec::new(); + let mut content: Option<(Range, ContentKind)> = None; + for capture in query_match.captures { + let capture_range = capture.node.byte_range(); + + if capture.index == *import_ix { + new_import_range = Some(capture_range); + } else if Some(capture.index) == *namespace_ix { + modules.push(ModuleRange::Namespace(capture_range)); + } else if Some(capture.index) == *source_ix { + modules.push(ModuleRange::Source(capture_range)); + } else if Some(capture.index) == *alias_ix { + alias_range = Some(capture_range); + } else { + let mut found_content = None; + if Some(capture.index) == *name_ix { + found_content = Some((capture_range, ContentKind::Name)); + } else if Some(capture.index) == *list_ix { + found_content = Some((capture_range, ContentKind::List)); + } else if Some(capture.index) == *wildcard_ix { + found_content = Some((capture_range, ContentKind::Wildcard)); + } + if let Some((found_content_range, found_kind)) = found_content { + if let Some((_, old_kind)) = content { + let point = found_content_range.to_point(snapshot); + log::warn!( + "bug in {} imports query: unexpected multiple captures of {} and {} ({}:{}:{})", + query_match.language.name(), + old_kind.capture_name(), + found_kind.capture_name(), + snapshot + .file() + .map(|p| p.path().display(PathStyle::Posix)) + .unwrap_or_default(), + point.start.row + 1, + point.start.column + 1 + ); + } + content = Some((found_content_range, found_kind)); + } + } + } + + if let Some(new_import_range) = new_import_range { + log::trace!("starting new import {:?}", new_import_range); + Self::gather_from_import_statement( + &detached_nodes, + &snapshot, + parent_abs_path, + &mut identifier_to_imports, + &mut wildcard_modules, + ); + detached_nodes.clear(); + import_range = Some(new_import_range.clone()); + } + + if let Some((content, content_kind)) = content { + if import_range + .as_ref() + .is_some_and(|import_range| import_range.contains_inclusive(&content)) + { + detached_nodes.push(DetachedNode { + modules, + content: content.clone(), + content_kind, + alias: alias_range.unwrap_or(0..0), + language: query_match.language.clone(), + }); + } else { + log::trace!( + "filtered out match not inside import range: {content_kind:?} at {content:?}" + ); + } + } + + matches.advance(); + } + + Self::gather_from_import_statement( + &detached_nodes, + &snapshot, + parent_abs_path, + &mut identifier_to_imports, + &mut wildcard_modules, + ); + + Imports { + identifier_to_imports, + wildcard_modules, + } + } + + fn gather_from_import_statement( + detached_nodes: &[DetachedNode], + snapshot: &BufferSnapshot, + parent_abs_path: Option<&Path>, + identifier_to_imports: &mut HashMap>, + wildcard_modules: &mut Vec, + ) { + let mut trees = Vec::new(); + + for detached_node in detached_nodes { + if let Some(node) = Self::attach_node(detached_node.into(), &mut trees) { + trees.push(node); + } + log::trace!( + "Attached node to tree\n{:#?}\nAttach result:\n{:#?}", + detached_node, + trees + .iter() + .map(|tree| tree.debug(snapshot)) + .collect::>() + ); + } + + for tree in &trees { + let mut module = Module::empty(); + Self::gather_from_tree( + tree, + snapshot, + parent_abs_path, + &mut module, + identifier_to_imports, + wildcard_modules, + ); + } + } + + fn attach_node(mut node: ImportTree, trees: &mut Vec) -> Option { + let mut tree_index = 0; + while tree_index < trees.len() { + let tree = &mut trees[tree_index]; + if !node.content.is_empty() && node.content == tree.content { + // multiple matches can apply to the same name/list/wildcard. This keeps the queries + // simpler by combining info from these matches. + if tree.module.is_empty() { + tree.module = node.module; + tree.module_children = node.module_children; + } + if tree.alias.is_empty() { + tree.alias = node.alias; + } + return None; + } else if !node.module.is_empty() && node.module.contains_inclusive(&tree.range()) { + node.module_children.push(trees.remove(tree_index)); + continue; + } else if !node.content.is_empty() && node.content.contains_inclusive(&tree.content) { + node.content_children.push(trees.remove(tree_index)); + continue; + } else if !tree.content.is_empty() && tree.content.contains_inclusive(&node.content) { + if let Some(node) = Self::attach_node(node, &mut tree.content_children) { + tree.content_children.push(node); + } + return None; + } + tree_index += 1; + } + Some(node) + } + + fn gather_from_tree( + tree: &ImportTree, + snapshot: &BufferSnapshot, + parent_abs_path: Option<&Path>, + current_module: &mut Module, + identifier_to_imports: &mut HashMap>, + wildcard_modules: &mut Vec, + ) { + let mut pop_count = 0; + + if tree.module_children.is_empty() { + pop_count += + current_module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); + } else { + for child in &tree.module_children { + pop_count += Self::extend_namespace_from_tree( + child, + snapshot, + parent_abs_path, + current_module, + ); + } + }; + + if tree.content_children.is_empty() && !tree.content.is_empty() { + match tree.content_kind { + ContentKind::Name | ContentKind::List => { + if tree.alias.is_empty() { + identifier_to_imports + .entry(Identifier { + language_id: tree.language.id(), + name: range_text(snapshot, &tree.content), + }) + .or_default() + .push(Import::Direct { + module: current_module.clone(), + }); + } else { + let alias_name: Arc = range_text(snapshot, &tree.alias); + let external_name = range_text(snapshot, &tree.content); + // Language specific: skip "_" aliases for Rust + if alias_name.as_ref() != "_" { + identifier_to_imports + .entry(Identifier { + language_id: tree.language.id(), + name: alias_name, + }) + .or_default() + .push(Import::Alias { + module: current_module.clone(), + external_identifier: Identifier { + language_id: tree.language.id(), + name: external_name, + }, + }); + } + } + } + ContentKind::Wildcard => wildcard_modules.push(current_module.clone()), + } + } else { + for child in &tree.content_children { + Self::gather_from_tree( + child, + snapshot, + parent_abs_path, + current_module, + identifier_to_imports, + wildcard_modules, + ); + } + } + + if pop_count > 0 { + match current_module { + Module::SourceExact(_) | Module::SourceFuzzy(_) => { + log::warn!( + "bug in imports query: encountered both @namespace and @source match" + ); + } + Module::Namespace(namespace) => { + namespace.0.drain(namespace.0.len() - pop_count..); + } + } + } + } + + fn extend_namespace_from_tree( + tree: &ImportTree, + snapshot: &BufferSnapshot, + parent_abs_path: Option<&Path>, + module: &mut Module, + ) -> usize { + let mut pop_count = 0; + if tree.module_children.is_empty() { + pop_count += module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); + } else { + for child in &tree.module_children { + pop_count += + Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); + } + } + if tree.content_children.is_empty() { + pop_count += module.push_range( + &ModuleRange::Namespace(tree.content.clone()), + snapshot, + &tree.language, + parent_abs_path, + ); + } else { + for child in &tree.content_children { + pop_count += + Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); + } + } + pop_count + } +} + +fn range_text(snapshot: &BufferSnapshot, range: &Range) -> Arc { + snapshot + .text_for_range(range.clone()) + .collect::>() + .into() +} + +#[derive(Debug)] +struct DetachedNode { + modules: Vec, + content: Range, + content_kind: ContentKind, + alias: Range, + language: Arc, +} + +#[derive(Debug, Clone, Copy)] +enum ContentKind { + Name, + Wildcard, + List, +} + +impl ContentKind { + fn capture_name(&self) -> &'static str { + match self { + ContentKind::Name => "name", + ContentKind::Wildcard => "wildcard", + ContentKind::List => "list", + } + } +} + +#[derive(Debug)] +struct ImportTree { + module: ModuleRange, + /// When non-empty, provides namespace / source info which should be used instead of `module`. + module_children: Vec, + content: Range, + /// When non-empty, provides content which should be used instead of `content`. + content_children: Vec, + content_kind: ContentKind, + alias: Range, + language: Arc, +} + +impl ImportTree { + fn range(&self) -> Range { + self.module.start.min(self.content.start)..self.module.end.max(self.content.end) + } + + #[allow(dead_code)] + fn debug<'a>(&'a self, snapshot: &'a BufferSnapshot) -> ImportTreeDebug<'a> { + ImportTreeDebug { + tree: self, + snapshot, + } + } + + fn from_module_range(module: &ModuleRange, language: Arc) -> Self { + ImportTree { + module: module.clone(), + module_children: Vec::new(), + content: 0..0, + content_children: Vec::new(), + content_kind: ContentKind::Name, + alias: 0..0, + language, + } + } +} + +impl From<&DetachedNode> for ImportTree { + fn from(value: &DetachedNode) -> Self { + let module; + let module_children; + match value.modules.len() { + 0 => { + module = ModuleRange::Namespace(0..0); + module_children = Vec::new(); + } + 1 => { + module = value.modules[0].clone(); + module_children = Vec::new(); + } + _ => { + module = ModuleRange::Namespace( + value.modules.first().unwrap().start..value.modules.last().unwrap().end, + ); + module_children = value + .modules + .iter() + .map(|module| ImportTree::from_module_range(module, value.language.clone())) + .collect(); + } + } + + ImportTree { + module, + module_children, + content: value.content.clone(), + content_children: Vec::new(), + content_kind: value.content_kind, + alias: value.alias.clone(), + language: value.language.clone(), + } + } +} + +struct ImportTreeDebug<'a> { + tree: &'a ImportTree, + snapshot: &'a BufferSnapshot, +} + +impl std::fmt::Debug for ImportTreeDebug<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportTree") + .field("module_range", &self.tree.module) + .field("module_text", &range_text(self.snapshot, &self.tree.module)) + .field( + "module_children", + &self + .tree + .module_children + .iter() + .map(|child| child.debug(&self.snapshot)) + .collect::>(), + ) + .field("content_range", &self.tree.content) + .field( + "content_text", + &range_text(self.snapshot, &self.tree.content), + ) + .field( + "content_children", + &self + .tree + .content_children + .iter() + .map(|child| child.debug(&self.snapshot)) + .collect::>(), + ) + .field("content_kind", &self.tree.content_kind) + .field("alias_range", &self.tree.alias) + .field("alias_text", &range_text(self.snapshot, &self.tree.alias)) + .finish() + } +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + use std::sync::{Arc, LazyLock}; + + use super::*; + use collections::HashSet; + use gpui::{TestAppContext, prelude::*}; + use indoc::indoc; + use language::{ + Buffer, Language, LanguageConfig, tree_sitter_python, tree_sitter_rust, + tree_sitter_typescript, + }; + use regex::Regex; + + #[gpui::test] + fn test_rust_simple(cx: &mut TestAppContext) { + check_imports( + &RUST, + "use std::collections::HashMap;", + &[&["std", "collections", "HashMap"]], + cx, + ); + + check_imports( + &RUST, + "pub use std::collections::HashMap;", + &[&["std", "collections", "HashMap"]], + cx, + ); + + check_imports( + &RUST, + "use std::collections::{HashMap, HashSet};", + &[ + &["std", "collections", "HashMap"], + &["std", "collections", "HashSet"], + ], + cx, + ); + } + + #[gpui::test] + fn test_rust_nested(cx: &mut TestAppContext) { + check_imports( + &RUST, + "use std::{any::TypeId, collections::{HashMap, HashSet}};", + &[ + &["std", "any", "TypeId"], + &["std", "collections", "HashMap"], + &["std", "collections", "HashSet"], + ], + cx, + ); + + check_imports( + &RUST, + "use a::b::c::{d::e::F, g::h::I};", + &[ + &["a", "b", "c", "d", "e", "F"], + &["a", "b", "c", "g", "h", "I"], + ], + cx, + ); + } + + #[gpui::test] + fn test_rust_multiple_imports(cx: &mut TestAppContext) { + check_imports( + &RUST, + indoc! {" + use std::collections::HashMap; + use std::any::{TypeId, Any}; + "}, + &[ + &["std", "collections", "HashMap"], + &["std", "any", "TypeId"], + &["std", "any", "Any"], + ], + cx, + ); + + check_imports( + &RUST, + indoc! {" + use std::collections::HashSet; + + fn main() { + let unqualified = HashSet::new(); + let qualified = std::collections::HashMap::new(); + } + + use std::any::TypeId; + "}, + &[ + &["std", "collections", "HashSet"], + &["std", "any", "TypeId"], + ], + cx, + ); + } + + #[gpui::test] + fn test_rust_wildcard(cx: &mut TestAppContext) { + check_imports(&RUST, "use prelude::*;", &[&["prelude", "WILDCARD"]], cx); + + check_imports( + &RUST, + "use zed::prelude::*;", + &[&["zed", "prelude", "WILDCARD"]], + cx, + ); + + check_imports(&RUST, "use prelude::{*};", &[&["prelude", "WILDCARD"]], cx); + + check_imports( + &RUST, + "use prelude::{File, *};", + &[&["prelude", "File"], &["prelude", "WILDCARD"]], + cx, + ); + + check_imports( + &RUST, + "use zed::{App, prelude::*};", + &[&["zed", "App"], &["zed", "prelude", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_rust_alias(cx: &mut TestAppContext) { + check_imports( + &RUST, + "use std::io::Result as IoResult;", + &[&["std", "io", "Result AS IoResult"]], + cx, + ); + } + + #[gpui::test] + fn test_rust_crate_and_super(cx: &mut TestAppContext) { + check_imports(&RUST, "use crate::a::b::c;", &[&["a", "b", "c"]], cx); + check_imports(&RUST, "use super::a::b::c;", &[&["a", "b", "c"]], cx); + // TODO: Consider stripping leading "::". Not done for now because for the text similarity matching usecase this + // is fine. + check_imports(&RUST, "use ::a::b::c;", &[&["::a", "b", "c"]], cx); + } + + #[gpui::test] + fn test_typescript_imports(cx: &mut TestAppContext) { + let parent_abs_path = PathBuf::from("/home/user/project"); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import "./maths.js";"#, + &[&["SOURCE /home/user/project/maths", "WILDCARD"]], + cx, + ); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import "../maths.js";"#, + &[&["SOURCE /home/user/maths", "WILDCARD"]], + cx, + ); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import RandomNumberGenerator, { pi as π } from "./maths.js";"#, + &[ + &["SOURCE /home/user/project/maths", "RandomNumberGenerator"], + &["SOURCE /home/user/project/maths", "pi AS π"], + ], + cx, + ); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import { pi, phi, absolute } from "./maths.js";"#, + &[ + &["SOURCE /home/user/project/maths", "pi"], + &["SOURCE /home/user/project/maths", "phi"], + &["SOURCE /home/user/project/maths", "absolute"], + ], + cx, + ); + + // index.js is removed by import_path_strip_regex + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import { pi, phi, absolute } from "./maths/index.js";"#, + &[ + &["SOURCE /home/user/project/maths", "pi"], + &["SOURCE /home/user/project/maths", "phi"], + &["SOURCE /home/user/project/maths", "absolute"], + ], + cx, + ); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import type { SomeThing } from "./some-module.js";"#, + &[&["SOURCE /home/user/project/some-module", "SomeThing"]], + cx, + ); + + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import { type SomeThing, OtherThing } from "./some-module.js";"#, + &[ + &["SOURCE /home/user/project/some-module", "SomeThing"], + &["SOURCE /home/user/project/some-module", "OtherThing"], + ], + cx, + ); + + // index.js is removed by import_path_strip_regex + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import { type SomeThing, OtherThing } from "./some-module/index.js";"#, + &[ + &["SOURCE /home/user/project/some-module", "SomeThing"], + &["SOURCE /home/user/project/some-module", "OtherThing"], + ], + cx, + ); + + // fuzzy paths + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import { type SomeThing, OtherThing } from "@my-app/some-module.js";"#, + &[ + &["SOURCE FUZZY @my-app/some-module", "SomeThing"], + &["SOURCE FUZZY @my-app/some-module", "OtherThing"], + ], + cx, + ); + } + + #[gpui::test] + fn test_typescript_named_module_imports(cx: &mut TestAppContext) { + let parent_abs_path = PathBuf::from("/home/user/project"); + + // TODO: These should provide the name that the module is bound to. + // For now instead these are treated as unqualified wildcard imports. + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import * as math from "./maths.js";"#, + // &[&["/home/user/project/maths.js", "WILDCARD AS math"]], + &[&["SOURCE /home/user/project/maths", "WILDCARD"]], + cx, + ); + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &TYPESCRIPT, + r#"import math = require("./maths");"#, + // &[&["/home/user/project/maths", "WILDCARD AS math"]], + &[&["SOURCE /home/user/project/maths", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_python_imports(cx: &mut TestAppContext) { + check_imports(&PYTHON, "from math import pi", &[&["math", "pi"]], cx); + + check_imports( + &PYTHON, + "from math import pi, sin, cos", + &[&["math", "pi"], &["math", "sin"], &["math", "cos"]], + cx, + ); + + check_imports(&PYTHON, "from math import *", &[&["math", "WILDCARD"]], cx); + + check_imports( + &PYTHON, + "from math import foo.bar.baz", + &[&["math", "foo", "bar", "baz"]], + cx, + ); + + check_imports( + &PYTHON, + "from math import pi as PI", + &[&["math", "pi AS PI"]], + cx, + ); + + check_imports( + &PYTHON, + "from serializers.json import JsonSerializer", + &[&["serializers", "json", "JsonSerializer"]], + cx, + ); + + check_imports( + &PYTHON, + "from custom.serializers import json, xml, yaml", + &[ + &["custom", "serializers", "json"], + &["custom", "serializers", "xml"], + &["custom", "serializers", "yaml"], + ], + cx, + ); + } + + #[gpui::test] + fn test_python_named_module_imports(cx: &mut TestAppContext) { + // TODO: These should provide the name that the module is bound to. + // For now instead these are treated as unqualified wildcard imports. + // + // check_imports(&PYTHON, "import math", &[&["math", "WILDCARD as math"]], cx); + // check_imports(&PYTHON, "import math as maths", &[&["math", "WILDCARD AS maths"]], cx); + // + // Something like: + // + // (import_statement + // name: [ + // (dotted_name + // (identifier)* @namespace + // (identifier) @name.module .) + // (aliased_import + // name: (dotted_name + // ((identifier) ".")* @namespace + // (identifier) @name.module .) + // alias: (identifier) @alias) + // ]) @import + + check_imports(&PYTHON, "import math", &[&["math", "WILDCARD"]], cx); + + check_imports( + &PYTHON, + "import math as maths", + &[&["math", "WILDCARD"]], + cx, + ); + + check_imports(&PYTHON, "import a.b.c", &[&["a", "b", "c", "WILDCARD"]], cx); + + check_imports( + &PYTHON, + "import a.b.c as d", + &[&["a", "b", "c", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_python_package_relative_imports(cx: &mut TestAppContext) { + // TODO: These should provide info about the dir they are relative to, to provide more + // precise resolution. Instead, fuzzy matching is used as usual. + + check_imports(&PYTHON, "from . import math", &[&["math"]], cx); + + check_imports(&PYTHON, "from .a import math", &[&["a", "math"]], cx); + + check_imports( + &PYTHON, + "from ..a.b import math", + &[&["a", "b", "math"]], + cx, + ); + + check_imports( + &PYTHON, + "from ..a.b import *", + &[&["a", "b", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_c_imports(cx: &mut TestAppContext) { + let parent_abs_path = PathBuf::from("/home/user/project"); + + // TODO: Distinguish that these are not relative to current path + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &C, + r#"#include "#, + &[&["SOURCE FUZZY math.h", "WILDCARD"]], + cx, + ); + + // TODO: These should be treated as relative, but don't start with ./ or ../ + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &C, + r#"#include "math.h""#, + &[&["SOURCE FUZZY math.h", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_cpp_imports(cx: &mut TestAppContext) { + let parent_abs_path = PathBuf::from("/home/user/project"); + + // TODO: Distinguish that these are not relative to current path + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &CPP, + r#"#include "#, + &[&["SOURCE FUZZY math.h", "WILDCARD"]], + cx, + ); + + // TODO: These should be treated as relative, but don't start with ./ or ../ + check_imports_with_file_abs_path( + Some(&parent_abs_path), + &CPP, + r#"#include "math.h""#, + &[&["SOURCE FUZZY math.h", "WILDCARD"]], + cx, + ); + } + + #[gpui::test] + fn test_go_imports(cx: &mut TestAppContext) { + check_imports( + &GO, + r#"import . "lib/math""#, + &[&["lib/math", "WILDCARD"]], + cx, + ); + + // not included, these are only for side-effects + check_imports(&GO, r#"import _ "lib/math""#, &[], cx); + } + + #[gpui::test] + fn test_go_named_module_imports(cx: &mut TestAppContext) { + // TODO: These should provide the name that the module is bound to. + // For now instead these are treated as unqualified wildcard imports. + + check_imports( + &GO, + r#"import "lib/math""#, + &[&["lib/math", "WILDCARD"]], + cx, + ); + check_imports( + &GO, + r#"import m "lib/math""#, + &[&["lib/math", "WILDCARD"]], + cx, + ); + } + + #[track_caller] + fn check_imports( + language: &Arc, + source: &str, + expected: &[&[&str]], + cx: &mut TestAppContext, + ) { + check_imports_with_file_abs_path(None, language, source, expected, cx); + } + + #[track_caller] + fn check_imports_with_file_abs_path( + parent_abs_path: Option<&Path>, + language: &Arc, + source: &str, + expected: &[&[&str]], + cx: &mut TestAppContext, + ) { + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(source, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + + let imports = Imports::gather(&snapshot, parent_abs_path); + let mut actual_symbols = imports + .identifier_to_imports + .iter() + .flat_map(|(identifier, imports)| { + imports + .iter() + .map(|import| import.to_identifier_parts(identifier.name.as_ref())) + }) + .chain( + imports + .wildcard_modules + .iter() + .map(|module| module.to_identifier_parts("WILDCARD")), + ) + .collect::>(); + let mut expected_symbols = expected + .iter() + .map(|expected| expected.iter().map(|s| s.to_string()).collect::>()) + .collect::>(); + actual_symbols.sort(); + expected_symbols.sort(); + if actual_symbols != expected_symbols { + let top_layer = snapshot.syntax_layers().next().unwrap(); + panic!( + "Expected imports: {:?}\n\ + Actual imports: {:?}\n\ + Tree:\n{}", + expected_symbols, + actual_symbols, + tree_to_string(&top_layer.node()), + ); + } + } + + fn tree_to_string(node: &tree_sitter::Node) -> String { + let mut cursor = node.walk(); + let mut result = String::new(); + let mut depth = 0; + 'outer: loop { + result.push_str(&" ".repeat(depth)); + if let Some(field_name) = cursor.field_name() { + result.push_str(field_name); + result.push_str(": "); + } + if cursor.node().is_named() { + result.push_str(cursor.node().kind()); + } else { + result.push('"'); + result.push_str(cursor.node().kind()); + result.push('"'); + } + result.push('\n'); + + if cursor.goto_first_child() { + depth += 1; + continue; + } + if cursor.goto_next_sibling() { + continue; + } + while cursor.goto_parent() { + depth -= 1; + if cursor.goto_next_sibling() { + continue 'outer; + } + } + break; + } + result + } + + static RUST: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + ignored_import_segments: HashSet::from_iter(["crate".into(), "super".into()]), + import_path_strip_regex: Some(Regex::new("/(lib|mod)\\.rs$").unwrap()), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_imports_query(include_str!("../../languages/src/rust/imports.scm")) + .unwrap(), + ) + }); + + static TYPESCRIPT: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + import_path_strip_regex: Some(Regex::new("(?:/index)?\\.[jt]s$").unwrap()), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_imports_query(include_str!("../../languages/src/typescript/imports.scm")) + .unwrap(), + ) + }); + + static PYTHON: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "Python".into(), + import_path_strip_regex: Some(Regex::new("/__init__\\.py$").unwrap()), + ..Default::default() + }, + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_imports_query(include_str!("../../languages/src/python/imports.scm")) + .unwrap(), + ) + }); + + // TODO: Ideally should use actual language configurations + static C: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "C".into(), + import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), + ..Default::default() + }, + Some(tree_sitter_c::LANGUAGE.into()), + ) + .with_imports_query(include_str!("../../languages/src/c/imports.scm")) + .unwrap(), + ) + }); + + static CPP: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "C++".into(), + import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), + ..Default::default() + }, + Some(tree_sitter_cpp::LANGUAGE.into()), + ) + .with_imports_query(include_str!("../../languages/src/cpp/imports.scm")) + .unwrap(), + ) + }); + + static GO: LazyLock> = LazyLock::new(|| { + Arc::new( + Language::new( + LanguageConfig { + name: "Go".into(), + ..Default::default() + }, + Some(tree_sitter_go::LANGUAGE.into()), + ) + .with_imports_query(include_str!("../../languages/src/go/imports.scm")) + .unwrap(), + ) + }); + + impl Import { + fn to_identifier_parts(&self, identifier: &str) -> Vec { + match self { + Import::Direct { module } => module.to_identifier_parts(identifier), + Import::Alias { + module, + external_identifier: external_name, + } => { + module.to_identifier_parts(&format!("{} AS {}", external_name.name, identifier)) + } + } + } + } + + impl Module { + fn to_identifier_parts(&self, identifier: &str) -> Vec { + match self { + Self::Namespace(namespace) => namespace.to_identifier_parts(identifier), + Self::SourceExact(path) => { + vec![ + format!("SOURCE {}", path.display().to_string().replace("\\", "/")), + identifier.to_string(), + ] + } + Self::SourceFuzzy(path) => { + vec![ + format!( + "SOURCE FUZZY {}", + path.display().to_string().replace("\\", "/") + ), + identifier.to_string(), + ] + } + } + } + } + + impl Namespace { + fn to_identifier_parts(&self, identifier: &str) -> Vec { + self.0 + .iter() + .map(|chunk| chunk.to_string()) + .chain(std::iter::once(identifier.to_string())) + .collect::>() + } + } +} diff --git a/crates/edit_prediction_context/src/outline.rs b/crates/edit_prediction_context/src/outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec02c869dfae4cb861206cb801c285462e734f36 --- /dev/null +++ b/crates/edit_prediction_context/src/outline.rs @@ -0,0 +1,126 @@ +use language::{BufferSnapshot, SyntaxMapMatches}; +use std::{cmp::Reverse, ops::Range}; + +use crate::declaration::Identifier; + +// TODO: +// +// * how to handle multiple name captures? for now last one wins +// +// * annotation ranges +// +// * new "signature" capture for outline queries +// +// * Check parent behavior of "int x, y = 0" declarations in a test + +pub struct OutlineDeclaration { + pub parent_index: Option, + pub identifier: Identifier, + pub item_range: Range, + pub signature_range: Range, +} + +pub fn declarations_in_buffer(buffer: &BufferSnapshot) -> Vec { + declarations_overlapping_range(0..buffer.len(), buffer) +} + +pub fn declarations_overlapping_range( + range: Range, + buffer: &BufferSnapshot, +) -> Vec { + let mut declarations = OutlineIterator::new(range, buffer).collect::>(); + declarations.sort_unstable_by_key(|item| (item.item_range.start, Reverse(item.item_range.end))); + + let mut parent_stack: Vec<(usize, Range)> = Vec::new(); + for (index, declaration) in declarations.iter_mut().enumerate() { + while let Some((top_parent_index, top_parent_range)) = parent_stack.last() { + if declaration.item_range.start >= top_parent_range.end { + parent_stack.pop(); + } else { + declaration.parent_index = Some(*top_parent_index); + break; + } + } + parent_stack.push((index, declaration.item_range.clone())); + } + declarations +} + +/// Iterates outline items without being ordered w.r.t. nested items and without populating +/// `parent`. +pub struct OutlineIterator<'a> { + buffer: &'a BufferSnapshot, + matches: SyntaxMapMatches<'a>, +} + +impl<'a> OutlineIterator<'a> { + pub fn new(range: Range, buffer: &'a BufferSnapshot) -> Self { + let matches = buffer.syntax.matches(range, &buffer.text, |grammar| { + grammar.outline_config.as_ref().map(|c| &c.query) + }); + + Self { buffer, matches } + } +} + +impl<'a> Iterator for OutlineIterator<'a> { + type Item = OutlineDeclaration; + + fn next(&mut self) -> Option { + while let Some(mat) = self.matches.peek() { + let config = self.matches.grammars()[mat.grammar_index] + .outline_config + .as_ref() + .unwrap(); + + let mut name_range = None; + let mut item_range = None; + let mut signature_start = None; + let mut signature_end = None; + + let mut add_to_signature = |range: Range| { + if signature_start.is_none() { + signature_start = Some(range.start); + } + signature_end = Some(range.end); + }; + + for capture in mat.captures { + let range = capture.node.byte_range(); + if capture.index == config.name_capture_ix { + name_range = Some(range.clone()); + add_to_signature(range); + } else if Some(capture.index) == config.context_capture_ix + || Some(capture.index) == config.extra_context_capture_ix + { + add_to_signature(range); + } else if capture.index == config.item_capture_ix { + item_range = Some(range.clone()); + } + } + + let language_id = mat.language.id(); + self.matches.advance(); + + if let Some(name_range) = name_range + && let Some(item_range) = item_range + && let Some(signature_start) = signature_start + && let Some(signature_end) = signature_end + { + let name = self + .buffer + .text_for_range(name_range) + .collect::() + .into(); + + return Some(OutlineDeclaration { + identifier: Identifier { name, language_id }, + item_range: item_range, + signature_range: signature_start..signature_end, + parent_index: None, + }); + } + } + None + } +} diff --git a/crates/edit_prediction_context/src/reference.rs b/crates/edit_prediction_context/src/reference.rs new file mode 100644 index 0000000000000000000000000000000000000000..699adf1d8036802a7a4b9e34ca8e8094e4f97458 --- /dev/null +++ b/crates/edit_prediction_context/src/reference.rs @@ -0,0 +1,173 @@ +use collections::HashMap; +use language::BufferSnapshot; +use std::ops::Range; +use util::RangeExt; + +use crate::{ + declaration::Identifier, + excerpt::{EditPredictionExcerpt, EditPredictionExcerptText}, +}; + +#[derive(Debug, Clone)] +pub struct Reference { + pub identifier: Identifier, + pub range: Range, + pub region: ReferenceRegion, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ReferenceRegion { + Breadcrumb, + Nearby, +} + +pub fn references_in_excerpt( + excerpt: &EditPredictionExcerpt, + excerpt_text: &EditPredictionExcerptText, + snapshot: &BufferSnapshot, +) -> HashMap> { + let mut references = references_in_range( + excerpt.range.clone(), + excerpt_text.body.as_str(), + ReferenceRegion::Nearby, + snapshot, + ); + + for ((_, range), text) in excerpt + .parent_declarations + .iter() + .zip(excerpt_text.parent_signatures.iter()) + { + references.extend(references_in_range( + range.clone(), + text.as_str(), + ReferenceRegion::Breadcrumb, + snapshot, + )); + } + + let mut identifier_to_references: HashMap> = HashMap::default(); + for reference in references { + identifier_to_references + .entry(reference.identifier.clone()) + .or_insert_with(Vec::new) + .push(reference); + } + identifier_to_references +} + +/// Finds all nodes which have a "variable" match from the highlights query within the offset range. +pub fn references_in_range( + range: Range, + range_text: &str, + reference_region: ReferenceRegion, + buffer: &BufferSnapshot, +) -> Vec { + let mut matches = buffer + .syntax + .matches(range.clone(), &buffer.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + + let mut references = Vec::new(); + let mut last_added_range = None; + while let Some(mat) = matches.peek() { + let config = matches.grammars()[mat.grammar_index] + .highlights_config + .as_ref(); + + if let Some(config) = config { + for capture in mat.captures { + if config.identifier_capture_indices.contains(&capture.index) { + let node_range = capture.node.byte_range(); + + // sometimes multiple highlight queries match - this deduplicates them + if Some(node_range.clone()) == last_added_range { + continue; + } + + if !range.contains_inclusive(&node_range) { + continue; + } + + let identifier_text = + &range_text[node_range.start - range.start..node_range.end - range.start]; + + references.push(Reference { + identifier: Identifier { + name: identifier_text.into(), + language_id: mat.language.id(), + }, + range: node_range.clone(), + region: reference_region, + }); + last_added_range = Some(node_range); + } + } + } + + matches.advance(); + } + references +} + +#[cfg(test)] +mod test { + use gpui::{TestAppContext, prelude::*}; + use indoc::indoc; + use language::{BufferSnapshot, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + + use crate::reference::{ReferenceRegion, references_in_range}; + + #[gpui::test] + fn test_identifier_node_truncated(cx: &mut TestAppContext) { + let code = indoc! { r#" + fn main() { + add(1, 2); + } + + fn add(a: i32, b: i32) -> i32 { + a + b + } + "# }; + let buffer = create_buffer(code, cx); + + let range = 0..35; + let references = references_in_range( + range.clone(), + &code[range], + ReferenceRegion::Breadcrumb, + &buffer, + ); + assert_eq!(references.len(), 2); + assert_eq!(references[0].identifier.name.as_ref(), "main"); + assert_eq!(references[1].identifier.name.as_ref(), "add"); + } + + fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { + let buffer = + cx.new(|cx| language::Buffer::local(text, cx).with_language(rust_lang().into(), cx)); + buffer.read_with(cx, |buffer, _| buffer.snapshot()) + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) + .unwrap() + .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) + .unwrap() + } +} diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs new file mode 100644 index 0000000000000000000000000000000000000000..76aa10c076d95aa10bd830bace23ad7b410d8102 --- /dev/null +++ b/crates/edit_prediction_context/src/syntax_index.rs @@ -0,0 +1,1071 @@ +use anyhow::{Result, anyhow}; +use collections::{HashMap, HashSet}; +use futures::channel::mpsc; +use futures::lock::Mutex; +use futures::{FutureExt as _, StreamExt, future}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; +use itertools::Itertools; + +use language::{Buffer, BufferEvent}; +use postage::stream::Stream as _; +use project::buffer_store::{BufferStore, BufferStoreEvent}; +use project::worktree_store::{WorktreeStore, WorktreeStoreEvent}; +use project::{PathChange, Project, ProjectEntryId, ProjectPath}; +use slotmap::SlotMap; +use std::iter; +use std::ops::{DerefMut, Range}; +use std::sync::Arc; +use text::BufferId; +use util::{RangeExt as _, debug_panic, some_or_debug_panic}; + +use crate::CachedDeclarationPath; +use crate::declaration::{ + BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier, +}; +use crate::outline::declarations_in_buffer; + +// TODO +// +// * Also queue / debounce buffer changes. A challenge for this is that use of +// `buffer_declarations_containing_range` assumes that the index is always immediately up to date. +// +// * Add a per language configuration for skipping indexing. +// +// * Handle tsx / ts / js referencing each-other + +// Potential future improvements: +// +// * Prevent indexing of a large file from blocking the queue. +// +// * Send multiple selected excerpt ranges. Challenge is that excerpt ranges influence which +// references are present and their scores. +// +// * Include single-file worktrees / non visible worktrees? E.g. go to definition that resolves to a +// file in a build dependency. Should not be editable in that case - but how to distinguish the case +// where it should be editable? + +// Potential future optimizations: +// +// * Index files on multiple threads in Zed (currently only parallel for the CLI). Adding some kind +// of priority system to the background executor could help - it's single threaded for now to avoid +// interfering with other work. +// +// * Parse files directly instead of loading into a Rope. +// +// - This would allow the task handling dirty_files to be done entirely on the background executor. +// +// - Make SyntaxMap generic to handle embedded languages? Will also need to find line boundaries, +// but that can be done by scanning characters in the flat representation. +// +// * Use something similar to slotmap without key versions. +// +// * Concurrent slotmap + +pub struct SyntaxIndex { + state: Arc>, + project: WeakEntity, + initial_file_indexing_done_rx: postage::watch::Receiver, + _file_indexing_task: Option>, +} + +pub struct SyntaxIndexState { + declarations: SlotMap, + identifiers: HashMap>, + files: HashMap, + buffers: HashMap, + dirty_files: HashMap, + dirty_files_tx: mpsc::Sender<()>, +} + +#[derive(Debug, Default)] +struct FileState { + declarations: Vec, +} + +#[derive(Default)] +struct BufferState { + declarations: Vec, + task: Option>, +} + +impl SyntaxIndex { + pub fn new( + project: &Entity, + file_indexing_parallelism: usize, + cx: &mut Context, + ) -> Self { + assert!(file_indexing_parallelism > 0); + let (dirty_files_tx, mut dirty_files_rx) = mpsc::channel::<()>(1); + let (mut initial_file_indexing_done_tx, initial_file_indexing_done_rx) = + postage::watch::channel(); + + let initial_state = SyntaxIndexState { + declarations: SlotMap::default(), + identifiers: HashMap::default(), + files: HashMap::default(), + buffers: HashMap::default(), + dirty_files: HashMap::default(), + dirty_files_tx, + }; + let mut this = Self { + project: project.downgrade(), + state: Arc::new(Mutex::new(initial_state)), + initial_file_indexing_done_rx, + _file_indexing_task: None, + }; + + let worktree_store = project.read(cx).worktree_store(); + let initial_worktree_snapshots = worktree_store + .read(cx) + .worktrees() + .map(|w| w.read(cx).snapshot()) + .collect::>(); + this._file_indexing_task = Some(cx.spawn(async move |this, cx| { + let snapshots_file_count = initial_worktree_snapshots + .iter() + .map(|worktree| worktree.file_count()) + .sum::(); + if snapshots_file_count > 0 { + let chunk_size = snapshots_file_count.div_ceil(file_indexing_parallelism); + let chunk_count = snapshots_file_count.div_ceil(chunk_size); + let file_chunks = initial_worktree_snapshots + .iter() + .flat_map(|worktree| { + let worktree_id = worktree.id(); + worktree.files(false, 0).map(move |entry| { + ( + entry.id, + ProjectPath { + worktree_id, + path: entry.path.clone(), + }, + ) + }) + }) + .chunks(chunk_size); + + let mut tasks = Vec::with_capacity(chunk_count); + for chunk in file_chunks.into_iter() { + tasks.push(Self::update_dirty_files( + &this, + chunk.into_iter().collect(), + cx.clone(), + )); + } + futures::future::join_all(tasks).await; + log::info!("Finished initial file indexing"); + } + + *initial_file_indexing_done_tx.borrow_mut() = true; + + let Ok(state) = this.read_with(cx, |this, _cx| Arc::downgrade(&this.state)) else { + return; + }; + while dirty_files_rx.next().await.is_some() { + let Some(state) = state.upgrade() else { + return; + }; + let mut state = state.lock().await; + let was_underused = state.dirty_files.capacity() > 255 + && state.dirty_files.len() * 8 < state.dirty_files.capacity(); + let dirty_files = state.dirty_files.drain().collect::>(); + if was_underused { + state.dirty_files.shrink_to_fit(); + } + drop(state); + if dirty_files.is_empty() { + continue; + } + + let chunk_size = dirty_files.len().div_ceil(file_indexing_parallelism); + let chunk_count = dirty_files.len().div_ceil(chunk_size); + let mut tasks = Vec::with_capacity(chunk_count); + let chunks = dirty_files.into_iter().chunks(chunk_size); + for chunk in chunks.into_iter() { + tasks.push(Self::update_dirty_files( + &this, + chunk.into_iter().collect(), + cx.clone(), + )); + } + futures::future::join_all(tasks).await; + } + })); + + cx.subscribe(&worktree_store, Self::handle_worktree_store_event) + .detach(); + + let buffer_store = project.read(cx).buffer_store().clone(); + for buffer in buffer_store.read(cx).buffers().collect::>() { + this.register_buffer(&buffer, cx); + } + cx.subscribe(&buffer_store, Self::handle_buffer_store_event) + .detach(); + + this + } + + async fn update_dirty_files( + this: &WeakEntity, + dirty_files: Vec<(ProjectEntryId, ProjectPath)>, + mut cx: AsyncApp, + ) { + for (entry_id, project_path) in dirty_files { + let Ok(task) = this.update(&mut cx, |this, cx| { + this.update_file(entry_id, project_path, cx) + }) else { + return; + }; + task.await; + } + } + + pub fn wait_for_initial_file_indexing(&self, cx: &App) -> Task> { + if *self.initial_file_indexing_done_rx.borrow() { + Task::ready(Ok(())) + } else { + let mut rx = self.initial_file_indexing_done_rx.clone(); + cx.background_spawn(async move { + loop { + match rx.recv().await { + Some(true) => return Ok(()), + Some(false) => {} + None => { + return Err(anyhow!( + "SyntaxIndex dropped while waiting for initial file indexing" + )); + } + } + } + }) + } + } + + pub fn indexed_file_paths(&self, cx: &App) -> Task> { + let state = self.state.clone(); + let project = self.project.clone(); + + cx.spawn(async move |cx| { + let state = state.lock().await; + let Some(project) = project.upgrade() else { + return vec![]; + }; + project + .read_with(cx, |project, cx| { + state + .files + .keys() + .filter_map(|entry_id| project.path_for_entry(*entry_id, cx)) + .collect() + }) + .unwrap_or_default() + }) + } + + fn handle_worktree_store_event( + &mut self, + _worktree_store: Entity, + event: &WorktreeStoreEvent, + cx: &mut Context, + ) { + use WorktreeStoreEvent::*; + match event { + WorktreeUpdatedEntries(worktree_id, updated_entries_set) => { + let state = Arc::downgrade(&self.state); + let worktree_id = *worktree_id; + let updated_entries_set = updated_entries_set.clone(); + cx.background_spawn(async move { + let Some(state) = state.upgrade() else { return }; + let mut state = state.lock().await; + for (path, entry_id, path_change) in updated_entries_set.iter() { + if let PathChange::Removed = path_change { + state.files.remove(entry_id); + state.dirty_files.remove(entry_id); + } else { + let project_path = ProjectPath { + worktree_id, + path: path.clone(), + }; + state.dirty_files.insert(*entry_id, project_path); + } + } + match state.dirty_files_tx.try_send(()) { + Err(err) if err.is_disconnected() => { + log::error!("bug: syntax indexing queue is disconnected"); + } + _ => {} + } + }) + .detach(); + } + WorktreeDeletedEntry(_worktree_id, project_entry_id) => { + let project_entry_id = *project_entry_id; + self.with_state(cx, move |state| { + state.files.remove(&project_entry_id); + }) + } + _ => {} + } + } + + fn handle_buffer_store_event( + &mut self, + _buffer_store: Entity, + event: &BufferStoreEvent, + cx: &mut Context, + ) { + use BufferStoreEvent::*; + match event { + BufferAdded(buffer) => self.register_buffer(buffer, cx), + BufferOpened { .. } + | BufferChangedFilePath { .. } + | BufferDropped { .. } + | SharedBufferClosed { .. } => {} + } + } + + pub fn state(&self) -> &Arc> { + &self.state + } + + fn with_state(&self, cx: &mut App, f: impl FnOnce(&mut SyntaxIndexState) + Send + 'static) { + if let Some(mut state) = self.state.try_lock() { + f(&mut state); + return; + } + let state = Arc::downgrade(&self.state); + cx.background_spawn(async move { + let Some(state) = state.upgrade() else { + return; + }; + let mut state = state.lock().await; + f(&mut state) + }) + .detach(); + } + + fn register_buffer(&self, buffer: &Entity, cx: &mut Context) { + let buffer_id = buffer.read(cx).remote_id(); + cx.observe_release(buffer, move |this, _buffer, cx| { + this.with_state(cx, move |state| { + if let Some(buffer_state) = state.buffers.remove(&buffer_id) { + SyntaxIndexState::remove_buffer_declarations( + &buffer_state.declarations, + &mut state.declarations, + &mut state.identifiers, + ); + } + }) + }) + .detach(); + cx.subscribe(buffer, Self::handle_buffer_event).detach(); + + self.update_buffer(buffer.clone(), cx); + } + + fn handle_buffer_event( + &mut self, + buffer: Entity, + event: &BufferEvent, + cx: &mut Context, + ) { + match event { + BufferEvent::Edited | + // paths are cached and so should be updated + BufferEvent::FileHandleChanged => self.update_buffer(buffer, cx), + _ => {} + } + } + + fn update_buffer(&self, buffer_entity: Entity, cx: &mut Context) { + let buffer = buffer_entity.read(cx); + if buffer.language().is_none() { + return; + } + + let Some((project_entry_id, cached_path)) = project::File::from_dyn(buffer.file()) + .and_then(|f| { + let project_entry_id = f.project_entry_id()?; + let cached_path = CachedDeclarationPath::new( + f.worktree.read(cx).abs_path(), + &f.path, + buffer.language(), + ); + Some((project_entry_id, cached_path)) + }) + else { + return; + }; + let buffer_id = buffer.remote_id(); + + let mut parse_status = buffer.parse_status(); + let snapshot_task = cx.spawn({ + let weak_buffer = buffer_entity.downgrade(); + async move |_, cx| { + while *parse_status.borrow() != language::ParseStatus::Idle { + parse_status.changed().await?; + } + weak_buffer.read_with(cx, |buffer, _cx| buffer.snapshot()) + } + }); + + let state = Arc::downgrade(&self.state); + let task = cx.background_spawn(async move { + // TODO: How to handle errors? + let Ok(snapshot) = snapshot_task.await else { + return; + }; + let rope = snapshot.text.as_rope(); + + let declarations = declarations_in_buffer(&snapshot) + .into_iter() + .map(|item| { + ( + item.parent_index, + BufferDeclaration::from_outline(item, &rope), + ) + }) + .collect::>(); + + let Some(state) = state.upgrade() else { + return; + }; + let mut state = state.lock().await; + let state = state.deref_mut(); + + let buffer_state = state + .buffers + .entry(buffer_id) + .or_insert_with(Default::default); + + SyntaxIndexState::remove_buffer_declarations( + &buffer_state.declarations, + &mut state.declarations, + &mut state.identifiers, + ); + + let mut new_ids = Vec::with_capacity(declarations.len()); + state.declarations.reserve(declarations.len()); + for (parent_index, mut declaration) in declarations { + declaration.parent = + parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); + + let identifier = declaration.identifier.clone(); + let declaration_id = state.declarations.insert(Declaration::Buffer { + rope: rope.clone(), + buffer_id, + declaration, + project_entry_id, + cached_path: cached_path.clone(), + }); + new_ids.push(declaration_id); + + state + .identifiers + .entry(identifier) + .or_default() + .insert(declaration_id); + } + + buffer_state.declarations = new_ids; + }); + + self.with_state(cx, move |state| { + state + .buffers + .entry(buffer_id) + .or_insert_with(Default::default) + .task = Some(task) + }); + } + + fn update_file( + &mut self, + entry_id: ProjectEntryId, + project_path: ProjectPath, + cx: &mut Context, + ) -> Task<()> { + let Some(project) = self.project.upgrade() else { + return Task::ready(()); + }; + let project = project.read(cx); + + let language_registry = project.languages(); + let Some(available_language) = + language_registry.language_for_file_path(project_path.path.as_std_path()) + else { + return Task::ready(()); + }; + let language = if let Some(Ok(Ok(language))) = language_registry + .load_language(&available_language) + .now_or_never() + { + if language + .grammar() + .is_none_or(|grammar| grammar.outline_config.is_none()) + { + return Task::ready(()); + } + future::Either::Left(async { Ok(language) }) + } else { + let language_registry = language_registry.clone(); + future::Either::Right(async move { + anyhow::Ok( + language_registry + .load_language(&available_language) + .await??, + ) + }) + }; + + let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) else { + return Task::ready(()); + }; + + let snapshot_task = worktree.update(cx, |worktree, cx| { + let load_task = worktree.load_file(&project_path.path, cx); + let worktree_abs_path = worktree.abs_path(); + cx.spawn(async move |_this, cx| { + let loaded_file = load_task.await?; + let language = language.await?; + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(loaded_file.text, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + })?; + + let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; + while *parse_status.borrow() != language::ParseStatus::Idle { + parse_status.changed().await?; + } + + let cached_path = CachedDeclarationPath::new( + worktree_abs_path, + &project_path.path, + Some(&language), + ); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; + + anyhow::Ok((snapshot, cached_path)) + }) + }); + + let state = Arc::downgrade(&self.state); + cx.background_spawn(async move { + // TODO: How to handle errors? + let Ok((snapshot, cached_path)) = snapshot_task.await else { + return; + }; + let rope = snapshot.as_rope(); + let declarations = declarations_in_buffer(&snapshot) + .into_iter() + .map(|item| (item.parent_index, FileDeclaration::from_outline(item, rope))) + .collect::>(); + + let Some(state) = state.upgrade() else { + return; + }; + let mut state = state.lock().await; + let state = state.deref_mut(); + + let file_state = state.files.entry(entry_id).or_insert_with(Default::default); + for old_declaration_id in &file_state.declarations { + let Some(declaration) = state.declarations.remove(*old_declaration_id) else { + debug_panic!("declaration not found"); + continue; + }; + if let Some(identifier_declarations) = + state.identifiers.get_mut(declaration.identifier()) + { + identifier_declarations.remove(old_declaration_id); + } + } + + let mut new_ids = Vec::with_capacity(declarations.len()); + state.declarations.reserve(declarations.len()); + for (parent_index, mut declaration) in declarations { + declaration.parent = + parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); + + let identifier = declaration.identifier.clone(); + let declaration_id = state.declarations.insert(Declaration::File { + project_entry_id: entry_id, + declaration, + cached_path: cached_path.clone(), + }); + new_ids.push(declaration_id); + + state + .identifiers + .entry(identifier) + .or_default() + .insert(declaration_id); + } + file_state.declarations = new_ids; + }) + } +} + +impl SyntaxIndexState { + pub fn declaration(&self, id: DeclarationId) -> Option<&Declaration> { + self.declarations.get(id) + } + + /// Returns declarations for the identifier. If the limit is exceeded, returns an empty vector. + /// + /// TODO: Consider doing some pre-ranking and instead truncating when N is exceeded. + pub fn declarations_for_identifier( + &self, + identifier: &Identifier, + ) -> Vec<(DeclarationId, &Declaration)> { + // make sure to not have a large stack allocation + assert!(N < 32); + + let Some(declaration_ids) = self.identifiers.get(&identifier) else { + return vec![]; + }; + + let mut result = Vec::with_capacity(N); + let mut included_buffer_entry_ids = arrayvec::ArrayVec::<_, N>::new(); + let mut file_declarations = Vec::new(); + + for declaration_id in declaration_ids { + let declaration = self.declarations.get(*declaration_id); + let Some(declaration) = some_or_debug_panic(declaration) else { + continue; + }; + match declaration { + Declaration::Buffer { + project_entry_id, .. + } => { + included_buffer_entry_ids.push(*project_entry_id); + result.push((*declaration_id, declaration)); + if result.len() == N { + return Vec::new(); + } + } + Declaration::File { + project_entry_id, .. + } => { + if !included_buffer_entry_ids.contains(&project_entry_id) { + file_declarations.push((*declaration_id, declaration)); + } + } + } + } + + for (declaration_id, declaration) in file_declarations { + match declaration { + Declaration::File { + project_entry_id, .. + } => { + if !included_buffer_entry_ids.contains(&project_entry_id) { + result.push((declaration_id, declaration)); + + if result.len() == N { + return Vec::new(); + } + } + } + Declaration::Buffer { .. } => {} + } + } + + result + } + + pub fn buffer_declarations_containing_range( + &self, + buffer_id: BufferId, + range: Range, + ) -> impl Iterator { + let Some(buffer_state) = self.buffers.get(&buffer_id) else { + return itertools::Either::Left(iter::empty()); + }; + + let iter = buffer_state + .declarations + .iter() + .filter_map(move |declaration_id| { + let Some(declaration) = self + .declarations + .get(*declaration_id) + .and_then(|d| d.as_buffer()) + else { + log::error!("bug: missing buffer outline declaration"); + return None; + }; + if declaration.item_range.contains_inclusive(&range) { + return Some((*declaration_id, declaration)); + } + return None; + }); + itertools::Either::Right(iter) + } + + pub fn file_declaration_count(&self, declaration: &Declaration) -> usize { + match declaration { + Declaration::File { + project_entry_id, .. + } => self + .files + .get(project_entry_id) + .map(|file_state| file_state.declarations.len()) + .unwrap_or_default(), + Declaration::Buffer { buffer_id, .. } => self + .buffers + .get(buffer_id) + .map(|buffer_state| buffer_state.declarations.len()) + .unwrap_or_default(), + } + } + + fn remove_buffer_declarations( + old_declaration_ids: &[DeclarationId], + declarations: &mut SlotMap, + identifiers: &mut HashMap>, + ) { + for old_declaration_id in old_declaration_ids { + let Some(declaration) = declarations.remove(*old_declaration_id) else { + debug_panic!("declaration not found"); + continue; + }; + if let Some(identifier_declarations) = identifiers.get_mut(declaration.identifier()) { + identifier_declarations.remove(old_declaration_id); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use gpui::TestAppContext; + use indoc::indoc; + use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use text::OffsetRangeExt as _; + use util::{path, rel_path::rel_path}; + + use crate::syntax_index::SyntaxIndex; + + #[gpui::test] + async fn test_unopen_indexed_files(cx: &mut TestAppContext) { + let (project, index, rust_lang_id) = init_test(cx).await; + let main = Identifier { + name: "main".into(), + language_id: rust_lang_id, + }; + + let index_state = index.read_with(cx, |index, _cx| index.state().clone()); + let index_state = index_state.lock().await; + cx.update(|cx| { + let decls = index_state.declarations_for_identifier::<8>(&main); + assert_eq!(decls.len(), 2); + + let decl = expect_file_decl("a.rs", &decls[0].1, &project, cx); + assert_eq!(decl.identifier, main); + assert_eq!(decl.item_range, 0..98); + + let decl = expect_file_decl("c.rs", &decls[1].1, &project, cx); + assert_eq!(decl.identifier, main.clone()); + assert_eq!(decl.item_range, 32..280); + }); + } + + #[gpui::test] + async fn test_parents_in_file(cx: &mut TestAppContext) { + let (project, index, rust_lang_id) = init_test(cx).await; + let test_process_data = Identifier { + name: "test_process_data".into(), + language_id: rust_lang_id, + }; + + let index_state = index.read_with(cx, |index, _cx| index.state().clone()); + let index_state = index_state.lock().await; + cx.update(|cx| { + let decls = index_state.declarations_for_identifier::<8>(&test_process_data); + assert_eq!(decls.len(), 1); + + let decl = expect_file_decl("c.rs", &decls[0].1, &project, cx); + assert_eq!(decl.identifier, test_process_data); + + let parent_id = decl.parent.unwrap(); + let parent = index_state.declaration(parent_id).unwrap(); + let parent_decl = expect_file_decl("c.rs", &parent, &project, cx); + assert_eq!( + parent_decl.identifier, + Identifier { + name: "tests".into(), + language_id: rust_lang_id + } + ); + assert_eq!(parent_decl.parent, None); + }); + } + + #[gpui::test] + async fn test_parents_in_buffer(cx: &mut TestAppContext) { + let (project, index, rust_lang_id) = init_test(cx).await; + let test_process_data = Identifier { + name: "test_process_data".into(), + language_id: rust_lang_id, + }; + + let buffer = project + .update(cx, |project, cx| { + let project_path = project.find_project_path("c.rs", cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let index_state = index.read_with(cx, |index, _cx| index.state().clone()); + let index_state = index_state.lock().await; + cx.update(|cx| { + let decls = index_state.declarations_for_identifier::<8>(&test_process_data); + assert_eq!(decls.len(), 1); + + let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); + assert_eq!(decl.identifier, test_process_data); + + let parent_id = decl.parent.unwrap(); + let parent = index_state.declaration(parent_id).unwrap(); + let parent_decl = expect_buffer_decl("c.rs", &parent, &project, cx); + assert_eq!( + parent_decl.identifier, + Identifier { + name: "tests".into(), + language_id: rust_lang_id + } + ); + assert_eq!(parent_decl.parent, None); + }); + + drop(buffer); + } + + #[gpui::test] + async fn test_declarations_limit(cx: &mut TestAppContext) { + let (_, index, rust_lang_id) = init_test(cx).await; + + let index_state = index.read_with(cx, |index, _cx| index.state().clone()); + let index_state = index_state.lock().await; + let decls = index_state.declarations_for_identifier::<1>(&Identifier { + name: "main".into(), + language_id: rust_lang_id, + }); + assert_eq!(decls.len(), 0); + } + + #[gpui::test] + async fn test_buffer_shadow(cx: &mut TestAppContext) { + let (project, index, rust_lang_id) = init_test(cx).await; + + let main = Identifier { + name: "main".into(), + language_id: rust_lang_id, + }; + + let buffer = project + .update(cx, |project, cx| { + let project_path = project.find_project_path("c.rs", cx).unwrap(); + project.open_buffer(project_path, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let index_state_arc = index.read_with(cx, |index, _cx| index.state().clone()); + { + let index_state = index_state_arc.lock().await; + + cx.update(|cx| { + let decls = index_state.declarations_for_identifier::<8>(&main); + assert_eq!(decls.len(), 2); + let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); + assert_eq!(decl.identifier, main); + assert_eq!(decl.item_range.to_offset(&buffer.read(cx)), 32..280); + + expect_file_decl("a.rs", &decls[1].1, &project, cx); + }); + } + + // Drop the buffer and wait for release + cx.update(|_| { + drop(buffer); + }); + cx.run_until_parked(); + + let index_state = index_state_arc.lock().await; + + cx.update(|cx| { + let decls = index_state.declarations_for_identifier::<8>(&main); + assert_eq!(decls.len(), 2); + expect_file_decl("a.rs", &decls[0].1, &project, cx); + expect_file_decl("c.rs", &decls[1].1, &project, cx); + }); + } + + fn expect_buffer_decl<'a>( + path: &str, + declaration: &'a Declaration, + project: &Entity, + cx: &App, + ) -> &'a BufferDeclaration { + if let Declaration::Buffer { + declaration, + project_entry_id, + .. + } = declaration + { + let project_path = project + .read(cx) + .path_for_entry(*project_entry_id, cx) + .unwrap(); + assert_eq!(project_path.path.as_ref(), rel_path(path),); + declaration + } else { + panic!("Expected a buffer declaration, found {:?}", declaration); + } + } + + fn expect_file_decl<'a>( + path: &str, + declaration: &'a Declaration, + project: &Entity, + cx: &App, + ) -> &'a FileDeclaration { + if let Declaration::File { + declaration, + project_entry_id: file, + .. + } = declaration + { + assert_eq!( + project + .read(cx) + .path_for_entry(*file, cx) + .unwrap() + .path + .as_ref(), + rel_path(path), + ); + declaration + } else { + panic!("Expected a file declaration, found {:?}", declaration); + } + } + + async fn init_test( + cx: &mut TestAppContext, + ) -> (Entity, Entity, LanguageId) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "a.rs": indoc! {r#" + fn main() { + let x = 1; + let y = 2; + let z = add(x, y); + println!("Result: {}", z); + } + + fn add(a: i32, b: i32) -> i32 { + a + b + } + "#}, + "b.rs": indoc! {" + pub struct Config { + pub name: String, + pub value: i32, + } + + impl Config { + pub fn new(name: String, value: i32) -> Self { + Config { name, value } + } + } + "}, + "c.rs": indoc! {r#" + use std::collections::HashMap; + + fn main() { + let args: Vec = std::env::args().collect(); + let data: Vec = args[1..] + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + let result = process_data(data); + println!("{:?}", result); + } + + fn process_data(data: Vec) -> HashMap { + let mut counts = HashMap::new(); + for value in data { + *counts.entry(value).or_insert(0) += 1; + } + counts + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_process_data() { + let data = vec![1, 2, 2, 3]; + let result = process_data(data); + assert_eq!(result.get(&2), Some(&2)); + } + } + "#} + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let lang = rust_lang(); + let lang_id = lang.id(); + language_registry.add(Arc::new(lang)); + + let file_indexing_parallelism = 2; + let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); + cx.run_until_parked(); + + (project, index, lang_id) + } + + fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) + .unwrap() + } +} diff --git a/crates/edit_prediction_context/src/text_similarity.rs b/crates/edit_prediction_context/src/text_similarity.rs new file mode 100644 index 0000000000000000000000000000000000000000..308a9570206084fc223c72f2e1c49109ea157714 --- /dev/null +++ b/crates/edit_prediction_context/src/text_similarity.rs @@ -0,0 +1,314 @@ +use hashbrown::HashTable; +use regex::Regex; +use std::{ + borrow::Cow, + hash::{Hash, Hasher as _}, + path::Path, + sync::LazyLock, +}; +use util::rel_path::RelPath; + +use crate::reference::Reference; + +// TODO: Consider implementing sliding window similarity matching like +// https://github.com/sourcegraph/cody-public-snapshot/blob/8e20ac6c1460c08b0db581c0204658112a246eda/vscode/src/completions/context/retrievers/jaccard-similarity/bestJaccardMatch.ts +// +// That implementation could actually be more efficient - no need to track words in the window that +// are not in the query. + +// TODO: Consider a flat sorted Vec<(String, usize)> representation. Intersection can just walk the +// two in parallel. + +static IDENTIFIER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\b\w+\b").unwrap()); + +/// Multiset of text occurrences for text similarity that only stores hashes and counts. +#[derive(Debug, Default)] +pub struct Occurrences { + table: HashTable, + total_count: usize, +} + +#[derive(Debug)] +struct OccurrenceEntry { + hash: u64, + count: usize, +} + +impl Occurrences { + pub fn within_string(text: &str) -> Self { + Self::from_identifiers(IDENTIFIER_REGEX.find_iter(text).map(|mat| mat.as_str())) + } + + #[allow(dead_code)] + pub fn within_references(references: &[Reference]) -> Self { + Self::from_identifiers( + references + .iter() + .map(|reference| reference.identifier.name.as_ref()), + ) + } + + pub fn from_identifiers(identifiers: impl IntoIterator>) -> Self { + let mut this = Self::default(); + // TODO: Score matches that match case higher? + // + // TODO: Also include unsplit identifier? + for identifier in identifiers { + for identifier_part in split_identifier(identifier.as_ref()) { + this.add_hash(fx_hash(&identifier_part.to_lowercase())); + } + } + this + } + + pub fn from_worktree_path(worktree_name: Option>, rel_path: &RelPath) -> Self { + if let Some(worktree_name) = worktree_name { + Self::from_identifiers( + std::iter::once(worktree_name) + .chain(iter_path_without_extension(rel_path.as_std_path())), + ) + } else { + Self::from_path(rel_path.as_std_path()) + } + } + + pub fn from_path(path: &Path) -> Self { + Self::from_identifiers(iter_path_without_extension(path)) + } + + fn add_hash(&mut self, hash: u64) { + self.table + .entry( + hash, + |entry: &OccurrenceEntry| entry.hash == hash, + |entry| entry.hash, + ) + .and_modify(|entry| entry.count += 1) + .or_insert(OccurrenceEntry { hash, count: 1 }); + self.total_count += 1; + } + + fn contains_hash(&self, hash: u64) -> bool { + self.get_count(hash) != 0 + } + + fn get_count(&self, hash: u64) -> usize { + self.table + .find(hash, |entry| entry.hash == hash) + .map(|entry| entry.count) + .unwrap_or(0) + } +} + +fn iter_path_without_extension(path: &Path) -> impl Iterator> { + let last_component: Option> = path.file_stem().map(|stem| stem.to_string_lossy()); + let mut path_components = path.components(); + path_components.next_back(); + path_components + .map(|component| component.as_os_str().to_string_lossy()) + .chain(last_component) +} + +pub fn fx_hash(data: &T) -> u64 { + let mut hasher = collections::FxHasher::default(); + data.hash(&mut hasher); + hasher.finish() +} + +// Splits camelcase / snakecase / kebabcase / pascalcase +// +// TODO: Make this more efficient / elegant. +fn split_identifier(identifier: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let chars: Vec = identifier.chars().collect(); + + if chars.is_empty() { + return parts; + } + + let mut i = 0; + while i < chars.len() { + let ch = chars[i]; + + // Handle explicit delimiters (underscore and hyphen) + if ch == '_' || ch == '-' { + if i > start { + parts.push(&identifier[start..i]); + } + start = i + 1; + i += 1; + continue; + } + + // Handle camelCase and PascalCase transitions + if i > 0 && i < chars.len() { + let prev_char = chars[i - 1]; + + // Transition from lowercase/digit to uppercase + if (prev_char.is_lowercase() || prev_char.is_ascii_digit()) && ch.is_uppercase() { + parts.push(&identifier[start..i]); + start = i; + } + // Handle sequences like "XMLParser" -> ["XML", "Parser"] + else if i + 1 < chars.len() + && ch.is_uppercase() + && chars[i + 1].is_lowercase() + && prev_char.is_uppercase() + { + parts.push(&identifier[start..i]); + start = i; + } + } + + i += 1; + } + + // Add the last part if there's any remaining + if start < identifier.len() { + parts.push(&identifier[start..]); + } + + // Filter out empty strings + parts.into_iter().filter(|s| !s.is_empty()).collect() +} + +pub fn jaccard_similarity<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { + if set_a.table.len() > set_b.table.len() { + std::mem::swap(&mut set_a, &mut set_b); + } + let intersection = set_a + .table + .iter() + .filter(|entry| set_b.contains_hash(entry.hash)) + .count(); + let union = set_a.table.len() + set_b.table.len() - intersection; + intersection as f32 / union as f32 +} + +// TODO +#[allow(dead_code)] +pub fn overlap_coefficient<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { + if set_a.table.len() > set_b.table.len() { + std::mem::swap(&mut set_a, &mut set_b); + } + let intersection = set_a + .table + .iter() + .filter(|entry| set_b.contains_hash(entry.hash)) + .count(); + intersection as f32 / set_a.table.len() as f32 +} + +// TODO +#[allow(dead_code)] +pub fn weighted_jaccard_similarity<'a>( + mut set_a: &'a Occurrences, + mut set_b: &'a Occurrences, +) -> f32 { + if set_a.table.len() > set_b.table.len() { + std::mem::swap(&mut set_a, &mut set_b); + } + + let mut numerator = 0; + let mut denominator_a = 0; + let mut used_count_b = 0; + for entry_a in set_a.table.iter() { + let count_a = entry_a.count; + let count_b = set_b.get_count(entry_a.hash); + numerator += count_a.min(count_b); + denominator_a += count_a.max(count_b); + used_count_b += count_b; + } + + let denominator = denominator_a + (set_b.total_count - used_count_b); + if denominator == 0 { + 0.0 + } else { + numerator as f32 / denominator as f32 + } +} + +pub fn weighted_overlap_coefficient<'a>( + mut set_a: &'a Occurrences, + mut set_b: &'a Occurrences, +) -> f32 { + if set_a.table.len() > set_b.table.len() { + std::mem::swap(&mut set_a, &mut set_b); + } + + let mut numerator = 0; + for entry_a in set_a.table.iter() { + let count_a = entry_a.count; + let count_b = set_b.get_count(entry_a.hash); + numerator += count_a.min(count_b); + } + + let denominator = set_a.total_count.min(set_b.total_count); + if denominator == 0 { + 0.0 + } else { + numerator as f32 / denominator as f32 + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_split_identifier() { + assert_eq!(split_identifier("snake_case"), vec!["snake", "case"]); + assert_eq!(split_identifier("kebab-case"), vec!["kebab", "case"]); + assert_eq!(split_identifier("PascalCase"), vec!["Pascal", "Case"]); + assert_eq!(split_identifier("camelCase"), vec!["camel", "Case"]); + assert_eq!(split_identifier("XMLParser"), vec!["XML", "Parser"]); + } + + #[test] + fn test_similarity_functions() { + // 10 identifier parts, 8 unique + // Repeats: 2 "outline", 2 "items" + let set_a = Occurrences::within_string( + "let mut outline_items = query_outline_items(&language, &tree, &source);", + ); + // 14 identifier parts, 11 unique + // Repeats: 2 "outline", 2 "language", 2 "tree" + let set_b = Occurrences::within_string( + "pub fn query_outline_items(language: &Language, tree: &Tree, source: &str) -> Vec {", + ); + + // 6 overlaps: "outline", "items", "query", "language", "tree", "source" + // 7 non-overlaps: "let", "mut", "pub", "fn", "vec", "item", "str" + assert_eq!(jaccard_similarity(&set_a, &set_b), 6.0 / (6.0 + 7.0)); + + // Numerator is one more than before due to both having 2 "outline". + // Denominator is the same except for 3 more due to the non-overlapping duplicates + assert_eq!( + weighted_jaccard_similarity(&set_a, &set_b), + 7.0 / (7.0 + 7.0 + 3.0) + ); + + // Numerator is the same as jaccard_similarity. Denominator is the size of the smaller set, 8. + assert_eq!(overlap_coefficient(&set_a, &set_b), 6.0 / 8.0); + + // Numerator is the same as weighted_jaccard_similarity. Denominator is the total weight of + // the smaller set, 10. + assert_eq!(weighted_overlap_coefficient(&set_a, &set_b), 7.0 / 10.0); + } + + #[test] + fn test_iter_path_without_extension() { + let mut iter = iter_path_without_extension(Path::new("")); + assert_eq!(iter.next(), None); + + let iter = iter_path_without_extension(Path::new("foo")); + assert_eq!(iter.collect::>(), ["foo"]); + + let iter = iter_path_without_extension(Path::new("foo/bar.txt")); + assert_eq!(iter.collect::>(), ["foo", "bar"]); + + let iter = iter_path_without_extension(Path::new("foo/bar/baz.txt")); + assert_eq!(iter.collect::>(), ["foo", "bar", "baz"]); + } +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 339f98ae8bd88263f1fea12c535569864faae294..62226f5dec2aa88f0ccdb6ad59935f6bdfe6536e 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -64,6 +64,7 @@ project.workspace = true rand.workspace = true regex.workspace = true rpc.workspace = true +rope.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -89,11 +90,12 @@ ui.workspace = true url.workspace = true util.workspace = true uuid.workspace = true +vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] +criterion.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } @@ -119,3 +121,12 @@ util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } zlog.workspace = true + + +[[bench]] +name = "editor_render" +harness = false + +[[bench]] +name = "display_map" +harness = false diff --git a/crates/editor/benches/display_map.rs b/crates/editor/benches/display_map.rs new file mode 100644 index 0000000000000000000000000000000000000000..919249ad01b87fe5fbabe1b5fe6e563179b41d10 --- /dev/null +++ b/crates/editor/benches/display_map.rs @@ -0,0 +1,102 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use editor::MultiBuffer; +use gpui::TestDispatcher; +use itertools::Itertools; +use rand::{Rng, SeedableRng, rngs::StdRng}; +use std::num::NonZeroU32; +use text::Bias; +use util::RandomCharIter; + +fn to_tab_point_benchmark(c: &mut Criterion) { + let rng = StdRng::seed_from_u64(1); + let dispatcher = TestDispatcher::new(rng); + let cx = gpui::TestAppContext::build(dispatcher, None); + + let create_tab_map = |length: usize| { + let mut rng = StdRng::seed_from_u64(1); + let text = RandomCharIter::new(&mut rng) + .take(length) + .collect::(); + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + + let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx)); + use editor::display_map::*; + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); + let fold_point = fold_snapshot.to_fold_point( + inlay_snapshot.to_point(InlayOffset(rng.random_range(0..length))), + Bias::Left, + ); + let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap()); + + (length, snapshot, fold_point) + }; + + let inputs = [1024].into_iter().map(create_tab_map).collect_vec(); + + let mut group = c.benchmark_group("To tab point"); + + for (batch_size, snapshot, fold_point) in inputs { + group.bench_with_input( + BenchmarkId::new("to_tab_point", batch_size), + &snapshot, + |bench, snapshot| { + bench.iter(|| { + snapshot.to_tab_point(fold_point); + }); + }, + ); + } + + group.finish(); +} + +fn to_fold_point_benchmark(c: &mut Criterion) { + let rng = StdRng::seed_from_u64(1); + let dispatcher = TestDispatcher::new(rng); + let cx = gpui::TestAppContext::build(dispatcher, None); + + let create_tab_map = |length: usize| { + let mut rng = StdRng::seed_from_u64(1); + let text = RandomCharIter::new(&mut rng) + .take(length) + .collect::(); + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + + let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx)); + use editor::display_map::*; + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); + + let fold_point = fold_snapshot.to_fold_point( + inlay_snapshot.to_point(InlayOffset(rng.random_range(0..length))), + Bias::Left, + ); + + let (_, snapshot) = TabMap::new(fold_snapshot, NonZeroU32::new(4).unwrap()); + let tab_point = snapshot.to_tab_point(fold_point); + + (length, snapshot, tab_point) + }; + + let inputs = [1024].into_iter().map(create_tab_map).collect_vec(); + + let mut group = c.benchmark_group("To fold point"); + + for (batch_size, snapshot, tab_point) in inputs { + group.bench_with_input( + BenchmarkId::new("to_fold_point", batch_size), + &snapshot, + |bench, snapshot| { + bench.iter(|| { + snapshot.to_fold_point(tab_point, Bias::Left); + }); + }, + ); + } + + group.finish(); +} + +criterion_group!(benches, to_tab_point_benchmark, to_fold_point_benchmark); +criterion_main!(benches); diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs new file mode 100644 index 0000000000000000000000000000000000000000..0ae1af5537fb62a7658ccd306545503b818c28ae --- /dev/null +++ b/crates/editor/benches/editor_render.rs @@ -0,0 +1,172 @@ +use criterion::{Bencher, BenchmarkId}; +use editor::{ + Editor, EditorMode, MultiBuffer, + actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines}, +}; +use gpui::{AppContext, Focusable as _, TestAppContext, TestDispatcher}; +use project::Project; +use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; +use settings::SettingsStore; +use ui::IntoElement; +use util::RandomCharIter; + +fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext) { + let mut cx = cx.clone(); + let text = String::from_iter(["line:\n"; 1000]); + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + + let cx = cx.add_empty_window(); + let editor = cx.update(|window, cx| { + let editor = cx.new(|cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_style(editor::EditorStyle::default(), window, cx); + editor.select_all(&SelectAll, window, cx); + editor.split_selection_into_lines( + &SplitSelectionIntoLines { + keep_selections: true, + }, + window, + cx, + ); + editor + }); + window.focus(&editor.focus_handle(cx)); + editor + }); + + bencher.iter(|| { + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + editor.handle_input("hello world", window, cx); + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); + }); + }) + }); +} + +fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, TestAppContext)) { + let (text, cx) = args; + let mut cx = cx.clone(); + + bencher.iter(|| { + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + + let cx = cx.add_empty_window(); + let _ = cx.update(|window, cx| { + let editor = cx.new(|cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_style(editor::EditorStyle::default(), window, cx); + editor + }); + window.focus(&editor.focus_handle(cx)); + editor + }); + }); +} + +fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { + let mut cx = cx.clone(); + let buffer = cx.update(|cx| { + let mut rng = StdRng::seed_from_u64(1); + let text_len = rng.random_range(10000..90000); + if rng.random() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + } + }); + + let cx = cx.add_empty_window(); + let editor = cx.update(|window, cx| { + let editor = cx.new(|cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_style(editor::EditorStyle::default(), window, cx); + editor + }); + window.focus(&editor.focus_handle(cx)); + editor + }); + + bencher.iter(|| { + cx.update(|window, cx| { + // editor.update(cx, |editor, cx| editor.move_down(&MoveDown, window, cx)); + let mut view = editor.clone().into_any_element(); + let _ = view.request_layout(window, cx); + let _ = view.prepaint(window, cx); + view.paint(window, cx); + }); + }) +} + +pub fn benches() { + let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(1)); + let cx = gpui::TestAppContext::build(dispatcher, None); + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + assets::Assets.load_test_fonts(cx); + theme::init(theme::LoadThemes::JustBase, cx); + // release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + editor::init(cx); + }); + + let mut criterion: criterion::Criterion<_> = + (criterion::Criterion::default()).configure_from_args(); + + // setup app context + let mut group = criterion.benchmark_group("Time to render"); + group.bench_with_input( + BenchmarkId::new("editor_render", "TestAppContext"), + &cx, + editor_render, + ); + + group.finish(); + + let text = String::from_iter(["char"; 1000]); + let mut group = criterion.benchmark_group("Build buffer with one long line"); + group.bench_with_input( + BenchmarkId::new("editor_with_one_long_line", "(String, TestAppContext )"), + &(text, cx.clone()), + open_editor_with_one_long_line, + ); + + group.finish(); + + let mut group = criterion.benchmark_group("multi cursor edits"); + group.bench_with_input( + BenchmarkId::new("editor_input_with_1000_cursors", "TestAppContext"), + &cx, + editor_input_with_1000_cursors, + ); + group.finish(); +} + +fn main() { + benches(); + criterion::Criterion::default() + .configure_from_args() + .final_summary(); +} diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ce02c4d2bf39c6bc5513280a1d81b071a9e6cd6a..276f20a7aacc9315f27a929876984342edc8d394 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -228,21 +228,38 @@ pub struct ShowCompletions { pub struct HandleInput(pub String); /// Deletes from the cursor to the end of the next word. +/// Stops before the end of the next word, if whitespace sequences of length >= 2 are encountered. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct DeleteToNextWordEnd { #[serde(default)] pub ignore_newlines: bool, + // Whether to stop before the end of the next word, if language-defined bracket is encountered. + #[serde(default)] + pub ignore_brackets: bool, } /// Deletes from the cursor to the start of the previous word. +/// Stops before the start of the previous word, if whitespace sequences of length >= 2 are encountered. #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct DeleteToPreviousWordStart { #[serde(default)] pub ignore_newlines: bool, + // Whether to stop before the start of the previous word, if language-defined bracket is encountered. + #[serde(default)] + pub ignore_brackets: bool, +} + +/// Cuts from cursor to end of line. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct CutToEndOfLine { + #[serde(default)] + pub stop_at_newlines: bool, } /// Folds all code blocks at the specified indentation level. @@ -301,6 +318,24 @@ pub struct GoToPreviousDiagnostic { pub severity: GoToDiagnosticSeverityFilter, } +/// Adds a cursor above the current selection. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct AddSelectionAbove { + #[serde(default = "default_true")] + pub skip_soft_wrap: bool, +} + +/// Adds a cursor below the current selection. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct AddSelectionBelow { + #[serde(default = "default_true")] + pub skip_soft_wrap: bool, +} + actions!( debugger, [ @@ -328,10 +363,6 @@ actions!( /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] AcceptPartialEditPrediction, - /// Adds a cursor above the current selection. - AddSelectionAbove, - /// Adds a cursor below the current selection. - AddSelectionBelow, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. @@ -404,8 +435,6 @@ actions!( CopyPermalinkToLine, /// Cuts selected text to the clipboard. Cut, - /// Cuts from cursor to end of line. - CutToEndOfLine, /// Deletes the character after the cursor. Delete, /// Deletes the current line. @@ -429,6 +458,8 @@ actions!( /// Expands all diff hunks in the editor. #[action(deprecated_aliases = ["editor::ExpandAllHunkDiffs"])] ExpandAllDiffHunks, + /// Collapses all diff hunks in the editor. + CollapseAllDiffHunks, /// Expands macros recursively at cursor position. ExpandMacroRecursively, /// Finds all references to the symbol at cursor. @@ -441,6 +472,33 @@ actions!( Fold, /// Folds all foldable regions in the editor. FoldAll, + /// Folds all code blocks at indentation level 1. + #[action(name = "FoldAtLevel_1")] + FoldAtLevel1, + /// Folds all code blocks at indentation level 2. + #[action(name = "FoldAtLevel_2")] + FoldAtLevel2, + /// Folds all code blocks at indentation level 3. + #[action(name = "FoldAtLevel_3")] + FoldAtLevel3, + /// Folds all code blocks at indentation level 4. + #[action(name = "FoldAtLevel_4")] + FoldAtLevel4, + /// Folds all code blocks at indentation level 5. + #[action(name = "FoldAtLevel_5")] + FoldAtLevel5, + /// Folds all code blocks at indentation level 6. + #[action(name = "FoldAtLevel_6")] + FoldAtLevel6, + /// Folds all code blocks at indentation level 7. + #[action(name = "FoldAtLevel_7")] + FoldAtLevel7, + /// Folds all code blocks at indentation level 8. + #[action(name = "FoldAtLevel_8")] + FoldAtLevel8, + /// Folds all code blocks at indentation level 9. + #[action(name = "FoldAtLevel_9")] + FoldAtLevel9, /// Folds all function bodies in the editor. FoldFunctionBodies, /// Folds the current code block and all its children. @@ -481,10 +539,18 @@ actions!( GoToParentModule, /// Goes to the previous change in the file. GoToPreviousChange, + /// Goes to the next reference to the symbol under the cursor. + GoToNextReference, + /// Goes to the previous reference to the symbol under the cursor. + GoToPreviousReference, /// Goes to the type definition of the symbol at cursor. GoToTypeDefinition, /// Goes to type definition in a split pane. GoToTypeDefinitionSplit, + /// Goes to the next document highlight. + GoToNextDocumentHighlight, + /// Goes to the previous document highlight. + GoToPreviousDocumentHighlight, /// Scrolls down by half a page. HalfPageDown, /// Scrolls up by half a page. @@ -555,6 +621,8 @@ actions!( NextEditPrediction, /// Scrolls to the next screen. NextScreen, + /// Goes to the next snippet tabstop if one exists. + NextSnippetTabstop, /// Opens the context menu at cursor position. OpenContextMenu, /// Opens excerpts from the current file. @@ -588,6 +656,8 @@ actions!( Paste, /// Navigates to the previous edit prediction. PreviousEditPrediction, + /// Goes to the previous snippet tabstop if one exists. + PreviousSnippetTabstop, /// Redoes the last undone edit. Redo, /// Redoes the last selection change. @@ -632,6 +702,10 @@ actions!( SelectEnclosingSymbol, /// Selects the next larger syntax node. SelectLargerSyntaxNode, + /// Selects the next syntax node sibling. + SelectNextSyntaxNode, + /// Selects the previous syntax node sibling. + SelectPreviousSyntaxNode, /// Extends selection left. SelectLeft, /// Selects the current line. @@ -753,6 +827,10 @@ actions!( UniqueLinesCaseInsensitive, /// Removes duplicate lines (case-sensitive). UniqueLinesCaseSensitive, - UnwrapSyntaxNode + /// Removes the surrounding syntax node (for example brackets, or closures) + /// from the current selections. + UnwrapSyntaxNode, + /// Wraps selections in tag specified by language. + WrapSelectionsInTag ] ); diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index 3239fdc653e0e2acdbdaa3396e30c0546ef259cf..17ed522211369fafef4ea40c15b6dc365cb33f33 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -1,9 +1,11 @@ +use std::path::PathBuf; + use anyhow::Context as _; use gpui::{App, Context, Entity, Window}; use language::Language; use project::lsp_store::lsp_ext_command::SwitchSourceHeaderResult; use rpc::proto; -use url::Url; +use util::paths::PathStyle; use workspace::{OpenOptions, OpenVisible}; use crate::lsp_ext::find_specific_language_server_in_selection; @@ -13,7 +15,7 @@ use crate::{Editor, SwitchSourceHeader, element::register_action}; use project::lsp_store::clangd_ext::CLANGD_SERVER_NAME; fn is_c_language(language: &Language) -> bool { - return language.name() == "C++".into() || language.name() == "C".into(); + language.name() == "C++".into() || language.name() == "C".into() } pub fn switch_source_header( @@ -38,7 +40,11 @@ pub fn switch_source_header( let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client(); cx.spawn_in(window, async move |_editor, cx| { let source_file = buffer.read_with(cx, |buffer, _| { - buffer.file().map(|file| file.path()).map(|path| path.to_string_lossy().to_string()).unwrap_or_else(|| "Unknown".to_string()) + buffer + .file() + .map(|file| file.path()) + .map(|path| path.display(PathStyle::local()).to_string()) + .unwrap_or_else(|| "Unknown".to_string()) })?; let switch_source_header = if let Some((client, project_id)) = upstream_client { @@ -53,40 +59,51 @@ pub fn switch_source_header( .context("lsp ext switch source header proto request")?; SwitchSourceHeaderResult(response.target_file) } else { - project.update(cx, |project, cx| { - project.request_lsp( - buffer, - project::LanguageServerToQuery::Other(server_to_query), - project::lsp_store::lsp_ext_command::SwitchSourceHeader, - cx, - ) - })?.await.with_context(|| format!("Switch source/header LSP request for path \"{source_file}\" failed"))? + project + .update(cx, |project, cx| { + project.request_lsp( + buffer, + project::LanguageServerToQuery::Other(server_to_query), + project::lsp_store::lsp_ext_command::SwitchSourceHeader, + cx, + ) + })? + .await + .with_context(|| { + format!("Switch source/header LSP request for path \"{source_file}\" failed") + })? }; if switch_source_header.0.is_empty() { - log::info!("Clangd returned an empty string when requesting to switch source/header from \"{source_file}\"" ); return Ok(()); } - let goto = Url::parse(&switch_source_header.0).with_context(|| { - format!( - "Parsing URL \"{}\" returned from switch source/header failed", - switch_source_header.0 - ) - })?; + let goto = switch_source_header + .0 + .strip_prefix("file://") + .with_context(|| { + format!( + "Parsing file url \"{}\" returned from switch source/header failed", + switch_source_header.0 + ) + })?; - let path = goto.to_file_path().map_err(|()| { - anyhow::anyhow!("URL conversion to file path failed for \"{goto}\"") - })?; + let path = PathBuf::from(goto); workspace .update_in(cx, |workspace, window, cx| { - workspace.open_abs_path(path, OpenOptions { visible: Some(OpenVisible::None), ..Default::default() }, window, cx) + workspace.open_abs_path( + path, + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) }) .with_context(|| { - format!( - "Switch source/header could not open \"{goto}\" in workspace" - ) + format!("Switch source/header could not open \"{goto}\" in workspace") })? .await .map(|_| ()) @@ -104,6 +121,6 @@ pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: & .filter_map(|buffer| buffer.read(cx).language()) .any(|language| is_c_language(language)) { - register_action(&editor, window, switch_source_header); + register_action(editor, window, switch_source_header); } } diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index fd8db29584d8eb6944ff674dd8bf5d860ce32428..ec97c0ebb31952da9ad8e9e6f4f75b4b0078c4a3 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -1,9 +1,10 @@ -use crate::{code_context_menus::CompletionsMenu, editor_settings::SnippetSortOrder}; +use crate::code_context_menus::CompletionsMenu; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::TestAppContext; use language::CodeLabel; use lsp::{CompletionItem, CompletionItemKind, LanguageServerId}; use project::{Completion, CompletionSource}; +use settings::SnippetSortOrder; use std::sync::Arc; use std::sync::atomic::AtomicBool; use text::Anchor; @@ -317,7 +318,7 @@ async fn filter_and_sort_matches( let candidates: Arc<[StringMatchCandidate]> = completions .iter() .enumerate() - .map(|(id, completion)| StringMatchCandidate::new(id, &completion.label.filter_text())) + .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text())) .collect(); let cancel_flag = Arc::new(AtomicBool::new(false)); let background_executor = cx.executor(); @@ -331,5 +332,5 @@ async fn filter_and_sort_matches( background_executor, ) .await; - CompletionsMenu::sort_string_matches(matches, Some(query), snippet_sort_order, &completions) + CompletionsMenu::sort_string_matches(matches, Some(query), snippet_sort_order, completions) } diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 4ae2a14ca730dafa7cfecd9e9b3bacbe3f7bc47b..359c985ee9208a1a83e3458635df883c2cf991a8 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -1,7 +1,9 @@ +use crate::scroll::ScrollAmount; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString, - Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, uniform_list, + AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollHandle, ScrollStrategy, + SharedString, Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, + uniform_list, }; use itertools::Itertools; use language::CodeLabel; @@ -9,9 +11,9 @@ use language::{Buffer, LanguageName, LanguageRegistry}; use markdown::{Markdown, MarkdownElement}; use multi_buffer::{Anchor, ExcerptId}; use ordered_float::OrderedFloat; -use project::CompletionSource; use project::lsp_store::CompletionDocumentation; use project::{CodeAction, Completion, TaskSourceKind}; +use project::{CompletionDisplayOptions, CompletionSource}; use task::DebugScenario; use task::TaskContext; @@ -30,7 +32,6 @@ use ui::{Color, IntoElement, ListItem, Pixels, Popover, Styled, prelude::*}; use util::ResultExt; use crate::CodeActionSource; -use crate::editor_settings::SnippetSortOrder; use crate::hover_popover::{hover_markdown_style, open_markdown_url}; use crate::{ CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor, @@ -38,6 +39,7 @@ use crate::{ actions::{ConfirmCodeAction, ConfirmCompletion}, split_words, styled_runs_for_code_label, }; +use settings::SnippetSortOrder; pub const MENU_GAP: Pixels = px(4.); pub const MENU_ASIDE_X_PADDING: Pixels = px(16.); @@ -184,6 +186,20 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => false, } } + + pub fn scroll_aside( + &mut self, + scroll_amount: ScrollAmount, + window: &mut Window, + cx: &mut Context, + ) { + match self { + CodeContextMenu::Completions(completions_menu) => { + completions_menu.scroll_aside(scroll_amount, window, cx) + } + CodeContextMenu::CodeActions(_) => (), + } + } } pub enum ContextMenuOrigin { @@ -207,12 +223,16 @@ pub struct CompletionsMenu { filter_task: Task<()>, cancel_filter: Arc, scroll_handle: UniformListScrollHandle, + // The `ScrollHandle` used on the Markdown documentation rendered on the + // side of the completions menu. + pub scroll_handle_aside: ScrollHandle, resolve_completions: bool, show_completion_documentation: bool, last_rendered_range: Rc>>>, markdown_cache: Rc)>>>, language_registry: Option>, language: Option, + display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, } @@ -231,7 +251,7 @@ enum MarkdownCacheKey { pub enum CompletionsMenuSource { Normal, SnippetChoices, - Words, + Words { ignore_threshold: bool }, } // TODO: There should really be a wrapper around fuzzy match tasks that does this. @@ -252,6 +272,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, language: Option, @@ -279,11 +300,13 @@ impl CompletionsMenu { filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), scroll_handle: UniformListScrollHandle::new(), + scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), markdown_cache: RefCell::new(VecDeque::new()).into(), language_registry, language, + display_options, snippet_sort_order, }; @@ -305,11 +328,7 @@ impl CompletionsMenu { .map(|choice| Completion { replace_range: selection.start.text_anchor..selection.end.text_anchor, new_text: choice.to_string(), - label: CodeLabel { - text: choice.to_string(), - runs: Default::default(), - filter_range: Default::default(), - }, + label: CodeLabel::plain(choice.to_string(), None), icon_path: None, documentation: None, confirm: None, @@ -321,7 +340,7 @@ impl CompletionsMenu { let match_candidates = choices .iter() .enumerate() - .map(|(id, completion)| StringMatchCandidate::new(id, &completion)) + .map(|(id, completion)| StringMatchCandidate::new(id, completion)) .collect(); let entries = choices .iter() @@ -348,12 +367,14 @@ impl CompletionsMenu { filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), scroll_handle: UniformListScrollHandle::new(), + scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, last_rendered_range: RefCell::new(None).into(), markdown_cache: RefCell::new(VecDeque::new()).into(), language_registry: None, language: None, + display_options: CompletionDisplayOptions::default(), snippet_sort_order, } } @@ -514,7 +535,7 @@ impl CompletionsMenu { // Expand the range to resolve more completions than are predicted to be visible, to reduce // jank on navigation. let entry_indices = util::expanded_and_wrapped_usize_range( - entry_range.clone(), + entry_range, RESOLVE_BEFORE_ITEMS, RESOLVE_AFTER_ITEMS, entries.len(), @@ -716,6 +737,33 @@ impl CompletionsMenu { cx: &mut Context, ) -> AnyElement { let show_completion_documentation = self.show_completion_documentation; + let widest_completion_ix = if self.display_options.dynamic_width { + let completions = self.completions.borrow(); + let widest_completion_ix = self + .entries + .borrow() + .iter() + .enumerate() + .max_by_key(|(_, mat)| { + let completion = &completions[mat.candidate_id]; + let documentation = &completion.documentation; + + let mut len = completion.label.text.chars().count(); + if let Some(CompletionDocumentation::SingleLine(text)) = documentation { + if show_completion_documentation { + len += text.chars().count(); + } + } + + len + }) + .map(|(ix, _)| ix); + drop(completions); + widest_completion_ix + } else { + None + }; + let selected_item = self.selected_item; let completions = self.completions.clone(); let entries = self.entries.clone(); @@ -842,7 +890,13 @@ impl CompletionsMenu { .max_h(max_height_in_lines as f32 * window.line_height()) .track_scroll(self.scroll_handle.clone()) .with_sizing_behavior(ListSizingBehavior::Infer) - .w(rems(34.)); + .map(|this| { + if self.display_options.dynamic_width { + this.with_width_from_item(widest_completion_ix) + } else { + this.w(rems(34.)) + } + }); Popover::new().child(list).into_any_element() } @@ -911,6 +965,7 @@ impl CompletionsMenu { .max_w(max_size.width) .max_h(max_size.height) .overflow_y_scroll() + .track_scroll(&self.scroll_handle_aside) .occlude(), ) .into_any_element(), @@ -1111,10 +1166,8 @@ impl CompletionsMenu { let query_start_doesnt_match_split_words = query_start_lower .map(|query_char| { !split_words(&string_match.string).any(|word| { - word.chars() - .next() - .and_then(|c| c.to_lowercase().next()) - .map_or(false, |word_char| word_char == query_char) + word.chars().next().and_then(|c| c.to_lowercase().next()) + == Some(query_char) }) }) .unwrap_or(false); @@ -1177,6 +1230,23 @@ impl CompletionsMenu { } }); } + + pub fn scroll_aside( + &mut self, + amount: ScrollAmount, + window: &mut Window, + cx: &mut Context, + ) { + let mut offset = self.scroll_handle_aside.offset(); + + offset.y -= amount.pixels( + window.line_height(), + self.scroll_handle_aside.bounds().size.height - px(16.), + ) / 2.0; + + cx.notify(); + self.scroll_handle_aside.set_offset(offset); + } } #[derive(Clone)] @@ -1407,6 +1477,8 @@ impl CodeActionsMenu { ) -> AnyElement { let actions = self.actions.clone(); let selected_item = self.selected_item; + let is_quick_action_bar = matches!(self.origin(), ContextMenuOrigin::QuickActionBar); + let list = uniform_list( "code_actions_menu", self.actions.len(), @@ -1428,6 +1500,7 @@ impl CodeActionsMenu { this.child( h_flex() .overflow_hidden() + .when(is_quick_action_bar, |this| this.text_ui(cx)) .child( // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. action.lsp_action.title().replace("\n", ""), @@ -1441,6 +1514,7 @@ impl CodeActionsMenu { this.child( h_flex() .overflow_hidden() + .when(is_quick_action_bar, |this| this.text_ui(cx)) .child(task.resolved_label.replace("\n", "")) .when(selected, |this| { this.text_color(colors.text_accent) @@ -1451,6 +1525,7 @@ impl CodeActionsMenu { this.child( h_flex() .overflow_hidden() + .when(is_quick_action_bar, |this| this.text_ui(cx)) .child("debug: ") .child(scenario.label.clone()) .when(selected, |this| { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index a16e516a70c9638965585cc5d6a23d8a9f67b639..7a225d6019edf8f09b1758d62e8181917649cc2b 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -27,7 +27,7 @@ mod tab_map; mod wrap_map; use crate::{ - EditorStyle, InlayId, RowExt, hover_links::InlayHighlight, movement::TextLayoutDetails, + EditorStyle, RowExt, hover_links::InlayHighlight, inlays::Inlay, movement::TextLayoutDetails, }; pub use block_map::{ Block, BlockChunks as DisplayChunks, BlockContext, BlockId, BlockMap, BlockPlacement, @@ -37,22 +37,22 @@ pub use block_map::{ use block_map::{BlockRow, BlockSnapshot}; use collections::{HashMap, HashSet}; pub use crease_map::*; +use fold_map::FoldSnapshot; pub use fold_map::{ ChunkRenderer, ChunkRendererContext, ChunkRendererId, Fold, FoldId, FoldPlaceholder, FoldPoint, }; -use fold_map::{FoldMap, FoldSnapshot}; use gpui::{App, Context, Entity, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle}; -pub use inlay_map::Inlay; -use inlay_map::{InlayMap, InlaySnapshot}; +use inlay_map::InlaySnapshot; pub use inlay_map::{InlayOffset, InlayPoint}; pub use invisibles::{is_invisible, replacement}; use language::{ OffsetUtf16, Point, Subscription as BufferSubscription, language_settings::language_settings, }; use multi_buffer::{ - Anchor, AnchorRangeExt, ExcerptId, MultiBuffer, MultiBufferPoint, MultiBufferRow, - MultiBufferSnapshot, RowInfo, ToOffset, ToPoint, + Anchor, AnchorRangeExt, MultiBuffer, MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, + RowInfo, ToOffset, ToPoint, }; +use project::InlayId; use project::project_settings::DiagnosticSeverity; use serde::Deserialize; @@ -66,12 +66,14 @@ use std::{ sync::Arc, }; use sum_tree::{Bias, TreeMap}; -use tab_map::{TabMap, TabSnapshot}; +use tab_map::TabSnapshot; use text::{BufferId, LineIndent}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; use wrap_map::{WrapMap, WrapSnapshot}; +pub use crate::display_map::{fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap}; + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum FoldStatus { Folded, @@ -168,20 +170,15 @@ impl DisplayMap { let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); let (inlay_snapshot, edits) = self.inlay_map.sync(buffer_snapshot, edits); - let (fold_snapshot, edits) = self.fold_map.read(inlay_snapshot.clone(), edits); + let (fold_snapshot, edits) = self.fold_map.read(inlay_snapshot, edits); let tab_size = Self::tab_size(&self.buffer, cx); - let (tab_snapshot, edits) = self.tab_map.sync(fold_snapshot.clone(), edits, tab_size); + let (tab_snapshot, edits) = self.tab_map.sync(fold_snapshot, edits, tab_size); let (wrap_snapshot, edits) = self .wrap_map - .update(cx, |map, cx| map.sync(tab_snapshot.clone(), edits, cx)); - let block_snapshot = self.block_map.read(wrap_snapshot.clone(), edits).snapshot; + .update(cx, |map, cx| map.sync(tab_snapshot, edits, cx)); + let block_snapshot = self.block_map.read(wrap_snapshot, edits).snapshot; DisplaySnapshot { - buffer_snapshot: self.buffer.read(cx).snapshot(cx), - fold_snapshot, - inlay_snapshot, - tab_snapshot, - wrap_snapshot, block_snapshot, diagnostics_max_severity: self.diagnostics_max_severity, crease_snapshot: self.crease_map.snapshot(), @@ -196,10 +193,10 @@ impl DisplayMap { pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold( other - .folds_in_range(0..other.buffer_snapshot.len()) + .folds_in_range(0..other.buffer_snapshot().len()) .map(|fold| { Crease::simple( - fold.range.to_offset(&other.buffer_snapshot), + fold.range.to_offset(other.buffer_snapshot()), fold.placeholder.clone(), ) }) @@ -597,21 +594,6 @@ impl DisplayMap { self.block_map.read(snapshot, edits); } - pub fn remove_inlays_for_excerpts(&mut self, excerpts_removed: &[ExcerptId]) { - let to_remove = self - .inlay_map - .current_inlays() - .filter_map(|inlay| { - if excerpts_removed.contains(&inlay.position.excerpt_id) { - Some(inlay.id) - } else { - None - } - }) - .collect::>(); - self.inlay_map.splice(&to_remove, Vec::new()); - } - fn tab_size(buffer: &Entity, cx: &App) -> NonZeroU32 { let buffer = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx)); let language = buffer @@ -703,9 +685,8 @@ impl<'a> HighlightedChunk<'a> { }), ..Default::default() }; - let invisible_style = if let Some(mut style) = style { - style.highlight(invisible_highlight); - style + let invisible_style = if let Some(style) = style { + style.highlight(invisible_highlight) } else { invisible_highlight }; @@ -726,9 +707,8 @@ impl<'a> HighlightedChunk<'a> { }), ..Default::default() }; - let invisible_style = if let Some(mut style) = style { - style.highlight(invisible_highlight); - style + let invisible_style = if let Some(style) = style { + style.highlight(invisible_highlight) } else { invisible_highlight }; @@ -762,12 +742,7 @@ impl<'a> HighlightedChunk<'a> { #[derive(Clone)] pub struct DisplaySnapshot { - pub buffer_snapshot: MultiBufferSnapshot, - pub fold_snapshot: FoldSnapshot, pub crease_snapshot: CreaseSnapshot, - inlay_snapshot: InlaySnapshot, - tab_snapshot: TabSnapshot, - wrap_snapshot: WrapSnapshot, block_snapshot: BlockSnapshot, text_highlights: TextHighlights, inlay_highlights: InlayHighlights, @@ -778,13 +753,43 @@ pub struct DisplaySnapshot { } impl DisplaySnapshot { + pub fn wrap_snapshot(&self) -> &WrapSnapshot { + &self.block_snapshot.wrap_snapshot + } + pub fn tab_snapshot(&self) -> &TabSnapshot { + &self.block_snapshot.wrap_snapshot.tab_snapshot + } + + pub fn fold_snapshot(&self) -> &FoldSnapshot { + &self.block_snapshot.wrap_snapshot.tab_snapshot.fold_snapshot + } + + pub fn inlay_snapshot(&self) -> &InlaySnapshot { + &self + .block_snapshot + .wrap_snapshot + .tab_snapshot + .fold_snapshot + .inlay_snapshot + } + + pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot { + &self + .block_snapshot + .wrap_snapshot + .tab_snapshot + .fold_snapshot + .inlay_snapshot + .buffer + } + #[cfg(test)] pub fn fold_count(&self) -> usize { - self.fold_snapshot.fold_count() + self.fold_snapshot().fold_count() } pub fn is_empty(&self) -> bool { - self.buffer_snapshot.len() == 0 + self.buffer_snapshot().len() == 0 } pub fn row_infos(&self, start_row: DisplayRow) -> impl Iterator + '_ { @@ -792,16 +797,16 @@ impl DisplaySnapshot { } pub fn widest_line_number(&self) -> u32 { - self.buffer_snapshot.widest_line_number() + self.buffer_snapshot().widest_line_number() } pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { loop { - let mut inlay_point = self.inlay_snapshot.to_inlay_point(point); - let mut fold_point = self.fold_snapshot.to_fold_point(inlay_point, Bias::Left); + let mut inlay_point = self.inlay_snapshot().to_inlay_point(point); + let mut fold_point = self.fold_snapshot().to_fold_point(inlay_point, Bias::Left); fold_point.0.column = 0; - inlay_point = fold_point.to_inlay_point(&self.fold_snapshot); - point = self.inlay_snapshot.to_buffer_point(inlay_point); + inlay_point = fold_point.to_inlay_point(self.fold_snapshot()); + point = self.inlay_snapshot().to_buffer_point(inlay_point); let mut display_point = self.point_to_display_point(point, Bias::Left); *display_point.column_mut() = 0; @@ -819,11 +824,11 @@ impl DisplaySnapshot { ) -> (MultiBufferPoint, DisplayPoint) { let original_point = point; loop { - let mut inlay_point = self.inlay_snapshot.to_inlay_point(point); - let mut fold_point = self.fold_snapshot.to_fold_point(inlay_point, Bias::Right); - fold_point.0.column = self.fold_snapshot.line_len(fold_point.row()); - inlay_point = fold_point.to_inlay_point(&self.fold_snapshot); - point = self.inlay_snapshot.to_buffer_point(inlay_point); + let mut inlay_point = self.inlay_snapshot().to_inlay_point(point); + let mut fold_point = self.fold_snapshot().to_fold_point(inlay_point, Bias::Right); + fold_point.0.column = self.fold_snapshot().line_len(fold_point.row()); + inlay_point = fold_point.to_inlay_point(self.fold_snapshot()); + point = self.inlay_snapshot().to_buffer_point(inlay_point); let mut display_point = self.point_to_display_point(point, Bias::Right); *display_point.column_mut() = self.line_len(display_point.row()); @@ -841,7 +846,8 @@ impl DisplaySnapshot { let new_end = if range.end.column > 0 { MultiBufferPoint::new( range.end.row, - self.buffer_snapshot.line_len(MultiBufferRow(range.end.row)), + self.buffer_snapshot() + .line_len(MultiBufferRow(range.end.row)), ) } else { range.end @@ -851,52 +857,52 @@ impl DisplaySnapshot { } pub fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint { - let inlay_point = self.inlay_snapshot.to_inlay_point(point); - let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias); - let tab_point = self.tab_snapshot.to_tab_point(fold_point); - let wrap_point = self.wrap_snapshot.tab_point_to_wrap_point(tab_point); + let inlay_point = self.inlay_snapshot().to_inlay_point(point); + let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias); + let tab_point = self.tab_snapshot().to_tab_point(fold_point); + let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point); let block_point = self.block_snapshot.to_block_point(wrap_point); DisplayPoint(block_point) } pub fn display_point_to_point(&self, point: DisplayPoint, bias: Bias) -> Point { - self.inlay_snapshot + self.inlay_snapshot() .to_buffer_point(self.display_point_to_inlay_point(point, bias)) } pub fn display_point_to_inlay_offset(&self, point: DisplayPoint, bias: Bias) -> InlayOffset { - self.inlay_snapshot + self.inlay_snapshot() .to_offset(self.display_point_to_inlay_point(point, bias)) } pub fn anchor_to_inlay_offset(&self, anchor: Anchor) -> InlayOffset { - self.inlay_snapshot - .to_inlay_offset(anchor.to_offset(&self.buffer_snapshot)) + self.inlay_snapshot() + .to_inlay_offset(anchor.to_offset(self.buffer_snapshot())) } pub fn display_point_to_anchor(&self, point: DisplayPoint, bias: Bias) -> Anchor { - self.buffer_snapshot + self.buffer_snapshot() .anchor_at(point.to_offset(self, bias), bias) } fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); - let tab_point = self.wrap_snapshot.to_tab_point(wrap_point); - let fold_point = self.tab_snapshot.to_fold_point(tab_point, bias).0; - fold_point.to_inlay_point(&self.fold_snapshot) + let tab_point = self.wrap_snapshot().to_tab_point(wrap_point); + let fold_point = self.tab_snapshot().to_fold_point(tab_point, bias).0; + fold_point.to_inlay_point(self.fold_snapshot()) } pub fn display_point_to_fold_point(&self, point: DisplayPoint, bias: Bias) -> FoldPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); - let tab_point = self.wrap_snapshot.to_tab_point(wrap_point); - self.tab_snapshot.to_fold_point(tab_point, bias).0 + let tab_point = self.wrap_snapshot().to_tab_point(wrap_point); + self.tab_snapshot().to_fold_point(tab_point, bias).0 } pub fn fold_point_to_display_point(&self, fold_point: FoldPoint) -> DisplayPoint { - let tab_point = self.tab_snapshot.to_tab_point(fold_point); - let wrap_point = self.wrap_snapshot.tab_point_to_wrap_point(tab_point); + let tab_point = self.tab_snapshot().to_tab_point(fold_point); + let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point); let block_point = self.block_snapshot.to_block_point(wrap_point); DisplayPoint(block_point) } @@ -962,62 +968,59 @@ impl DisplaySnapshot { }, ) .flat_map(|chunk| { - let mut highlight_style = chunk + let highlight_style = chunk .syntax_highlight_id .and_then(|id| id.style(&editor_style.syntax)); - if let Some(chunk_highlight) = chunk.highlight_style { - // For color inlays, blend the color with the editor background - let mut processed_highlight = chunk_highlight; - if chunk.is_inlay { - if let Some(inlay_color) = chunk_highlight.color { - // Only blend if the color has transparency (alpha < 1.0) - if inlay_color.a < 1.0 { - let blended_color = editor_style.background.blend(inlay_color); - processed_highlight.color = Some(blended_color); + let chunk_highlight = chunk.highlight_style.map(|chunk_highlight| { + HighlightStyle { + // For color inlays, blend the color with the editor background + // if the color has transparency (alpha < 1.0) + color: chunk_highlight.color.map(|color| { + if chunk.is_inlay && !color.is_opaque() { + editor_style.background.blend(color) + } else { + color } - } - } - - if let Some(highlight_style) = highlight_style.as_mut() { - highlight_style.highlight(processed_highlight); - } else { - highlight_style = Some(processed_highlight); + }), + ..chunk_highlight } - } - - let mut diagnostic_highlight = HighlightStyle::default(); + }); - if let Some(severity) = chunk.diagnostic_severity.filter(|severity| { - self.diagnostics_max_severity - .into_lsp() - .map_or(false, |max_severity| severity <= &max_severity) - }) { - if chunk.is_unnecessary { - diagnostic_highlight.fade_out = Some(editor_style.unnecessary_code_fade); - } - if chunk.underline - && editor_style.show_underlines - && !(chunk.is_unnecessary && severity > lsp::DiagnosticSeverity::WARNING) - { - let diagnostic_color = super::diagnostic_style(severity, &editor_style.status); - diagnostic_highlight.underline = Some(UnderlineStyle { - color: Some(diagnostic_color), - thickness: 1.0.into(), - wavy: true, - }); - } - } + let diagnostic_highlight = chunk + .diagnostic_severity + .filter(|severity| { + self.diagnostics_max_severity + .into_lsp() + .is_some_and(|max_severity| severity <= &max_severity) + }) + .map(|severity| HighlightStyle { + fade_out: chunk + .is_unnecessary + .then_some(editor_style.unnecessary_code_fade), + underline: (chunk.underline + && editor_style.show_underlines + && !(chunk.is_unnecessary && severity > lsp::DiagnosticSeverity::WARNING)) + .then(|| { + let diagnostic_color = + super::diagnostic_style(severity, &editor_style.status); + UnderlineStyle { + color: Some(diagnostic_color), + thickness: 1.0.into(), + wavy: true, + } + }), + ..Default::default() + }); - if let Some(highlight_style) = highlight_style.as_mut() { - highlight_style.highlight(diagnostic_highlight); - } else { - highlight_style = Some(diagnostic_highlight); - } + let style = [highlight_style, chunk_highlight, diagnostic_highlight] + .into_iter() + .flatten() + .reduce(|acc, highlight| acc.highlight(highlight)); HighlightedChunk { text: chunk.text, - style: highlight_style, + style, is_tab: chunk.is_tab, is_inlay: chunk.is_inlay, replacement: chunk.renderer.map(ChunkReplacement::Renderer), @@ -1121,7 +1124,7 @@ impl DisplaySnapshot { } pub fn buffer_chars_at(&self, mut offset: usize) -> impl Iterator + '_ { - self.buffer_snapshot.chars_at(offset).map(move |ch| { + self.buffer_snapshot().chars_at(offset).map(move |ch| { let ret = (ch, offset); offset += ch.len_utf8(); ret @@ -1132,7 +1135,7 @@ impl DisplaySnapshot { &self, mut offset: usize, ) -> impl Iterator + '_ { - self.buffer_snapshot + self.buffer_snapshot() .reversed_chars_at(offset) .map(move |ch| { offset -= ch.len_utf8(); @@ -1155,11 +1158,11 @@ impl DisplaySnapshot { pub fn clip_at_line_end(&self, display_point: DisplayPoint) -> DisplayPoint { let mut point = self.display_point_to_point(display_point, Bias::Left); - if point.column != self.buffer_snapshot.line_len(MultiBufferRow(point.row)) { + if point.column != self.buffer_snapshot().line_len(MultiBufferRow(point.row)) { return display_point; } point.column = point.column.saturating_sub(1); - point = self.buffer_snapshot.clip_point(point, Bias::Left); + point = self.buffer_snapshot().clip_point(point, Bias::Left); self.point_to_display_point(point, Bias::Left) } @@ -1167,7 +1170,7 @@ impl DisplaySnapshot { where T: ToOffset, { - self.fold_snapshot.folds_in_range(range) + self.fold_snapshot().folds_in_range(range) } pub fn blocks_in_range( @@ -1179,7 +1182,7 @@ impl DisplaySnapshot { .map(|(row, block)| (DisplayRow(row), block)) } - pub fn sticky_header_excerpt(&self, row: f32) -> Option> { + pub fn sticky_header_excerpt(&self, row: f64) -> Option> { self.block_snapshot.sticky_header_excerpt(row) } @@ -1188,12 +1191,12 @@ impl DisplaySnapshot { } pub fn intersects_fold(&self, offset: T) -> bool { - self.fold_snapshot.intersects_fold(offset) + self.fold_snapshot().intersects_fold(offset) } pub fn is_line_folded(&self, buffer_row: MultiBufferRow) -> bool { self.block_snapshot.is_line_replaced(buffer_row) - || self.fold_snapshot.is_line_folded(buffer_row) + || self.fold_snapshot().is_line_folded(buffer_row) } pub fn is_block_line(&self, display_row: DisplayRow) -> bool { @@ -1210,7 +1213,7 @@ impl DisplaySnapshot { .block_snapshot .to_wrap_point(BlockPoint::new(display_row.0, 0), Bias::Left) .row(); - self.wrap_snapshot.soft_wrap_indent(wrap_row) + self.wrap_snapshot().soft_wrap_indent(wrap_row) } pub fn text(&self) -> String { @@ -1231,7 +1234,7 @@ impl DisplaySnapshot { } pub fn line_indent_for_buffer_row(&self, buffer_row: MultiBufferRow) -> LineIndent { - self.buffer_snapshot.line_indent_for_row(buffer_row) + self.buffer_snapshot().line_indent_for_row(buffer_row) } pub fn line_len(&self, row: DisplayRow) -> u32 { @@ -1249,7 +1252,7 @@ impl DisplaySnapshot { } pub fn starts_indent(&self, buffer_row: MultiBufferRow) -> bool { - let max_row = self.buffer_snapshot.max_row(); + let max_row = self.buffer_snapshot().max_row(); if buffer_row >= max_row { return false; } @@ -1274,10 +1277,11 @@ impl DisplaySnapshot { } pub fn crease_for_buffer_row(&self, buffer_row: MultiBufferRow) -> Option> { - let start = MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot.line_len(buffer_row)); + let start = + MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot().line_len(buffer_row)); if let Some(crease) = self .crease_snapshot - .query_row(buffer_row, &self.buffer_snapshot) + .query_row(buffer_row, self.buffer_snapshot()) { match crease { Crease::Inline { @@ -1287,7 +1291,7 @@ impl DisplaySnapshot { render_trailer, metadata, } => Some(Crease::Inline { - range: range.to_point(&self.buffer_snapshot), + range: range.to_point(self.buffer_snapshot()), placeholder: placeholder.clone(), render_toggle: render_toggle.clone(), render_trailer: render_trailer.clone(), @@ -1301,7 +1305,7 @@ impl DisplaySnapshot { block_priority, render_toggle, } => Some(Crease::Block { - range: range.to_point(&self.buffer_snapshot), + range: range.to_point(self.buffer_snapshot()), block_height: *block_height, block_style: *block_style, render_block: render_block.clone(), @@ -1313,7 +1317,7 @@ impl DisplaySnapshot { && !self.is_line_folded(MultiBufferRow(start.row)) { let start_line_indent = self.line_indent_for_buffer_row(buffer_row); - let max_point = self.buffer_snapshot.max_point(); + let max_point = self.buffer_snapshot().max_point(); let mut end = None; for row in (buffer_row.0 + 1)..=max_point.row { @@ -1324,7 +1328,7 @@ impl DisplaySnapshot { let prev_row = row - 1; end = Some(Point::new( prev_row, - self.buffer_snapshot.line_len(MultiBufferRow(prev_row)), + self.buffer_snapshot().line_len(MultiBufferRow(prev_row)), )); break; } @@ -1333,7 +1337,7 @@ impl DisplaySnapshot { let mut row_before_line_breaks = end.unwrap_or(max_point); while row_before_line_breaks.row > start.row && self - .buffer_snapshot + .buffer_snapshot() .is_line_blank(MultiBufferRow(row_before_line_breaks.row)) { row_before_line_breaks.row -= 1; @@ -1341,7 +1345,7 @@ impl DisplaySnapshot { row_before_line_breaks = Point::new( row_before_line_breaks.row, - self.buffer_snapshot + self.buffer_snapshot() .line_len(MultiBufferRow(row_before_line_breaks.row)), ); @@ -1383,8 +1387,37 @@ impl DisplaySnapshot { pub fn excerpt_header_height(&self) -> u32 { self.block_snapshot.excerpt_header_height } + + /// Given a `DisplayPoint`, returns another `DisplayPoint` corresponding to + /// the start of the buffer row that is a given number of buffer rows away + /// from the provided point. + /// + /// This moves by buffer rows instead of display rows, a distinction that is + /// important when soft wrapping is enabled. + pub fn start_of_relative_buffer_row(&self, point: DisplayPoint, times: isize) -> DisplayPoint { + let start = self.display_point_to_fold_point(point, Bias::Left); + let target = start.row() as isize + times; + let new_row = (target.max(0) as u32).min(self.fold_snapshot().max_point().row()); + + self.clip_point( + self.fold_point_to_display_point( + self.fold_snapshot() + .clip_point(FoldPoint::new(new_row, 0), Bias::Right), + ), + Bias::Right, + ) + } } +impl std::ops::Deref for DisplaySnapshot { + type Target = BlockSnapshot; + + fn deref(&self) -> &Self::Target { + &self.block_snapshot + } +} + +/// A zero-indexed point in a text buffer consisting of a row and column adjusted for inserted blocks. #[derive(Copy, Clone, Default, Eq, Ord, PartialOrd, PartialEq)] pub struct DisplayPoint(BlockPoint); @@ -1485,23 +1518,23 @@ impl DisplayPoint { pub fn to_offset(self, map: &DisplaySnapshot, bias: Bias) -> usize { let wrap_point = map.block_snapshot.to_wrap_point(self.0, bias); - let tab_point = map.wrap_snapshot.to_tab_point(wrap_point); - let fold_point = map.tab_snapshot.to_fold_point(tab_point, bias).0; - let inlay_point = fold_point.to_inlay_point(&map.fold_snapshot); - map.inlay_snapshot - .to_buffer_offset(map.inlay_snapshot.to_offset(inlay_point)) + let tab_point = map.wrap_snapshot().to_tab_point(wrap_point); + let fold_point = map.tab_snapshot().to_fold_point(tab_point, bias).0; + let inlay_point = fold_point.to_inlay_point(map.fold_snapshot()); + map.inlay_snapshot() + .to_buffer_offset(map.inlay_snapshot().to_offset(inlay_point)) } } impl ToDisplayPoint for usize { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint { - map.point_to_display_point(self.to_point(&map.buffer_snapshot), Bias::Left) + map.point_to_display_point(self.to_point(map.buffer_snapshot()), Bias::Left) } } impl ToDisplayPoint for OffsetUtf16 { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint { - self.to_offset(&map.buffer_snapshot).to_display_point(map) + self.to_offset(map.buffer_snapshot()).to_display_point(map) } } @@ -1513,7 +1546,7 @@ impl ToDisplayPoint for Point { impl ToDisplayPoint for Anchor { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint { - self.to_point(&map.buffer_snapshot).to_display_point(map) + self.to_point(map.buffer_snapshot()).to_display_point(map) } } @@ -1532,12 +1565,11 @@ pub mod tests { use language::{ Buffer, Diagnostic, DiagnosticEntry, DiagnosticSet, Language, LanguageConfig, LanguageMatcher, - language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, }; use lsp::LanguageServerId; use project::Project; use rand::{Rng, prelude::*}; - use settings::SettingsStore; + use settings::{SettingsContent, SettingsStore}; use smol::stream::StreamExt; use std::{env, sync::Arc}; use text::PointUtf16; @@ -1552,27 +1584,29 @@ pub mod tests { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let mut tab_size = rng.gen_range(1..=4); - let buffer_start_excerpt_header_height = rng.gen_range(1..=5); - let excerpt_header_height = rng.gen_range(1..=5); + let mut tab_size = rng.random_range(1..=4); + let buffer_start_excerpt_header_height = rng.random_range(1..=5); + let excerpt_header_height = rng.random_range(1..=5); let font_size = px(14.0); let max_wrap_width = 300.0; - let mut wrap_width = if rng.gen_bool(0.1) { + let mut wrap_width = if rng.random_bool(0.1) { None } else { - Some(px(rng.gen_range(0.0..=max_wrap_width))) + Some(px(rng.random_range(0.0..=max_wrap_width))) }; log::info!("tab size: {}", tab_size); log::info!("wrap width: {:?}", wrap_width); cx.update(|cx| { - init_test(cx, |s| s.defaults.tab_size = NonZeroU32::new(tab_size)); + init_test(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(tab_size) + }); }); let buffer = cx.update(|cx| { - if rng.r#gen() { - let len = rng.gen_range(0..10); + if rng.random() { + let len = rng.random_range(0..10); let text = util::RandomCharIter::new(&mut rng) .take(len) .collect::(); @@ -1601,20 +1635,20 @@ pub mod tests { let mut blocks = Vec::new(); let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); - log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); - log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); - log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); - log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); + log::info!("buffer text: {:?}", snapshot.buffer_snapshot().text()); + log::info!("fold text: {:?}", snapshot.fold_snapshot().text()); + log::info!("tab text: {:?}", snapshot.tab_snapshot().text()); + log::info!("wrap text: {:?}", snapshot.wrap_snapshot().text()); log::info!("block text: {:?}", snapshot.block_snapshot.text()); log::info!("display text: {:?}", snapshot.text()); for _i in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..=19 => { - wrap_width = if rng.gen_bool(0.2) { + wrap_width = if rng.random_bool(0.2) { None } else { - Some(px(rng.gen_range(0.0..=max_wrap_width))) + Some(px(rng.random_range(0.0..=max_wrap_width))) }; log::info!("setting wrap width to {:?}", wrap_width); map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); @@ -1626,36 +1660,37 @@ pub mod tests { log::info!("setting tab size to {:?}", tab_size); cx.update(|cx| { cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |s| { - s.defaults.tab_size = NonZeroU32::new(tab_size); + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = + NonZeroU32::new(tab_size); }); }); }); } 30..=44 => { map.update(cx, |map, cx| { - if rng.r#gen() || blocks.is_empty() { - let buffer = map.snapshot(cx).buffer_snapshot; - let block_properties = (0..rng.gen_range(1..=1)) + if rng.random() || blocks.is_empty() { + let snapshot = map.snapshot(cx); + let buffer = snapshot.buffer_snapshot(); + let block_properties = (0..rng.random_range(1..=1)) .map(|_| { - let position = - buffer.anchor_after(buffer.clip_offset( - rng.gen_range(0..=buffer.len()), - Bias::Left, - )); + let position = buffer.anchor_after(buffer.clip_offset( + rng.random_range(0..=buffer.len()), + Bias::Left, + )); - let placement = if rng.r#gen() { + let placement = if rng.random() { BlockPlacement::Above(position) } else { BlockPlacement::Below(position) }; - let height = rng.gen_range(1..5); + let height = rng.random_range(1..5); log::info!( "inserting block {:?} with height {}", placement.as_ref().map(|p| p.to_point(&buffer)), height ); - let priority = rng.gen_range(1..100); + let priority = rng.random_range(1..100); BlockProperties { placement, style: BlockStyle::Fixed, @@ -1668,9 +1703,9 @@ pub mod tests { blocks.extend(map.insert_blocks(block_properties, cx)); } else { blocks.shuffle(&mut rng); - let remove_count = rng.gen_range(1..=4.min(blocks.len())); + let remove_count = rng.random_range(1..=4.min(blocks.len())); let block_ids_to_remove = (0..remove_count) - .map(|_| blocks.remove(rng.gen_range(0..blocks.len()))) + .map(|_| blocks.remove(rng.random_range(0..blocks.len()))) .collect(); log::info!("removing block ids {:?}", block_ids_to_remove); map.remove_blocks(block_ids_to_remove, cx); @@ -1679,16 +1714,16 @@ pub mod tests { } 45..=79 => { let mut ranges = Vec::new(); - for _ in 0..rng.gen_range(1..=3) { + for _ in 0..rng.random_range(1..=3) { buffer.read_with(cx, |buffer, cx| { let buffer = buffer.read(cx); - let end = buffer.clip_offset(rng.gen_range(0..=buffer.len()), Right); - let start = buffer.clip_offset(rng.gen_range(0..=end), Left); + let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right); + let start = buffer.clip_offset(rng.random_range(0..=end), Left); ranges.push(start..end); }); } - if rng.r#gen() && fold_count > 0 { + if rng.random() && fold_count > 0 { log::info!("unfolding ranges: {:?}", ranges); map.update(cx, |map, cx| { map.unfold_intersecting(ranges, true, cx); @@ -1717,18 +1752,18 @@ pub mod tests { let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); fold_count = snapshot.fold_count(); - log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); - log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); - log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); - log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); + log::info!("buffer text: {:?}", snapshot.buffer_snapshot().text()); + log::info!("fold text: {:?}", snapshot.fold_snapshot().text()); + log::info!("tab text: {:?}", snapshot.tab_snapshot().text()); + log::info!("wrap text: {:?}", snapshot.wrap_snapshot().text()); log::info!("block text: {:?}", snapshot.block_snapshot.text()); log::info!("display text: {:?}", snapshot.text()); // Line boundaries - let buffer = &snapshot.buffer_snapshot; + let buffer = snapshot.buffer_snapshot(); for _ in 0..5 { - let row = rng.gen_range(0..=buffer.max_point().row); - let column = rng.gen_range(0..=buffer.line_len(MultiBufferRow(row))); + let row = rng.random_range(0..=buffer.max_point().row); + let column = rng.random_range(0..=buffer.line_len(MultiBufferRow(row))); let point = buffer.clip_point(Point::new(row, column), Left); let (prev_buffer_bound, prev_display_bound) = snapshot.prev_line_boundary(point); @@ -1776,8 +1811,8 @@ pub mod tests { let min_point = snapshot.clip_point(DisplayPoint::new(DisplayRow(0), 0), Left); let max_point = snapshot.clip_point(snapshot.max_point(), Right); for _ in 0..5 { - let row = rng.gen_range(0..=snapshot.max_point().row().0); - let column = rng.gen_range(0..=snapshot.line_len(DisplayRow(row))); + let row = rng.random_range(0..=snapshot.max_point().row().0); + let column = rng.random_range(0..=snapshot.line_len(DisplayRow(row))); let point = snapshot.clip_point(DisplayPoint::new(DisplayRow(row), column), Left); log::info!("Moving from point {:?}", point); @@ -1879,37 +1914,37 @@ pub mod tests { ), ( DisplayPoint::new(DisplayRow(0), 7), - language::SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(f64::from(x)) ) ); assert_eq!( movement::down( &snapshot, DisplayPoint::new(DisplayRow(0), 7), - language::SelectionGoal::HorizontalPosition(x.0), + language::SelectionGoal::HorizontalPosition(f64::from(x)), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(1), 10), - language::SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(f64::from(x)) ) ); assert_eq!( movement::down( &snapshot, DisplayPoint::new(DisplayRow(1), 10), - language::SelectionGoal::HorizontalPosition(x.0), + language::SelectionGoal::HorizontalPosition(f64::from(x)), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(2), 4), - language::SelectionGoal::HorizontalPosition(x.0) + language::SelectionGoal::HorizontalPosition(f64::from(x)) ) ); - let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); + let ix = snapshot.buffer_snapshot().text().find("seven").unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(ix..ix, "and ")], None, cx); }); @@ -1922,7 +1957,7 @@ pub mod tests { // Re-wrap on font size changes map.update(cx, |map, cx| { - map.set_font(font("Helvetica"), px(font_size.0 + 3.), cx) + map.set_font(font("Helvetica"), font_size + Pixels::from(3.), cx) }); let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); @@ -2088,7 +2123,11 @@ pub mod tests { ); language.set_theme(&theme); - cx.update(|cx| init_test(cx, |s| s.defaults.tab_size = Some(2.try_into().unwrap()))); + cx.update(|cx| { + init_test(cx, |s| { + s.project.all_languages.defaults.tab_size = Some(2.try_into().unwrap()) + }) + }); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); cx.condition(&buffer, |buf, _| !buf.is_parsing()).await; @@ -2351,11 +2390,12 @@ pub mod tests { .highlight_style .and_then(|style| style.color) .map_or(black, |color| color.to_rgb()); - if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut() { - if *last_severity == chunk.diagnostic_severity && *last_color == color { - last_chunk.push_str(chunk.text); - continue; - } + if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut() + && *last_severity == chunk.diagnostic_severity + && *last_color == color + { + last_chunk.push_str(chunk.text); + continue; } chunks.push((chunk.text.to_string(), chunk.diagnostic_severity, color)); @@ -2609,7 +2649,7 @@ pub mod tests { ); language.set_theme(&theme); - let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»: B = "c «d»""#, false); + let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»«:» B = "c «d»""#, false); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); cx.condition(&buffer, |buf, _| !buf.is_parsing()).await; @@ -2658,7 +2698,7 @@ pub mod tests { [ ("const ".to_string(), None, None), ("a".to_string(), None, Some(Hsla::blue())), - (":".to_string(), Some(Hsla::red()), None), + (":".to_string(), Some(Hsla::red()), Some(Hsla::blue())), (" B = ".to_string(), None, None), ("\"c ".to_string(), Some(Hsla::green()), None), ("d".to_string(), Some(Hsla::green()), Some(Hsla::blue())), @@ -2901,18 +2941,19 @@ pub mod tests { .syntax_highlight_id .and_then(|id| id.style(theme)?.color); let highlight_color = chunk.highlight_style.and_then(|style| style.color); - if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() { - if syntax_color == *last_syntax_color && highlight_color == *last_highlight_color { - last_chunk.push_str(chunk.text); - continue; - } + if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() + && syntax_color == *last_syntax_color + && highlight_color == *last_highlight_color + { + last_chunk.push_str(chunk.text); + continue; } chunks.push((chunk.text.to_string(), syntax_color, highlight_color)); } chunks } - fn init_test(cx: &mut App, f: impl Fn(&mut AllLanguageSettingsContent)) { + fn init_test(cx: &mut App, f: impl Fn(&mut SettingsContent)) { let settings = SettingsStore::test(cx); cx.set_global(settings); workspace::init_settings(cx); @@ -2921,7 +2962,7 @@ pub mod tests { Project::init_settings(cx); theme::init(LoadThemes::JustBase, cx); cx.update_global::(|store, cx| { - store.update_user_settings::(cx, f); + store.update_user_settings(cx, f); }); } } diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index c4c9f2004adedcda0a2215aa3f073ef10f5aa78e..99234899d3af7505c911355e34abe8f3fea3d0d2 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -22,19 +22,19 @@ use std::{ atomic::{AtomicUsize, Ordering::SeqCst}, }, }; -use sum_tree::{Bias, Dimensions, SumTree, Summary, TreeMap}; +use sum_tree::{Bias, ContextLessSummary, Dimensions, SumTree, TreeMap}; use text::{BufferId, Edit}; use ui::ElementId; -const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; -const BULLETS: &str = "********************************************************************************************************************************"; +const NEWLINES: &[u8; rope::Chunk::MASK_BITS] = &[b'\n'; _]; +const BULLETS: &[u8; rope::Chunk::MASK_BITS] = &[b'*'; _]; /// Tracks custom blocks such as diagnostics that should be displayed within buffer. /// /// See the [`display_map` module documentation](crate::display_map) for more information. pub struct BlockMap { + pub(super) wrap_snapshot: RefCell, next_block_id: AtomicUsize, - wrap_snapshot: RefCell, custom_blocks: Vec>, custom_blocks_by_id: TreeMap>, transforms: RefCell>, @@ -53,7 +53,7 @@ pub struct BlockMapWriter<'a>(&'a mut BlockMap); #[derive(Clone)] pub struct BlockSnapshot { - wrap_snapshot: WrapSnapshot, + pub(super) wrap_snapshot: WrapSnapshot, transforms: SumTree, custom_blocks_by_id: TreeMap>, pub(super) buffer_header_height: u32, @@ -69,6 +69,8 @@ impl From for ElementId { } } +/// A zero-indexed point in a text buffer consisting of a row and column +/// adjusted for inserted blocks, wrapped rows, tabs, folds and inlays. #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] pub struct BlockPoint(pub Point); @@ -80,11 +82,16 @@ struct WrapRow(u32); pub type RenderBlock = Arc AnyElement>; +/// Where to place a block. #[derive(Clone, Debug, Eq, PartialEq)] pub enum BlockPlacement { + /// Place the block above the given position. Above(T), + /// Place the block below the given position. Below(T), + /// Place the block next the given position. Near(T), + /// Replace the given range of positions with the block. Replace(RangeInclusive), } @@ -128,10 +135,10 @@ impl BlockPlacement { } } - fn sort_order(&self) -> u8 { + fn tie_break(&self) -> u8 { match self { - BlockPlacement::Above(_) => 0, - BlockPlacement::Replace(_) => 1, + BlockPlacement::Replace(_) => 0, + BlockPlacement::Above(_) => 1, BlockPlacement::Near(_) => 2, BlockPlacement::Below(_) => 3, } @@ -143,7 +150,7 @@ impl BlockPlacement { self.start() .cmp(other.start(), buffer) .then_with(|| other.end().cmp(self.end(), buffer)) - .then_with(|| self.sort_order().cmp(&other.sort_order())) + .then_with(|| self.tie_break().cmp(&other.tie_break())) } fn to_wrap_row(&self, wrap_snapshot: &WrapSnapshot) -> Option> { @@ -290,7 +297,10 @@ pub enum Block { ExcerptBoundary { excerpt: ExcerptInfo, height: u32, - starts_new_buffer: bool, + }, + BufferHeader { + excerpt: ExcerptInfo, + height: u32, }, } @@ -303,27 +313,37 @@ impl Block { .. } => BlockId::ExcerptBoundary(next_excerpt.id), Block::FoldedBuffer { first_excerpt, .. } => BlockId::FoldedBuffer(first_excerpt.id), + Block::BufferHeader { + excerpt: next_excerpt, + .. + } => BlockId::ExcerptBoundary(next_excerpt.id), } } pub fn has_height(&self) -> bool { match self { Block::Custom(block) => block.height.is_some(), - Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => true, + Block::ExcerptBoundary { .. } + | Block::FoldedBuffer { .. } + | Block::BufferHeader { .. } => true, } } pub fn height(&self) -> u32 { match self { Block::Custom(block) => block.height.unwrap_or(0), - Block::ExcerptBoundary { height, .. } | Block::FoldedBuffer { height, .. } => *height, + Block::ExcerptBoundary { height, .. } + | Block::FoldedBuffer { height, .. } + | Block::BufferHeader { height, .. } => *height, } } pub fn style(&self) -> BlockStyle { match self { Block::Custom(block) => block.style, - Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => BlockStyle::Sticky, + Block::ExcerptBoundary { .. } + | Block::FoldedBuffer { .. } + | Block::BufferHeader { .. } => BlockStyle::Sticky, } } @@ -332,6 +352,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Above(_)), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => true, + Block::BufferHeader { .. } => true, } } @@ -340,6 +361,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Near(_)), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -351,6 +373,7 @@ impl Block { ), Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -359,6 +382,7 @@ impl Block { Block::Custom(block) => matches!(block.placement, BlockPlacement::Replace(_)), Block::FoldedBuffer { .. } => true, Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => false, } } @@ -367,6 +391,7 @@ impl Block { Block::Custom(_) => false, Block::FoldedBuffer { .. } => true, Block::ExcerptBoundary { .. } => true, + Block::BufferHeader { .. } => true, } } @@ -374,9 +399,8 @@ impl Block { match self { Block::Custom(_) => false, Block::FoldedBuffer { .. } => true, - Block::ExcerptBoundary { - starts_new_buffer, .. - } => *starts_new_buffer, + Block::ExcerptBoundary { .. } => false, + Block::BufferHeader { .. } => true, } } } @@ -393,14 +417,14 @@ impl Debug for Block { .field("first_excerpt", &first_excerpt) .field("height", height) .finish(), - Self::ExcerptBoundary { - starts_new_buffer, - excerpt, - height, - } => f + Self::ExcerptBoundary { excerpt, height } => f .debug_struct("ExcerptBoundary") .field("excerpt", excerpt) - .field("starts_new_buffer", starts_new_buffer) + .field("height", height) + .finish(), + Self::BufferHeader { excerpt, height } => f + .debug_struct("BufferHeader") + .field("excerpt", excerpt) .field("height", height) .finish(), } @@ -416,7 +440,7 @@ struct TransformSummary { } pub struct BlockChunks<'a> { - transforms: sum_tree::Cursor<'a, Transform, Dimensions>, + transforms: sum_tree::Cursor<'a, 'static, Transform, Dimensions>, input_chunks: wrap_map::WrapChunks<'a>, input_chunk: Chunk<'a>, output_row: u32, @@ -426,7 +450,7 @@ pub struct BlockChunks<'a> { #[derive(Clone)] pub struct BlockRows<'a> { - transforms: sum_tree::Cursor<'a, Transform, Dimensions>, + transforms: sum_tree::Cursor<'a, 'static, Transform, Dimensions>, input_rows: wrap_map::WrapRows<'a>, output_row: BlockRow, started: bool, @@ -510,7 +534,7 @@ impl BlockMap { let mut transforms = self.transforms.borrow_mut(); let mut new_transforms = SumTree::default(); - let mut cursor = transforms.cursor::(&()); + let mut cursor = transforms.cursor::(()); let mut last_block_ix = 0; let mut blocks_in_edit = Vec::new(); let mut edits = edits.into_iter().peekable(); @@ -524,27 +548,23 @@ impl BlockMap { // * Isomorphic transforms that end *at* the start of the edit // * Below blocks that end at the start of the edit // However, if we hit a replace block that ends at the start of the edit we want to reconstruct it. - new_transforms.append(cursor.slice(&old_start, Bias::Left), &()); - if let Some(transform) = cursor.item() { - if transform.summary.input_rows > 0 - && cursor.end() == old_start - && transform - .block - .as_ref() - .map_or(true, |b| !b.is_replacement()) - { - // Preserve the transform (push and next) - new_transforms.push(transform.clone(), &()); - cursor.next(); + new_transforms.append(cursor.slice(&old_start, Bias::Left), ()); + if let Some(transform) = cursor.item() + && transform.summary.input_rows > 0 + && cursor.end() == old_start + && transform.block.as_ref().is_none_or(|b| !b.is_replacement()) + { + // Preserve the transform (push and next) + new_transforms.push(transform.clone(), ()); + cursor.next(); - // Preserve below blocks at end of edit - while let Some(transform) = cursor.item() { - if transform.block.as_ref().map_or(false, |b| b.place_below()) { - new_transforms.push(transform.clone(), &()); - cursor.next(); - } else { - break; - } + // Preserve below blocks at end of edit + while let Some(transform) = cursor.item() { + if transform.block.as_ref().is_some_and(|b| b.place_below()) { + new_transforms.push(transform.clone(), ()); + cursor.next(); + } else { + break; } } } @@ -607,7 +627,7 @@ impl BlockMap { // Discard below blocks at the end of the edit. They'll be reconstructed. while let Some(transform) = cursor.item() { - if transform.block.as_ref().map_or(false, |b| b.place_below()) { + if transform.block.as_ref().is_some_and(|b| b.place_below()) { cursor.next(); } else { break; @@ -657,27 +677,26 @@ impl BlockMap { .iter() .filter_map(|block| { let placement = block.placement.to_wrap_row(wrap_snapshot)?; - if let BlockPlacement::Above(row) = placement { - if row < new_start { - return None; - } + if let BlockPlacement::Above(row) = placement + && row < new_start + { + return None; } Some((placement, Block::Custom(block.clone()))) }), ); - if buffer.show_headers() { - blocks_in_edit.extend(self.header_and_footer_blocks( - buffer, - (start_bound, end_bound), - wrap_snapshot, - )); - } + blocks_in_edit.extend(self.header_and_footer_blocks( + buffer, + (start_bound, end_bound), + wrap_snapshot, + )); BlockMap::sort_blocks(&mut blocks_in_edit); // For each of these blocks, insert a new isomorphic transform preceding the block, // and then insert the block itself. + let mut just_processed_folded_buffer = false; for (block_placement, block) in blocks_in_edit.drain(..) { let mut summary = TransformSummary { input_rows: 0, @@ -690,8 +709,12 @@ impl BlockMap { match block_placement { BlockPlacement::Above(position) => { rows_before_block = position.0 - new_transforms.summary().input_rows; + just_processed_folded_buffer = false; } BlockPlacement::Near(position) | BlockPlacement::Below(position) => { + if just_processed_folded_buffer { + continue; + } if position.0 + 1 < new_transforms.summary().input_rows { continue; } @@ -700,6 +723,7 @@ impl BlockMap { BlockPlacement::Replace(range) => { rows_before_block = range.start().0 - new_transforms.summary().input_rows; summary.input_rows = range.end().0 - range.start().0 + 1; + just_processed_folded_buffer = matches!(block, Block::FoldedBuffer { .. }); } } @@ -709,7 +733,7 @@ impl BlockMap { summary, block: Some(block), }, - &(), + (), ); } @@ -720,7 +744,7 @@ impl BlockMap { push_isomorphic(&mut new_transforms, rows_after_last_block, wrap_snapshot); } - new_transforms.append(cursor.suffix(), &()); + new_transforms.append(cursor.suffix(), ()); debug_assert_eq!( new_transforms.summary().input_rows, wrap_snapshot.max_point().row() + 1 @@ -775,7 +799,7 @@ impl BlockMap { if self.buffers_with_disabled_headers.contains(&new_buffer_id) { continue; } - if self.folded_buffers.contains(&new_buffer_id) { + if self.folded_buffers.contains(&new_buffer_id) && buffer.show_headers() { let mut last_excerpt_end_row = first_excerpt.end_row; while let Some(next_boundary) = boundaries.peek() { @@ -808,20 +832,24 @@ impl BlockMap { } } - if new_buffer_id.is_some() { + let starts_new_buffer = new_buffer_id.is_some(); + let block = if starts_new_buffer && buffer.show_headers() { height += self.buffer_header_height; - } else { + Block::BufferHeader { + excerpt: excerpt_boundary.next, + height, + } + } else if excerpt_boundary.prev.is_some() { height += self.excerpt_header_height; - } - - return Some(( - BlockPlacement::Above(WrapRow(wrap_row)), Block::ExcerptBoundary { excerpt: excerpt_boundary.next, height, - starts_new_buffer: new_buffer_id.is_some(), - }, - )); + } + } else { + continue; + }; + + return Some((BlockPlacement::Above(WrapRow(wrap_row)), block)); } }) } @@ -832,6 +860,7 @@ impl BlockMap { .start() .cmp(placement_b.start()) .then_with(|| placement_b.end().cmp(placement_a.end())) + .then_with(|| placement_a.tie_break().cmp(&placement_b.tie_break())) .then_with(|| { if block_a.is_header() { Ordering::Less @@ -841,18 +870,29 @@ impl BlockMap { Ordering::Equal } }) - .then_with(|| placement_a.sort_order().cmp(&placement_b.sort_order())) .then_with(|| match (block_a, block_b) { ( Block::ExcerptBoundary { excerpt: excerpt_a, .. + } + | Block::BufferHeader { + excerpt: excerpt_a, .. }, Block::ExcerptBoundary { excerpt: excerpt_b, .. + } + | Block::BufferHeader { + excerpt: excerpt_b, .. }, ) => Some(excerpt_a.id).cmp(&Some(excerpt_b.id)), - (Block::ExcerptBoundary { .. }, Block::Custom(_)) => Ordering::Less, - (Block::Custom(_), Block::ExcerptBoundary { .. }) => Ordering::Greater, + ( + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }, + Block::Custom(_), + ) => Ordering::Less, + ( + Block::Custom(_), + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. }, + ) => Ordering::Greater, (Block::Custom(block_a), Block::Custom(block_b)) => block_a .priority .cmp(&block_b.priority) @@ -898,11 +938,11 @@ fn push_isomorphic(tree: &mut SumTree, rows: u32, wrap_snapshot: &Wra tree.update_last( |last_transform| { if last_transform.block.is_none() { - last_transform.summary.add_summary(&summary, &()); + last_transform.summary.add_summary(&summary); merged = true; } }, - &(), + (), ); if !merged { tree.push( @@ -910,7 +950,7 @@ fn push_isomorphic(tree: &mut SumTree, rows: u32, wrap_snapshot: &Wra summary, block: None, }, - &(), + (), ); } } @@ -970,17 +1010,17 @@ impl BlockMapReader<'_> { .unwrap_or(self.wrap_snapshot.max_point().row() + 1), ); - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&start_wrap_row, Bias::Left); while let Some(transform) = cursor.item() { if cursor.start().0 > end_wrap_row { break; } - if let Some(BlockId::Custom(id)) = transform.block.as_ref().map(|block| block.id()) { - if id == block_id { - return Some(cursor.start().1); - } + if let Some(BlockId::Custom(id)) = transform.block.as_ref().map(|block| block.id()) + && id == block_id + { + return Some(cursor.start().1); } cursor.next(); } @@ -1153,18 +1193,14 @@ impl BlockMapWriter<'_> { self.0.sync(wrap_snapshot, edits); } - pub fn remove_intersecting_replace_blocks( + pub fn remove_intersecting_replace_blocks( &mut self, - ranges: impl IntoIterator>, + ranges: impl IntoIterator>, inclusive: bool, - ) where - T: ToOffset, - { + ) { let wrap_snapshot = self.0.wrap_snapshot.borrow(); let mut blocks_to_remove = HashSet::default(); for range in ranges { - let range = range.start.to_offset(wrap_snapshot.buffer_snapshot()) - ..range.end.to_offset(wrap_snapshot.buffer_snapshot()); for block in self.blocks_intersecting_buffer_range(range, inclusive) { if matches!(block.placement, BlockPlacement::Replace(_)) { blocks_to_remove.insert(block.id); @@ -1237,36 +1273,30 @@ impl BlockMapWriter<'_> { range: Range, inclusive: bool, ) -> &[Arc] { + if range.is_empty() && !inclusive { + return &[]; + } let wrap_snapshot = self.0.wrap_snapshot.borrow(); let buffer = wrap_snapshot.buffer_snapshot(); let start_block_ix = match self.0.custom_blocks.binary_search_by(|block| { let block_end = block.end().to_offset(buffer); - block_end.cmp(&range.start).then_with(|| { - if inclusive || (range.is_empty() && block.start().to_offset(buffer) == block_end) { - Ordering::Greater - } else { - Ordering::Less - } - }) + block_end.cmp(&range.start).then(Ordering::Greater) }) { Ok(ix) | Err(ix) => ix, }; - let end_block_ix = match self.0.custom_blocks.binary_search_by(|block| { - block - .start() - .to_offset(buffer) - .cmp(&range.end) - .then(if inclusive { - Ordering::Less - } else { - Ordering::Greater - }) + let end_block_ix = match self.0.custom_blocks[start_block_ix..].binary_search_by(|block| { + let block_start = block.start().to_offset(buffer); + block_start.cmp(&range.end).then(if inclusive { + Ordering::Less + } else { + Ordering::Greater + }) }) { Ok(ix) | Err(ix) => ix, }; - &self.0.custom_blocks[start_block_ix..end_block_ix] + &self.0.custom_blocks[start_block_ix..][..end_block_ix] } } @@ -1292,21 +1322,21 @@ impl BlockSnapshot { ) -> BlockChunks<'a> { let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows); - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&BlockRow(rows.start), Bias::Right); let transform_output_start = cursor.start().0.0; let transform_input_start = cursor.start().1.0; let mut input_start = transform_input_start; let mut input_end = transform_input_start; - if let Some(transform) = cursor.item() { - if transform.block.is_none() { - input_start += rows.start - transform_output_start; - input_end += cmp::min( - rows.end - transform_output_start, - transform.summary.input_rows, - ); - } + if let Some(transform) = cursor.item() + && transform.block.is_none() + { + input_start += rows.start - transform_output_start; + input_end += cmp::min( + rows.end - transform_output_start, + transform.summary.input_rows, + ); } BlockChunks { @@ -1324,12 +1354,12 @@ impl BlockSnapshot { } pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows<'_> { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&start_row, Bias::Right); let Dimensions(output_start, input_start, _) = cursor.start(); let overshoot = if cursor .item() - .map_or(false, |transform| transform.block.is_none()) + .is_some_and(|transform| transform.block.is_none()) { start_row.0 - output_start.0 } else { @@ -1345,7 +1375,7 @@ impl BlockSnapshot { } pub fn blocks_in_range(&self, rows: Range) -> impl Iterator { - let mut cursor = self.transforms.cursor::(&()); + let mut cursor = self.transforms.cursor::(()); cursor.seek(&BlockRow(rows.start), Bias::Left); while cursor.start().0 < rows.start && cursor.end().0 <= rows.start { cursor.next(); @@ -1359,7 +1389,7 @@ impl BlockSnapshot { && transform .block .as_ref() - .map_or(false, |block| block.height() > 0)) + .is_some_and(|block| block.height() > 0)) { break; } @@ -1374,14 +1404,16 @@ impl BlockSnapshot { }) } - pub fn sticky_header_excerpt(&self, position: f32) -> Option> { + pub(crate) fn sticky_header_excerpt(&self, position: f64) -> Option> { let top_row = position as u32; - let mut cursor = self.transforms.cursor::(&()); + let mut cursor = self.transforms.cursor::(()); cursor.seek(&BlockRow(top_row), Bias::Right); while let Some(transform) = cursor.item() { match &transform.block { - Some(Block::ExcerptBoundary { excerpt, .. }) => { + Some( + Block::ExcerptBoundary { excerpt, .. } | Block::BufferHeader { excerpt, .. }, + ) => { return Some(StickyHeaderExcerpt { excerpt }); } Some(block) if block.is_buffer_header() => return None, @@ -1413,7 +1445,7 @@ impl BlockSnapshot { }; let wrap_row = WrapRow(wrap_point.row()); - let mut cursor = self.transforms.cursor::(&()); + let mut cursor = self.transforms.cursor::(()); cursor.seek(&wrap_row, Bias::Left); while let Some(transform) = cursor.item() { @@ -1441,7 +1473,7 @@ impl BlockSnapshot { } pub fn longest_row_in_range(&self, range: Range) -> BlockRow { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&range.start, Bias::Right); let mut longest_row = range.start; @@ -1472,18 +1504,18 @@ impl BlockSnapshot { longest_row_chars = summary.longest_row_chars; } - if let Some(transform) = cursor.item() { - if transform.block.is_none() { - let Dimensions(output_start, input_start, _) = cursor.start(); - let overshoot = range.end.0 - output_start.0; - let wrap_start_row = input_start.0; - let wrap_end_row = input_start.0 + overshoot; - let summary = self - .wrap_snapshot - .text_summary_for_range(wrap_start_row..wrap_end_row); - if summary.longest_row_chars > longest_row_chars { - longest_row = BlockRow(output_start.0 + summary.longest_row); - } + if let Some(transform) = cursor.item() + && transform.block.is_none() + { + let Dimensions(output_start, input_start, _) = cursor.start(); + let overshoot = range.end.0 - output_start.0; + let wrap_start_row = input_start.0; + let wrap_end_row = input_start.0 + overshoot; + let summary = self + .wrap_snapshot + .text_summary_for_range(wrap_start_row..wrap_end_row); + if summary.longest_row_chars > longest_row_chars { + longest_row = BlockRow(output_start.0 + summary.longest_row); } } } @@ -1492,10 +1524,11 @@ impl BlockSnapshot { } pub(super) fn line_len(&self, row: BlockRow) -> u32 { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&BlockRow(row.0), Bias::Right); - if let Some(transform) = cursor.item() { - let Dimensions(output_start, input_start, _) = cursor.start(); + let (start, _, item) = + self.transforms + .find::, _>((), &row, Bias::Right); + if let Some(transform) = item { + let Dimensions(output_start, input_start, _) = start; let overshoot = row.0 - output_start.0; if transform.block.is_some() { 0 @@ -1510,15 +1543,13 @@ impl BlockSnapshot { } pub(super) fn is_block_line(&self, row: BlockRow) -> bool { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&row, Bias::Right); - cursor.item().map_or(false, |t| t.block.is_some()) + let (_, _, item) = self.transforms.find::((), &row, Bias::Right); + item.is_some_and(|t| t.block.is_some()) } pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&row, Bias::Right); - let Some(transform) = cursor.item() else { + let (_, _, item) = self.transforms.find::((), &row, Bias::Right); + let Some(transform) = item else { return false; }; matches!(transform.block, Some(Block::FoldedBuffer { .. })) @@ -1528,18 +1559,19 @@ impl BlockSnapshot { let wrap_point = self .wrap_snapshot .make_wrap_point(Point::new(row.0, 0), Bias::Left); - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&WrapRow(wrap_point.row()), Bias::Right); - cursor.item().map_or(false, |transform| { + let (_, _, item) = + self.transforms + .find::((), &WrapRow(wrap_point.row()), Bias::Right); + item.is_some_and(|transform| { transform .block .as_ref() - .map_or(false, |block| block.is_replacement()) + .is_some_and(|block| block.is_replacement()) }) } pub fn clip_point(&self, point: BlockPoint, bias: Bias) -> BlockPoint { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&BlockRow(point.row), Bias::Right); let max_input_row = WrapRow(self.transforms.summary().input_rows); @@ -1557,12 +1589,11 @@ impl BlockSnapshot { match transform.block.as_ref() { Some(block) => { - if block.is_replacement() { - if ((bias == Bias::Left || search_left) && output_start <= point.0) - || (!search_left && output_start >= point.0) - { - return BlockPoint(output_start); - } + if block.is_replacement() + && (((bias == Bias::Left || search_left) && output_start <= point.0) + || (!search_left && output_start >= point.0)) + { + return BlockPoint(output_start); } } None => { @@ -1599,13 +1630,16 @@ impl BlockSnapshot { } pub fn to_block_point(&self, wrap_point: WrapPoint) -> BlockPoint { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&WrapRow(wrap_point.row()), Bias::Right); - if let Some(transform) = cursor.item() { + let (start, _, item) = self.transforms.find::, _>( + (), + &WrapRow(wrap_point.row()), + Bias::Right, + ); + if let Some(transform) = item { if transform.block.is_some() { - BlockPoint::new(cursor.start().1.0, 0) + BlockPoint::new(start.1.0, 0) } else { - let Dimensions(input_start_row, output_start_row, _) = cursor.start(); + let Dimensions(input_start_row, output_start_row, _) = start; let input_start = Point::new(input_start_row.0, 0); let output_start = Point::new(output_start_row.0, 0); let input_overshoot = wrap_point.0 - input_start; @@ -1617,26 +1651,29 @@ impl BlockSnapshot { } pub fn to_wrap_point(&self, block_point: BlockPoint, bias: Bias) -> WrapPoint { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&BlockRow(block_point.row), Bias::Right); - if let Some(transform) = cursor.item() { + let (start, end, item) = self.transforms.find::, _>( + (), + &BlockRow(block_point.row), + Bias::Right, + ); + if let Some(transform) = item { match transform.block.as_ref() { Some(block) => { if block.place_below() { - let wrap_row = cursor.start().1.0 - 1; + let wrap_row = start.1.0 - 1; WrapPoint::new(wrap_row, self.wrap_snapshot.line_len(wrap_row)) } else if block.place_above() { - WrapPoint::new(cursor.start().1.0, 0) + WrapPoint::new(start.1.0, 0) } else if bias == Bias::Left { - WrapPoint::new(cursor.start().1.0, 0) + WrapPoint::new(start.1.0, 0) } else { - let wrap_row = cursor.end().1.0 - 1; + let wrap_row = end.1.0 - 1; WrapPoint::new(wrap_row, self.wrap_snapshot.line_len(wrap_row)) } } None => { - let overshoot = block_point.row - cursor.start().0.0; - let wrap_row = cursor.start().1.0 + overshoot; + let overshoot = block_point.row - start.0.0; + let wrap_row = start.1.0 + overshoot; WrapPoint::new(wrap_row, block_point.column) } } @@ -1655,7 +1692,7 @@ impl BlockChunks<'_> { if transform .block .as_ref() - .map_or(false, |block| block.height() == 0) + .is_some_and(|block| block.height() == 0) { self.transforms.next(); } else { @@ -1666,7 +1703,7 @@ impl BlockChunks<'_> { if self .transforms .item() - .map_or(false, |transform| transform.block.is_none()) + .is_some_and(|transform| transform.block.is_none()) { let start_input_row = self.transforms.start().1.0; let start_output_row = self.transforms.start().0.0; @@ -1704,11 +1741,13 @@ impl<'a> Iterator for BlockChunks<'a> { let start_in_block = self.output_row - block_start; let end_in_block = cmp::min(self.max_output_row, block_end) - block_start; - let line_count = end_in_block - start_in_block; + // todo: We need to split the chunk here? + let line_count = cmp::min(end_in_block - start_in_block, u128::BITS); self.output_row += line_count; return Some(Chunk { text: unsafe { std::str::from_utf8_unchecked(&NEWLINES[..line_count as usize]) }, + chars: 1u128.unbounded_shl(line_count) - 1, ..Default::default() }); } @@ -1723,6 +1762,7 @@ impl<'a> Iterator for BlockChunks<'a> { if self.transforms.item().is_some() { return Some(Chunk { text: "\n", + chars: 1, ..Default::default() }); } @@ -1738,17 +1778,26 @@ impl<'a> Iterator for BlockChunks<'a> { let (mut prefix, suffix) = self.input_chunk.text.split_at(prefix_bytes); self.input_chunk.text = suffix; + self.input_chunk.tabs >>= prefix_bytes.saturating_sub(1); + self.input_chunk.chars >>= prefix_bytes.saturating_sub(1); + + let mut tabs = self.input_chunk.tabs; + let mut chars = self.input_chunk.chars; if self.masked { // Not great for multibyte text because to keep cursor math correct we - // need to have the same number of bytes in the input as output. - let chars = prefix.chars().count(); - let bullet_len = chars; - prefix = &BULLETS[..bullet_len]; + // need to have the same number of chars in the input as output. + let chars_count = prefix.chars().count(); + let bullet_len = chars_count; + prefix = unsafe { std::str::from_utf8_unchecked(&BULLETS[..bullet_len]) }; + chars = 1u128.unbounded_shl(bullet_len as u32).wrapping_sub(1); + tabs = 0; } let chunk = Chunk { text: prefix, + tabs, + chars, ..self.input_chunk.clone() }; @@ -1776,7 +1825,7 @@ impl Iterator for BlockRows<'_> { if transform .block .as_ref() - .map_or(false, |block| block.height() == 0) + .is_some_and(|block| block.height() == 0) { self.transforms.next(); } else { @@ -1788,7 +1837,7 @@ impl Iterator for BlockRows<'_> { if transform .block .as_ref() - .map_or(true, |block| block.is_replacement()) + .is_none_or(|block| block.is_replacement()) { self.input_rows.seek(self.transforms.start().1.0); } @@ -1814,19 +1863,17 @@ impl Iterator for BlockRows<'_> { impl sum_tree::Item for Transform { type Summary = TransformSummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { self.summary.clone() } } -impl sum_tree::Summary for TransformSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for TransformSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, summary: &Self, _: &()) { + fn add_summary(&mut self, summary: &Self) { if summary.longest_row_chars > self.longest_row_chars { self.longest_row = self.output_rows + summary.longest_row; self.longest_row_chars = summary.longest_row_chars; @@ -1837,21 +1884,21 @@ impl sum_tree::Summary for TransformSummary { } impl<'a> sum_tree::Dimension<'a, TransformSummary> for WrapRow { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += summary.input_rows; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for BlockRow { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += summary.output_rows; } } @@ -2161,7 +2208,7 @@ mod tests { } let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); - let (_, inlay_snapshot) = InlayMap::new(multi_buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(multi_buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); let (_, wraps_snapshot) = WrapMap::new(tab_snapshot, font, font_size, Some(wrap_width), cx); @@ -2280,7 +2327,7 @@ mod tests { new_heights.insert(block_ids[0], 3); block_map_writer.resize(new_heights); - let snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + let snapshot = block_map.read(wraps_snapshot, Default::default()); // Same height as before, should remain the same assert_eq!(snapshot.text(), "aaa\n\n\n\n\n\nbbb\nccc\nddd\n\n\n"); } @@ -2365,16 +2412,14 @@ mod tests { buffer.edit([(Point::new(2, 0)..Point::new(3, 0), "")], None, cx); buffer.snapshot(cx) }); - let (inlay_snapshot, inlay_edits) = inlay_map.sync( - buffer_snapshot.clone(), - buffer_subscription.consume().into_inner(), - ); + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot, buffer_subscription.consume().into_inner()); let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); let (tab_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size); let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { wrap_map.sync(tab_snapshot, tab_edits, cx) }); - let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits); + let blocks_snapshot = block_map.read(wraps_snapshot, wrap_edits); assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\nline5"); let buffer_snapshot = buffer.update(cx, |buffer, cx| { @@ -2459,7 +2504,7 @@ mod tests { // Removing the replace block shows all the hidden blocks again. let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); writer.remove(HashSet::from_iter([replace_block_id])); - let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + let blocks_snapshot = block_map.read(wraps_snapshot, Default::default()); assert_eq!( blocks_snapshot.text(), "\nline1\n\nline2\n\n\nline 2.1\nline2.2\nline 2.3\nline 2.4\n\nline4\n\nline5" @@ -2798,7 +2843,7 @@ mod tests { buffer.read_with(cx, |buffer, cx| { writer.fold_buffers([buffer_id_3], buffer, cx); }); - let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default()); let blocks = blocks_snapshot .blocks_in_range(0..u32::MAX) .collect::>(); @@ -2851,7 +2896,7 @@ mod tests { assert_eq!(buffer_ids.len(), 1); let buffer_id = buffer_ids[0]; - let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); let (_, wrap_snapshot) = @@ -2865,7 +2910,7 @@ mod tests { buffer.read_with(cx, |buffer, cx| { writer.fold_buffers([buffer_id], buffer, cx); }); - let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default()); let blocks = blocks_snapshot .blocks_in_range(0..u32::MAX) .collect::>(); @@ -2873,12 +2918,7 @@ mod tests { 1, blocks .iter() - .filter(|(_, block)| { - match block { - Block::FoldedBuffer { .. } => true, - _ => false, - } - }) + .filter(|(_, block)| { matches!(block, Block::FoldedBuffer { .. }) }) .count(), "Should have one folded block, producing a header of the second buffer" ); @@ -2901,21 +2941,21 @@ mod tests { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let wrap_width = if rng.gen_bool(0.2) { + let wrap_width = if rng.random_bool(0.2) { None } else { - Some(px(rng.gen_range(0.0..=100.0))) + Some(px(rng.random_range(0.0..=100.0))) }; let tab_size = 1.try_into().unwrap(); let font_size = px(14.0); - let buffer_start_header_height = rng.gen_range(1..=5); - let excerpt_header_height = rng.gen_range(1..=5); + let buffer_start_header_height = rng.random_range(1..=5); + let excerpt_header_height = rng.random_range(1..=5); log::info!("Wrap width: {:?}", wrap_width); log::info!("Excerpt Header Height: {:?}", excerpt_header_height); - let is_singleton = rng.r#gen(); + let is_singleton = rng.random(); let buffer = if is_singleton { - let len = rng.gen_range(0..10); + let len = rng.random_range(0..10); let text = RandomCharIter::new(&mut rng).take(len).collect::(); log::info!("initial singleton buffer text: {:?}", text); cx.update(|cx| MultiBuffer::build_simple(&text, cx)) @@ -2945,30 +2985,30 @@ mod tests { for _ in 0..operations { let mut buffer_edits = Vec::new(); - match rng.gen_range(0..=100) { + match rng.random_range(0..=100) { 0..=19 => { - let wrap_width = if rng.gen_bool(0.2) { + let wrap_width = if rng.random_bool(0.2) { None } else { - Some(px(rng.gen_range(0.0..=100.0))) + Some(px(rng.random_range(0.0..=100.0))) }; log::info!("Setting wrap width to {:?}", wrap_width); wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); } 20..=39 => { - let block_count = rng.gen_range(1..=5); + let block_count = rng.random_range(1..=5); let block_properties = (0..block_count) .map(|_| { let buffer = cx.update(|cx| buffer.read(cx).read(cx).clone()); let offset = - buffer.clip_offset(rng.gen_range(0..=buffer.len()), Bias::Left); + buffer.clip_offset(rng.random_range(0..=buffer.len()), Bias::Left); let mut min_height = 0; - let placement = match rng.gen_range(0..3) { + let placement = match rng.random_range(0..3) { 0 => { min_height = 1; let start = buffer.anchor_after(offset); let end = buffer.anchor_after(buffer.clip_offset( - rng.gen_range(offset..=buffer.len()), + rng.random_range(offset..=buffer.len()), Bias::Left, )); BlockPlacement::Replace(start..=end) @@ -2977,7 +3017,7 @@ mod tests { _ => BlockPlacement::Below(buffer.anchor_after(offset)), }; - let height = rng.gen_range(min_height..5); + let height = rng.random_range(min_height..5); BlockProperties { style: BlockStyle::Fixed, placement, @@ -3019,7 +3059,7 @@ mod tests { } } 40..=59 if !block_map.custom_blocks.is_empty() => { - let block_count = rng.gen_range(1..=4.min(block_map.custom_blocks.len())); + let block_count = rng.random_range(1..=4.min(block_map.custom_blocks.len())); let block_ids_to_remove = block_map .custom_blocks .choose_multiple(&mut rng, block_count) @@ -3074,8 +3114,8 @@ mod tests { let mut folded_count = folded_buffers.len(); let mut unfolded_count = unfolded_buffers.len(); - let fold = !unfolded_buffers.is_empty() && rng.gen_bool(0.5); - let unfold = !folded_buffers.is_empty() && rng.gen_bool(0.5); + let fold = !unfolded_buffers.is_empty() && rng.random_bool(0.5); + let unfold = !folded_buffers.is_empty() && rng.random_bool(0.5); if !fold && !unfold { log::info!( "Noop fold/unfold operation. Unfolded buffers: {unfolded_count}, folded buffers: {folded_count}" @@ -3086,7 +3126,7 @@ mod tests { buffer.update(cx, |buffer, cx| { if fold { let buffer_to_fold = - unfolded_buffers[rng.gen_range(0..unfolded_buffers.len())]; + unfolded_buffers[rng.random_range(0..unfolded_buffers.len())]; log::info!("Folding {buffer_to_fold:?}"); let related_excerpts = buffer_snapshot .excerpts() @@ -3112,7 +3152,7 @@ mod tests { } if unfold { let buffer_to_unfold = - folded_buffers[rng.gen_range(0..folded_buffers.len())]; + folded_buffers[rng.random_range(0..folded_buffers.len())]; log::info!("Unfolding {buffer_to_unfold:?}"); unfolded_count += 1; folded_count -= 1; @@ -3125,7 +3165,7 @@ mod tests { } _ => { buffer.update(cx, |buffer, cx| { - let mutation_count = rng.gen_range(1..=5); + let mutation_count = rng.random_range(1..=5); let subscription = buffer.subscribe(); buffer.randomly_mutate(&mut rng, mutation_count, cx); buffer_snapshot = buffer.snapshot(cx); @@ -3195,9 +3235,9 @@ mod tests { // so we special case row 0 to assume a leading '\n'. // // Linehood is the birthright of strings. - let mut input_text_lines = input_text.split('\n').enumerate().peekable(); + let input_text_lines = input_text.split('\n').enumerate().peekable(); let mut block_row = 0; - while let Some((wrap_row, input_line)) = input_text_lines.next() { + for (wrap_row, input_line) in input_text_lines { let wrap_row = wrap_row as u32; let multibuffer_row = wraps_snapshot .to_point(WrapPoint::new(wrap_row, 0), Bias::Left) @@ -3228,34 +3268,32 @@ mod tests { let mut is_in_replace_block = false; if let Some((BlockPlacement::Replace(replace_range), block)) = sorted_blocks_iter.peek() + && wrap_row >= replace_range.start().0 { - if wrap_row >= replace_range.start().0 { - is_in_replace_block = true; + is_in_replace_block = true; - if wrap_row == replace_range.start().0 { - if matches!(block, Block::FoldedBuffer { .. }) { - expected_buffer_rows.push(None); - } else { - expected_buffer_rows - .push(input_buffer_rows[multibuffer_row as usize]); - } + if wrap_row == replace_range.start().0 { + if matches!(block, Block::FoldedBuffer { .. }) { + expected_buffer_rows.push(None); + } else { + expected_buffer_rows.push(input_buffer_rows[multibuffer_row as usize]); } + } - if wrap_row == replace_range.end().0 { - expected_block_positions.push((block_row, block.id())); - let text = "\n".repeat((block.height() - 1) as usize); - if block_row > 0 { - expected_text.push('\n'); - } - expected_text.push_str(&text); - - for _ in 1..block.height() { - expected_buffer_rows.push(None); - } - block_row += block.height(); + if wrap_row == replace_range.end().0 { + expected_block_positions.push((block_row, block.id())); + let text = "\n".repeat((block.height() - 1) as usize); + if block_row > 0 { + expected_text.push('\n'); + } + expected_text.push_str(&text); - sorted_blocks_iter.next(); + for _ in 1..block.height() { + expected_buffer_rows.push(None); } + block_row += block.height(); + + sorted_blocks_iter.next(); } } @@ -3312,7 +3350,7 @@ mod tests { ); for start_row in 0..expected_row_count { - let end_row = rng.gen_range(start_row + 1..=expected_row_count); + let end_row = rng.random_range(start_row + 1..=expected_row_count); let mut expected_text = expected_lines[start_row..end_row].join("\n"); if end_row < expected_row_count { expected_text.push('\n'); @@ -3407,8 +3445,8 @@ mod tests { ); for _ in 0..10 { - let end_row = rng.gen_range(1..=expected_lines.len()); - let start_row = rng.gen_range(0..end_row); + let end_row = rng.random_range(1..=expected_lines.len()); + let start_row = rng.random_range(0..end_row); let mut expected_longest_rows_in_range = vec![]; let mut longest_line_len_in_range = 0; @@ -3535,14 +3573,108 @@ mod tests { let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); writer.remove_intersecting_replace_blocks( - [buffer_snapshot.anchor_after(Point::new(1, 0)) - ..buffer_snapshot.anchor_after(Point::new(1, 0))], + [buffer_snapshot + .anchor_after(Point::new(1, 0)) + .to_offset(&buffer_snapshot) + ..buffer_snapshot + .anchor_after(Point::new(1, 0)) + .to_offset(&buffer_snapshot)], false, ); - let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + let blocks_snapshot = block_map.read(wraps_snapshot, Default::default()); assert_eq!(blocks_snapshot.text(), "abc\n\ndef\nghi\njkl\nmno"); } + #[gpui::test] + fn test_folded_buffer_with_near_blocks(cx: &mut gpui::TestAppContext) { + cx.update(init_test); + + let text = "line 1\nline 2\nline 3"; + let buffer = cx.update(|cx| { + MultiBuffer::build_multi([(text, vec![Point::new(0, 0)..Point::new(2, 6)])], cx) + }); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let buffer_ids = buffer_snapshot + .excerpts() + .map(|(_, buffer_snapshot, _)| buffer_snapshot.remote_id()) + .dedup() + .collect::>(); + assert_eq!(buffer_ids.len(), 1); + let buffer_id = buffer_ids[0]; + + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wrap_snapshot.clone(), 1, 1); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + writer.insert(vec![BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Near(buffer_snapshot.anchor_after(Point::new(0, 0))), + height: Some(1), + render: Arc::new(|_| div().into_any()), + priority: 0, + }]); + + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + assert_eq!(blocks_snapshot.text(), "\nline 1\n\nline 2\nline 3"); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffers([buffer_id], buffer, cx); + }); + + let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default()); + assert_eq!(blocks_snapshot.text(), ""); + } + + #[gpui::test] + fn test_folded_buffer_with_near_blocks_on_last_line(cx: &mut gpui::TestAppContext) { + cx.update(init_test); + + let text = "line 1\nline 2\nline 3\nline 4"; + let buffer = cx.update(|cx| { + MultiBuffer::build_multi([(text, vec![Point::new(0, 0)..Point::new(3, 6)])], cx) + }); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let buffer_ids = buffer_snapshot + .excerpts() + .map(|(_, buffer_snapshot, _)| buffer_snapshot.remote_id()) + .dedup() + .collect::>(); + assert_eq!(buffer_ids.len(), 1); + let buffer_id = buffer_ids[0]; + + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wrap_snapshot.clone(), 1, 1); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + writer.insert(vec![BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Near(buffer_snapshot.anchor_after(Point::new(3, 6))), + height: Some(1), + render: Arc::new(|_| div().into_any()), + priority: 0, + }]); + + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + assert_eq!(blocks_snapshot.text(), "\nline 1\nline 2\nline 3\nline 4\n"); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffers([buffer_id], buffer, cx); + }); + + let blocks_snapshot = block_map.read(wrap_snapshot, Patch::default()); + assert_eq!(blocks_snapshot.text(), ""); + } + fn init_test(cx: &mut gpui::App) { let settings = SettingsStore::test(cx); cx.set_global(settings); diff --git a/crates/editor/src/display_map/crease_map.rs b/crates/editor/src/display_map/crease_map.rs index bdac982fa785e7b6628352572ab143fd978938b2..a68c27886733d34a60ef0ce2ef4006b92b679db9 100644 --- a/crates/editor/src/display_map/crease_map.rs +++ b/crates/editor/src/display_map/crease_map.rs @@ -365,9 +365,9 @@ impl Default for ItemSummary { } impl sum_tree::Summary for ItemSummary { - type Context = MultiBufferSnapshot; + type Context<'a> = &'a MultiBufferSnapshot; - fn zero(_cx: &Self::Context) -> Self { + fn zero(_cx: Self::Context<'_>) -> Self { Default::default() } diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index ae69e9cf8c710acecc840ef14082c8f9d91d7c03..c6b22bb0b8247420200c2bb8d9e22f55d638386d 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -25,9 +25,8 @@ pub struct CustomHighlightsChunks<'a> { #[derive(Debug, Copy, Clone, Eq, PartialEq)] struct HighlightEndpoint { offset: usize, - is_start: bool, tag: HighlightKey, - style: HighlightStyle, + style: Option, } impl<'a> CustomHighlightsChunks<'a> { @@ -77,7 +76,7 @@ fn create_highlight_endpoints( let ranges = &text_highlights.1; let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe.end.cmp(&start, &buffer); + let cmp = probe.end.cmp(&start, buffer); if cmp.is_gt() { cmp::Ordering::Greater } else { @@ -88,21 +87,24 @@ fn create_highlight_endpoints( }; for range in &ranges[start_ix..] { - if range.start.cmp(&end, &buffer).is_ge() { + if range.start.cmp(&end, buffer).is_ge() { break; } + let start = range.start.to_offset(buffer); + let end = range.end.to_offset(buffer); + if start == end { + continue; + } highlight_endpoints.push(HighlightEndpoint { - offset: range.start.to_offset(&buffer), - is_start: true, + offset: start, tag, - style, + style: Some(style), }); highlight_endpoints.push(HighlightEndpoint { - offset: range.end.to_offset(&buffer), - is_start: false, + offset: end, tag, - style, + style: None, }); } } @@ -118,8 +120,8 @@ impl<'a> Iterator for CustomHighlightsChunks<'a> { let mut next_highlight_endpoint = usize::MAX; while let Some(endpoint) = self.highlight_endpoints.peek().copied() { if endpoint.offset <= self.offset { - if endpoint.is_start { - self.active_highlights.insert(endpoint.tag, endpoint.style); + if let Some(style) = endpoint.style { + self.active_highlights.insert(endpoint.tag, style); } else { self.active_highlights.remove(&endpoint.tag); } @@ -130,29 +132,37 @@ impl<'a> Iterator for CustomHighlightsChunks<'a> { } } - let chunk = self - .buffer_chunk - .get_or_insert_with(|| self.buffer_chunks.next().unwrap()); - if chunk.text.is_empty() { - *chunk = self.buffer_chunks.next().unwrap(); + let chunk = match &mut self.buffer_chunk { + Some(it) => it, + slot => slot.insert(self.buffer_chunks.next()?), + }; + while chunk.text.is_empty() { + *chunk = self.buffer_chunks.next()?; } - let (prefix, suffix) = chunk - .text - .split_at(chunk.text.len().min(next_highlight_endpoint - self.offset)); - - chunk.text = suffix; + let split_idx = chunk.text.len().min(next_highlight_endpoint - self.offset); + let (prefix, suffix) = chunk.text.split_at(split_idx); self.offset += prefix.len(); + + let mask = 1u128.unbounded_shl(split_idx as u32).wrapping_sub(1); + let chars = chunk.chars & mask; + let tabs = chunk.tabs & mask; let mut prefix = Chunk { text: prefix, + chars, + tabs, ..chunk.clone() }; + + chunk.chars = chunk.chars.unbounded_shr(split_idx as u32); + chunk.tabs = chunk.tabs.unbounded_shr(split_idx as u32); + chunk.text = suffix; if !self.active_highlights.is_empty() { - let mut highlight_style = HighlightStyle::default(); - for active_highlight in self.active_highlights.values() { - highlight_style.highlight(*active_highlight); - } - prefix.highlight_style = Some(highlight_style); + prefix.highlight_style = self + .active_highlights + .values() + .copied() + .reduce(|acc, active_highlight| acc.highlight(active_highlight)); } Some(prefix) } @@ -168,6 +178,143 @@ impl Ord for HighlightEndpoint { fn cmp(&self, other: &Self) -> cmp::Ordering { self.offset .cmp(&other.offset) - .then_with(|| other.is_start.cmp(&self.is_start)) + .then_with(|| self.style.is_some().cmp(&other.style.is_some())) + } +} + +#[cfg(test)] +mod tests { + use std::{any::TypeId, sync::Arc}; + + use super::*; + use crate::MultiBuffer; + use gpui::App; + use rand::prelude::*; + use util::RandomCharIter; + + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + // Generate random buffer using existing test infrastructure + let len = rng.random_range(10..10000); + let buffer = if rng.random() { + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let buffer_snapshot = buffer.read(cx).snapshot(cx); + + // Create random highlights + let mut highlights = sum_tree::TreeMap::default(); + let highlight_count = rng.random_range(1..10); + + for _i in 0..highlight_count { + let style = HighlightStyle { + color: Some(gpui::Hsla { + h: rng.random::(), + s: rng.random::(), + l: rng.random::(), + a: 1.0, + }), + ..Default::default() + }; + + let mut ranges = Vec::new(); + let range_count = rng.random_range(1..10); + let text = buffer_snapshot.text(); + for _ in 0..range_count { + if buffer_snapshot.len() == 0 { + continue; + } + + let mut start = rng.random_range(0..=buffer_snapshot.len().saturating_sub(10)); + + while !text.is_char_boundary(start) { + start = start.saturating_sub(1); + } + + let end_end = buffer_snapshot.len().min(start + 100); + let mut end = rng.random_range(start..=end_end); + while !text.is_char_boundary(end) { + end = end.saturating_sub(1); + } + + if start < end { + start = end; + } + let start_anchor = buffer_snapshot.anchor_before(start); + let end_anchor = buffer_snapshot.anchor_after(end); + ranges.push(start_anchor..end_anchor); + } + + let type_id = TypeId::of::<()>(); // Simple type ID for testing + highlights.insert(HighlightKey::Type(type_id), Arc::new((style, ranges))); + } + + // Get all chunks and verify their bitmaps + let chunks = + CustomHighlightsChunks::new(0..buffer_snapshot.len(), false, None, &buffer_snapshot); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } } } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index c4e53a0f4361d83429158f106bd81326c8ddb573..a31599ef9b276246226c12640fa8ffbec57eb9e3 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -1,4 +1,4 @@ -use crate::{InlayId, display_map::inlay_map::InlayChunk}; +use crate::display_map::inlay_map::InlayChunk; use super::{ Highlights, @@ -9,6 +9,7 @@ use language::{Edit, HighlightId, Point, TextSummary}; use multi_buffer::{ Anchor, AnchorRangeExt, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, }; +use project::InlayId; use std::{ any::TypeId, cmp::{self, Ordering}, @@ -98,39 +99,37 @@ impl FoldPoint { } pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint { - let mut cursor = snapshot + let (start, _, _) = snapshot .transforms - .cursor::>(&()); - cursor.seek(&self, Bias::Right); - let overshoot = self.0 - cursor.start().0.0; - InlayPoint(cursor.start().1.0 + overshoot) + .find::, _>((), &self, Bias::Right); + let overshoot = self.0 - start.0.0; + InlayPoint(start.1.0 + overshoot) } pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset { - let mut cursor = snapshot + let (start, _, item) = snapshot .transforms - .cursor::>(&()); - cursor.seek(&self, Bias::Right); - let overshoot = self.0 - cursor.start().1.output.lines; - let mut offset = cursor.start().1.output.len; + .find::, _>((), &self, Bias::Right); + let overshoot = self.0 - start.1.output.lines; + let mut offset = start.1.output.len; if !overshoot.is_zero() { - let transform = cursor.item().expect("display point out of range"); + let transform = item.expect("display point out of range"); assert!(transform.placeholder.is_none()); let end_inlay_offset = snapshot .inlay_snapshot - .to_offset(InlayPoint(cursor.start().1.input.lines + overshoot)); - offset += end_inlay_offset.0 - cursor.start().1.input.len; + .to_offset(InlayPoint(start.1.input.lines + overshoot)); + offset += end_inlay_offset.0 - start.1.input.len; } FoldOffset(offset) } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.output.lines; } } @@ -289,25 +288,25 @@ impl FoldMapWriter<'_> { let ChunkRendererId::Fold(id) = id else { continue; }; - if let Some(metadata) = self.0.snapshot.fold_metadata_by_id.get(&id).cloned() { - if Some(new_width) != metadata.width { - let buffer_start = metadata.range.start.to_offset(buffer); - let buffer_end = metadata.range.end.to_offset(buffer); - let inlay_range = inlay_snapshot.to_inlay_offset(buffer_start) - ..inlay_snapshot.to_inlay_offset(buffer_end); - edits.push(InlayEdit { - old: inlay_range.clone(), - new: inlay_range.clone(), - }); + if let Some(metadata) = self.0.snapshot.fold_metadata_by_id.get(&id).cloned() + && Some(new_width) != metadata.width + { + let buffer_start = metadata.range.start.to_offset(buffer); + let buffer_end = metadata.range.end.to_offset(buffer); + let inlay_range = inlay_snapshot.to_inlay_offset(buffer_start) + ..inlay_snapshot.to_inlay_offset(buffer_end); + edits.push(InlayEdit { + old: inlay_range.clone(), + new: inlay_range.clone(), + }); - self.0.snapshot.fold_metadata_by_id.insert( - id, - FoldMetadata { - range: metadata.range, - width: Some(new_width), - }, - ); - } + self.0.snapshot.fold_metadata_by_id.insert( + id, + FoldMetadata { + range: metadata.range, + width: Some(new_width), + }, + ); } } @@ -320,13 +319,13 @@ impl FoldMapWriter<'_> { /// Decides where the fold indicators should be; also tracks parts of a source file that are currently folded. /// /// See the [`display_map` module documentation](crate::display_map) for more information. -pub(crate) struct FoldMap { +pub struct FoldMap { snapshot: FoldSnapshot, next_fold_id: FoldId, } impl FoldMap { - pub(crate) fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { + pub fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { let this = Self { snapshot: FoldSnapshot { folds: SumTree::new(&inlay_snapshot.buffer), @@ -338,9 +337,9 @@ impl FoldMap { }, placeholder: None, }, - &(), + (), ), - inlay_snapshot: inlay_snapshot.clone(), + inlay_snapshot: inlay_snapshot, version: 0, fold_metadata_by_id: TreeMap::default(), }, @@ -360,7 +359,7 @@ impl FoldMap { (self.snapshot.clone(), edits) } - pub fn write( + pub(crate) fn write( &mut self, inlay_snapshot: InlaySnapshot, edits: Vec, @@ -382,7 +381,7 @@ impl FoldMap { if !transform.is_fold() && prev_transform_isomorphic { panic!( "found adjacent isomorphic transforms: {:?}", - self.snapshot.transforms.items(&()) + self.snapshot.transforms.items(()) ); } prev_transform_isomorphic = !transform.is_fold(); @@ -413,24 +412,24 @@ impl FoldMap { let mut inlay_edits_iter = inlay_edits.iter().cloned().peekable(); let mut new_transforms = SumTree::::default(); - let mut cursor = self.snapshot.transforms.cursor::(&()); + let mut cursor = self.snapshot.transforms.cursor::(()); cursor.seek(&InlayOffset(0), Bias::Right); while let Some(mut edit) = inlay_edits_iter.next() { - if let Some(item) = cursor.item() { - if !item.is_fold() { - new_transforms.update_last( - |transform| { - if !transform.is_fold() { - transform.summary.add_summary(&item.summary, &()); - cursor.next(); - } - }, - &(), - ); - } + if let Some(item) = cursor.item() + && !item.is_fold() + { + new_transforms.update_last( + |transform| { + if !transform.is_fold() { + transform.summary.add_summary(&item.summary, ()); + cursor.next(); + } + }, + (), + ); } - new_transforms.append(cursor.slice(&edit.old.start, Bias::Left), &()); + new_transforms.append(cursor.slice(&edit.old.start, Bias::Left), ()); edit.new.start -= edit.old.start - *cursor.start(); edit.old.start = *cursor.start(); @@ -491,14 +490,14 @@ impl FoldMap { while folds .peek() - .map_or(false, |(_, fold_range)| fold_range.start < edit.new.end) + .is_some_and(|(_, fold_range)| fold_range.start < edit.new.end) { let (fold, mut fold_range) = folds.next().unwrap(); let sum = new_transforms.summary(); assert!(fold_range.start.0 >= sum.input.len); - while folds.peek().map_or(false, |(next_fold, next_fold_range)| { + while folds.peek().is_some_and(|(next_fold, next_fold_range)| { next_fold_range.start < fold_range.end || (next_fold_range.start == fold_range.end && fold.placeholder.merge_adjacent @@ -529,6 +528,7 @@ impl FoldMap { }, placeholder: Some(TransformPlaceholder { text: ELLIPSIS, + chars: 1, renderer: ChunkRenderer { id: ChunkRendererId::Fold(fold.id), render: Arc::new(move |cx| { @@ -543,7 +543,7 @@ impl FoldMap { }, }), }, - &(), + (), ); } } @@ -556,7 +556,7 @@ impl FoldMap { } } - new_transforms.append(cursor.suffix(), &()); + new_transforms.append(cursor.suffix(), ()); if new_transforms.is_empty() { let text_summary = inlay_snapshot.text_summary(); push_isomorphic(&mut new_transforms, text_summary); @@ -569,20 +569,20 @@ impl FoldMap { let mut old_transforms = self .snapshot .transforms - .cursor::>(&()); + .cursor::>(()); let mut new_transforms = - new_transforms.cursor::>(&()); + new_transforms.cursor::>(()); for mut edit in inlay_edits { old_transforms.seek(&edit.old.start, Bias::Left); - if old_transforms.item().map_or(false, |t| t.is_fold()) { + if old_transforms.item().is_some_and(|t| t.is_fold()) { edit.old.start = old_transforms.start().0; } let old_start = old_transforms.start().1.0 + (edit.old.start - old_transforms.start().0).0; old_transforms.seek_forward(&edit.old.end, Bias::Right); - if old_transforms.item().map_or(false, |t| t.is_fold()) { + if old_transforms.item().is_some_and(|t| t.is_fold()) { old_transforms.next(); edit.old.end = old_transforms.start().0; } @@ -590,14 +590,14 @@ impl FoldMap { old_transforms.start().1.0 + (edit.old.end - old_transforms.start().0).0; new_transforms.seek(&edit.new.start, Bias::Left); - if new_transforms.item().map_or(false, |t| t.is_fold()) { + if new_transforms.item().is_some_and(|t| t.is_fold()) { edit.new.start = new_transforms.start().0; } let new_start = new_transforms.start().1.0 + (edit.new.start - new_transforms.start().0).0; new_transforms.seek_forward(&edit.new.end, Bias::Right); - if new_transforms.item().map_or(false, |t| t.is_fold()) { + if new_transforms.item().is_some_and(|t| t.is_fold()) { new_transforms.next(); edit.new.end = new_transforms.start().0; } @@ -623,10 +623,10 @@ impl FoldMap { #[derive(Clone)] pub struct FoldSnapshot { + pub inlay_snapshot: InlaySnapshot, transforms: SumTree, folds: SumTree, fold_metadata_by_id: TreeMap, - pub inlay_snapshot: InlaySnapshot, pub version: usize, } @@ -656,7 +656,7 @@ impl FoldSnapshot { let mut cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&range.start, Bias::Right); if let Some(transform) = cursor.item() { let start_in_transform = range.start.0 - cursor.start().0.0; @@ -705,19 +705,18 @@ impl FoldSnapshot { } pub fn to_fold_point(&self, point: InlayPoint, bias: Bias) -> FoldPoint { - let mut cursor = self + let (start, end, item) = self .transforms - .cursor::>(&()); - cursor.seek(&point, Bias::Right); - if cursor.item().map_or(false, |t| t.is_fold()) { - if bias == Bias::Left || point == cursor.start().0 { - cursor.start().1 + .find::, _>((), &point, Bias::Right); + if item.is_some_and(|t| t.is_fold()) { + if bias == Bias::Left || point == start.0 { + start.1 } else { - cursor.end().1 + end.1 } } else { - let overshoot = point.0 - cursor.start().0.0; - FoldPoint(cmp::min(cursor.start().1.0 + overshoot, cursor.end().1.0)) + let overshoot = point.0 - start.0.0; + FoldPoint(cmp::min(start.1.0 + overshoot, end.1.0)) } } @@ -743,7 +742,7 @@ impl FoldSnapshot { let fold_point = FoldPoint::new(start_row, 0); let mut cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&fold_point, Bias::Left); let overshoot = fold_point.0 - cursor.start().0.0; @@ -786,16 +785,17 @@ impl FoldSnapshot { { let buffer_offset = offset.to_offset(&self.inlay_snapshot.buffer); let inlay_offset = self.inlay_snapshot.to_inlay_offset(buffer_offset); - let mut cursor = self.transforms.cursor::(&()); - cursor.seek(&inlay_offset, Bias::Right); - cursor.item().map_or(false, |t| t.placeholder.is_some()) + let (_, _, item) = self + .transforms + .find::((), &inlay_offset, Bias::Right); + item.is_some_and(|t| t.placeholder.is_some()) } pub fn is_line_folded(&self, buffer_row: MultiBufferRow) -> bool { let mut inlay_point = self .inlay_snapshot .to_inlay_point(Point::new(buffer_row.0, 0)); - let mut cursor = self.transforms.cursor::(&()); + let mut cursor = self.transforms.cursor::(()); cursor.seek(&inlay_point, Bias::Right); loop { match cursor.item() { @@ -827,7 +827,7 @@ impl FoldSnapshot { ) -> FoldChunks<'a> { let mut transform_cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); transform_cursor.seek(&range.start, Bias::Right); let inlay_start = { @@ -839,7 +839,7 @@ impl FoldSnapshot { let inlay_end = if transform_cursor .item() - .map_or(true, |transform| transform.is_fold()) + .is_none_or(|transform| transform.is_fold()) { inlay_start } else if range.end < transform_end.0 { @@ -872,6 +872,14 @@ impl FoldSnapshot { .flat_map(|chunk| chunk.text.chars()) } + pub fn chunks_at(&self, start: FoldPoint) -> FoldChunks<'_> { + self.chunks( + start.to_offset(self)..self.len(), + false, + Highlights::default(), + ) + } + #[cfg(test)] pub fn clip_offset(&self, offset: FoldOffset, bias: Bias) -> FoldOffset { if offset > self.len() { @@ -882,23 +890,22 @@ impl FoldSnapshot { } pub fn clip_point(&self, point: FoldPoint, bias: Bias) -> FoldPoint { - let mut cursor = self + let (start, end, item) = self .transforms - .cursor::>(&()); - cursor.seek(&point, Bias::Right); - if let Some(transform) = cursor.item() { - let transform_start = cursor.start().0.0; + .find::, _>((), &point, Bias::Right); + if let Some(transform) = item { + let transform_start = start.0.0; if transform.placeholder.is_some() { if point.0 == transform_start || matches!(bias, Bias::Left) { FoldPoint(transform_start) } else { - FoldPoint(cursor.end().0.0) + FoldPoint(end.0.0) } } else { let overshoot = InlayPoint(point.0 - transform_start); - let inlay_point = cursor.start().1 + overshoot; + let inlay_point = start.1 + overshoot; let clipped_inlay_point = self.inlay_snapshot.clip_point(inlay_point, bias); - FoldPoint(cursor.start().0.0 + (clipped_inlay_point - cursor.start().1).0) + FoldPoint(start.0.0 + (clipped_inlay_point - start.1).0) } } else { FoldPoint(self.transforms.summary().output.lines) @@ -916,7 +923,7 @@ fn push_isomorphic(transforms: &mut SumTree, summary: TextSummary) { did_merge = true; } }, - &(), + (), ); if !did_merge { transforms.push( @@ -927,7 +934,7 @@ fn push_isomorphic(transforms: &mut SumTree, summary: TextSummary) { }, placeholder: None, }, - &(), + (), ) } } @@ -937,7 +944,7 @@ fn intersecting_folds<'a>( folds: &'a SumTree, range: Range, inclusive: bool, -) -> FilterCursor<'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, usize> { +) -> FilterCursor<'a, 'a, impl 'a + FnMut(&FoldSummary) -> bool, Fold, usize> { let buffer = &inlay_snapshot.buffer; let start = buffer.anchor_before(range.start.to_offset(buffer)); let end = buffer.anchor_after(range.end.to_offset(buffer)); @@ -1034,6 +1041,7 @@ struct Transform { #[derive(Clone, Debug)] struct TransformPlaceholder { text: &'static str, + chars: u128, renderer: ChunkRenderer, } @@ -1052,19 +1060,17 @@ struct TransformSummary { impl sum_tree::Item for Transform { type Summary = TransformSummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { self.summary.clone() } } -impl sum_tree::Summary for TransformSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for TransformSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, _: &()) { + fn add_summary(&mut self, other: &Self) { self.input += &other.input; self.output += &other.output; } @@ -1151,13 +1157,13 @@ impl Default for FoldSummary { } impl sum_tree::Summary for FoldSummary { - type Context = MultiBufferSnapshot; + type Context<'a> = &'a MultiBufferSnapshot; fn zero(_cx: &MultiBufferSnapshot) -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, buffer: &Self::Context) { + fn add_summary(&mut self, other: &Self, buffer: Self::Context<'_>) { if other.min_start.cmp(&self.min_start, buffer) == Ordering::Less { self.min_start = other.min_start; } @@ -1209,7 +1215,7 @@ impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize { #[derive(Clone)] pub struct FoldRows<'a> { - cursor: Cursor<'a, Transform, Dimensions>, + cursor: Cursor<'a, 'static, Transform, Dimensions>, input_rows: InlayBufferRows<'a>, fold_point: FoldPoint, } @@ -1274,6 +1280,10 @@ pub struct Chunk<'a> { pub is_inlay: bool, /// An optional recipe for how the chunk should be presented. pub renderer: Option, + /// Bitmap of tab character locations in chunk + pub tabs: u128, + /// Bitmap of character locations in chunk + pub chars: u128, } #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -1326,7 +1336,7 @@ impl DerefMut for ChunkRendererContext<'_, '_> { } pub struct FoldChunks<'a> { - transform_cursor: Cursor<'a, Transform, Dimensions>, + transform_cursor: Cursor<'a, 'static, Transform, Dimensions>, inlay_chunks: InlayChunks<'a>, inlay_chunk: Option<(InlayOffset, InlayChunk<'a>)>, inlay_offset: InlayOffset, @@ -1348,7 +1358,7 @@ impl FoldChunks<'_> { let inlay_end = if self .transform_cursor .item() - .map_or(true, |transform| transform.is_fold()) + .is_none_or(|transform| transform.is_fold()) { inlay_start } else if range.end < transform_end.0 { @@ -1391,6 +1401,7 @@ impl<'a> Iterator for FoldChunks<'a> { self.output_offset.0 += placeholder.text.len(); return Some(Chunk { text: placeholder.text, + chars: placeholder.chars, renderer: Some(placeholder.renderer.clone()), ..Default::default() }); @@ -1426,8 +1437,15 @@ impl<'a> Iterator for FoldChunks<'a> { let transform_end = self.transform_cursor.end().1; let chunk_end = buffer_chunk_end.min(transform_end); - chunk.text = &chunk.text - [(self.inlay_offset - buffer_chunk_start).0..(chunk_end - buffer_chunk_start).0]; + let bit_start = (self.inlay_offset - buffer_chunk_start).0; + let bit_end = (chunk_end - buffer_chunk_start).0; + chunk.text = &chunk.text[bit_start..bit_end]; + + let bit_end = (chunk_end - buffer_chunk_start).0; + let mask = 1u128.unbounded_shl(bit_end as u32).wrapping_sub(1); + + chunk.tabs = (chunk.tabs >> bit_start) & mask; + chunk.chars = (chunk.chars >> bit_start) & mask; if chunk_end == transform_end { self.transform_cursor.next(); @@ -1439,6 +1457,8 @@ impl<'a> Iterator for FoldChunks<'a> { self.output_offset.0 += chunk.text.len(); return Some(Chunk { text: chunk.text, + tabs: chunk.tabs, + chars: chunk.chars, syntax_highlight_id: chunk.syntax_highlight_id, highlight_style: chunk.highlight_style, diagnostic_severity: chunk.diagnostic_severity, @@ -1459,28 +1479,26 @@ pub struct FoldOffset(pub usize); impl FoldOffset { pub fn to_point(self, snapshot: &FoldSnapshot) -> FoldPoint { - let mut cursor = snapshot + let (start, _, item) = snapshot .transforms - .cursor::>(&()); - cursor.seek(&self, Bias::Right); - let overshoot = if cursor.item().map_or(true, |t| t.is_fold()) { - Point::new(0, (self.0 - cursor.start().0.0) as u32) + .find::, _>((), &self, Bias::Right); + let overshoot = if item.is_none_or(|t| t.is_fold()) { + Point::new(0, (self.0 - start.0.0) as u32) } else { - let inlay_offset = cursor.start().1.input.len + self.0 - cursor.start().0.0; + let inlay_offset = start.1.input.len + self.0 - start.0.0; let inlay_point = snapshot.inlay_snapshot.to_point(InlayOffset(inlay_offset)); - inlay_point.0 - cursor.start().1.input.lines + inlay_point.0 - start.1.input.lines }; - FoldPoint(cursor.start().1.output.lines + overshoot) + FoldPoint(start.1.output.lines + overshoot) } #[cfg(test)] pub fn to_inlay_offset(self, snapshot: &FoldSnapshot) -> InlayOffset { - let mut cursor = snapshot + let (start, _, _) = snapshot .transforms - .cursor::>(&()); - cursor.seek(&self, Bias::Right); - let overshoot = self.0 - cursor.start().0.0; - InlayOffset(cursor.start().1.0 + overshoot) + .find::, _>((), &self, Bias::Right); + let overshoot = self.0 - start.0.0; + InlayOffset(start.1.0 + overshoot) } } @@ -1507,31 +1525,31 @@ impl Sub for FoldOffset { } impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldOffset { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.output.len; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.input.lines; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayOffset { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.input.len; } } @@ -1557,7 +1575,7 @@ mod tests { let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx); let subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); let mut map = FoldMap::new(inlay_snapshot.clone()).0; let (mut writer, _, _) = map.write(inlay_snapshot, vec![]); @@ -1636,7 +1654,7 @@ mod tests { let buffer = MultiBuffer::build_simple("abcdefghijkl", cx); let subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); { let mut map = FoldMap::new(inlay_snapshot.clone()).0; @@ -1712,7 +1730,7 @@ mod tests { let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx); let subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); let mut map = FoldMap::new(inlay_snapshot.clone()).0; let (mut writer, _, _) = map.write(inlay_snapshot.clone(), vec![]); @@ -1720,7 +1738,7 @@ mod tests { (Point::new(0, 2)..Point::new(2, 2), FoldPlaceholder::test()), (Point::new(3, 1)..Point::new(4, 1), FoldPlaceholder::test()), ]); - let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]); + let (snapshot, _) = map.read(inlay_snapshot, vec![]); assert_eq!(snapshot.text(), "aa⋯cccc\nd⋯eeeee"); let buffer_snapshot = buffer.update(cx, |buffer, cx| { @@ -1747,7 +1765,7 @@ mod tests { (Point::new(1, 2)..Point::new(3, 2), FoldPlaceholder::test()), (Point::new(3, 1)..Point::new(4, 1), FoldPlaceholder::test()), ]); - let (snapshot, _) = map.read(inlay_snapshot.clone(), vec![]); + let (snapshot, _) = map.read(inlay_snapshot, vec![]); let fold_ranges = snapshot .folds_in_range(Point::new(1, 0)..Point::new(1, 3)) .map(|fold| { @@ -1771,9 +1789,9 @@ mod tests { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let len = rng.gen_range(0..10); + let len = rng.random_range(0..10); let text = RandomCharIter::new(&mut rng).take(len).collect::(); - let buffer = if rng.r#gen() { + let buffer = if rng.random() { MultiBuffer::build_simple(&text, cx) } else { MultiBuffer::build_random(&mut rng, cx) @@ -1782,7 +1800,7 @@ mod tests { let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); let mut map = FoldMap::new(inlay_snapshot.clone()).0; - let (mut initial_snapshot, _) = map.read(inlay_snapshot.clone(), vec![]); + let (mut initial_snapshot, _) = map.read(inlay_snapshot, vec![]); let mut snapshot_edits = Vec::new(); let mut next_inlay_id = 0; @@ -1790,7 +1808,7 @@ mod tests { log::info!("text: {:?}", buffer_snapshot.text()); let mut buffer_edits = Vec::new(); let mut inlay_edits = Vec::new(); - match rng.gen_range(0..=100) { + match rng.random_range(0..=100) { 0..=39 => { snapshot_edits.extend(map.randomly_mutate(&mut rng)); } @@ -1800,7 +1818,7 @@ mod tests { } _ => buffer.update(cx, |buffer, cx| { let subscription = buffer.subscribe(); - let edit_count = rng.gen_range(1..=5); + let edit_count = rng.random_range(1..=5); buffer.randomly_mutate(&mut rng, edit_count, cx); buffer_snapshot = buffer.snapshot(cx); let edits = subscription.consume().into_inner(); @@ -1917,10 +1935,14 @@ mod tests { } for _ in 0..5 { - let mut start = snapshot - .clip_offset(FoldOffset(rng.gen_range(0..=snapshot.len().0)), Bias::Left); - let mut end = snapshot - .clip_offset(FoldOffset(rng.gen_range(0..=snapshot.len().0)), Bias::Right); + let mut start = snapshot.clip_offset( + FoldOffset(rng.random_range(0..=snapshot.len().0)), + Bias::Left, + ); + let mut end = snapshot.clip_offset( + FoldOffset(rng.random_range(0..=snapshot.len().0)), + Bias::Right, + ); if start > end { mem::swap(&mut start, &mut end); } @@ -1975,8 +1997,8 @@ mod tests { for _ in 0..5 { let end = - buffer_snapshot.clip_offset(rng.gen_range(0..=buffer_snapshot.len()), Right); - let start = buffer_snapshot.clip_offset(rng.gen_range(0..=end), Left); + buffer_snapshot.clip_offset(rng.random_range(0..=buffer_snapshot.len()), Right); + let start = buffer_snapshot.clip_offset(rng.random_range(0..=end), Left); let expected_folds = map .snapshot .folds @@ -2001,10 +2023,10 @@ mod tests { let text = snapshot.text(); for _ in 0..5 { - let start_row = rng.gen_range(0..=snapshot.max_point().row()); - let start_column = rng.gen_range(0..=snapshot.line_len(start_row)); - let end_row = rng.gen_range(0..=snapshot.max_point().row()); - let end_column = rng.gen_range(0..=snapshot.line_len(end_row)); + let start_row = rng.random_range(0..=snapshot.max_point().row()); + let start_column = rng.random_range(0..=snapshot.line_len(start_row)); + let end_row = rng.random_range(0..=snapshot.max_point().row()); + let end_column = rng.random_range(0..=snapshot.line_len(end_row)); let mut start = snapshot.clip_point(FoldPoint::new(start_row, start_column), Bias::Left); let mut end = snapshot.clip_point(FoldPoint::new(end_row, end_column), Bias::Right); @@ -2068,6 +2090,97 @@ mod tests { ); } + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut gpui::App, mut rng: StdRng) { + init_test(cx); + + // Generate random buffer using existing test infrastructure + let text_len = rng.random_range(0..10000); + let buffer = if rng.random() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (mut fold_map, _) = FoldMap::new(inlay_snapshot.clone()); + + // Perform random mutations + let mutation_count = rng.random_range(1..10); + for _ in 0..mutation_count { + fold_map.randomly_mutate(&mut rng); + } + + let (snapshot, _) = fold_map.read(inlay_snapshot, vec![]); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks( + FoldOffset(0)..FoldOffset(snapshot.len().0), + false, + Highlights::default(), + ); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } + fn init_test(cx: &mut gpui::App) { let store = SettingsStore::test(cx); cx.set_global(store); @@ -2109,17 +2222,17 @@ mod tests { rng: &mut impl Rng, ) -> Vec<(FoldSnapshot, Vec)> { let mut snapshot_edits = Vec::new(); - match rng.gen_range(0..=100) { + match rng.random_range(0..=100) { 0..=39 if !self.snapshot.folds.is_empty() => { let inlay_snapshot = self.snapshot.inlay_snapshot.clone(); let buffer = &inlay_snapshot.buffer; let mut to_unfold = Vec::new(); - for _ in 0..rng.gen_range(1..=3) { - let end = buffer.clip_offset(rng.gen_range(0..=buffer.len()), Right); - let start = buffer.clip_offset(rng.gen_range(0..=end), Left); + for _ in 0..rng.random_range(1..=3) { + let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right); + let start = buffer.clip_offset(rng.random_range(0..=end), Left); to_unfold.push(start..end); } - let inclusive = rng.r#gen(); + let inclusive = rng.random(); log::info!("unfolding {:?} (inclusive: {})", to_unfold, inclusive); let (mut writer, snapshot, edits) = self.write(inlay_snapshot, vec![]); snapshot_edits.push((snapshot, edits)); @@ -2130,9 +2243,9 @@ mod tests { let inlay_snapshot = self.snapshot.inlay_snapshot.clone(); let buffer = &inlay_snapshot.buffer; let mut to_fold = Vec::new(); - for _ in 0..rng.gen_range(1..=2) { - let end = buffer.clip_offset(rng.gen_range(0..=buffer.len()), Right); - let start = buffer.clip_offset(rng.gen_range(0..=end), Left); + for _ in 0..rng.random_range(1..=2) { + let end = buffer.clip_offset(rng.random_range(0..=buffer.len()), Right); + let start = buffer.clip_offset(rng.random_range(0..=end), Left); to_fold.push((start..end, FoldPlaceholder::test())); } log::info!("folding {:?}", to_fold); diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index b296b3e62a39aa2ec8671676e051e94f5f9622cf..486676f1120bc2e9d85effd4c328a2b7a547e06b 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1,17 +1,18 @@ -use crate::{ChunkRenderer, HighlightStyles, InlayId}; +use crate::{ + ChunkRenderer, HighlightStyles, + inlays::{Inlay, InlayContent}, +}; use collections::BTreeSet; -use gpui::{Hsla, Rgba}; use language::{Chunk, Edit, Point, TextSummary}; -use multi_buffer::{ - Anchor, MultiBufferRow, MultiBufferRows, MultiBufferSnapshot, RowInfo, ToOffset, -}; +use multi_buffer::{MultiBufferRow, MultiBufferRows, MultiBufferSnapshot, RowInfo, ToOffset}; +use project::InlayId; use std::{ cmp, ops::{Add, AddAssign, Range, Sub, SubAssign}, sync::Arc, }; use sum_tree::{Bias, Cursor, Dimensions, SumTree}; -use text::{Patch, Rope}; +use text::{ChunkBitmaps, Patch}; use ui::{ActiveTheme, IntoElement as _, ParentElement as _, Styled as _, div}; use super::{Highlights, custom_highlights::CustomHighlightsChunks, fold_map::ChunkRendererId}; @@ -37,78 +38,10 @@ enum Transform { Inlay(Inlay), } -#[derive(Debug, Clone)] -pub struct Inlay { - pub id: InlayId, - pub position: Anchor, - pub text: text::Rope, - color: Option, -} - -impl Inlay { - pub fn hint(id: usize, position: Anchor, hint: &project::InlayHint) -> Self { - let mut text = hint.text(); - if hint.padding_right && text.chars_at(text.len().saturating_sub(1)).next() != Some(' ') { - text.push(" "); - } - if hint.padding_left && text.chars_at(0).next() != Some(' ') { - text.push_front(" "); - } - Self { - id: InlayId::Hint(id), - position, - text, - color: None, - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn mock_hint(id: usize, position: Anchor, text: impl Into) -> Self { - Self { - id: InlayId::Hint(id), - position, - text: text.into(), - color: None, - } - } - - pub fn color(id: usize, position: Anchor, color: Rgba) -> Self { - Self { - id: InlayId::Color(id), - position, - text: Rope::from("◼"), - color: Some(Hsla::from(color)), - } - } - - pub fn edit_prediction>(id: usize, position: Anchor, text: T) -> Self { - Self { - id: InlayId::EditPrediction(id), - position, - text: text.into(), - color: None, - } - } - - pub fn debugger>(id: usize, position: Anchor, text: T) -> Self { - Self { - id: InlayId::DebuggerValue(id), - position, - text: text.into(), - color: None, - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn get_color(&self) -> Option { - self.color - } -} - impl sum_tree::Item for Transform { type Summary = TransformSummary; - fn summary(&self, _: &()) -> Self::Summary { + fn summary(&self, _: ()) -> Self::Summary { match self { Transform::Isomorphic(summary) => TransformSummary { input: *summary, @@ -116,7 +49,7 @@ impl sum_tree::Item for Transform { }, Transform::Inlay(inlay) => TransformSummary { input: TextSummary::default(), - output: inlay.text.summary(), + output: inlay.text().summary(), }, } } @@ -128,14 +61,12 @@ struct TransformSummary { output: TextSummary, } -impl sum_tree::Summary for TransformSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for TransformSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, _: &()) { + fn add_summary(&mut self, other: &Self) { self.input += &other.input; self.output += &other.output; } @@ -175,11 +106,11 @@ impl SubAssign for InlayOffset { } impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayOffset { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.output.len; } } @@ -204,49 +135,50 @@ impl Sub for InlayPoint { } impl<'a> sum_tree::Dimension<'a, TransformSummary> for InlayPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += &summary.output.lines; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for usize { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { *self += &summary.input.len; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for Point { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { *self += &summary.input.lines; } } #[derive(Clone)] pub struct InlayBufferRows<'a> { - transforms: Cursor<'a, Transform, Dimensions>, + transforms: Cursor<'a, 'static, Transform, Dimensions>, buffer_rows: MultiBufferRows<'a>, inlay_row: u32, max_buffer_row: MultiBufferRow, } pub struct InlayChunks<'a> { - transforms: Cursor<'a, Transform, Dimensions>, + transforms: Cursor<'a, 'static, Transform, Dimensions>, buffer_chunks: CustomHighlightsChunks<'a>, buffer_chunk: Option>, - inlay_chunks: Option>, - inlay_chunk: Option<&'a str>, + inlay_chunks: Option>, + /// text, char bitmap, tabs bitmap + inlay_chunk: Option>, output_offset: InlayOffset, max_output_offset: InlayOffset, highlight_styles: HighlightStyles, @@ -315,12 +247,21 @@ impl<'a> Iterator for InlayChunks<'a> { }; let (prefix, suffix) = chunk.text.split_at(split_index); + self.output_offset.0 += prefix.len(); + + let mask = 1u128.unbounded_shl(split_index as u32).wrapping_sub(1); + let chars = chunk.chars & mask; + let tabs = chunk.tabs & mask; + chunk.chars = chunk.chars.unbounded_shr(split_index as u32); + chunk.tabs = chunk.tabs.unbounded_shr(split_index as u32); chunk.text = suffix; - self.output_offset.0 += prefix.len(); + InlayChunk { chunk: Chunk { text: prefix, + chars, + tabs, ..chunk.clone() }, renderer: None, @@ -341,7 +282,7 @@ impl<'a> Iterator for InlayChunks<'a> { let mut renderer = None; let mut highlight_style = match inlay.id { InlayId::EditPrediction(_) => self.highlight_styles.edit_prediction.map(|s| { - if inlay.text.chars().all(|c| c.is_whitespace()) { + if inlay.text().chars().all(|c| c.is_whitespace()) { s.whitespace } else { s.insertion @@ -350,7 +291,7 @@ impl<'a> Iterator for InlayChunks<'a> { InlayId::Hint(_) => self.highlight_styles.inlay_hint, InlayId::DebuggerValue(_) => self.highlight_styles.inlay_hint, InlayId::Color(_) => { - if let Some(color) = inlay.color { + if let InlayContent::Color(color) = inlay.content { renderer = Some(ChunkRenderer { id: ChunkRendererId::Inlay(inlay.id), render: Arc::new(move |cx| { @@ -363,7 +304,13 @@ impl<'a> Iterator for InlayChunks<'a> { .right_1() .size_3() .border_1() - .border_color(cx.theme().colors().border) + .border_color( + if cx.theme().appearance().is_light() { + gpui::black().opacity(0.5) + } else { + gpui::white().opacity(0.5) + }, + ) .bg(color), ) .into_any_element() @@ -385,9 +332,9 @@ impl<'a> Iterator for InlayChunks<'a> { next_inlay_highlight_endpoint = usize::MAX; } else { next_inlay_highlight_endpoint = range.end - offset_in_inlay.0; - highlight_style - .get_or_insert_with(Default::default) - .highlight(*style); + highlight_style = highlight_style + .map(|highlight| highlight.highlight(*style)) + .or_else(|| Some(*style)); } } else { next_inlay_highlight_endpoint = usize::MAX; @@ -397,9 +344,14 @@ impl<'a> Iterator for InlayChunks<'a> { let start = offset_in_inlay; let end = cmp::min(self.max_output_offset, self.transforms.end().0) - self.transforms.start().0; - inlay.text.chunks_in_range(start.0..end.0) + let chunks = inlay.text().chunks_in_range(start.0..end.0); + text::ChunkWithBitmaps(chunks) }); - let inlay_chunk = self + let ChunkBitmaps { + text: inlay_chunk, + chars, + tabs, + } = self .inlay_chunk .get_or_insert_with(|| inlay_chunks.next().unwrap()); @@ -421,6 +373,14 @@ impl<'a> Iterator for InlayChunks<'a> { let (chunk, remainder) = inlay_chunk.split_at(split_index); *inlay_chunk = remainder; + + let mask = 1u128.unbounded_shl(split_index as u32).wrapping_sub(1); + let new_chars = *chars & mask; + let new_tabs = *tabs & mask; + + *chars = chars.unbounded_shr(split_index as u32); + *tabs = tabs.unbounded_shr(split_index as u32); + if inlay_chunk.is_empty() { self.inlay_chunk = None; } @@ -430,6 +390,8 @@ impl<'a> Iterator for InlayChunks<'a> { InlayChunk { chunk: Chunk { text: chunk, + chars: new_chars, + tabs: new_tabs, highlight_style, is_inlay: true, ..Chunk::default() @@ -506,7 +468,7 @@ impl InlayMap { let version = 0; let snapshot = InlaySnapshot { buffer: buffer.clone(), - transforms: SumTree::from_iter(Some(Transform::Isomorphic(buffer.text_summary())), &()), + transforms: SumTree::from_iter(Some(Transform::Isomorphic(buffer.text_summary())), ()), version, }; @@ -553,15 +515,15 @@ impl InlayMap { let mut new_transforms = SumTree::default(); let mut cursor = snapshot .transforms - .cursor::>(&()); + .cursor::>(()); let mut buffer_edits_iter = buffer_edits.iter().peekable(); while let Some(buffer_edit) = buffer_edits_iter.next() { - new_transforms.append(cursor.slice(&buffer_edit.old.start, Bias::Left), &()); - if let Some(Transform::Isomorphic(transform)) = cursor.item() { - if cursor.end().0 == buffer_edit.old.start { - push_isomorphic(&mut new_transforms, *transform); - cursor.next(); - } + new_transforms.append(cursor.slice(&buffer_edit.old.start, Bias::Left), ()); + if let Some(Transform::Isomorphic(transform)) = cursor.item() + && cursor.end().0 == buffer_edit.old.start + { + push_isomorphic(&mut new_transforms, *transform); + cursor.next(); } // Remove all the inlays and transforms contained by the edit. @@ -606,7 +568,7 @@ impl InlayMap { buffer_snapshot.text_summary_for_range(prefix_start..prefix_end), ); - new_transforms.push(Transform::Inlay(inlay.clone()), &()); + new_transforms.push(Transform::Inlay(inlay.clone()), ()); } // Apply the rest of the edit. @@ -625,7 +587,7 @@ impl InlayMap { // we can push its remainder. if buffer_edits_iter .peek() - .map_or(true, |edit| edit.old.start >= cursor.end().0) + .is_none_or(|edit| edit.old.start >= cursor.end().0) { let transform_start = new_transforms.summary().input.len; let transform_end = @@ -638,9 +600,9 @@ impl InlayMap { } } - new_transforms.append(cursor.suffix(), &()); + new_transforms.append(cursor.suffix(), ()); if new_transforms.is_empty() { - new_transforms.push(Transform::Isomorphic(Default::default()), &()); + new_transforms.push(Transform::Isomorphic(Default::default()), ()); } drop(cursor); @@ -672,7 +634,7 @@ impl InlayMap { for inlay_to_insert in to_insert { // Avoid inserting empty inlays. - if inlay_to_insert.text.is_empty() { + if inlay_to_insert.text().is_empty() { continue; } @@ -719,14 +681,18 @@ impl InlayMap { let mut to_remove = Vec::new(); let mut to_insert = Vec::new(); let snapshot = &mut self.snapshot; - for i in 0..rng.gen_range(1..=5) { - if self.inlays.is_empty() || rng.r#gen() { + for i in 0..rng.random_range(1..=5) { + if self.inlays.is_empty() || rng.random() { let position = snapshot.buffer.random_byte_range(0, rng).start; - let bias = if rng.r#gen() { Bias::Left } else { Bias::Right }; - let len = if rng.gen_bool(0.01) { + let bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; + let len = if rng.random_bool(0.01) { 0 } else { - rng.gen_range(1..=5) + rng.random_range(1..=5) }; let text = util::RandomCharIter::new(&mut *rng) .filter(|ch| *ch != '\r') @@ -770,22 +736,21 @@ impl InlayMap { impl InlaySnapshot { pub fn to_point(&self, offset: InlayOffset) -> InlayPoint { - let mut cursor = self + let (start, _, item) = self .transforms - .cursor::>(&()); - cursor.seek(&offset, Bias::Right); - let overshoot = offset.0 - cursor.start().0.0; - match cursor.item() { + .find::, _>((), &offset, Bias::Right); + let overshoot = offset.0 - start.0.0; + match item { Some(Transform::Isomorphic(_)) => { - let buffer_offset_start = cursor.start().2; + let buffer_offset_start = start.2; let buffer_offset_end = buffer_offset_start + overshoot; let buffer_start = self.buffer.offset_to_point(buffer_offset_start); let buffer_end = self.buffer.offset_to_point(buffer_offset_end); - InlayPoint(cursor.start().1.0 + (buffer_end - buffer_start)) + InlayPoint(start.1.0 + (buffer_end - buffer_start)) } Some(Transform::Inlay(inlay)) => { - let overshoot = inlay.text.offset_to_point(overshoot); - InlayPoint(cursor.start().1.0 + overshoot) + let overshoot = inlay.text().offset_to_point(overshoot); + InlayPoint(start.1.0 + overshoot) } None => self.max_point(), } @@ -800,57 +765,54 @@ impl InlaySnapshot { } pub fn to_offset(&self, point: InlayPoint) -> InlayOffset { - let mut cursor = self + let (start, _, item) = self .transforms - .cursor::>(&()); - cursor.seek(&point, Bias::Right); - let overshoot = point.0 - cursor.start().0.0; - match cursor.item() { + .find::, _>((), &point, Bias::Right); + let overshoot = point.0 - start.0.0; + match item { Some(Transform::Isomorphic(_)) => { - let buffer_point_start = cursor.start().2; + let buffer_point_start = start.2; let buffer_point_end = buffer_point_start + overshoot; let buffer_offset_start = self.buffer.point_to_offset(buffer_point_start); let buffer_offset_end = self.buffer.point_to_offset(buffer_point_end); - InlayOffset(cursor.start().1.0 + (buffer_offset_end - buffer_offset_start)) + InlayOffset(start.1.0 + (buffer_offset_end - buffer_offset_start)) } Some(Transform::Inlay(inlay)) => { - let overshoot = inlay.text.point_to_offset(overshoot); - InlayOffset(cursor.start().1.0 + overshoot) + let overshoot = inlay.text().point_to_offset(overshoot); + InlayOffset(start.1.0 + overshoot) } None => self.len(), } } pub fn to_buffer_point(&self, point: InlayPoint) -> Point { - let mut cursor = self.transforms.cursor::>(&()); - cursor.seek(&point, Bias::Right); - match cursor.item() { + let (start, _, item) = + self.transforms + .find::, _>((), &point, Bias::Right); + match item { Some(Transform::Isomorphic(_)) => { - let overshoot = point.0 - cursor.start().0.0; - cursor.start().1 + overshoot + let overshoot = point.0 - start.0.0; + start.1 + overshoot } - Some(Transform::Inlay(_)) => cursor.start().1, + Some(Transform::Inlay(_)) => start.1, None => self.buffer.max_point(), } } pub fn to_buffer_offset(&self, offset: InlayOffset) -> usize { - let mut cursor = self - .transforms - .cursor::>(&()); - cursor.seek(&offset, Bias::Right); - match cursor.item() { + let (start, _, item) = + self.transforms + .find::, _>((), &offset, Bias::Right); + match item { Some(Transform::Isomorphic(_)) => { - let overshoot = offset - cursor.start().0; - cursor.start().1 + overshoot.0 + let overshoot = offset - start.0; + start.1 + overshoot.0 } - Some(Transform::Inlay(_)) => cursor.start().1, + Some(Transform::Inlay(_)) => start.1, None => self.buffer.len(), } } pub fn to_inlay_offset(&self, offset: usize) -> InlayOffset { - let mut cursor = self - .transforms - .cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&offset, Bias::Left); loop { match cursor.item() { @@ -883,7 +845,7 @@ impl InlaySnapshot { } } pub fn to_inlay_point(&self, point: Point) -> InlayPoint { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&point, Bias::Left); loop { match cursor.item() { @@ -917,7 +879,7 @@ impl InlaySnapshot { } pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&point, Bias::Left); loop { match cursor.item() { @@ -1014,9 +976,7 @@ impl InlaySnapshot { pub fn text_summary_for_range(&self, range: Range) -> TextSummary { let mut summary = TextSummary::default(); - let mut cursor = self - .transforms - .cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&range.start, Bias::Right); let overshoot = range.start.0 - cursor.start().0.0; @@ -1032,7 +992,7 @@ impl InlaySnapshot { Some(Transform::Inlay(inlay)) => { let suffix_start = overshoot; let suffix_end = cmp::min(cursor.end().0, range.end).0 - cursor.start().0.0; - summary = inlay.text.cursor(suffix_start).summary(suffix_end); + summary = inlay.text().cursor(suffix_start).summary(suffix_end); cursor.next(); } None => {} @@ -1054,7 +1014,7 @@ impl InlaySnapshot { } Some(Transform::Inlay(inlay)) => { let prefix_end = overshoot; - summary += inlay.text.cursor(0).summary::(prefix_end); + summary += inlay.text().cursor(0).summary::(prefix_end); } None => {} } @@ -1064,7 +1024,7 @@ impl InlaySnapshot { } pub fn row_infos(&self, row: u32) -> InlayBufferRows<'_> { - let mut cursor = self.transforms.cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); let inlay_point = InlayPoint::new(row, 0); cursor.seek(&inlay_point, Bias::Left); @@ -1106,9 +1066,7 @@ impl InlaySnapshot { language_aware: bool, highlights: Highlights<'a>, ) -> InlayChunks<'a> { - let mut cursor = self - .transforms - .cursor::>(&()); + let mut cursor = self.transforms.cursor::>(()); cursor.seek(&range.start, Bias::Right); let buffer_range = self.to_buffer_offset(range.start)..self.to_buffer_offset(range.end); @@ -1172,11 +1130,11 @@ fn push_isomorphic(sum_tree: &mut SumTree, summary: TextSummary) { *transform += summary.take().unwrap(); } }, - &(), + (), ); if let Some(summary) = summary { - sum_tree.push(Transform::Isomorphic(summary), &()); + sum_tree.push(Transform::Isomorphic(summary), ()); } } @@ -1209,28 +1167,30 @@ const fn is_utf8_char_boundary(byte: u8) -> bool { mod tests { use super::*; use crate::{ - InlayId, MultiBuffer, + MultiBuffer, display_map::{HighlightKey, InlayHighlights, TextHighlights}, hover_links::InlayHighlight, }; use gpui::{App, HighlightStyle}; + use multi_buffer::Anchor; use project::{InlayHint, InlayHintLabel, ResolveState}; use rand::prelude::*; use settings::SettingsStore; use std::{any::TypeId, cmp::Reverse, env, sync::Arc}; use sum_tree::TreeMap; - use text::Patch; + use text::{Patch, Rope}; + use util::RandomCharIter; use util::post_inc; #[test] fn test_inlay_properties_label_padding() { assert_eq!( Inlay::hint( - 0, + InlayId::Hint(0), Anchor::min(), &InlayHint { label: InlayHintLabel::String("a".to_string()), - position: text::Anchor::default(), + position: text::Anchor::MIN, padding_left: false, padding_right: false, tooltip: None, @@ -1238,7 +1198,7 @@ mod tests { resolve_state: ResolveState::Resolved, }, ) - .text + .text() .to_string(), "a", "Should not pad label if not requested" @@ -1246,11 +1206,11 @@ mod tests { assert_eq!( Inlay::hint( - 0, + InlayId::Hint(0), Anchor::min(), &InlayHint { label: InlayHintLabel::String("a".to_string()), - position: text::Anchor::default(), + position: text::Anchor::MIN, padding_left: true, padding_right: true, tooltip: None, @@ -1258,7 +1218,7 @@ mod tests { resolve_state: ResolveState::Resolved, }, ) - .text + .text() .to_string(), " a ", "Should pad label for every side requested" @@ -1266,11 +1226,11 @@ mod tests { assert_eq!( Inlay::hint( - 0, + InlayId::Hint(0), Anchor::min(), &InlayHint { label: InlayHintLabel::String(" a ".to_string()), - position: text::Anchor::default(), + position: text::Anchor::MIN, padding_left: false, padding_right: false, tooltip: None, @@ -1278,7 +1238,7 @@ mod tests { resolve_state: ResolveState::Resolved, }, ) - .text + .text() .to_string(), " a ", "Should not change already padded label" @@ -1286,11 +1246,11 @@ mod tests { assert_eq!( Inlay::hint( - 0, + InlayId::Hint(0), Anchor::min(), &InlayHint { label: InlayHintLabel::String(" a ".to_string()), - position: text::Anchor::default(), + position: text::Anchor::MIN, padding_left: true, padding_right: true, tooltip: None, @@ -1298,13 +1258,36 @@ mod tests { resolve_state: ResolveState::Resolved, }, ) - .text + .text() .to_string(), " a ", "Should not change already padded label" ); } + #[gpui::test] + fn test_inlay_hint_padding_with_multibyte_chars() { + assert_eq!( + Inlay::hint( + InlayId::Hint(0), + Anchor::min(), + &InlayHint { + label: InlayHintLabel::String("🎨".to_string()), + position: text::Anchor::MIN, + padding_left: true, + padding_right: true, + tooltip: None, + kind: None, + resolve_state: ResolveState::Resolved, + }, + ) + .text() + .to_string(), + " 🎨 ", + "Should pad single emoji correctly" + ); + } + #[gpui::test] fn test_basic_inlays(cx: &mut App) { let buffer = MultiBuffer::build_simple("abcdefghi", cx); @@ -1642,8 +1625,8 @@ mod tests { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let len = rng.gen_range(0..30); - let buffer = if rng.r#gen() { + let len = rng.random_range(0..30); + let buffer = if rng.random() { let text = util::RandomCharIter::new(&mut rng) .take(len) .collect::(); @@ -1660,7 +1643,7 @@ mod tests { let mut prev_inlay_text = inlay_snapshot.text(); let mut buffer_edits = Vec::new(); - match rng.gen_range(0..=100) { + match rng.random_range(0..=100) { 0..=50 => { let (snapshot, edits) = inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); log::info!("mutated text: {:?}", snapshot.text()); @@ -1668,7 +1651,7 @@ mod tests { } _ => buffer.update(cx, |buffer, cx| { let subscription = buffer.subscribe(); - let edit_count = rng.gen_range(1..=5); + let edit_count = rng.random_range(1..=5); buffer.randomly_mutate(&mut rng, edit_count, cx); buffer_snapshot = buffer.snapshot(cx); let edits = subscription.consume().into_inner(); @@ -1696,7 +1679,7 @@ mod tests { .collect::>(); let mut expected_text = Rope::from(&buffer_snapshot.text()); for (offset, inlay) in inlays.iter().rev() { - expected_text.replace(*offset..*offset, &inlay.text.to_string()); + expected_text.replace(*offset..*offset, &inlay.text().to_string()); } assert_eq!(inlay_snapshot.text(), expected_text.to_string()); @@ -1717,7 +1700,7 @@ mod tests { } let mut text_highlights = TextHighlights::default(); - let text_highlight_count = rng.gen_range(0_usize..10); + let text_highlight_count = rng.random_range(0_usize..10); let mut text_highlight_ranges = (0..text_highlight_count) .map(|_| buffer_snapshot.random_byte_range(0, &mut rng)) .collect::>(); @@ -1739,17 +1722,17 @@ mod tests { let mut inlay_highlights = InlayHighlights::default(); if !inlays.is_empty() { - let inlay_highlight_count = rng.gen_range(0..inlays.len()); + let inlay_highlight_count = rng.random_range(0..inlays.len()); let mut inlay_indices = BTreeSet::default(); while inlay_indices.len() < inlay_highlight_count { - inlay_indices.insert(rng.gen_range(0..inlays.len())); + inlay_indices.insert(rng.random_range(0..inlays.len())); } let new_highlights = TreeMap::from_ordered_entries( inlay_indices .into_iter() .filter_map(|i| { let (_, inlay) = &inlays[i]; - let inlay_text_len = inlay.text.len(); + let inlay_text_len = inlay.text().len(); match inlay_text_len { 0 => None, 1 => Some(InlayHighlight { @@ -1758,9 +1741,9 @@ mod tests { range: 0..1, }), n => { - let inlay_text = inlay.text.to_string(); - let mut highlight_end = rng.gen_range(1..n); - let mut highlight_start = rng.gen_range(0..highlight_end); + let inlay_text = inlay.text().to_string(); + let mut highlight_end = rng.random_range(1..n); + let mut highlight_start = rng.random_range(0..highlight_end); while !inlay_text.is_char_boundary(highlight_end) { highlight_end += 1; } @@ -1782,9 +1765,9 @@ mod tests { } for _ in 0..5 { - let mut end = rng.gen_range(0..=inlay_snapshot.len().0); + let mut end = rng.random_range(0..=inlay_snapshot.len().0); end = expected_text.clip_offset(end, Bias::Right); - let mut start = rng.gen_range(0..=end); + let mut start = rng.random_range(0..=end); start = expected_text.clip_offset(start, Bias::Right); let range = InlayOffset(start)..InlayOffset(end); @@ -1939,6 +1922,102 @@ mod tests { } } + #[gpui::test(iterations = 100)] + fn test_random_chunk_bitmaps(cx: &mut gpui::App, mut rng: StdRng) { + init_test(cx); + + // Generate random buffer using existing test infrastructure + let text_len = rng.random_range(0..10000); + let buffer = if rng.random() { + let text = RandomCharIter::new(&mut rng) + .take(text_len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (mut inlay_map, _) = InlayMap::new(buffer_snapshot.clone()); + + // Perform random mutations to add inlays + let mut next_inlay_id = 0; + let mutation_count = rng.random_range(1..10); + for _ in 0..mutation_count { + inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); + } + + let (snapshot, _) = inlay_map.sync(buffer_snapshot, vec![]); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks( + InlayOffset(0)..InlayOffset(snapshot.len().0), + false, + Highlights::default(), + ); + + for chunk in chunks.into_iter().map(|inlay_chunk| inlay_chunk.chunk) { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } + } + fn init_test(cx: &mut App) { let store = SettingsStore::test(cx); cx.set_global(store); @@ -1988,8 +2067,7 @@ mod tests { let inlay = Inlay { id: InlayId::Hint(0), position, - text: text::Rope::from(inlay_text), - color: None, + content: InlayContent::Text(text::Rope::from(inlay_text)), }; let (inlay_snapshot, _) = inlay_map.splice(&[], vec![inlay]); @@ -2103,8 +2181,7 @@ mod tests { let inlay = Inlay { id: InlayId::Hint(0), position, - text: text::Rope::from(test_case.inlay_text), - color: None, + content: InlayContent::Text(text::Rope::from(test_case.inlay_text)), }; let (inlay_snapshot, _) = inlay_map.splice(&[], vec![inlay]); diff --git a/crates/editor/src/display_map/invisibles.rs b/crates/editor/src/display_map/invisibles.rs index 199986f2a41c82894acc0259be578a28e785a235..5622a659b7acf850d24f6a476b23b53d214d855d 100644 --- a/crates/editor/src/display_map/invisibles.rs +++ b/crates/editor/src/display_map/invisibles.rs @@ -36,8 +36,8 @@ pub fn is_invisible(c: char) -> bool { } else if c >= '\u{7f}' { c <= '\u{9f}' || (c.is_whitespace() && c != IDEOGRAPHIC_SPACE) - || contains(c, &FORMAT) - || contains(c, &OTHER) + || contains(c, FORMAT) + || contains(c, OTHER) } else { false } @@ -50,25 +50,28 @@ pub fn replacement(c: char) -> Option<&'static str> { Some(C0_SYMBOLS[c as usize]) } else if c == '\x7f' { Some(DEL) - } else if contains(c, &PRESERVE) { + } else if contains(c, PRESERVE) { None } else { - Some("\u{2007}") // fixed width space + Some(FIXED_WIDTH_SPACE) } } + +const FIXED_WIDTH_SPACE: &str = "\u{2007}"; + // IDEOGRAPHIC SPACE is common alongside Chinese and other wide character sets. // We don't highlight this for now (as it already shows up wide in the editor), // but could if we tracked state in the classifier. const IDEOGRAPHIC_SPACE: char = '\u{3000}'; -const C0_SYMBOLS: &'static [&'static str] = &[ +const C0_SYMBOLS: &[&str] = &[ "␀", "␁", "␂", "␃", "␄", "␅", "␆", "␇", "␈", "␉", "␊", "␋", "␌", "␍", "␎", "␏", "␐", "␑", "␒", "␓", "␔", "␕", "␖", "␗", "␘", "␙", "␚", "␛", "␜", "␝", "␞", "␟", ]; -const DEL: &'static str = "␡"; +const DEL: &str = "␡"; // generated using ucd-generate: ucd-generate general-category --include Format --chars ucd-16.0.0 -pub const FORMAT: &'static [(char, char)] = &[ +pub const FORMAT: &[(char, char)] = &[ ('\u{ad}', '\u{ad}'), ('\u{600}', '\u{605}'), ('\u{61c}', '\u{61c}'), @@ -93,7 +96,7 @@ pub const FORMAT: &'static [(char, char)] = &[ ]; // hand-made base on https://invisible-characters.com (Excluding Cf) -pub const OTHER: &'static [(char, char)] = &[ +pub const OTHER: &[(char, char)] = &[ ('\u{034f}', '\u{034f}'), ('\u{115F}', '\u{1160}'), ('\u{17b4}', '\u{17b5}'), @@ -107,7 +110,7 @@ pub const OTHER: &'static [(char, char)] = &[ ]; // a subset of FORMAT/OTHER that may appear within glyphs -const PRESERVE: &'static [(char, char)] = &[ +const PRESERVE: &[(char, char)] = &[ ('\u{034f}', '\u{034f}'), ('\u{200d}', '\u{200d}'), ('\u{17b4}', '\u{17b5}'), @@ -117,11 +120,11 @@ const PRESERVE: &'static [(char, char)] = &[ ]; fn contains(c: char, list: &[(char, char)]) -> bool { - for (start, end) in list { - if c < *start { + for &(start, end) in list { + if c < start { return false; } - if c <= *end { + if c <= end { return true; } } diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index eb5d57d48472bdd4b2d4f150c40da05c7e422e19..7a63723f53a49483eaa728373a5ae8530aa6f4d6 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -2,6 +2,7 @@ use super::{ Highlights, fold_map::{self, Chunk, FoldChunks, FoldEdit, FoldPoint, FoldSnapshot}, }; + use language::Point; use multi_buffer::MultiBufferSnapshot; use std::{cmp, mem, num::NonZeroU32, ops::Range}; @@ -9,6 +10,10 @@ use sum_tree::Bias; const MAX_EXPANSION_COLUMN: u32 = 256; +// Handles a tab width <= 128 +const SPACES: &[u8; rope::Chunk::MASK_BITS] = &[b' '; _]; +const MAX_TABS: NonZeroU32 = NonZeroU32::new(SPACES.len() as u32).unwrap(); + /// Keeps track of hard tabs in a text buffer. /// /// See the [`display_map` module documentation](crate::display_map) for more information. @@ -18,7 +23,7 @@ impl TabMap { pub fn new(fold_snapshot: FoldSnapshot, tab_size: NonZeroU32) -> (Self, TabSnapshot) { let snapshot = TabSnapshot { fold_snapshot, - tab_size, + tab_size: tab_size.min(MAX_TABS), max_expansion_column: MAX_EXPANSION_COLUMN, version: 0, }; @@ -40,7 +45,7 @@ impl TabMap { let old_snapshot = &mut self.0; let mut new_snapshot = TabSnapshot { fold_snapshot, - tab_size, + tab_size: tab_size.min(MAX_TABS), max_expansion_column: old_snapshot.max_expansion_column, version: old_snapshot.version, }; @@ -49,9 +54,7 @@ impl TabMap { new_snapshot.version += 1; } - let mut tab_edits = Vec::with_capacity(fold_edits.len()); - - if old_snapshot.tab_size == new_snapshot.tab_size { + let tab_edits = if old_snapshot.tab_size == new_snapshot.tab_size { // Expand each edit to include the next tab on the same line as the edit, // and any subsequent tabs on that line that moved across the tab expansion // boundary. @@ -72,6 +75,7 @@ impl TabMap { false, Highlights::default(), ) { + // todo(performance use tabs bitmask) for (ix, _) in chunk.text.match_indices('\t') { let offset_from_edit = offset_from_edit + (ix as u32); if first_tab_offset.is_none() { @@ -106,7 +110,7 @@ impl TabMap { let _old_alloc_ptr = fold_edits.as_ptr(); // Combine any edits that overlap due to the expansion. let mut fold_edits = fold_edits.into_iter(); - let fold_edits = if let Some(mut first_edit) = fold_edits.next() { + if let Some(mut first_edit) = fold_edits.next() { // This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them. #[allow(clippy::filter_map_identity)] let mut v: Vec<_> = fold_edits @@ -116,7 +120,7 @@ impl TabMap { state.new.end = edit.new.end; Some(None) // Skip this edit, it's merged } else { - let new_state = edit.clone(); + let new_state = edit; let result = Some(Some(state.clone())); // Yield the previous edit **state = new_state; result @@ -126,29 +130,30 @@ impl TabMap { .collect(); v.push(first_edit); debug_assert_eq!(v.as_ptr(), _old_alloc_ptr, "Fold edits were reallocated"); - v + v.into_iter() + .map(|fold_edit| { + let old_start = fold_edit.old.start.to_point(&old_snapshot.fold_snapshot); + let old_end = fold_edit.old.end.to_point(&old_snapshot.fold_snapshot); + let new_start = fold_edit.new.start.to_point(&new_snapshot.fold_snapshot); + let new_end = fold_edit.new.end.to_point(&new_snapshot.fold_snapshot); + TabEdit { + old: old_snapshot.to_tab_point(old_start) + ..old_snapshot.to_tab_point(old_end), + new: new_snapshot.to_tab_point(new_start) + ..new_snapshot.to_tab_point(new_end), + } + }) + .collect() } else { vec![] - }; - - for fold_edit in fold_edits { - let old_start = fold_edit.old.start.to_point(&old_snapshot.fold_snapshot); - let old_end = fold_edit.old.end.to_point(&old_snapshot.fold_snapshot); - let new_start = fold_edit.new.start.to_point(&new_snapshot.fold_snapshot); - let new_end = fold_edit.new.end.to_point(&new_snapshot.fold_snapshot); - tab_edits.push(TabEdit { - old: old_snapshot.to_tab_point(old_start)..old_snapshot.to_tab_point(old_end), - new: new_snapshot.to_tab_point(new_start)..new_snapshot.to_tab_point(new_end), - }); } } else { new_snapshot.version += 1; - tab_edits.push(TabEdit { + vec![TabEdit { old: TabPoint::zero()..old_snapshot.max_point(), new: TabPoint::zero()..new_snapshot.max_point(), - }); - } - + }] + }; *old_snapshot = new_snapshot; (old_snapshot.clone(), tab_edits) } @@ -189,37 +194,28 @@ impl TabSnapshot { .fold_snapshot .text_summary_for_range(input_start..input_end); - let mut first_line_chars = 0; let line_end = if range.start.row() == range.end.row() { range.end } else { self.max_point() }; - for c in self + let first_line_chars = self .chunks(range.start..line_end, false, Highlights::default()) .flat_map(|chunk| chunk.text.chars()) - { - if c == '\n' { - break; - } - first_line_chars += 1; - } + .take_while(|&c| c != '\n') + .count() as u32; - let mut last_line_chars = 0; - if range.start.row() == range.end.row() { - last_line_chars = first_line_chars; + let last_line_chars = if range.start.row() == range.end.row() { + first_line_chars } else { - for _ in self - .chunks( - TabPoint::new(range.end.row(), 0)..range.end, - false, - Highlights::default(), - ) - .flat_map(|chunk| chunk.text.chars()) - { - last_line_chars += 1; - } - } + self.chunks( + TabPoint::new(range.end.row(), 0)..range.end, + false, + Highlights::default(), + ) + .flat_map(|chunk| chunk.text.chars()) + .count() as u32 + }; TextSummary { lines: range.end.0 - range.start.0, @@ -230,7 +226,7 @@ impl TabSnapshot { } } - pub fn chunks<'a>( + pub(crate) fn chunks<'a>( &'a self, range: Range, language_aware: bool, @@ -264,7 +260,7 @@ impl TabSnapshot { max_output_position: range.end.0, tab_size: self.tab_size, chunk: Chunk { - text: &SPACES[0..(to_next_stop as usize)], + text: unsafe { std::str::from_utf8_unchecked(&SPACES[..to_next_stop as usize]) }, is_tab: true, ..Default::default() }, @@ -299,16 +295,22 @@ impl TabSnapshot { } pub fn to_tab_point(&self, input: FoldPoint) -> TabPoint { - let chars = self.fold_snapshot.chars_at(FoldPoint::new(input.row(), 0)); - let expanded = self.expand_tabs(chars, input.column()); + let chunks = self.fold_snapshot.chunks_at(FoldPoint::new(input.row(), 0)); + let tab_cursor = TabStopCursor::new(chunks); + let expanded = self.expand_tabs(tab_cursor, input.column()); TabPoint::new(input.row(), expanded) } pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { - let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0)); + let chunks = self + .fold_snapshot + .chunks_at(FoldPoint::new(output.row(), 0)); + + let tab_cursor = TabStopCursor::new(chunks); let expanded = output.column(); let (collapsed, expanded_char_column, to_next_stop) = - self.collapse_tabs(chars, expanded, bias); + self.collapse_tabs(tab_cursor, expanded, bias); + ( FoldPoint::new(output.row(), collapsed), expanded_char_column, @@ -330,72 +332,90 @@ impl TabSnapshot { .to_buffer_point(inlay_point) } - fn expand_tabs(&self, chars: impl Iterator, column: u32) -> u32 { + fn expand_tabs<'a, I>(&self, mut cursor: TabStopCursor<'a, I>, column: u32) -> u32 + where + I: Iterator>, + { let tab_size = self.tab_size.get(); - let mut expanded_chars = 0; - let mut expanded_bytes = 0; - let mut collapsed_bytes = 0; let end_column = column.min(self.max_expansion_column); - for c in chars { - if collapsed_bytes >= end_column { - break; - } - if c == '\t' { - let tab_len = tab_size - expanded_chars % tab_size; - expanded_bytes += tab_len; - expanded_chars += tab_len; - } else { - expanded_bytes += c.len_utf8() as u32; - expanded_chars += 1; - } - collapsed_bytes += c.len_utf8() as u32; + let mut seek_target = end_column; + let mut tab_count = 0; + let mut expanded_tab_len = 0; + + while let Some(tab_stop) = cursor.seek(seek_target) { + let expanded_chars_old = tab_stop.char_offset + expanded_tab_len - tab_count; + let tab_len = tab_size - ((expanded_chars_old - 1) % tab_size); + tab_count += 1; + expanded_tab_len += tab_len; + + seek_target = end_column - cursor.byte_offset; } + + let left_over_char_bytes = if !cursor.is_char_boundary() { + cursor.bytes_until_next_char().unwrap_or(0) as u32 + } else { + 0 + }; + + let collapsed_bytes = cursor.byte_offset() + left_over_char_bytes; + let expanded_bytes = + cursor.byte_offset() + expanded_tab_len - tab_count + left_over_char_bytes; + expanded_bytes + column.saturating_sub(collapsed_bytes) } - fn collapse_tabs( + fn collapse_tabs<'a, I>( &self, - chars: impl Iterator, + mut cursor: TabStopCursor<'a, I>, column: u32, bias: Bias, - ) -> (u32, u32, u32) { + ) -> (u32, u32, u32) + where + I: Iterator>, + { let tab_size = self.tab_size.get(); - - let mut expanded_bytes = 0; - let mut expanded_chars = 0; - let mut collapsed_bytes = 0; - for c in chars { - if expanded_bytes >= column { - break; - } - if collapsed_bytes >= self.max_expansion_column { - break; - } - - if c == '\t' { - let tab_len = tab_size - (expanded_chars % tab_size); - expanded_chars += tab_len; - expanded_bytes += tab_len; - if expanded_bytes > column { - expanded_chars -= expanded_bytes - column; - return match bias { - Bias::Left => (collapsed_bytes, expanded_chars, expanded_bytes - column), - Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), - }; - } + let mut collapsed_column = column; + let mut seek_target = column.min(self.max_expansion_column); + let mut tab_count = 0; + let mut expanded_tab_len = 0; + + while let Some(tab_stop) = cursor.seek(seek_target) { + // Calculate how much we want to expand this tab stop (into spaces) + let expanded_chars_old = tab_stop.char_offset + expanded_tab_len - tab_count; + let tab_len = tab_size - ((expanded_chars_old - 1) % tab_size); + // Increment tab count + tab_count += 1; + // The count of how many spaces we've added to this line in place of tab bytes + expanded_tab_len += tab_len; + + // The count of bytes at this point in the iteration while considering tab_count and previous expansions + let expanded_bytes = tab_stop.byte_offset + expanded_tab_len - tab_count; + + // Did we expand past the search target? + if expanded_bytes > column { + let mut expanded_chars = tab_stop.char_offset + expanded_tab_len - tab_count; + // We expanded past the search target, so need to account for the offshoot + expanded_chars -= expanded_bytes - column; + return match bias { + Bias::Left => ( + cursor.byte_offset() - 1, + expanded_chars, + expanded_bytes - column, + ), + Bias::Right => (cursor.byte_offset(), expanded_chars, 0), + }; } else { - expanded_chars += 1; - expanded_bytes += c.len_utf8() as u32; - } - - if expanded_bytes > column && matches!(bias, Bias::Left) { - expanded_chars -= 1; - break; + // otherwise we only want to move the cursor collapse column forward + collapsed_column = collapsed_column - tab_len + 1; + seek_target = (collapsed_column - cursor.byte_offset) + .min(self.max_expansion_column - cursor.byte_offset); } - - collapsed_bytes += c.len_utf8() as u32; } + + let collapsed_bytes = cursor.byte_offset(); + let expanded_bytes = cursor.byte_offset() + expanded_tab_len - tab_count; + let expanded_chars = cursor.char_offset() + expanded_tab_len - tab_count; ( collapsed_bytes + column.saturating_sub(expanded_bytes), expanded_chars, @@ -482,20 +502,19 @@ impl<'a> std::ops::AddAssign<&'a Self> for TextSummary { } } -// Handles a tab width <= 16 -const SPACES: &str = " "; - pub struct TabChunks<'a> { snapshot: &'a TabSnapshot, + max_expansion_column: u32, + max_output_position: Point, + tab_size: NonZeroU32, + // region: iteration state fold_chunks: FoldChunks<'a>, chunk: Chunk<'a>, column: u32, - max_expansion_column: u32, output_position: Point, input_column: u32, - max_output_position: Point, - tab_size: NonZeroU32, inside_leading_tab: bool, + // endregion: iteration state } impl TabChunks<'_> { @@ -521,8 +540,9 @@ impl TabChunks<'_> { self.output_position = range.start.0; self.max_output_position = range.end.0; self.chunk = Chunk { - text: &SPACES[0..(to_next_stop as usize)], + text: unsafe { std::str::from_utf8_unchecked(&SPACES[..to_next_stop as usize]) }, is_tab: true, + chars: 1u128.unbounded_shl(to_next_stop) - 1, ..Default::default() }; self.inside_leading_tab = to_next_stop > 0; @@ -546,38 +566,50 @@ impl<'a> Iterator for TabChunks<'a> { } } + //todo(improve performance by using tab cursor) for (ix, c) in self.chunk.text.char_indices() { match c { + '\t' if ix > 0 => { + let (prefix, suffix) = self.chunk.text.split_at(ix); + + let mask = 1u128.unbounded_shl(ix as u32).wrapping_sub(1); + let chars = self.chunk.chars & mask; + let tabs = self.chunk.tabs & mask; + self.chunk.tabs = self.chunk.tabs.unbounded_shr(ix as u32); + self.chunk.chars = self.chunk.chars.unbounded_shr(ix as u32); + self.chunk.text = suffix; + return Some(Chunk { + text: prefix, + chars, + tabs, + ..self.chunk.clone() + }); + } '\t' => { - if ix > 0 { - let (prefix, suffix) = self.chunk.text.split_at(ix); - self.chunk.text = suffix; - return Some(Chunk { - text: prefix, - ..self.chunk.clone() - }); + self.chunk.text = &self.chunk.text[1..]; + self.chunk.tabs >>= 1; + self.chunk.chars >>= 1; + let tab_size = if self.input_column < self.max_expansion_column { + self.tab_size.get() } else { - self.chunk.text = &self.chunk.text[1..]; - let tab_size = if self.input_column < self.max_expansion_column { - self.tab_size.get() - } else { - 1 - }; - let mut len = tab_size - self.column % tab_size; - let next_output_position = cmp::min( - self.output_position + Point::new(0, len), - self.max_output_position, - ); - len = next_output_position.column - self.output_position.column; - self.column += len; - self.input_column += 1; - self.output_position = next_output_position; - return Some(Chunk { - text: &SPACES[..len as usize], - is_tab: true, - ..self.chunk.clone() - }); - } + 1 + }; + let mut len = tab_size - self.column % tab_size; + let next_output_position = cmp::min( + self.output_position + Point::new(0, len), + self.max_output_position, + ); + len = next_output_position.column - self.output_position.column; + self.column += len; + self.input_column += 1; + self.output_position = next_output_position; + return Some(Chunk { + text: unsafe { std::str::from_utf8_unchecked(&SPACES[..len as usize]) }, + is_tab: true, + chars: 1u128.unbounded_shl(len) - 1, + tabs: 0, + ..self.chunk.clone() + }); } '\n' => { self.column = 0; @@ -603,21 +635,270 @@ mod tests { use super::*; use crate::{ MultiBuffer, - display_map::{fold_map::FoldMap, inlay_map::InlayMap}, + display_map::{ + fold_map::{FoldMap, FoldOffset}, + inlay_map::InlayMap, + }, }; use rand::{Rng, prelude::StdRng}; + use util; + + impl TabSnapshot { + fn expected_collapse_tabs( + &self, + chars: impl Iterator, + column: u32, + bias: Bias, + ) -> (u32, u32, u32) { + let tab_size = self.tab_size.get(); + + let mut expanded_bytes = 0; + let mut expanded_chars = 0; + let mut collapsed_bytes = 0; + for c in chars { + if expanded_bytes >= column { + break; + } + if collapsed_bytes >= self.max_expansion_column { + break; + } + + if c == '\t' { + let tab_len = tab_size - (expanded_chars % tab_size); + expanded_chars += tab_len; + expanded_bytes += tab_len; + if expanded_bytes > column { + expanded_chars -= expanded_bytes - column; + return match bias { + Bias::Left => { + (collapsed_bytes, expanded_chars, expanded_bytes - column) + } + Bias::Right => (collapsed_bytes + 1, expanded_chars, 0), + }; + } + } else { + expanded_chars += 1; + expanded_bytes += c.len_utf8() as u32; + } + + if expanded_bytes > column && matches!(bias, Bias::Left) { + expanded_chars -= 1; + break; + } + + collapsed_bytes += c.len_utf8() as u32; + } + + ( + collapsed_bytes + column.saturating_sub(expanded_bytes), + expanded_chars, + 0, + ) + } + + pub fn expected_to_tab_point(&self, input: FoldPoint) -> TabPoint { + let chars = self.fold_snapshot.chars_at(FoldPoint::new(input.row(), 0)); + let expanded = self.expected_expand_tabs(chars, input.column()); + TabPoint::new(input.row(), expanded) + } + + fn expected_expand_tabs(&self, chars: impl Iterator, column: u32) -> u32 { + let tab_size = self.tab_size.get(); + + let mut expanded_chars = 0; + let mut expanded_bytes = 0; + let mut collapsed_bytes = 0; + let end_column = column.min(self.max_expansion_column); + for c in chars { + if collapsed_bytes >= end_column { + break; + } + if c == '\t' { + let tab_len = tab_size - expanded_chars % tab_size; + expanded_bytes += tab_len; + expanded_chars += tab_len; + } else { + expanded_bytes += c.len_utf8() as u32; + expanded_chars += 1; + } + collapsed_bytes += c.len_utf8() as u32; + } + + expanded_bytes + column.saturating_sub(collapsed_bytes) + } + + fn expected_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { + let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0)); + let expanded = output.column(); + let (collapsed, expanded_char_column, to_next_stop) = + self.expected_collapse_tabs(chars, expanded, bias); + ( + FoldPoint::new(output.row(), collapsed), + expanded_char_column, + to_next_stop, + ) + } + } #[gpui::test] fn test_expand_tabs(cx: &mut gpui::App) { + let test_values = [ + ("κg🏀 f\nwo🏀❌by🍐❎β🍗c\tβ❎ \ncλ🎉", 17), + (" \twςe", 4), + ("fε", 1), + ("i❎\t", 3), + ]; let buffer = MultiBuffer::build_simple("", cx); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + + for (text, column) in test_values { + let mut tabs = 0u128; + let mut chars = 0u128; + for (idx, c) in text.char_indices() { + if c == '\t' { + tabs |= 1 << idx; + } + chars |= 1 << idx; + } + + let chunks = [Chunk { + text, + tabs, + chars, + ..Default::default() + }]; + + let cursor = TabStopCursor::new(chunks); + + assert_eq!( + tab_snapshot.expected_expand_tabs(text.chars(), column), + tab_snapshot.expand_tabs(cursor, column) + ); + } + } + + #[gpui::test] + fn test_collapse_tabs(cx: &mut gpui::App) { + let input = "A\tBC\tDEF\tG\tHI\tJ\tK\tL\tM"; + + let buffer = MultiBuffer::build_simple(input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + + for (ix, _) in input.char_indices() { + let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); + + assert_eq!( + tab_snapshot.expected_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left), + "Failed with tab_point at column {ix}" + ); + assert_eq!( + tab_snapshot.expected_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right), + "Failed with tab_point at column {ix}" + ); + + assert_eq!( + tab_snapshot.expected_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left), + "Failed with tab_point at column {ix}" + ); + assert_eq!( + tab_snapshot.expected_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right), + "Failed with tab_point at column {ix}" + ); + } + } + + #[gpui::test] + fn test_to_fold_point_panic_reproduction(cx: &mut gpui::App) { + // This test reproduces a specific panic where to_fold_point returns incorrect results + let _text = "use macro_rules_attribute::apply;\nuse serde_json::Value;\nuse smol::{\n io::AsyncReadExt,\n process::{Command, Stdio},\n};\nuse smol_macros::main;\nuse std::io;\n\nfn test_random() {\n // Generate a random value\n let random_value = std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_secs()\n % 100;\n\n // Create some complex nested data structures\n let mut vector = Vec::new();\n for i in 0..random_value {\n vector.push(i);\n }\n "; + + let text = "γ\tw⭐\n🍐🍗 \t"; + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); - assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 0), 0); - assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 1), 4); - assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5); + // This should panic with the expected vs actual mismatch + let tab_point = TabPoint::new(0, 9); + let result = tab_snapshot.to_fold_point(tab_point, Bias::Left); + let expected = tab_snapshot.expected_to_fold_point(tab_point, Bias::Left); + + assert_eq!(result, expected); + } + + #[gpui::test(iterations = 100)] + fn test_collapse_tabs_random(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 200 characters including tabs + // to stay within the MAX_EXPANSION_COLUMN limit of 256 + let len = rng.random_range(0..=2048); + let tab_size = NonZeroU32::new(rng.random_range(1..=4)).unwrap(); + let mut input = String::with_capacity(len); + + for _ in 0..len { + if rng.random_bool(0.1) { + // 10% chance of inserting a tab + input.push('\t'); + } else { + // 90% chance of inserting a random ASCII character (excluding tab, newline, carriage return) + let ch = loop { + let ascii_code = rng.random_range(32..=126); // printable ASCII range + let ch = ascii_code as u8 as char; + if ch != '\t' { + break ch; + } + }; + input.push(ch); + } + } + + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + tab_snapshot.max_expansion_column = rng.random_range(0..323); + tab_snapshot.tab_size = tab_size; + + for (ix, _) in input.char_indices() { + let range = TabPoint::new(0, ix as u32)..tab_snapshot.max_point(); + + assert_eq!( + tab_snapshot.expected_to_fold_point(range.start, Bias::Left), + tab_snapshot.to_fold_point(range.start, Bias::Left), + "Failed with input: {}, with idx: {ix}", + input + ); + assert_eq!( + tab_snapshot.expected_to_fold_point(range.start, Bias::Right), + tab_snapshot.to_fold_point(range.start, Bias::Right), + "Failed with input: {}, with idx: {ix}", + input + ); + + assert_eq!( + tab_snapshot.expected_to_fold_point(range.end, Bias::Left), + tab_snapshot.to_fold_point(range.end, Bias::Left), + "Failed with input: {}, with idx: {ix}", + input + ); + assert_eq!( + tab_snapshot.expected_to_fold_point(range.end, Bias::Right), + tab_snapshot.to_fold_point(range.end, Bias::Right), + "Failed with input: {}, with idx: {ix}", + input + ); + } } #[gpui::test] @@ -628,7 +909,7 @@ mod tests { let buffer = MultiBuffer::build_simple(input, cx); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); @@ -675,7 +956,7 @@ mod tests { let buffer = MultiBuffer::build_simple(input, cx); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, mut tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); @@ -689,7 +970,7 @@ mod tests { let buffer = MultiBuffer::build_simple(input, cx); let buffer_snapshot = buffer.read(cx).snapshot(cx); - let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); @@ -736,9 +1017,9 @@ mod tests { #[gpui::test(iterations = 100)] fn test_random_tabs(cx: &mut gpui::App, mut rng: StdRng) { - let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); - let len = rng.gen_range(0..30); - let buffer = if rng.r#gen() { + let tab_size = NonZeroU32::new(rng.random_range(1..=4)).unwrap(); + let len = rng.random_range(0..30); + let buffer = if rng.random() { let text = util::RandomCharIter::new(&mut rng) .take(len) .collect::(); @@ -749,7 +1030,7 @@ mod tests { let buffer_snapshot = buffer.read(cx).snapshot(cx); log::info!("Buffer text: {:?}", buffer_snapshot.text()); - let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); log::info!("InlayMap text: {:?}", inlay_snapshot.text()); let (mut fold_map, _) = FoldMap::new(inlay_snapshot.clone()); fold_map.randomly_mutate(&mut rng); @@ -758,7 +1039,7 @@ mod tests { let (inlay_snapshot, _) = inlay_map.randomly_mutate(&mut 0, &mut rng); log::info!("InlayMap text: {:?}", inlay_snapshot.text()); - let (mut tab_map, _) = TabMap::new(fold_snapshot.clone(), tab_size); + let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size); let tabs_snapshot = tab_map.set_max_expansion_column(32); let text = text::Rope::from(tabs_snapshot.text().as_str()); @@ -769,11 +1050,11 @@ mod tests { ); for _ in 0..5 { - let end_row = rng.gen_range(0..=text.max_point().row); - let end_column = rng.gen_range(0..=text.line_len(end_row)); + let end_row = rng.random_range(0..=text.max_point().row); + let end_column = rng.random_range(0..=text.line_len(end_row)); let mut end = TabPoint(text.clip_point(Point::new(end_row, end_column), Bias::Right)); - let start_row = rng.gen_range(0..=text.max_point().row); - let start_column = rng.gen_range(0..=text.line_len(start_row)); + let start_row = rng.random_range(0..=text.max_point().row); + let start_column = rng.random_range(0..=text.line_len(start_row)); let mut start = TabPoint(text.clip_point(Point::new(start_row, start_column), Bias::Left)); if start > end { @@ -811,4 +1092,479 @@ mod tests { ); } } + + #[gpui::test(iterations = 100)] + fn test_to_tab_point_random(cx: &mut gpui::App, mut rng: StdRng) { + let tab_size = NonZeroU32::new(rng.random_range(1..=16)).unwrap(); + let len = rng.random_range(0..=2000); + + // Generate random text using RandomCharIter + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); + + // Create buffer and tab map + let buffer = MultiBuffer::build_simple(&text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size); + + let mut next_inlay_id = 0; + let (inlay_snapshot, inlay_edits) = inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let max_fold_point = fold_snapshot.max_point(); + let (mut tab_snapshot, _) = tab_map.sync(fold_snapshot.clone(), fold_edits, tab_size); + + // Test random fold points + for _ in 0..50 { + tab_snapshot.max_expansion_column = rng.random_range(0..=256); + // Generate random fold point + let row = rng.random_range(0..=max_fold_point.row()); + let max_column = if row < max_fold_point.row() { + fold_snapshot.line_len(row) + } else { + max_fold_point.column() + }; + let column = rng.random_range(0..=max_column + 10); + let fold_point = FoldPoint::new(row, column); + + let actual = tab_snapshot.to_tab_point(fold_point); + let expected = tab_snapshot.expected_to_tab_point(fold_point); + + assert_eq!( + actual, expected, + "to_tab_point mismatch for fold_point {:?} in text {:?}", + fold_point, text + ); + } + } + + #[gpui::test] + fn test_tab_stop_cursor_utf8(cx: &mut gpui::App) { + let text = "\tfoo\tbarbarbar\t\tbaz\n"; + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let chunks = fold_snapshot.chunks( + FoldOffset(0)..fold_snapshot.len(), + false, + Default::default(), + ); + let mut cursor = TabStopCursor::new(chunks); + assert!(cursor.seek(0).is_none()); + let mut tab_stops = Vec::new(); + + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 0; + for (offset, ch) in buffer.read(cx).snapshot(cx).text().char_indices() { + byte_offset += ch.len_utf8() as u32; + + if ch == '\t' { + all_tab_stops.push(TabStop { + byte_offset, + char_offset: offset as u32 + 1, + }); + } + } + + while let Some(tab_stop) = cursor.seek(u32::MAX) { + tab_stops.push(tab_stop); + } + pretty_assertions::assert_eq!(tab_stops.as_slice(), all_tab_stops.as_slice(),); + + assert_eq!(cursor.byte_offset(), byte_offset); + } + + #[gpui::test] + fn test_tab_stop_with_end_range_utf8(cx: &mut gpui::App) { + let input = "A\tBC\t"; // DEF\tG\tHI\tJ\tK\tL\tM + + let buffer = MultiBuffer::build_simple(input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); + let mut cursor = TabStopCursor::new(chunks); + + let mut actual_tab_stops = Vec::new(); + + let mut expected_tab_stops = Vec::new(); + let mut byte_offset = 0; + for (offset, ch) in buffer.read(cx).snapshot(cx).text().char_indices() { + byte_offset += ch.len_utf8() as u32; + + if ch == '\t' { + expected_tab_stops.push(TabStop { + byte_offset, + char_offset: offset as u32 + 1, + }); + } + } + + while let Some(tab_stop) = cursor.seek(u32::MAX) { + actual_tab_stops.push(tab_stop); + } + pretty_assertions::assert_eq!(actual_tab_stops.as_slice(), expected_tab_stops.as_slice(),); + + assert_eq!(cursor.byte_offset(), byte_offset); + } + + #[gpui::test(iterations = 100)] + fn test_tab_stop_cursor_random_utf8(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 512 characters including tabs + let len = rng.random_range(0..=2048); + let mut input = String::with_capacity(len); + + let mut skip_tabs = rng.random_bool(0.10); + for idx in 0..len { + if idx % 128 == 0 { + skip_tabs = rng.random_bool(0.10); + } + + if rng.random_bool(0.15) && !skip_tabs { + input.push('\t'); + } else { + let ch = loop { + let ascii_code = rng.random_range(32..=126); // printable ASCII range + let ch = ascii_code as u8 as char; + if ch != '\t' { + break ch; + } + }; + input.push(ch); + } + } + + // Build the buffer and create cursor + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + // First, collect all expected tab positions + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 1; + let mut char_offset = 1; + for ch in buffer_snapshot.text().chars() { + if ch == '\t' { + all_tab_stops.push(TabStop { + byte_offset, + char_offset, + }); + } + byte_offset += ch.len_utf8() as u32; + char_offset += 1; + } + + // Test with various distances + let distances = vec![1, 5, 10, 50, 100, u32::MAX]; + // let distances = vec![150]; + + for distance in distances { + let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); + let mut cursor = TabStopCursor::new(chunks); + + let mut found_tab_stops = Vec::new(); + let mut position = distance; + while let Some(tab_stop) = cursor.seek(position) { + found_tab_stops.push(tab_stop); + position = distance - tab_stop.byte_offset; + } + + let expected_found_tab_stops: Vec<_> = all_tab_stops + .iter() + .take_while(|tab_stop| tab_stop.byte_offset <= distance) + .cloned() + .collect(); + + pretty_assertions::assert_eq!( + found_tab_stops, + expected_found_tab_stops, + "TabStopCursor output mismatch for distance {}. Input: {:?}", + distance, + input + ); + + let final_position = cursor.byte_offset(); + if !found_tab_stops.is_empty() { + let last_tab_stop = found_tab_stops.last().unwrap(); + assert!( + final_position >= last_tab_stop.byte_offset, + "Cursor final position {} is before last tab stop {}. Input: {:?}", + final_position, + last_tab_stop.byte_offset, + input + ); + } + } + } + + #[gpui::test] + fn test_tab_stop_cursor_utf16(cx: &mut gpui::App) { + let text = "\r\t😁foo\tb😀arbar🤯bar\t\tbaz\n"; + let buffer = MultiBuffer::build_simple(text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let chunks = fold_snapshot.chunks( + FoldOffset(0)..fold_snapshot.len(), + false, + Default::default(), + ); + let mut cursor = TabStopCursor::new(chunks); + assert!(cursor.seek(0).is_none()); + + let mut expected_tab_stops = Vec::new(); + let mut byte_offset = 0; + for (i, ch) in fold_snapshot.chars_at(FoldPoint::new(0, 0)).enumerate() { + byte_offset += ch.len_utf8() as u32; + + if ch == '\t' { + expected_tab_stops.push(TabStop { + byte_offset, + char_offset: i as u32 + 1, + }); + } + } + + let mut actual_tab_stops = Vec::new(); + while let Some(tab_stop) = cursor.seek(u32::MAX) { + actual_tab_stops.push(tab_stop); + } + + pretty_assertions::assert_eq!(actual_tab_stops.as_slice(), expected_tab_stops.as_slice(),); + + assert_eq!(cursor.byte_offset(), byte_offset); + } + + #[gpui::test(iterations = 100)] + fn test_tab_stop_cursor_random_utf16(cx: &mut gpui::App, mut rng: StdRng) { + // Generate random input string with up to 512 characters including tabs + let len = rng.random_range(0..=2048); + let input = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); + + // Build the buffer and create cursor + let buffer = MultiBuffer::build_simple(&input, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + + // First, collect all expected tab positions + let mut all_tab_stops = Vec::new(); + let mut byte_offset = 0; + for (i, ch) in buffer_snapshot.text().chars().enumerate() { + byte_offset += ch.len_utf8() as u32; + if ch == '\t' { + all_tab_stops.push(TabStop { + byte_offset, + char_offset: i as u32 + 1, + }); + } + } + + // Test with various distances + // let distances = vec![1, 5, 10, 50, 100, u32::MAX]; + let distances = vec![150]; + + for distance in distances { + let chunks = fold_snapshot.chunks_at(FoldPoint::new(0, 0)); + let mut cursor = TabStopCursor::new(chunks); + + let mut found_tab_stops = Vec::new(); + let mut position = distance; + while let Some(tab_stop) = cursor.seek(position) { + found_tab_stops.push(tab_stop); + position = distance - tab_stop.byte_offset; + } + + let expected_found_tab_stops: Vec<_> = all_tab_stops + .iter() + .take_while(|tab_stop| tab_stop.byte_offset <= distance) + .cloned() + .collect(); + + pretty_assertions::assert_eq!( + found_tab_stops, + expected_found_tab_stops, + "TabStopCursor output mismatch for distance {}. Input: {:?}", + distance, + input + ); + + let final_position = cursor.byte_offset(); + if !found_tab_stops.is_empty() { + let last_tab_stop = found_tab_stops.last().unwrap(); + assert!( + final_position >= last_tab_stop.byte_offset, + "Cursor final position {} is before last tab stop {}. Input: {:?}", + final_position, + last_tab_stop.byte_offset, + input + ); + } + } + } +} + +struct TabStopCursor<'a, I> +where + I: Iterator>, +{ + chunks: I, + byte_offset: u32, + char_offset: u32, + /// Chunk + /// last tab position iterated through + current_chunk: Option<(Chunk<'a>, u32)>, +} + +impl<'a, I> TabStopCursor<'a, I> +where + I: Iterator>, +{ + fn new(chunks: impl IntoIterator, IntoIter = I>) -> Self { + Self { + chunks: chunks.into_iter(), + byte_offset: 0, + char_offset: 0, + current_chunk: None, + } + } + + fn bytes_until_next_char(&self) -> Option { + self.current_chunk.as_ref().and_then(|(chunk, idx)| { + let mut idx = *idx; + let mut diff = 0; + while idx > 0 && chunk.chars & (1u128.unbounded_shl(idx)) == 0 { + idx -= 1; + diff += 1; + } + + if chunk.chars & (1 << idx) != 0 { + Some( + (chunk.text[idx as usize..].chars().next()?) + .len_utf8() + .saturating_sub(diff), + ) + } else { + None + } + }) + } + + fn is_char_boundary(&self) -> bool { + self.current_chunk + .as_ref() + .is_some_and(|(chunk, idx)| (chunk.chars & 1u128.unbounded_shl(*idx)) != 0) + } + + /// distance: length to move forward while searching for the next tab stop + fn seek(&mut self, distance: u32) -> Option { + if distance == 0 { + return None; + } + + let mut distance_traversed = 0; + + while let Some((mut chunk, chunk_position)) = self + .current_chunk + .take() + .or_else(|| self.chunks.next().zip(Some(0))) + { + if chunk.tabs == 0 { + let chunk_distance = chunk.text.len() as u32 - chunk_position; + if chunk_distance + distance_traversed >= distance { + let overshoot = distance_traversed.abs_diff(distance); + + self.byte_offset += overshoot; + self.char_offset += get_char_offset( + chunk_position..(chunk_position + overshoot).saturating_sub(1), + chunk.chars, + ); + + if chunk_position + overshoot < 128 { + self.current_chunk = Some((chunk, chunk_position + overshoot)); + } + + return None; + } + + self.byte_offset += chunk_distance; + self.char_offset += get_char_offset( + chunk_position..(chunk_position + chunk_distance).saturating_sub(1), + chunk.chars, + ); + distance_traversed += chunk_distance; + continue; + } + let tab_position = chunk.tabs.trailing_zeros() + 1; + + if distance_traversed + tab_position - chunk_position > distance { + let cursor_position = distance_traversed.abs_diff(distance); + + self.char_offset += get_char_offset( + chunk_position..(chunk_position + cursor_position - 1), + chunk.chars, + ); + self.current_chunk = Some((chunk, cursor_position + chunk_position)); + self.byte_offset += cursor_position; + + return None; + } + + self.byte_offset += tab_position - chunk_position; + self.char_offset += get_char_offset(chunk_position..(tab_position - 1), chunk.chars); + + let tabstop = TabStop { + char_offset: self.char_offset, + byte_offset: self.byte_offset, + }; + + chunk.tabs = (chunk.tabs - 1) & chunk.tabs; + + if tab_position as usize != chunk.text.len() { + self.current_chunk = Some((chunk, tab_position)); + } + + return Some(tabstop); + } + + None + } + + fn byte_offset(&self) -> u32 { + self.byte_offset + } + + fn char_offset(&self) -> u32 { + self.char_offset + } +} + +#[inline(always)] +fn get_char_offset(range: Range, bit_map: u128) -> u32 { + if range.start == range.end { + return if (1u128 << range.start) & bit_map == 0 { + 0 + } else { + 1 + }; + } + let end_shift: u128 = 127u128 - range.end as u128; + let mut bit_mask = (u128::MAX >> range.start) << range.start; + bit_mask = (bit_mask << end_shift) >> end_shift; + let bit_map = bit_map & bit_mask; + + bit_map.count_ones() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TabStop { + char_offset: u32, + byte_offset: u32, } diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index caa4882a6ebbb00aaa1e498e49dfb530153a0e8e..7371eb678538dbc12abe43bde4073ffd9d2bdb21 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -30,7 +30,7 @@ pub struct WrapMap { #[derive(Clone)] pub struct WrapSnapshot { - tab_snapshot: TabSnapshot, + pub(super) tab_snapshot: TabSnapshot, transforms: SumTree, interpolated: bool, } @@ -55,7 +55,7 @@ pub struct WrapChunks<'a> { input_chunk: Chunk<'a>, output_position: WrapPoint, max_output_row: u32, - transforms: Cursor<'a, Transform, Dimensions>, + transforms: Cursor<'a, 'static, Transform, Dimensions>, snapshot: &'a WrapSnapshot, } @@ -66,7 +66,7 @@ pub struct WrapRows<'a> { output_row: u32, soft_wrapped: bool, max_output_row: u32, - transforms: Cursor<'a, Transform, Dimensions>, + transforms: Cursor<'a, 'static, Transform, Dimensions>, } impl WrapRows<'_> { @@ -74,10 +74,10 @@ impl WrapRows<'_> { self.transforms .seek(&WrapPoint::new(start_row, 0), Bias::Left); let mut input_row = self.transforms.start().1.row(); - if self.transforms.item().map_or(false, |t| t.is_isomorphic()) { + if self.transforms.item().is_some_and(|t| t.is_isomorphic()) { input_row += start_row - self.transforms.start().0.row(); } - self.soft_wrapped = self.transforms.item().map_or(false, |t| !t.is_isomorphic()); + self.soft_wrapped = self.transforms.item().is_some_and(|t| !t.is_isomorphic()); self.input_buffer_rows.seek(input_row); self.input_buffer_row = self.input_buffer_rows.next().unwrap(); self.output_row = start_row; @@ -221,7 +221,7 @@ impl WrapMap { if !summary.lines.is_zero() { self.snapshot .transforms - .push(Transform::isomorphic(summary), &()); + .push(Transform::isomorphic(summary), ()); } let new_rows = self.snapshot.transforms.summary().output.lines.row + 1; self.snapshot.interpolated = false; @@ -249,48 +249,48 @@ impl WrapMap { return; } - if let Some(wrap_width) = self.wrap_width { - if self.background_task.is_none() { - let pending_edits = self.pending_edits.clone(); - let mut snapshot = self.snapshot.clone(); - let text_system = cx.text_system().clone(); - let (font, font_size) = self.font_with_size.clone(); - let update_task = cx.background_spawn(async move { - let mut edits = Patch::default(); - let mut line_wrapper = text_system.line_wrapper(font, font_size); - for (tab_snapshot, tab_edits) in pending_edits { - let wrap_edits = snapshot - .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper) - .await; - edits = edits.compose(&wrap_edits); - } - (snapshot, edits) - }); + if let Some(wrap_width) = self.wrap_width + && self.background_task.is_none() + { + let pending_edits = self.pending_edits.clone(); + let mut snapshot = self.snapshot.clone(); + let text_system = cx.text_system().clone(); + let (font, font_size) = self.font_with_size.clone(); + let update_task = cx.background_spawn(async move { + let mut edits = Patch::default(); + let mut line_wrapper = text_system.line_wrapper(font, font_size); + for (tab_snapshot, tab_edits) in pending_edits { + let wrap_edits = snapshot + .update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper) + .await; + edits = edits.compose(&wrap_edits); + } + (snapshot, edits) + }); - match cx - .background_executor() - .block_with_timeout(Duration::from_millis(1), update_task) - { - Ok((snapshot, output_edits)) => { - self.snapshot = snapshot; - self.edits_since_sync = self.edits_since_sync.compose(&output_edits); - } - Err(update_task) => { - self.background_task = Some(cx.spawn(async move |this, cx| { - let (snapshot, edits) = update_task.await; - this.update(cx, |this, cx| { - this.snapshot = snapshot; - this.edits_since_sync = this - .edits_since_sync - .compose(mem::take(&mut this.interpolated_edits).invert()) - .compose(&edits); - this.background_task = None; - this.flush_edits(cx); - cx.notify(); - }) - .ok(); - })); - } + match cx + .background_executor() + .block_with_timeout(Duration::from_millis(1), update_task) + { + Ok((snapshot, output_edits)) => { + self.snapshot = snapshot; + self.edits_since_sync = self.edits_since_sync.compose(&output_edits); + } + Err(update_task) => { + self.background_task = Some(cx.spawn(async move |this, cx| { + let (snapshot, edits) = update_task.await; + this.update(cx, |this, cx| { + this.snapshot = snapshot; + this.edits_since_sync = this + .edits_since_sync + .compose(mem::take(&mut this.interpolated_edits).invert()) + .compose(&edits); + this.background_task = None; + this.flush_edits(cx); + cx.notify(); + }) + .ok(); + })); } } } @@ -318,7 +318,7 @@ impl WrapSnapshot { let mut transforms = SumTree::default(); let extent = tab_snapshot.text_summary(); if !extent.lines.is_zero() { - transforms.push(Transform::isomorphic(extent), &()); + transforms.push(Transform::isomorphic(extent), ()); } Self { transforms, @@ -336,7 +336,7 @@ impl WrapSnapshot { if tab_edits.is_empty() { new_transforms = self.transforms.clone(); } else { - let mut old_cursor = self.transforms.cursor::(&()); + let mut old_cursor = self.transforms.cursor::(()); let mut tab_edits_iter = tab_edits.iter().peekable(); new_transforms = @@ -368,7 +368,7 @@ impl WrapSnapshot { old_cursor.next(); new_transforms - .append(old_cursor.slice(&next_edit.old.start, Bias::Right), &()); + .append(old_cursor.slice(&next_edit.old.start, Bias::Right), ()); } } else { if old_cursor.end() > edit.old.end { @@ -378,7 +378,7 @@ impl WrapSnapshot { new_transforms.push_or_extend(Transform::isomorphic(summary)); } old_cursor.next(); - new_transforms.append(old_cursor.suffix(), &()); + new_transforms.append(old_cursor.suffix(), ()); } } } @@ -434,7 +434,7 @@ impl WrapSnapshot { new_transforms = self.transforms.clone(); } else { let mut row_edits = row_edits.into_iter().peekable(); - let mut old_cursor = self.transforms.cursor::(&()); + let mut old_cursor = self.transforms.cursor::(()); new_transforms = old_cursor.slice( &TabPoint::new(row_edits.peek().unwrap().old_rows.start, 0), @@ -511,7 +511,7 @@ impl WrapSnapshot { if let Some(transform) = edit_transforms.next() { new_transforms.push_or_extend(transform); } - new_transforms.extend(edit_transforms, &()); + new_transforms.extend(edit_transforms, ()); old_cursor.seek_forward(&TabPoint::new(edit.old_rows.end, 0), Bias::Right); if let Some(next_edit) = row_edits.peek() { @@ -526,7 +526,7 @@ impl WrapSnapshot { new_transforms.append( old_cursor .slice(&TabPoint::new(next_edit.old_rows.start, 0), Bias::Right), - &(), + (), ); } } else { @@ -537,7 +537,7 @@ impl WrapSnapshot { new_transforms.push_or_extend(Transform::isomorphic(summary)); } old_cursor.next(); - new_transforms.append(old_cursor.suffix(), &()); + new_transforms.append(old_cursor.suffix(), ()); } } } @@ -556,8 +556,8 @@ impl WrapSnapshot { fn compute_edits(&self, tab_edits: &[TabEdit], new_snapshot: &WrapSnapshot) -> Patch { let mut wrap_edits = Vec::with_capacity(tab_edits.len()); - let mut old_cursor = self.transforms.cursor::(&()); - let mut new_cursor = new_snapshot.transforms.cursor::(&()); + let mut old_cursor = self.transforms.cursor::(()); + let mut new_cursor = new_snapshot.transforms.cursor::(()); for mut tab_edit in tab_edits.iter().cloned() { tab_edit.old.start.0.column = 0; tab_edit.old.end.0 += Point::new(1, 0); @@ -568,7 +568,7 @@ impl WrapSnapshot { let mut old_start = old_cursor.start().output.lines; old_start += tab_edit.old.start.0 - old_cursor.start().input.lines; - old_cursor.seek(&tab_edit.old.end, Bias::Right); + old_cursor.seek_forward(&tab_edit.old.end, Bias::Right); let mut old_end = old_cursor.start().output.lines; old_end += tab_edit.old.end.0 - old_cursor.start().input.lines; @@ -576,7 +576,7 @@ impl WrapSnapshot { let mut new_start = new_cursor.start().output.lines; new_start += tab_edit.new.start.0 - new_cursor.start().input.lines; - new_cursor.seek(&tab_edit.new.end, Bias::Right); + new_cursor.seek_forward(&tab_edit.new.end, Bias::Right); let mut new_end = new_cursor.start().output.lines; new_end += tab_edit.new.end.0 - new_cursor.start().input.lines; @@ -600,10 +600,10 @@ impl WrapSnapshot { let output_end = WrapPoint::new(rows.end, 0); let mut transforms = self .transforms - .cursor::>(&()); + .cursor::>(()); transforms.seek(&output_start, Bias::Right); let mut input_start = TabPoint(transforms.start().1.0); - if transforms.item().map_or(false, |t| t.is_isomorphic()) { + if transforms.item().is_some_and(|t| t.is_isomorphic()) { input_start.0 += output_start.0 - transforms.start().0.0; } let input_end = self @@ -628,24 +628,22 @@ impl WrapSnapshot { } pub fn line_len(&self, row: u32) -> u32 { - let mut cursor = self - .transforms - .cursor::>(&()); - cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Left); - if cursor - .item() - .map_or(false, |transform| transform.is_isomorphic()) - { - let overshoot = row - cursor.start().0.row(); - let tab_row = cursor.start().1.row() + overshoot; + let (start, _, item) = self.transforms.find::, _>( + (), + &WrapPoint::new(row + 1, 0), + Bias::Left, + ); + if item.is_some_and(|transform| transform.is_isomorphic()) { + let overshoot = row - start.0.row(); + let tab_row = start.1.row() + overshoot; let tab_line_len = self.tab_snapshot.line_len(tab_row); if overshoot == 0 { - cursor.start().0.column() + (tab_line_len - cursor.start().1.column()) + start.0.column() + (tab_line_len - start.1.column()) } else { tab_line_len } } else { - cursor.start().0.column() + start.0.column() } } @@ -657,7 +655,7 @@ impl WrapSnapshot { let mut cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&start, Bias::Right); if let Some(transform) = cursor.item() { let start_in_transform = start.0 - cursor.start().0.0; @@ -711,9 +709,10 @@ impl WrapSnapshot { } pub fn soft_wrap_indent(&self, row: u32) -> Option { - let mut cursor = self.transforms.cursor::(&()); - cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Right); - cursor.item().and_then(|transform| { + let (.., item) = + self.transforms + .find::((), &WrapPoint::new(row + 1, 0), Bias::Right); + item.and_then(|transform| { if transform.is_isomorphic() { None } else { @@ -729,13 +728,13 @@ impl WrapSnapshot { pub fn row_infos(&self, start_row: u32) -> WrapRows<'_> { let mut transforms = self .transforms - .cursor::>(&()); + .cursor::>(()); transforms.seek(&WrapPoint::new(start_row, 0), Bias::Left); let mut input_row = transforms.start().1.row(); - if transforms.item().map_or(false, |t| t.is_isomorphic()) { + if transforms.item().is_some_and(|t| t.is_isomorphic()) { input_row += start_row - transforms.start().0.row(); } - let soft_wrapped = transforms.item().map_or(false, |t| !t.is_isomorphic()); + let soft_wrapped = transforms.item().is_some_and(|t| !t.is_isomorphic()); let mut input_buffer_rows = self.tab_snapshot.rows(input_row); let input_buffer_row = input_buffer_rows.next().unwrap(); WrapRows { @@ -749,13 +748,12 @@ impl WrapSnapshot { } pub fn to_tab_point(&self, point: WrapPoint) -> TabPoint { - let mut cursor = self - .transforms - .cursor::>(&()); - cursor.seek(&point, Bias::Right); - let mut tab_point = cursor.start().1.0; - if cursor.item().map_or(false, |t| t.is_isomorphic()) { - tab_point += point.0 - cursor.start().0.0; + let (start, _, item) = + self.transforms + .find::, _>((), &point, Bias::Right); + let mut tab_point = start.1.0; + if item.is_some_and(|t| t.is_isomorphic()) { + tab_point += point.0 - start.0.0; } TabPoint(tab_point) } @@ -769,19 +767,19 @@ impl WrapSnapshot { } pub fn tab_point_to_wrap_point(&self, point: TabPoint) -> WrapPoint { - let mut cursor = self - .transforms - .cursor::>(&()); - cursor.seek(&point, Bias::Right); - WrapPoint(cursor.start().1.0 + (point.0 - cursor.start().0.0)) + let (start, ..) = + self.transforms + .find::, _>((), &point, Bias::Right); + WrapPoint(start.1.0 + (point.0 - start.0.0)) } pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint { if bias == Bias::Left { - let mut cursor = self.transforms.cursor::(&()); - cursor.seek(&point, Bias::Right); - if cursor.item().map_or(false, |t| !t.is_isomorphic()) { - point = *cursor.start(); + let (start, _, item) = self + .transforms + .find::((), &point, Bias::Right); + if item.is_some_and(|t| !t.is_isomorphic()) { + point = start; *point.column_mut() -= 1; } } @@ -798,7 +796,7 @@ impl WrapSnapshot { let mut cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&point, Bias::Right); if cursor.item().is_none() { cursor.prev(); @@ -820,7 +818,7 @@ impl WrapSnapshot { let mut cursor = self .transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&point, Bias::Right); while let Some(transform) = cursor.item() { if transform.is_isomorphic() && cursor.start().1.column() == 0 { @@ -857,7 +855,7 @@ impl WrapSnapshot { ); { - let mut transforms = self.transforms.cursor::<()>(&()).peekable(); + let mut transforms = self.transforms.cursor::<()>(()).peekable(); while let Some(transform) = transforms.next() { if let Some(next_transform) = transforms.peek() { assert!(transform.is_isomorphic() != next_transform.is_isomorphic()); @@ -901,7 +899,7 @@ impl WrapChunks<'_> { let output_end = WrapPoint::new(rows.end, 0); self.transforms.seek(&output_start, Bias::Right); let mut input_start = TabPoint(self.transforms.start().1.0); - if self.transforms.item().map_or(false, |t| t.is_isomorphic()) { + if self.transforms.item().is_some_and(|t| t.is_isomorphic()) { input_start.0 += output_start.0 - self.transforms.start().0.0; } let input_end = self @@ -970,9 +968,18 @@ impl<'a> Iterator for WrapChunks<'a> { } let (prefix, suffix) = self.input_chunk.text.split_at(input_len); + + let mask = 1u128.unbounded_shl(input_len as u32).wrapping_sub(1); + let chars = self.input_chunk.chars & mask; + let tabs = self.input_chunk.tabs & mask; + self.input_chunk.tabs = self.input_chunk.tabs.unbounded_shr(input_len as u32); + self.input_chunk.chars = self.input_chunk.chars.unbounded_shr(input_len as u32); + self.input_chunk.text = suffix; Some(Chunk { text: prefix, + chars, + tabs, ..self.input_chunk.clone() }) } @@ -993,7 +1000,7 @@ impl Iterator for WrapRows<'_> { self.output_row += 1; self.transforms .seek_forward(&WrapPoint::new(self.output_row, 0), Bias::Left); - if self.transforms.item().map_or(false, |t| t.is_isomorphic()) { + if self.transforms.item().is_some_and(|t| t.is_isomorphic()) { self.input_buffer_row = self.input_buffer_rows.next().unwrap(); self.soft_wrapped = false; } else { @@ -1059,18 +1066,18 @@ impl Transform { impl sum_tree::Item for Transform { type Summary = TransformSummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { self.summary.clone() } } fn push_isomorphic(transforms: &mut Vec, summary: TextSummary) { - if let Some(last_transform) = transforms.last_mut() { - if last_transform.is_isomorphic() { - last_transform.summary.input += &summary; - last_transform.summary.output += &summary; - return; - } + if let Some(last_transform) = transforms.last_mut() + && last_transform.is_isomorphic() + { + last_transform.summary.input += &summary; + last_transform.summary.output += &summary; + return; } transforms.push(Transform::isomorphic(summary)); } @@ -1090,11 +1097,11 @@ impl SumTreeExt for SumTree { last_transform.summary.output += &transform.summary.output; } }, - &(), + (), ); if let Some(transform) = transform { - self.push(transform, &()); + self.push(transform, ()); } } } @@ -1121,41 +1128,39 @@ impl WrapPoint { } } -impl sum_tree::Summary for TransformSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for TransformSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, _: &()) { + fn add_summary(&mut self, other: &Self) { self.input += &other.input; self.output += &other.output; } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for TabPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += summary.input.lines; } } impl sum_tree::SeekTarget<'_, TransformSummary, TransformSummary> for TabPoint { - fn cmp(&self, cursor_location: &TransformSummary, _: &()) -> std::cmp::Ordering { + fn cmp(&self, cursor_location: &TransformSummary, _: ()) -> std::cmp::Ordering { Ord::cmp(&self.0, &cursor_location.input.lines) } } impl<'a> sum_tree::Dimension<'a, TransformSummary> for WrapPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a TransformSummary, _: ()) { self.0 += summary.output.lines; } } @@ -1215,12 +1220,12 @@ mod tests { .unwrap_or(10); let text_system = cx.read(|cx| cx.text_system().clone()); - let mut wrap_width = if rng.gen_bool(0.1) { + let mut wrap_width = if rng.random_bool(0.1) { None } else { - Some(px(rng.gen_range(0.0..=1000.0))) + Some(px(rng.random_range(0.0..=1000.0))) }; - let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); + let tab_size = NonZeroU32::new(rng.random_range(1..=4)).unwrap(); let font = test_font(); let _font_id = text_system.resolve_font(&font); @@ -1230,10 +1235,10 @@ mod tests { log::info!("Wrap width: {:?}", wrap_width); let buffer = cx.update(|cx| { - if rng.r#gen() { + if rng.random() { MultiBuffer::build_random(&mut rng, cx) } else { - let len = rng.gen_range(0..10); + let len = rng.random_range(0..10); let text = util::RandomCharIter::new(&mut rng) .take(len) .collect::(); @@ -1281,12 +1286,12 @@ mod tests { log::info!("{} ==============================================", _i); let mut buffer_edits = Vec::new(); - match rng.gen_range(0..=100) { + match rng.random_range(0..=100) { 0..=19 => { - wrap_width = if rng.gen_bool(0.2) { + wrap_width = if rng.random_bool(0.2) { None } else { - Some(px(rng.gen_range(0.0..=1000.0))) + Some(px(rng.random_range(0.0..=1000.0))) }; log::info!("Setting wrap width to {:?}", wrap_width); wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); @@ -1317,7 +1322,7 @@ mod tests { _ => { buffer.update(cx, |buffer, cx| { let subscription = buffer.subscribe(); - let edit_count = rng.gen_range(1..=5); + let edit_count = rng.random_range(1..=5); buffer.randomly_mutate(&mut rng, edit_count, cx); buffer_snapshot = buffer.snapshot(cx); buffer_edits.extend(subscription.consume()); @@ -1341,7 +1346,7 @@ mod tests { snapshot.verify_chunks(&mut rng); edits.push((snapshot, wrap_edits)); - if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) && rng.gen_bool(0.4) { + if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) && rng.random_bool(0.4) { log::info!("Waiting for wrapping to finish"); while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { notifications.next().await.unwrap(); @@ -1369,7 +1374,7 @@ mod tests { let mut summary = TextSummary::default(); for (ix, item) in wrapped_snapshot .transforms - .items(&()) + .items(()) .into_iter() .enumerate() { @@ -1461,7 +1466,7 @@ mod tests { } let mut prev_ix = 0; - for boundary in line_wrapper.wrap_line(&[LineFragment::text(&line)], wrap_width) { + for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) { wrapped_text.push_str(&line[prev_ix..boundary.ix]); wrapped_text.push('\n'); wrapped_text.push_str(&" ".repeat(boundary.next_indent as usize)); @@ -1479,8 +1484,8 @@ mod tests { impl WrapSnapshot { fn verify_chunks(&mut self, rng: &mut impl Rng) { for _ in 0..5 { - let mut end_row = rng.gen_range(0..=self.max_point().row()); - let start_row = rng.gen_range(0..=end_row); + let mut end_row = rng.random_range(0..=self.max_point().row()); + let start_row = rng.random_range(0..=end_row); end_row += 1; let mut expected_text = self.text_chunks(start_row).collect::(); diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 7bf51e45d72f383b4af34cf6ad493792f8e9d351..7d64dd9749c68cb0e436c1cfcb04e3458d052872 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -2,7 +2,6 @@ use edit_prediction::EditPredictionProvider; use gpui::{Entity, prelude::*}; use indoc::indoc; use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; -use project::Project; use std::ops::Range; use text::{Point, ToOffset}; @@ -261,7 +260,7 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui: EditPrediction::Edit { .. } => { // This is expected for non-Zed providers } - EditPrediction::Move { .. } => { + EditPrediction::MoveWithin { .. } | EditPrediction::MoveOutside { .. } => { panic!( "Non-Zed providers should not show Move predictions (jump functionality)" ); @@ -299,7 +298,7 @@ fn assert_editor_active_move_completion( .as_ref() .expect("editor has no active completion"); - if let EditPrediction::Move { target, .. } = &completion_state.completion { + if let EditPrediction::MoveWithin { target, .. } = &completion_state.completion { assert(editor.buffer().read(cx).snapshot(cx), *target); } else { panic!("expected move completion"); @@ -326,7 +325,7 @@ fn propose_edits( cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction { + provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { id: None, edits: edits.collect(), edit_preview: None, @@ -357,7 +356,7 @@ fn propose_edits_non_zed( cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction { + provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { id: None, edits: edits.collect(), edit_preview: None, @@ -418,7 +417,6 @@ impl EditPredictionProvider for FakeEditPredictionProvider { fn refresh( &mut self, - _project: Option>, _buffer: gpui::Entity, _cursor_position: language::Anchor, _debounce: bool, @@ -492,7 +490,6 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider { fn refresh( &mut self, - _project: Option>, _buffer: gpui::Entity, _cursor_position: language::Anchor, _debounce: bool, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0111e913471649beabd972db3033816aa41fd858..3839da917078ae2340ead97f9cf4fa624b5c588a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7,7 +7,6 @@ //! * [`element`] — the place where all rendering happens //! * [`display_map`] - chunks up text in the editor into the logical blocks, establishes coordinates and mapping between each of them. //! Contains all metadata related to text transformations (folds, fake inlay text insertions, soft wraps, tab markup, etc.). -//! * [`inlay_hint_cache`] - is a storage of inlay hints out of LSP requests, responsible for querying LSP and updating `display_map`'s state accordingly. //! //! All other submodules and structs are mostly concerned with holding editor data about the way it displays current buffer region(s). //! @@ -18,14 +17,13 @@ mod clangd_ext; pub mod code_context_menus; pub mod display_map; mod editor_settings; -mod editor_settings_controls; mod element; mod git; mod highlight_matching_bracket; mod hover_links; pub mod hover_popover; mod indent_guides; -mod inlay_hint_cache; +mod inlays; pub mod items; mod jsx_tag_auto_close; mod linked_editing_ranges; @@ -34,7 +32,6 @@ mod lsp_ext; mod mouse_context_menu; pub mod movement; mod persistence; -mod proposed_changes_editor; mod rust_analyzer_ext; pub mod scroll; mod selections_collection; @@ -55,14 +52,14 @@ pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPla pub use edit_prediction::Direction; pub use editor_settings::{ CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode, - ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, ShowScrollbar, + ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, }; -pub use editor_settings_controls::*; pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, }; pub use git::blame::BlameRenderer; pub use hover_popover::hover_markdown_style; +pub use inlays::Inlay; pub use items::MAX_TAB_TITLE_LEN; pub use lsp::CompletionContext; pub use lsp_ext::lsp_tasks; @@ -70,21 +67,19 @@ pub use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, PathKey, RowInfo, ToOffset, ToPoint, }; -pub use proposed_changes_editor::{ - ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, -}; pub use text::Bias; use ::git::{ Restore, blame::{BlameEntry, ParsedCommitMessage}, + status::FileStatus, }; use aho_corasick::AhoCorasick; use anyhow::{Context as _, Result, anyhow}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; -use client::{Collaborator, ParticipantIndex}; -use clock::{AGENT_REPLICA_ID, ReplicaId}; +use client::{Collaborator, ParticipantIndex, parse_zed_link}; +use clock::ReplicaId; use code_context_menus::{ AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, CompletionsMenu, ContextMenuOrigin, @@ -113,21 +108,20 @@ use gpui::{ UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, }; -use highlight_matching_bracket::refresh_matching_bracket_highlights; -use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file}; +use hover_links::{HoverLink, HoveredLinkState, find_file}; use hover_popover::{HoverState, hide_hover}; use indent_guides::ActiveIndentGuidesState; -use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; +use inlays::{InlaySplice, inlay_hints::InlayHintRefreshReason}; use itertools::{Either, Itertools}; use language::{ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow, - BufferSnapshot, Capability, CharClassifier, CharKind, CodeLabel, CursorShape, DiagnosticEntry, - DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, - Language, OffsetRangeExt, Point, Runnable, RunnableRange, Selection, SelectionGoal, TextObject, - TransactionId, TreeSitterOptions, WordsQuery, + BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, + DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, + IndentSize, Language, OffsetRangeExt, Point, Runnable, RunnableRange, Selection, SelectionGoal, + TextObject, TransactionId, TreeSitterOptions, WordsQuery, language_settings::{ - self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, - all_language_settings, language_settings, + self, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, + language_settings, }, point_from_lsp, point_to_lsp, text_diff_with_options, }; @@ -142,70 +136,66 @@ use mouse_context_menu::MouseContextMenu; use movement::TextLayoutDetails; use multi_buffer::{ ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, - MultiOrSingleBufferOffsetRange, ToOffsetUtf16, }; use parking_lot::Mutex; use persistence::DB; use project::{ - BreakpointWithPosition, CodeAction, Completion, CompletionIntent, CompletionResponse, - CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, Location, LocationLink, - PrepareRenameResponse, Project, ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind, - debugger::breakpoint_store::Breakpoint, + BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent, + CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, + InvalidationStrategy, Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, + ProjectPath, ProjectTransaction, TaskSourceKind, debugger::{ breakpoint_store::{ - BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore, - BreakpointStoreEvent, + Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, + BreakpointStore, BreakpointStoreEvent, }, session::{Session, SessionEvent}, }, - git_store::{GitStoreEvent, RepositoryEvent}, - lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle}, - project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter}, - project_settings::{GitGutterSetting, ProjectSettings}, + git_store::GitStoreEvent, + lsp_store::{ + CacheInlayHints, CompletionDocumentation, FormatTrigger, LspFormatTarget, + OpenLspBufferHandle, + }, + project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings}, }; -use rand::{seq::SliceRandom, thread_rng}; +use rand::seq::SliceRandom; use rpc::{ErrorCode, ErrorExt, proto::PeerId}; -use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide}; -use selections_collection::{ - MutableSelectionsCollection, SelectionsCollection, resolve_selections, -}; +use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager}; +use selections_collection::{MutableSelectionsCollection, SelectionsCollection}; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsLocation, SettingsStore, update_settings_file}; +use settings::{GitGutterSetting, Settings, SettingsLocation, SettingsStore, update_settings_file}; use smallvec::{SmallVec, smallvec}; use snippet::Snippet; use std::{ - any::TypeId, + any::{Any, TypeId}, borrow::Cow, - cell::OnceCell, - cell::RefCell, + cell::{OnceCell, RefCell}, cmp::{self, Ordering, Reverse}, - iter::Peekable, + iter::{self, Peekable}, mem, num::NonZeroU32, - ops::Not, - ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive}, + ops::{Deref, DerefMut, Not, Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, time::{Duration, Instant}, }; -use sum_tree::TreeMap; use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; -use text::{BufferId, FromAnchor, OffsetUtf16, Rope}; +use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _}; use theme::{ ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings, observe_buffer_font_size_adjustment, }; use ui::{ ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, - IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, + IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, }; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; use workspace::{ CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings, - item::{ItemHandle, PreviewTabsSettings, SaveOptions}, + item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions}, notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, searchable::SearchEvent, }; @@ -214,12 +204,17 @@ use crate::{ code_context_menus::CompletionsMenuSource, editor_settings::MultiCursorModifier, hover_links::{find_url, find_url_from_range}, + inlays::{ + InlineValueCache, + inlay_hints::{LspInlayHintData, inlay_hint_settings}, + }, + scroll::{ScrollOffset, ScrollPixelOffset}, + selections_collection::resolve_selections_wrapping_blocks, signature_help::{SignatureHelpHiddenBy, SignatureHelpState}, }; pub const FILE_HEADER_HEIGHT: u32 = 2; pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1; -pub const DEFAULT_MULTIBUFFER_CONTEXT: u32 = 2; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; @@ -227,11 +222,12 @@ const MAX_SELECTION_HISTORY_LEN: usize = 1024; pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000); #[doc(hidden)] pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); -const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); +pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); +pub const FETCH_COLORS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict"; @@ -253,7 +249,6 @@ pub type RenderDiffHunkControlsFn = Arc< enum ReportEditorEvent { Saved { auto_saved: bool }, EditorOpened, - ZetaTosClicked, Closed, } @@ -262,48 +257,11 @@ impl ReportEditorEvent { match self { Self::Saved { .. } => "Editor Saved", Self::EditorOpened => "Editor Opened", - Self::ZetaTosClicked => "Edit Prediction Provider ToS Clicked", Self::Closed => "Editor Closed", } } } -struct InlineValueCache { - enabled: bool, - inlays: Vec, - refresh_task: Task>, -} - -impl InlineValueCache { - fn new(enabled: bool) -> Self { - Self { - enabled, - inlays: Vec::new(), - refresh_task: Task::ready(None), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InlayId { - EditPrediction(usize), - DebuggerValue(usize), - // LSP - Hint(usize), - Color(usize), -} - -impl InlayId { - fn id(&self) -> usize { - match self { - Self::EditPrediction(id) => *id, - Self::DebuggerValue(id) => *id, - Self::Hint(id) => *id, - Self::Color(id) => *id, - } - } -} - pub enum ActiveDebugLine {} pub enum DebugStackFrameLine {} enum DocumentHighlightRead {} @@ -365,6 +323,7 @@ pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { workspace.register_action(Editor::new_file); + workspace.register_action(Editor::new_file_split); workspace.register_action(Editor::new_file_vertical); workspace.register_action(Editor::new_file_horizontal); workspace.register_action(Editor::cancel_language_server_work); @@ -411,7 +370,7 @@ pub fn set_blame_renderer(renderer: impl BlameRenderer + 'static, cx: &mut App) pub trait DiagnosticRenderer { fn render_group( &self, - diagnostic_group: Vec>, + diagnostic_group: Vec>, buffer_id: BufferId, snapshot: EditorSnapshot, editor: WeakEntity, @@ -420,7 +379,7 @@ pub trait DiagnosticRenderer { fn render_hover( &self, - diagnostic_group: Vec>, + diagnostic_group: Vec>, range: Range, buffer_id: BufferId, cx: &mut App, @@ -599,11 +558,22 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { .inlay_hints .show_background; - HighlightStyle { - color: Some(cx.theme().status().hint), - background_color: show_background.then(|| cx.theme().status().hint_background), - ..HighlightStyle::default() + let mut style = cx.theme().syntax().get("hint"); + + if style.color.is_none() { + style.color = Some(cx.theme().status().hint); + } + + if !show_background { + style.background_color = None; + return style; + } + + if style.background_color.is_none() { + style.background_color = Some(cx.theme().status().hint_background); } + + style } pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles { @@ -634,17 +604,23 @@ enum EditPrediction { display_mode: EditDisplayMode, snapshot: BufferSnapshot, }, - Move { + /// Move to a specific location in the active editor + MoveWithin { target: Anchor, snapshot: BufferSnapshot, }, + /// Move to a specific location in a different editor (not the active one) + MoveOutside { + target: language::Anchor, + snapshot: BufferSnapshot, + }, } struct EditPredictionState { inlay_ids: Vec, completion: EditPrediction, completion_id: Option, - invalidation_range: Range, + invalidation_range: Option>, } enum EditPredictionSettings { @@ -782,10 +758,7 @@ impl MinimapVisibility { } fn disabled(&self) -> bool { - match *self { - Self::Disabled => true, - _ => false, - } + matches!(*self, Self::Disabled) } fn settings_visibility(&self) -> bool { @@ -856,7 +829,7 @@ pub struct ResolvedTasks { #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] struct BufferOffset(usize); -// Addons allow storing per-editor state in other crates (e.g. Vim) +/// Addons allow storing per-editor state in other crates (e.g. Vim) pub trait Addon: 'static { fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} @@ -869,6 +842,10 @@ pub trait Addon: 'static { None } + fn override_status_for_buffer_id(&self, _: BufferId, _: &App) -> Option { + None + } + fn to_any(&self) -> &dyn std::any::Any; fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { @@ -942,10 +919,10 @@ impl ChangeList { } pub fn invert_last_group(&mut self) { - if let Some(last) = self.changes.last_mut() { - if let Some(current) = last.current.as_mut() { - mem::swap(&mut last.original, current); - } + if let Some(last) = self.changes.last_mut() + && let Some(current) = last.current.as_mut() + { + mem::swap(&mut last.original, current); } } } @@ -1014,6 +991,7 @@ pub struct Editor { /// Map of how text in the buffer should be displayed. /// Handles soft wraps, folds, fake inlay text insertions, etc. pub display_map: Entity, + placeholder_display_map: Option>, pub selections: SelectionsCollection, pub scroll_manager: ScrollManager, /// When inline assist editors are linked, they all render cursors because @@ -1036,6 +1014,7 @@ pub struct Editor { inline_diagnostics_update: Task<()>, inline_diagnostics_enabled: bool, diagnostics_enabled: bool, + word_completions_enabled: bool, inline_diagnostics: Vec<(Anchor, InlineDiagnostic)>, soft_wrap_mode_override: Option, hard_wrap: Option, @@ -1062,11 +1041,10 @@ pub struct Editor { show_breakpoints: Option, show_wrap_guides: Option, show_indent_guides: Option, - placeholder_text: Option>, highlight_order: usize, highlighted_rows: HashMap>, - background_highlights: TreeMap, - gutter_highlights: TreeMap, + background_highlights: HashMap, + gutter_highlights: HashMap, scrollbar_marker_state: ScrollbarMarkerState, active_indent_guides_state: ActiveIndentGuidesState, nav_history: Option, @@ -1115,8 +1093,8 @@ pub struct Editor { edit_prediction_preview: EditPredictionPreview, edit_prediction_indent_conflict: bool, edit_prediction_requires_modifier_in_indent_conflict: bool, - inlay_hint_cache: InlayHintCache, next_inlay_id: usize, + next_color_inlay_id: usize, _subscriptions: Vec, pixel_position_of_newest_cursor: Option>, gutter_dimensions: GutterDimensions, @@ -1180,9 +1158,20 @@ pub struct Editor { pub change_list: ChangeList, inline_value_cache: InlineValueCache, selection_drag_state: SelectionDragState, - next_color_inlay_id: usize, colors: Option, + post_scroll_update: Task<()>, + refresh_colors_task: Task<()>, + inlay_hints: Option, folding_newlines: Task<()>, + pub lookup_key: Option>, +} + +fn debounce_value(debounce_ms: u64) -> Option { + if debounce_ms > 0 { + Some(Duration::from_millis(debounce_ms)) + } else { + None + } } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] @@ -1214,7 +1203,7 @@ pub struct EditorSnapshot { show_breakpoints: Option, git_blame_gutter_max_author_length: Option, pub display_snapshot: DisplaySnapshot, - pub placeholder_text: Option>, + pub placeholder_display_snapshot: Option, is_focused: bool, scroll_anchor: ScrollAnchor, ongoing_scroll: OngoingScroll, @@ -1290,7 +1279,7 @@ enum SelectionHistoryMode { #[derive(Clone, PartialEq, Eq, Hash)] struct HoveredCursor { - replica_id: u16, + replica_id: ReplicaId, selection_id: usize, } @@ -1429,7 +1418,7 @@ impl SelectionHistory { if self .undo_stack .back() - .map_or(true, |e| e.selections != entry.selections) + .is_none_or(|e| e.selections != entry.selections) { self.undo_stack.push_back(entry); if self.undo_stack.len() > MAX_SELECTION_HISTORY_LEN { @@ -1442,7 +1431,7 @@ impl SelectionHistory { if self .redo_stack .back() - .map_or(true, |e| e.selections != entry.selections) + .is_none_or(|e| e.selections != entry.selections) { self.redo_stack.push_back(entry); if self.redo_stack.len() > MAX_SELECTION_HISTORY_LEN { @@ -1609,31 +1598,6 @@ pub enum GotoDefinitionKind { Implementation, } -#[derive(Debug, Clone)] -enum InlayHintRefreshReason { - ModifiersChanged(bool), - Toggle(bool), - SettingsChange(InlayHintSettings), - NewLinesShown, - BufferEdited(HashSet>), - RefreshRequested, - ExcerptsRemoved(Vec), -} - -impl InlayHintRefreshReason { - fn description(&self) -> &'static str { - match self { - Self::ModifiersChanged(_) => "modifiers changed", - Self::Toggle(_) => "toggle", - Self::SettingsChange(_) => "settings change", - Self::NewLinesShown => "new lines shown", - Self::BufferEdited(_) => "buffer edited", - Self::RefreshRequested => "refresh requested", - Self::ExcerptsRemoved(_) => "excerpts removed", - } - } -} - pub enum FormatTarget { Buffers(HashSet>), Ranges(Vec>), @@ -1776,7 +1740,7 @@ impl Editor { fn new_internal( mode: EditorMode, - buffer: Entity, + multi_buffer: Entity, project: Option>, display_map: Option>, window: &mut Window, @@ -1800,7 +1764,7 @@ impl Editor { let font_size = style.font_size.to_pixels(window.rem_size()); let editor = cx.entity().downgrade(); let fold_placeholder = FoldPlaceholder { - constrain_width: true, + constrain_width: false, render: Arc::new(move |fold_id, fold_range, cx| { let editor = editor.clone(); div() @@ -1834,7 +1798,7 @@ impl Editor { let display_map = display_map.unwrap_or_else(|| { cx.new(|cx| { DisplayMap::new( - buffer.clone(), + multi_buffer.clone(), style.font(), font_size, None, @@ -1847,7 +1811,7 @@ impl Editor { }) }); - let selections = SelectionsCollection::new(display_map.clone(), buffer.clone()); + let selections = SelectionsCollection::new(display_map.clone(), multi_buffer.clone()); let blink_manager = cx.new(|cx| { let mut blink_manager = BlinkManager::new(CURSOR_BLINK_INTERVAL, cx); @@ -1857,121 +1821,177 @@ impl Editor { blink_manager }); - let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. }) - .then(|| language_settings::SoftWrap::None); + let soft_wrap_mode_override = + matches!(mode, EditorMode::SingleLine).then(|| language_settings::SoftWrap::None); let mut project_subscriptions = Vec::new(); - if full_mode { - if let Some(project) = project.as_ref() { - project_subscriptions.push(cx.subscribe_in( - project, - window, - |editor, _, event, window, cx| match event { - project::Event::RefreshCodeLens => { - // we always query lens with actions, without storing them, always refreshing them - } - project::Event::RefreshInlayHints => { - editor - .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx); + if full_mode && let Some(project) = project.as_ref() { + project_subscriptions.push(cx.subscribe_in( + project, + window, + |editor, _, event, window, cx| match event { + project::Event::RefreshCodeLens => { + // we always query lens with actions, without storing them, always refreshing them + } + project::Event::RefreshInlayHints(server_id) => { + editor.refresh_inlay_hints( + InlayHintRefreshReason::RefreshRequested(*server_id), + cx, + ); + } + project::Event::LanguageServerRemoved(..) => { + if editor.tasks_update_task.is_none() { + editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); } - project::Event::LanguageServerAdded(..) - | project::Event::LanguageServerRemoved(..) => { - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = - Some(editor.refresh_runnables(window, cx)); - } + editor.registered_buffers.clear(); + editor.register_visible_buffers(cx); + } + project::Event::LanguageServerAdded(..) => { + if editor.tasks_update_task.is_none() { + editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); } - project::Event::SnippetEdit(id, snippet_edits) => { - if let Some(buffer) = editor.buffer.read(cx).buffer(*id) { - let focus_handle = editor.focus_handle(cx); - if focus_handle.is_focused(window) { - let snapshot = buffer.read(cx).snapshot(); - for (range, snippet) in snippet_edits { - let editor_range = - language::range_from_lsp(*range).to_offset(&snapshot); - editor - .insert_snippet( - &[editor_range], - snippet.clone(), - window, - cx, - ) - .ok(); - } + } + project::Event::SnippetEdit(id, snippet_edits) => { + if let Some(buffer) = editor.buffer.read(cx).buffer(*id) { + let focus_handle = editor.focus_handle(cx); + if focus_handle.is_focused(window) { + let snapshot = buffer.read(cx).snapshot(); + for (range, snippet) in snippet_edits { + let editor_range = + language::range_from_lsp(*range).to_offset(&snapshot); + editor + .insert_snippet( + &[editor_range], + snippet.clone(), + window, + cx, + ) + .ok(); } } } - project::Event::LanguageServerBufferRegistered { buffer_id, .. } => { - if editor.buffer().read(cx).buffer(*buffer_id).is_some() { - editor.update_lsp_data(false, Some(*buffer_id), window, cx); - } + } + project::Event::LanguageServerBufferRegistered { buffer_id, .. } => { + let buffer_id = *buffer_id; + if editor.buffer().read(cx).buffer(buffer_id).is_some() { + editor.register_buffer(buffer_id, cx); + editor.update_lsp_data(Some(buffer_id), window, cx); + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + refresh_linked_ranges(editor, window, cx); + editor.refresh_code_actions(window, cx); + editor.refresh_document_highlights(cx); } - _ => {} - }, - )); - if let Some(task_inventory) = project - .read(cx) - .task_store() - .read(cx) - .task_inventory() - .cloned() - { - project_subscriptions.push(cx.observe_in( - &task_inventory, - window, - |editor, _, window, cx| { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - }, - )); - }; + } - project_subscriptions.push(cx.subscribe_in( - &project.read(cx).breakpoint_store(), - window, - |editor, _, event, window, cx| match event { - BreakpointStoreEvent::ClearDebugLines => { - editor.clear_row_highlights::(); - editor.refresh_inline_values(cx); - } - BreakpointStoreEvent::SetDebugLine => { - if editor.go_to_active_debug_line(window, cx) { - cx.stop_propagation(); - } + project::Event::EntryRenamed(transaction) => { + let Some(workspace) = editor.workspace() else { + return; + }; + let Some(active_editor) = workspace.read(cx).active_item_as::(cx) + else { + return; + }; + if active_editor.entity_id() == cx.entity_id() { + let edited_buffers_already_open = { + let other_editors: Vec> = workspace + .read(cx) + .panes() + .iter() + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor.entity_id() != cx.entity_id()) + .collect(); + + transaction.0.keys().all(|buffer| { + other_editors.iter().any(|editor| { + let multi_buffer = editor.read(cx).buffer(); + multi_buffer.read(cx).is_singleton() + && multi_buffer.read(cx).as_singleton().map_or( + false, + |singleton| { + singleton.entity_id() == buffer.entity_id() + }, + ) + }) + }) + }; - editor.refresh_inline_values(cx); + if !edited_buffers_already_open { + let workspace = workspace.downgrade(); + let transaction = transaction.clone(); + cx.defer_in(window, move |_, window, cx| { + cx.spawn_in(window, async move |editor, cx| { + Self::open_project_transaction( + &editor, + workspace, + transaction, + "Rename".to_string(), + cx, + ) + .await + .ok() + }) + .detach(); + }); + } } - _ => {} + } + + _ => {} + }, + )); + if let Some(task_inventory) = project + .read(cx) + .task_store() + .read(cx) + .task_inventory() + .cloned() + { + project_subscriptions.push(cx.observe_in( + &task_inventory, + window, + |editor, _, window, cx| { + editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); }, )); - let git_store = project.read(cx).git_store().clone(); - let project = project.clone(); - project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| { - match event { - GitStoreEvent::RepositoryUpdated( - _, - RepositoryEvent::Updated { - new_instance: true, .. - }, - _, - ) => { - this.load_diff_task = Some( - update_uncommitted_diff_for_buffer( - cx.entity(), - &project, - this.buffer.read(cx).all_buffers(), - this.buffer.clone(), - cx, - ) - .shared(), - ); + }; + + project_subscriptions.push(cx.subscribe_in( + &project.read(cx).breakpoint_store(), + window, + |editor, _, event, window, cx| match event { + BreakpointStoreEvent::ClearDebugLines => { + editor.clear_row_highlights::(); + editor.refresh_inline_values(cx); + } + BreakpointStoreEvent::SetDebugLine => { + if editor.go_to_active_debug_line(window, cx) { + cx.stop_propagation(); } - _ => {} + + editor.refresh_inline_values(cx); } - })); - } + _ => {} + }, + )); + let git_store = project.read(cx).git_store().clone(); + let project = project.clone(); + project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| { + if let GitStoreEvent::RepositoryAdded = event { + this.load_diff_task = Some( + update_uncommitted_diff_for_buffer( + cx.entity(), + &project, + this.buffer.read(cx).all_buffers(), + this.buffer.clone(), + cx, + ) + .shared(), + ); + } + })); } - let buffer_snapshot = buffer.read(cx).snapshot(cx); + let buffer_snapshot = multi_buffer.read(cx).snapshot(cx); let inlay_hint_settings = inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); @@ -1989,14 +2009,12 @@ impl Editor { .detach(); } - let show_indent_guides = if matches!( - mode, - EditorMode::SingleLine { .. } | EditorMode::Minimap { .. } - ) { - Some(false) - } else { - None - }; + let show_indent_guides = + if matches!(mode, EditorMode::SingleLine | EditorMode::Minimap { .. }) { + Some(false) + } else { + None + }; let breakpoint_store = match (&mode, project.as_ref()) { (EditorMode::Full { .. }, Some(project)) => Some(project.read(cx).breakpoint_store()), @@ -2010,8 +2028,8 @@ impl Editor { update_uncommitted_diff_for_buffer( cx.entity(), &project, - buffer.read(cx).all_buffers(), - buffer.clone(), + multi_buffer.read(cx).all_buffers(), + multi_buffer.clone(), cx, ) .shared(), @@ -2023,8 +2041,9 @@ impl Editor { focus_handle, show_cursor_when_unfocused: false, last_focused_descendant: None, - buffer: buffer.clone(), + buffer: multi_buffer.clone(), display_map: display_map.clone(), + placeholder_display_map: None, selections, scroll_manager: ScrollManager::new(cx), columnar_selection_state: None, @@ -2056,7 +2075,7 @@ impl Editor { vertical: full_mode, }, minimap_visibility: MinimapVisibility::for_mode(&mode, cx), - offset_content: !matches!(mode, EditorMode::SingleLine { .. }), + offset_content: !matches!(mode, EditorMode::SingleLine), show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, show_gutter: full_mode, show_line_numbers: (!full_mode).then_some(false), @@ -2068,11 +2087,10 @@ impl Editor { show_breakpoints: None, show_wrap_guides: None, show_indent_guides, - placeholder_text: None, highlight_order: 0, highlighted_rows: HashMap::default(), - background_highlights: TreeMap::default(), - gutter_highlights: TreeMap::default(), + background_highlights: HashMap::default(), + gutter_highlights: HashMap::default(), scrollbar_marker_state: ScrollbarMarkerState::default(), active_indent_guides_state: ActiveIndentGuidesState::default(), nav_history: None, @@ -2123,8 +2141,8 @@ impl Editor { }, inline_diagnostics_enabled: full_mode, diagnostics_enabled: full_mode, + word_completions_enabled: full_mode, inline_value_cache: InlineValueCache::new(inlay_hint_settings.show_value_hints), - inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), gutter_hovered: false, pixel_position_of_newest_cursor: None, last_bounds: None, @@ -2148,7 +2166,7 @@ impl Editor { show_selection_menu: None, show_git_blame_inline_delay_task: None, git_blame_inline_enabled: full_mode - && ProjectSettings::get_global(cx).git.inline_blame_enabled(), + && ProjectSettings::get_global(cx).git.inline_blame.enabled, render_diff_hunk_controls: Arc::new(render_diff_hunk_controls), serialize_dirty_buffers: !is_minimap && ProjectSettings::get_global(cx) @@ -2164,8 +2182,8 @@ impl Editor { _subscriptions: (!is_minimap) .then(|| { vec![ - cx.observe(&buffer, Self::on_buffer_changed), - cx.subscribe_in(&buffer, window, Self::on_buffer_event), + cx.observe(&multi_buffer, Self::on_buffer_changed), + cx.subscribe_in(&multi_buffer, window, Self::on_buffer_event), cx.observe_in(&display_map, window, Self::on_display_map_changed), cx.observe(&blink_manager, |_, _, cx| cx.notify()), cx.observe_global_in::(window, Self::settings_changed), @@ -2189,7 +2207,10 @@ impl Editor { tasks_update_task: None, pull_diagnostics_task: Task::ready(()), colors: None, + refresh_colors_task: Task::ready(()), + inlay_hints: None, next_color_inlay_id: 0, + post_scroll_update: Task::ready(()), linked_edit_ranges: Default::default(), in_project_search: false, previous_search_ranges: None, @@ -2215,6 +2236,7 @@ impl Editor { mode, selection_drag_state: SelectionDragState::None, folding_newlines: Task::ready(()), + lookup_key: None, }; if is_minimap { @@ -2241,7 +2263,7 @@ impl Editor { let snapshot = editor.snapshot(window, cx); editor.update_restoration_data(cx, move |data| { data.scroll_position = ( - new_anchor.top_row(&snapshot.buffer_snapshot), + new_anchor.top_row(snapshot.buffer_snapshot()), new_anchor.offset, ); }); @@ -2251,21 +2273,22 @@ impl Editor { } EditorEvent::Edited { .. } => { if !vim_enabled(cx) { - let (map, selections) = editor.selections.all_adjusted_display(cx); + let display_map = editor.display_snapshot(cx); + let selections = editor.selections.all_adjusted_display(&display_map); let pop_state = editor .change_list .last() .map(|previous| { previous.len() == selections.len() && previous.iter().enumerate().all(|(ix, p)| { - p.to_display_point(&map).row() + p.to_display_point(&display_map).row() == selections[ix].head().row() }) }) .unwrap_or(false); let new_positions = selections .into_iter() - .map(|s| map.display_point_to_anchor(s.head(), Bias::Left)) + .map(|s| display_map.display_point_to_anchor(s.head(), Bias::Left)) .collect(); editor .change_list @@ -2311,7 +2334,7 @@ impl Editor { editor.selection_history.mode = SelectionHistoryMode::Normal; editor.scroll_manager.show_scrollbars(window, cx); - jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut editor, &buffer, cx); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut editor, &multi_buffer, cx); if full_mode { let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); @@ -2323,30 +2346,25 @@ impl Editor { editor.go_to_active_debug_line(window, cx); - if let Some(buffer) = buffer.read(cx).as_singleton() { - if let Some(project) = editor.project() { - let handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - editor - .registered_buffers - .insert(buffer.read(cx).remote_id(), handle); - } - } - editor.minimap = editor.create_minimap(EditorSettings::get_global(cx).minimap, window, cx); editor.colors = Some(LspColorData::new(cx)); - editor.update_lsp_data(false, None, window, cx); - } + editor.inlay_hints = Some(LspInlayHintData::new(inlay_hint_settings)); - if editor.mode.is_full() { + if let Some(buffer) = multi_buffer.read(cx).as_singleton() { + editor.register_buffer(buffer.read(cx).remote_id(), cx); + } + editor.update_lsp_data(None, window, cx); editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx); } editor } + pub fn display_snapshot(&self, cx: &mut App) -> DisplaySnapshot { + self.selections.display_map(cx) + } + pub fn deploy_mouse_context_menu( &mut self, position: gpui::Point, @@ -2372,21 +2390,17 @@ impl Editor { pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { if self .selections - .pending - .as_ref() + .pending_anchor() .is_some_and(|pending_selection| { let snapshot = self.buffer().read(cx).snapshot(cx); - pending_selection - .selection - .range() - .includes(&range, &snapshot) + pending_selection.range().includes(range, &snapshot) }) { return true; } self.selections - .disjoint_in_range::(range.clone(), cx) + .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) .into_iter() .any(|selection| { // This is needed to cover a corner case, if we just check for an existing @@ -2397,20 +2411,20 @@ impl Editor { }) } - pub fn key_context(&self, window: &Window, cx: &App) -> KeyContext { + pub fn key_context(&self, window: &mut Window, cx: &mut App) -> KeyContext { self.key_context_internal(self.has_active_edit_prediction(), window, cx) } fn key_context_internal( &self, has_active_edit_prediction: bool, - window: &Window, - cx: &App, + window: &mut Window, + cx: &mut App, ) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("Editor"); let mode = match self.mode { - EditorMode::SingleLine { .. } => "single_line", + EditorMode::SingleLine => "single_line", EditorMode::AutoHeight { .. } => "auto_height", EditorMode::Minimap { .. } => "minimap", EditorMode::Full { .. } => "full", @@ -2425,6 +2439,10 @@ impl Editor { key_context.add("renaming"); } + if !self.snippet_stack.is_empty() { + key_context.add("in_snippet"); + } + match self.context_menu.borrow().as_ref() { Some(CodeContextMenu::Completions(menu)) => { if menu.visible() { @@ -2455,12 +2473,15 @@ impl Editor { } if let Some(singleton_buffer) = self.buffer.read(cx).as_singleton() { - if let Some(extension) = singleton_buffer - .read(cx) - .file() - .and_then(|file| file.path().extension()?.to_str()) - { - key_context.set("extension", extension.to_string()); + if let Some(extension) = singleton_buffer.read(cx).file().and_then(|file| { + Some( + file.full_path(cx) + .extension()? + .to_string_lossy() + .into_owned(), + ) + }) { + key_context.set("extension", extension); } } else { key_context.add("multibuffer"); @@ -2479,9 +2500,24 @@ impl Editor { key_context.add("selection_mode"); } + let disjoint = self.selections.disjoint_anchors(); + let snapshot = self.snapshot(window, cx); + let snapshot = snapshot.buffer_snapshot(); + if self.mode == EditorMode::SingleLine + && let [selection] = disjoint + && selection.start == selection.end + && selection.end.to_offset(snapshot) == snapshot.len() + { + key_context.add("end_of_input"); + } + key_context } + pub fn last_bounds(&self) -> Option<&Bounds> { + self.last_bounds.as_ref() + } + fn show_mouse_cursor(&mut self, cx: &mut Context) { if self.mouse_cursor_hidden { self.mouse_cursor_hidden = false; @@ -2516,9 +2552,7 @@ impl Editor { .context_menu .borrow() .as_ref() - .map_or(false, |context| { - matches!(context, CodeContextMenu::Completions(_)) - }); + .is_some_and(|context| matches!(context, CodeContextMenu::Completions(_))); showing_completions || self.edit_prediction_requires_modifier() @@ -2530,8 +2564,8 @@ impl Editor { pub fn accept_edit_prediction_keybind( &self, accept_partial: bool, - window: &Window, - cx: &App, + window: &mut Window, + cx: &mut App, ) -> AcceptEditPredictionBinding { let key_context = self.key_context_internal(true, window, cx); let in_conflict = self.edit_prediction_in_conflict(); @@ -2549,7 +2583,7 @@ impl Editor { || binding .keystrokes() .first() - .map_or(false, |keystroke| keystroke.modifiers.modified()) + .is_some_and(|keystroke| keystroke.modifiers().modified()) })) } @@ -2579,7 +2613,7 @@ impl Editor { cx: &mut Context, ) -> Task>> { let project = workspace.project().clone(); - let create = project.update(cx, |project, cx| project.create_buffer(cx)); + let create = project.update(cx, |project, cx| project.create_buffer(true, cx)); cx.spawn_in(window, async move |workspace, cx| { let buffer = create.await?; @@ -2610,6 +2644,15 @@ impl Editor { Self::new_file_in_direction(workspace, SplitDirection::horizontal(cx), window, cx) } + fn new_file_split( + workspace: &mut Workspace, + action: &workspace::NewFileSplit, + window: &mut Window, + cx: &mut Context, + ) { + Self::new_file_in_direction(workspace, action.0, window, cx) + } + fn new_file_in_direction( workspace: &mut Workspace, direction: SplitDirection, @@ -2617,7 +2660,7 @@ impl Editor { cx: &mut Context, ) { let project = workspace.project().clone(); - let create = project.update(cx, |project, cx| project.create_buffer(cx)); + let create = project.update(cx, |project, cx| project.create_buffer(true, cx)); cx.spawn_in(window, async move |workspace, cx| { let buffer = create.await?; @@ -2664,7 +2707,7 @@ impl Editor { self.buffer().read(cx).title(cx) } - pub fn snapshot(&self, window: &mut Window, cx: &mut App) -> EditorSnapshot { + pub fn snapshot(&self, window: &Window, cx: &mut App) -> EditorSnapshot { let git_blame_gutter_max_author_length = self .render_git_blame_gutter(cx) .then(|| { @@ -2688,9 +2731,12 @@ impl Editor { show_breakpoints: self.show_breakpoints, git_blame_gutter_max_author_length, display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), + placeholder_display_snapshot: self + .placeholder_display_map + .as_ref() + .map(|display_map| display_map.update(cx, |map, cx| map.snapshot(cx))), scroll_anchor: self.scroll_manager.anchor(), ongoing_scroll: self.scroll_manager.ongoing_scroll(), - placeholder_text: self.placeholder_text.clone(), is_focused: self.focus_handle.is_focused(window), current_line_highlight: self .current_line_highlight @@ -2786,20 +2832,37 @@ impl Editor { self.refresh_edit_prediction(false, false, window, cx); } - pub fn placeholder_text(&self) -> Option<&str> { - self.placeholder_text.as_deref() + pub fn placeholder_text(&self, cx: &mut App) -> Option { + self.placeholder_display_map + .as_ref() + .map(|display_map| display_map.update(cx, |map, cx| map.snapshot(cx)).text()) } pub fn set_placeholder_text( &mut self, - placeholder_text: impl Into>, + placeholder_text: &str, + window: &mut Window, cx: &mut Context, ) { - let placeholder_text = Some(placeholder_text.into()); - if self.placeholder_text != placeholder_text { - self.placeholder_text = placeholder_text; - cx.notify(); - } + let multibuffer = cx + .new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local(placeholder_text, cx)), cx)); + + let style = window.text_style(); + + self.placeholder_display_map = Some(cx.new(|cx| { + DisplayMap::new( + multibuffer, + style.font(), + style.font_size.to_pixels(window.rem_size()), + None, + FILE_HEADER_HEIGHT, + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + Default::default(), + DiagnosticSeverity::Off, + cx, + ) + })); + cx.notify(); } pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape, cx: &mut Context) { @@ -2822,20 +2885,6 @@ impl Editor { self.collapse_matches = collapse_matches; } - fn register_buffers_with_language_servers(&mut self, cx: &mut Context) { - let buffers = self.buffer.read(cx).all_buffers(); - let Some(project) = self.project.as_ref() else { - return; - }; - project.update(cx, |project, cx| { - for buffer in buffers { - self.registered_buffers - .entry(buffer.read(cx).remote_id()) - .or_insert_with(|| project.register_buffer_with_language_servers(&buffer, cx)); - } - }) - } - pub fn range_for_match(&self, range: &Range) -> Range { if self.collapse_matches { return range.start..range.start; @@ -2945,7 +2994,7 @@ impl Editor { return false; }; - scope.override_name().map_or(false, |scope_name| { + scope.override_name().is_some_and(|scope_name| { settings .edit_predictions_disabled_in .iter() @@ -2974,7 +3023,7 @@ impl Editor { // Copy selections to primary selection buffer #[cfg(any(target_os = "linux", target_os = "freebsd"))] if local { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let buffer_handle = self.buffer.read(cx).read(cx); let mut text = String::new(); @@ -2994,13 +3043,13 @@ impl Editor { } } - let selection_anchors = self.selections.disjoint_anchors(); + let selection_anchors = self.selections.disjoint_anchors_arc(); if self.focus_handle.is_focused(window) && self.leader_id.is_none() { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( &selection_anchors, - self.selections.line_mode, + self.selections.line_mode(), self.cursor_shape, cx, ) @@ -3009,7 +3058,7 @@ impl Editor { let display_map = self .display_map .update(cx, |display_map, cx| display_map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; + let buffer = display_map.buffer_snapshot(); if self.selections.count() == 1 { self.add_selections_state = None; } @@ -3036,19 +3085,7 @@ impl Editor { if local { if let Some(buffer_id) = new_cursor_position.buffer_id { - if !self.registered_buffers.contains_key(&buffer_id) { - if let Some(project) = self.project.as_ref() { - project.update(cx, |project, cx| { - let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { - return; - }; - self.registered_buffers.insert( - buffer_id, - project.register_buffer_with_language_servers(&buffer, cx), - ); - }) - } - } + self.register_buffer(buffer_id, cx); } let mut context_menu = self.context_menu.borrow_mut(); @@ -3063,28 +3100,29 @@ impl Editor { let completion_position = completion_menu.map(|menu| menu.initial_position); drop(context_menu); - if effects.completions { - if let Some(completion_position) = completion_position { - let start_offset = selection_start.to_offset(buffer); - let position_matches = start_offset == completion_position.to_offset(buffer); - let continue_showing = if position_matches { - if self.snippet_stack.is_empty() { - buffer.char_kind_before(start_offset, true) == Some(CharKind::Word) - } else { - // Snippet choices can be shown even when the cursor is in whitespace. - // Dismissing the menu with actions like backspace is handled by - // invalidation regions. - true - } - } else { - false - }; - - if continue_showing { - self.show_completions(&ShowCompletions { trigger: None }, window, cx); + if effects.completions + && let Some(completion_position) = completion_position + { + let start_offset = selection_start.to_offset(buffer); + let position_matches = start_offset == completion_position.to_offset(buffer); + let continue_showing = if position_matches { + if self.snippet_stack.is_empty() { + buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) + == Some(CharKind::Word) } else { - self.hide_context_menu(window, cx); + // Snippet choices can be shown even when the cursor is in whitespace. + // Dismissing the menu with actions like backspace is handled by + // invalidation regions. + true } + } else { + false + }; + + if continue_showing { + self.show_completions(&ShowCompletions { trigger: None }, window, cx); + } else { + self.hide_context_menu(window, cx); } } @@ -3097,11 +3135,12 @@ impl Editor { } self.refresh_code_actions(window, cx); self.refresh_document_highlights(cx); + refresh_linked_ranges(self, window, cx); + self.refresh_selected_text_highlights(false, window, cx); - refresh_matching_bracket_highlights(self, window, cx); + self.refresh_matching_bracket_highlights(window, cx); self.update_visible_edit_prediction(window, cx); self.edit_prediction_requires_modifier_in_indent_conflict = true; - linked_editing_ranges::refresh_linked_ranges(self, window, cx); self.inline_blame_popover.take(); if self.git_blame_inline_enabled { self.start_inline_blame_timer(window, cx); @@ -3111,52 +3150,52 @@ impl Editor { self.blink_manager.update(cx, BlinkManager::pause_blinking); cx.emit(EditorEvent::SelectionsChanged { local }); - let selections = &self.selections.disjoint; + let selections = &self.selections.disjoint_anchors_arc(); if selections.len() == 1 { cx.emit(SearchEvent::ActiveMatchChanged) } - if local { - if let Some((_, _, buffer_snapshot)) = buffer.as_singleton() { - let inmemory_selections = selections - .iter() - .map(|s| { - text::ToPoint::to_point(&s.range().start.text_anchor, buffer_snapshot) - ..text::ToPoint::to_point(&s.range().end.text_anchor, buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.selections = inmemory_selections; - }); + if local && let Some((_, _, buffer_snapshot)) = buffer.as_singleton() { + let inmemory_selections = selections + .iter() + .map(|s| { + text::ToPoint::to_point(&s.range().start.text_anchor, buffer_snapshot) + ..text::ToPoint::to_point(&s.range().end.text_anchor, buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.selections = inmemory_selections; + }); - if WorkspaceSettings::get(None, cx).restore_on_startup - != RestoreOnStartupBehavior::None - { - if let Some(workspace_id) = - self.workspace.as_ref().and_then(|workspace| workspace.1) - { - let snapshot = self.buffer().read(cx).snapshot(cx); - let selections = selections.clone(); - let background_executor = cx.background_executor().clone(); - let editor_id = cx.entity().entity_id().as_u64() as ItemId; - self.serialize_selections = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - let db_selections = selections - .iter() - .map(|selection| { - ( - selection.start.to_offset(&snapshot), - selection.end.to_offset(&snapshot), - ) - }) - .collect(); + if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + && let Some(workspace_id) = + self.workspace.as_ref().and_then(|workspace| workspace.1) + { + let snapshot = self.buffer().read(cx).snapshot(cx); + let selections = selections.clone(); + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + self.serialize_selections = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + let db_selections = selections + .iter() + .map(|selection| { + ( + selection.start.to_offset(&snapshot), + selection.end.to_offset(&snapshot), + ) + }) + .collect(); - DB.save_editor_selections(editor_id, workspace_id, db_selections) - .await - .with_context(|| format!("persisting editor selections for editor {editor_id}, workspace {workspace_id:?}")) - .log_err(); - }); - } - } + DB.save_editor_selections(editor_id, workspace_id, db_selections) + .await + .with_context(|| { + format!( + "persisting editor selections for editor {editor_id}, \ + workspace {workspace_id:?}" + ) + }) + .log_err(); + }); } } @@ -3173,22 +3212,23 @@ impl Editor { return; } - let Some(singleton) = self.buffer().read(cx).as_singleton() else { + if !self.buffer().read(cx).is_singleton() { return; - }; - - let snapshot = singleton.read(cx).snapshot(); - let inmemory_folds = self.display_map.update(cx, |display_map, cx| { - let display_snapshot = display_map.snapshot(cx); + } - display_snapshot - .folds_in_range(0..display_snapshot.buffer_snapshot.len()) - .map(|fold| { - fold.range.start.text_anchor.to_point(&snapshot) - ..fold.range.end.text_anchor.to_point(&snapshot) - }) - .collect() - }); + let display_snapshot = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let Some((.., snapshot)) = display_snapshot.buffer_snapshot().as_singleton() else { + return; + }; + let inmemory_folds = display_snapshot + .folds_in_range(0..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + fold.range.start.text_anchor.to_point(&snapshot) + ..fold.range.end.text_anchor.to_point(&snapshot) + }) + .collect(); self.update_restoration_data(cx, |data| { data.folds = inmemory_folds; }); @@ -3198,18 +3238,15 @@ impl Editor { }; let background_executor = cx.background_executor().clone(); let editor_id = cx.entity().entity_id().as_u64() as ItemId; - let db_folds = self.display_map.update(cx, |display_map, cx| { - display_map - .snapshot(cx) - .folds_in_range(0..snapshot.len()) - .map(|fold| { - ( - fold.range.start.text_anchor.to_offset(&snapshot), - fold.range.end.text_anchor.to_offset(&snapshot), - ) - }) - .collect() - }); + let db_folds = display_snapshot + .folds_in_range(0..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + ( + fold.range.start.text_anchor.to_offset(&snapshot), + fold.range.end.text_anchor.to_offset(&snapshot), + ) + }) + .collect(); self.serialize_folds = cx.background_spawn(async move { background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; DB.save_editor_folds(editor_id, workspace_id, db_folds) @@ -3228,40 +3265,38 @@ impl Editor { other: Entity, cx: &mut Context, ) -> gpui::Subscription { - let other_selections = other.read(cx).selections.disjoint.to_vec(); - self.selections.change_with(cx, |selections| { - selections.select_anchors(other_selections); - }); + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if !other_selections.is_empty() { + self.selections.change_with(cx, |selections| { + selections.select_anchors(other_selections); + }); + } - let other_subscription = - cx.subscribe(&other, |this, other, other_evt, cx| match other_evt { - EditorEvent::SelectionsChanged { local: true } => { - let other_selections = other.read(cx).selections.disjoint.to_vec(); - if other_selections.is_empty() { - return; - } - this.selections.change_with(cx, |selections| { - selections.select_anchors(other_selections); - }); + let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = other_evt { + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if other_selections.is_empty() { + return; } - _ => {} - }); + this.selections.change_with(cx, |selections| { + selections.select_anchors(other_selections); + }); + } + }); - let this_subscription = - cx.subscribe_self::(move |this, this_evt, cx| match this_evt { - EditorEvent::SelectionsChanged { local: true } => { - let these_selections = this.selections.disjoint.to_vec(); - if these_selections.is_empty() { - return; - } - other.update(cx, |other_editor, cx| { - other_editor.selections.change_with(cx, |selections| { - selections.select_anchors(these_selections); - }) - }); + let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = this_evt { + let these_selections = this.selections.disjoint_anchors().to_vec(); + if these_selections.is_empty() { + return; } - _ => {} - }); + other.update(cx, |other_editor, cx| { + other_editor.selections.change_with(cx, |selections| { + selections.select_anchors(these_selections); + }) + }); + } + }); Subscription::join(other_subscription, this_subscription) } @@ -3289,7 +3324,7 @@ impl Editor { effects, old_cursor_position: self.selections.newest_anchor().head(), history_entry: SelectionHistoryEntry { - selections: self.selections.disjoint_anchors(), + selections: self.selections.disjoint_anchors_arc(), select_next_state: self.select_next_state.clone(), select_prev_state: self.select_prev_state.clone(), add_selections_state: self.add_selections_state.clone(), @@ -3342,9 +3377,9 @@ impl Editor { let old_cursor_position = &state.old_cursor_position; - self.selections_did_change(true, &old_cursor_position, state.effects, window, cx); + self.selections_did_change(true, old_cursor_position, state.effects, window, cx); - if self.should_open_signature_help_automatically(&old_cursor_position, cx) { + if self.should_open_signature_help_automatically(old_cursor_position, cx) { self.show_signature_help(&ShowSignatureHelp, window, cx); } } @@ -3440,26 +3475,47 @@ impl Editor { cx: &mut Context, ) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let tail = self.selections.newest::(cx).tail(); + let tail = self.selections.newest::(&display_map).tail(); + let click_count = click_count.max(match self.selections.select_mode() { + SelectMode::Character => 1, + SelectMode::Word(_) => 2, + SelectMode::Line(_) => 3, + SelectMode::All => 4, + }); self.begin_selection(position, false, click_count, window, cx); - let position = position.to_offset(&display_map, Bias::Left); - let tail_anchor = display_map.buffer_snapshot.anchor_before(tail); + let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); + + let current_selection = match self.selections.select_mode() { + SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, + SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), + }; let mut pending_selection = self .selections .pending_anchor() + .cloned() .expect("extend_selection not called with pending selection"); - if position >= tail { - pending_selection.start = tail_anchor; - } else { - pending_selection.end = tail_anchor; + + if pending_selection + .start + .cmp(¤t_selection.start, display_map.buffer_snapshot()) + == Ordering::Greater + { + pending_selection.start = current_selection.start; + } + if pending_selection + .end + .cmp(¤t_selection.end, display_map.buffer_snapshot()) + == Ordering::Less + { + pending_selection.end = current_selection.end; pending_selection.reversed = true; } let mut pending_mode = self.selections.pending_mode().unwrap(); match &mut pending_mode { - SelectMode::Word(range) | SelectMode::Line(range) => *range = tail_anchor..tail_anchor, + SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, _ => {} } @@ -3470,7 +3526,8 @@ impl Editor { }; self.change_selections(effects, window, cx, |s| { - s.set_pending(pending_selection, pending_mode) + s.set_pending(pending_selection.clone(), pending_mode); + s.set_is_extending(true); }); } @@ -3488,7 +3545,7 @@ impl Editor { } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; + let buffer = display_map.buffer_snapshot(); let position = display_map.clip_point(position, Bias::Left); let start; @@ -3506,7 +3563,7 @@ impl Editor { let position = display_map .clip_point(position, Bias::Left) .to_offset(&display_map, Bias::Left); - let (range, _) = buffer.surrounding_word(position, false); + let (range, _) = buffer.surrounding_word(position, None); start = buffer.anchor_before(range.start); end = buffer.anchor_before(range.end); mode = SelectMode::Word(start..end); @@ -3537,7 +3594,7 @@ impl Editor { let point_to_delete: Option = { let selected_points: Vec> = - self.selections.disjoint_in_range(start..end, cx); + self.selections.disjoint_in_range(start..end, &display_map); if !add || click_count > 1 { None @@ -3545,7 +3602,7 @@ impl Editor { Some(selected_points[0].id) } else { let clicked_point_already_selected = - self.selections.disjoint.iter().find(|selection| { + self.selections.disjoint_anchors().iter().find(|selection| { selection.start.to_point(buffer) == start.to_point(buffer) || selection.end.to_point(buffer) == end.to_point(buffer) }); @@ -3596,7 +3653,7 @@ impl Editor { if reset { let pointer_position = display_map - .buffer_snapshot + .buffer_snapshot() .anchor_before(position.to_point(&display_map)); self.change_selections( @@ -3613,8 +3670,8 @@ impl Editor { ); }; - let tail = self.selections.newest::(cx).tail(); - let selection_anchor = display_map.buffer_snapshot.anchor_before(tail); + let tail = self.selections.newest::(&display_map).tail(); + let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); self.columnar_selection_state = match mode { ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { selection_tail: selection_anchor, @@ -3650,8 +3707,8 @@ impl Editor { if self.columnar_selection_state.is_some() { self.select_columns(position, goal_column, &display_map, window, cx); - } else if let Some(mut pending) = self.selections.pending_anchor() { - let buffer = &display_map.buffer_snapshot; + } else if let Some(mut pending) = self.selections.pending_anchor().cloned() { + let buffer = display_map.buffer_snapshot(); let head; let tail; let mode = self.selections.pending_mode().unwrap(); @@ -3666,10 +3723,10 @@ impl Editor { .to_offset(&display_map, Bias::Left); let original_range = original_range.to_offset(buffer); - let head_offset = if buffer.is_inside_word(offset, false) + let head_offset = if buffer.is_inside_word(offset, None) || original_range.contains(&offset) { - let (word_range, _) = buffer.surrounding_word(offset, false); + let (word_range, _) = buffer.surrounding_word(offset, None); if word_range.start < original_range.start { word_range.start } else { @@ -3687,7 +3744,7 @@ impl Editor { } } SelectMode::Line(original_range) => { - let original_range = original_range.to_point(&display_map.buffer_snapshot); + let original_range = original_range.to_point(display_map.buffer_snapshot()); let position = display_map .clip_point(position, Bias::Left) @@ -3726,7 +3783,7 @@ impl Editor { } self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.set_pending(pending, mode); + s.set_pending(pending.clone(), mode); }); } else { log::error!("update_selection dispatched with no pending selection"); @@ -3739,11 +3796,16 @@ impl Editor { fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { self.columnar_selection_state.take(); - if self.selections.pending_anchor().is_some() { - let selections = self.selections.all::(cx); + if let Some(pending_mode) = self.selections.pending_mode() { + let selections = self.selections.all::(&self.display_snapshot(cx)); self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select(selections); s.clear_pending(); + if s.is_extending() { + s.set_is_extending(false); + } else { + s.set_select_mode(pending_mode); + } }); } } @@ -3764,9 +3826,9 @@ impl Editor { ColumnarSelectionState::FromMouse { selection_tail, display_point, - } => display_point.unwrap_or_else(|| selection_tail.to_display_point(&display_map)), + } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), ColumnarSelectionState::FromSelection { selection_tail } => { - selection_tail.to_display_point(&display_map) + selection_tail.to_display_point(display_map) } }; @@ -3799,6 +3861,9 @@ impl Editor { } }) .collect::>(); + if selection_ranges.is_empty() { + return; + } let ranges = match columnar_state { ColumnarSelectionState::FromMouse { .. } => { @@ -3821,9 +3886,9 @@ impl Editor { cx.notify(); } - pub fn has_non_empty_selection(&self, cx: &mut App) -> bool { + pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { self.selections - .all_adjusted(cx) + .all_adjusted(snapshot) .iter() .any(|selection| !selection.is_empty()) } @@ -3835,7 +3900,8 @@ impl Editor { }; pending_nonempty_selection - || (self.columnar_selection_state.is_some() && self.selections.disjoint.len() > 1) + || (self.columnar_selection_state.is_some() + && self.selections.disjoint_anchors().len() > 1) } pub fn has_pending_selection(&self) -> bool { @@ -3873,6 +3939,10 @@ impl Editor { return true; } + if self.hide_blame_popover(true, cx) { + return true; + } + if hide_hover(self, cx) { return true; } @@ -3971,7 +4041,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let selections = self.selections.all_adjusted(cx); + let selections = self.selections.all_adjusted(&self.display_snapshot(cx)); let mut bracket_inserted = false; let mut edits = Vec::new(); let mut linked_edits = HashMap::<_, Vec<_>>::default(); @@ -4043,18 +4113,18 @@ impl Editor { let following_text_allows_autoclose = snapshot .chars_at(selection.start) .next() - .map_or(true, |c| scope.should_autoclose_before(c)); + .is_none_or(|c| scope.should_autoclose_before(c)); let preceding_text_allows_autoclose = selection.start.column == 0 - || snapshot.reversed_chars_at(selection.start).next().map_or( - true, - |c| { + || snapshot + .reversed_chars_at(selection.start) + .next() + .is_none_or(|c| { bracket_pair.start != bracket_pair.end || !snapshot .char_classifier_at(selection.start) .is_word(c) - }, - ); + }); let is_closing_quote = if bracket_pair.end == bracket_pair.start && bracket_pair.start.len() == 1 @@ -4154,42 +4224,38 @@ impl Editor { if self.auto_replace_emoji_shortcode && selection.is_empty() && text.as_ref().ends_with(':') - { - if let Some(possible_emoji_short_code) = + && let Some(possible_emoji_short_code) = Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start) - { - if !possible_emoji_short_code.is_empty() { - if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) { - let emoji_shortcode_start = Point::new( - selection.start.row, - selection.start.column - possible_emoji_short_code.len() as u32 - 1, - ); + && !possible_emoji_short_code.is_empty() + && let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) + { + let emoji_shortcode_start = Point::new( + selection.start.row, + selection.start.column - possible_emoji_short_code.len() as u32 - 1, + ); - // Remove shortcode from buffer - edits.push(( - emoji_shortcode_start..selection.start, - "".to_string().into(), - )); - new_selections.push(( - Selection { - id: selection.id, - start: snapshot.anchor_after(emoji_shortcode_start), - end: snapshot.anchor_before(selection.start), - reversed: selection.reversed, - goal: selection.goal, - }, - 0, - )); + // Remove shortcode from buffer + edits.push(( + emoji_shortcode_start..selection.start, + "".to_string().into(), + )); + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(emoji_shortcode_start), + end: snapshot.anchor_before(selection.start), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); - // Insert emoji - let selection_start_anchor = snapshot.anchor_after(selection.start); - new_selections.push((selection.map(|_| selection_start_anchor), 0)); - edits.push((selection.start..selection.end, emoji.to_string().into())); + // Insert emoji + let selection_start_anchor = snapshot.anchor_after(selection.start); + new_selections.push((selection.map(|_| selection_start_anchor), 0)); + edits.push((selection.start..selection.end, emoji.to_string().into())); - continue; - } - } - } + continue; } // If not handling any auto-close operation, then just replace the selected @@ -4199,10 +4265,10 @@ impl Editor { if !self.linked_edit_ranges.is_empty() { let start_anchor = snapshot.anchor_before(selection.start); - let is_word_char = text.chars().next().map_or(true, |char| { + let is_word_char = text.chars().next().is_none_or(|char| { let classifier = snapshot .char_classifier_at(start_anchor.to_offset(&snapshot)) - .ignore_punctuation(true); + .scope_context(Some(CharScopeContext::LinkedEdit)); classifier.is_word(char) }); @@ -4256,28 +4322,33 @@ impl Editor { let new_anchor_selections = new_selections.iter().map(|e| &e.0); let new_selection_deltas = new_selections.iter().map(|e| e.1); let map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); - let new_selections = resolve_selections::(new_anchor_selections, &map) - .zip(new_selection_deltas) - .map(|(selection, delta)| Selection { - id: selection.id, - start: selection.start + delta, - end: selection.end + delta, - reversed: selection.reversed, - goal: SelectionGoal::None, - }) - .collect::>(); + let new_selections = + resolve_selections_wrapping_blocks::(new_anchor_selections, &map) + .zip(new_selection_deltas) + .map(|(selection, delta)| Selection { + id: selection.id, + start: selection.start + delta, + end: selection.end + delta, + reversed: selection.reversed, + goal: SelectionGoal::None, + }) + .collect::>(); let mut i = 0; for (position, delta, selection_id, pair) in new_autoclose_regions { - let position = position.to_offset(&map.buffer_snapshot) + delta; - let start = map.buffer_snapshot.anchor_before(position); - let end = map.buffer_snapshot.anchor_after(position); + let position = position.to_offset(map.buffer_snapshot()) + delta; + let start = map.buffer_snapshot().anchor_before(position); + let end = map.buffer_snapshot().anchor_after(position); while let Some(existing_state) = this.autoclose_regions.get(i) { - match existing_state.range.start.cmp(&start, &map.buffer_snapshot) { + match existing_state + .range + .start + .cmp(&start, map.buffer_snapshot()) + { Ordering::Less => i += 1, Ordering::Greater => break, Ordering::Equal => { - match end.cmp(&existing_state.range.end, &map.buffer_snapshot) { + match end.cmp(&existing_state.range.end, map.buffer_snapshot()) { Ordering::Less => i += 1, Ordering::Equal => break, Ordering::Greater => break, @@ -4303,12 +4374,11 @@ impl Editor { |s| s.select(new_selections), ); - if !bracket_inserted { - if let Some(on_type_format_task) = + if !bracket_inserted + && let Some(on_type_format_task) = this.trigger_on_type_formatting(text.to_string(), window, cx) - { - on_type_format_task.detach_and_log_err(cx); - } + { + on_type_format_task.detach_and_log_err(cx); } let editor_settings = EditorSettings::get_global(cx); @@ -4322,7 +4392,7 @@ impl Editor { let trigger_in_words = this.show_edit_predictions_in_menu() || !had_active_edit_prediction; if this.hard_wrap.is_some() { - let latest: Range = this.selections.newest(cx).range(); + let latest: Range = this.selections.newest(&map).range(); if latest.is_empty() && this .buffer() @@ -4341,7 +4411,7 @@ impl Editor { } } this.trigger_completion_on_input(&text, trigger_in_words, window, cx); - linked_editing_ranges::refresh_linked_ranges(this, window, cx); + refresh_linked_ranges(this, window, cx); this.refresh_edit_prediction(true, false, window, cx); jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); }); @@ -4398,7 +4468,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); self.transact(window, cx, |this, window, cx| { let (edits_with_flags, selection_info): (Vec<_>, Vec<_>) = { - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); let multi_buffer = this.buffer.read(cx); let buffer = multi_buffer.snapshot(cx); selections @@ -4554,7 +4624,7 @@ impl Editor { let mut char_position = 0u32; let mut end_tag_offset = None; - 'outer: for chunk in snapshot.text_for_range(range.clone()) { + 'outer: for chunk in snapshot.text_for_range(range) { if let Some(byte_pos) = chunk.find(&**end_tag) { let chars_before_match = chunk[..byte_pos].chars().count() as u32; @@ -4690,7 +4760,12 @@ impl Editor { let mut edits = Vec::new(); let mut rows = Vec::new(); - for (rows_inserted, selection) in self.selections.all_adjusted(cx).into_iter().enumerate() { + for (rows_inserted, selection) in self + .selections + .all_adjusted(&self.display_snapshot(cx)) + .into_iter() + .enumerate() + { let cursor = selection.head(); let row = cursor.row; @@ -4750,7 +4825,7 @@ impl Editor { let mut rows = Vec::new(); let mut rows_inserted = 0; - for selection in self.selections.all_adjusted(cx) { + for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { let cursor = selection.head(); let row = cursor.row; @@ -4822,7 +4897,7 @@ impl Editor { let text: Arc = text.into(); self.transact(window, cx, |this, window, cx| { - let old_selections = this.selections.all_adjusted(cx); + let old_selections = this.selections.all_adjusted(&this.display_snapshot(cx)); let selection_anchors = this.buffer.update(cx, |buffer, cx| { let anchors = { let snapshot = buffer.read(cx); @@ -4869,8 +4944,15 @@ impl Editor { }); match completions_source { - Some(CompletionsMenuSource::Words) => { - self.show_word_completions(&ShowWordCompletions, window, cx) + Some(CompletionsMenuSource::Words { .. }) => { + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: false, + }), + None, + window, + cx, + ); } Some(CompletionsMenuSource::Normal) | Some(CompletionsMenuSource::SnippetChoices) @@ -4904,11 +4986,7 @@ impl Editor { cx: &mut Context, ) -> bool { let position = self.selections.newest_anchor().head(); - let multibuffer = self.buffer.read(cx); - let Some(buffer) = position - .buffer_id - .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone()) - else { + let Some(buffer) = self.buffer.read(cx).buffer_for_anchor(position, cx) else { return false; }; @@ -4929,7 +5007,7 @@ impl Editor { /// If any empty selections is touching the start of its innermost containing autoclose /// region, expand it to select the brackets. fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let new_selections = self .selections_with_autoclose_regions(selections, &buffer) @@ -5057,7 +5135,8 @@ impl Editor { fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { let offset = position.to_offset(buffer); - let (word_range, kind) = buffer.surrounding_word(offset, true); + let (word_range, kind) = + buffer.surrounding_word(offset, Some(CharScopeContext::Completion)); if offset > word_range.start && kind == Some(CharKind::Word) { Some( buffer @@ -5069,177 +5148,8 @@ impl Editor { } } - pub fn toggle_inline_values( - &mut self, - _: &ToggleInlineValues, - _: &mut Window, - cx: &mut Context, - ) { - self.inline_value_cache.enabled = !self.inline_value_cache.enabled; - - self.refresh_inline_values(cx); - } - - pub fn toggle_inlay_hints( - &mut self, - _: &ToggleInlayHints, - _: &mut Window, - cx: &mut Context, - ) { - self.refresh_inlay_hints( - InlayHintRefreshReason::Toggle(!self.inlay_hints_enabled()), - cx, - ); - } - - pub fn inlay_hints_enabled(&self) -> bool { - self.inlay_hint_cache.enabled - } - - pub fn inline_values_enabled(&self) -> bool { - self.inline_value_cache.enabled - } - - #[cfg(any(test, feature = "test-support"))] - pub fn inline_value_inlays(&self, cx: &App) -> Vec { - self.display_map - .read(cx) - .current_inlays() - .filter(|inlay| matches!(inlay.id, InlayId::DebuggerValue(_))) - .cloned() - .collect() - } - - #[cfg(any(test, feature = "test-support"))] - pub fn all_inlays(&self, cx: &App) -> Vec { - self.display_map - .read(cx) - .current_inlays() - .cloned() - .collect() - } - - fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut Context) { - if self.semantics_provider.is_none() || !self.mode.is_full() { - return; - } - - let reason_description = reason.description(); - let ignore_debounce = matches!( - reason, - InlayHintRefreshReason::SettingsChange(_) - | InlayHintRefreshReason::Toggle(_) - | InlayHintRefreshReason::ExcerptsRemoved(_) - | InlayHintRefreshReason::ModifiersChanged(_) - ); - let (invalidate_cache, required_languages) = match reason { - InlayHintRefreshReason::ModifiersChanged(enabled) => { - match self.inlay_hint_cache.modifiers_override(enabled) { - Some(enabled) => { - if enabled { - (InvalidationStrategy::RefreshRequested, None) - } else { - self.splice_inlays( - &self - .visible_inlay_hints(cx) - .iter() - .map(|inlay| inlay.id) - .collect::>(), - Vec::new(), - cx, - ); - return; - } - } - None => return, - } - } - InlayHintRefreshReason::Toggle(enabled) => { - if self.inlay_hint_cache.toggle(enabled) { - if enabled { - (InvalidationStrategy::RefreshRequested, None) - } else { - self.splice_inlays( - &self - .visible_inlay_hints(cx) - .iter() - .map(|inlay| inlay.id) - .collect::>(), - Vec::new(), - cx, - ); - return; - } - } else { - return; - } - } - InlayHintRefreshReason::SettingsChange(new_settings) => { - match self.inlay_hint_cache.update_settings( - &self.buffer, - new_settings, - self.visible_inlay_hints(cx), - cx, - ) { - ControlFlow::Break(Some(InlaySplice { - to_remove, - to_insert, - })) => { - self.splice_inlays(&to_remove, to_insert, cx); - return; - } - ControlFlow::Break(None) => return, - ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None), - } - } - InlayHintRefreshReason::ExcerptsRemoved(excerpts_removed) => { - if let Some(InlaySplice { - to_remove, - to_insert, - }) = self.inlay_hint_cache.remove_excerpts(&excerpts_removed) - { - self.splice_inlays(&to_remove, to_insert, cx); - } - self.display_map.update(cx, |display_map, _| { - display_map.remove_inlays_for_excerpts(&excerpts_removed) - }); - return; - } - InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None), - InlayHintRefreshReason::BufferEdited(buffer_languages) => { - (InvalidationStrategy::BufferEdited, Some(buffer_languages)) - } - InlayHintRefreshReason::RefreshRequested => { - (InvalidationStrategy::RefreshRequested, None) - } - }; - - if let Some(InlaySplice { - to_remove, - to_insert, - }) = self.inlay_hint_cache.spawn_hint_refresh( - reason_description, - self.visible_excerpts(required_languages.as_ref(), cx), - invalidate_cache, - ignore_debounce, - cx, - ) { - self.splice_inlays(&to_remove, to_insert, cx); - } - } - - fn visible_inlay_hints(&self, cx: &Context) -> Vec { - self.display_map - .read(cx) - .current_inlays() - .filter(move |inlay| matches!(inlay.id, InlayId::Hint(_))) - .cloned() - .collect() - } - pub fn visible_excerpts( &self, - restrict_to_languages: Option<&HashSet>>, cx: &mut Context, ) -> HashMap, clock::Global, Range)> { let Some(project) = self.project() else { @@ -5258,9 +5168,8 @@ impl Editor { + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), Bias::Left, ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_visible_range) + .range_to_buffer_ranges(multi_buffer_visible_start..multi_buffer_visible_end) .into_iter() .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { @@ -5268,25 +5177,19 @@ impl Editor { let buffer_worktree = project.worktree_for_id(buffer_file.worktree_id(cx), cx)?; let worktree_entry = buffer_worktree .read(cx) - .entry_for_id(buffer_file.project_entry_id(cx)?)?; + .entry_for_id(buffer_file.project_entry_id()?)?; if worktree_entry.is_ignored { - return None; - } - - let language = buffer.language()?; - if let Some(restrict_to_languages) = restrict_to_languages { - if !restrict_to_languages.contains(language) { - return None; - } + None + } else { + Some(( + excerpt_id, + ( + multi_buffer.buffer(buffer.remote_id()).unwrap(), + buffer.version().clone(), + excerpt_visible_range, + ), + )) } - Some(( - excerpt_id, - ( - multi_buffer.buffer(buffer.remote_id()).unwrap(), - buffer.version().clone(), - excerpt_visible_range, - ), - )) }) .collect() } @@ -5302,18 +5205,6 @@ impl Editor { } } - pub fn splice_inlays( - &self, - to_remove: &[InlayId], - to_insert: Vec, - cx: &mut Context, - ) { - self.display_map.update(cx, |display_map, cx| { - display_map.splice_inlays(to_remove, to_insert, cx) - }); - cx.notify(); - } - fn trigger_on_type_formatting( &self, input: String, @@ -5382,7 +5273,14 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx); + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: true, + }), + None, + window, + cx, + ); } pub fn show_completions( @@ -5418,22 +5316,40 @@ impl Editor { if position.diff_base_anchor.is_some() { return; } - let (buffer, buffer_position) = - if let Some(output) = self.buffer.read(cx).text_anchor_for_position(position, cx) { - output - } else { - return; - }; + let buffer_position = multibuffer_snapshot.anchor_before(position); + let Some(buffer) = buffer_position + .buffer_id + .and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id)) + else { + return; + }; let buffer_snapshot = buffer.read(cx).snapshot(); let query: Option> = - Self::completion_query(&multibuffer_snapshot, position).map(|query| query.into()); + Self::completion_query(&multibuffer_snapshot, buffer_position) + .map(|query| query.into()); drop(multibuffer_snapshot); + // Hide the current completions menu when query is empty. Without this, cached + // completions from before the trigger char may be reused (#32774). + if query.is_none() { + let menu_is_open = matches!( + self.context_menu.borrow().as_ref(), + Some(CodeContextMenu::Completions(_)) + ); + if menu_is_open { + self.hide_context_menu(window, cx); + } + } + + let mut ignore_word_threshold = false; let provider = match requested_source { Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(), - Some(CompletionsMenuSource::Words) => None, + Some(CompletionsMenuSource::Words { ignore_threshold }) => { + ignore_word_threshold = ignore_threshold; + None + } Some(CompletionsMenuSource::SnippetChoices) => { log::error!("bug: SnippetChoices requested_source is not handled"); None @@ -5442,46 +5358,15 @@ impl Editor { let sort_completions = provider .as_ref() - .map_or(false, |provider| provider.sort_completions()); + .is_some_and(|provider| provider.sort_completions()); let filter_completions = provider .as_ref() - .map_or(true, |provider| provider.filter_completions()); + .is_none_or(|provider| provider.filter_completions()); - let trigger_kind = match trigger { - Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => { - CompletionTriggerKind::TRIGGER_CHARACTER - } - _ => CompletionTriggerKind::INVOKED, - }; - let completion_context = CompletionContext { - trigger_character: trigger.and_then(|trigger| { - if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER { - Some(String::from(trigger)) - } else { - None - } - }), - trigger_kind, - }; - - // Hide the current completions menu when a trigger char is typed. Without this, cached - // completions from before the trigger char may be reused (#32774). Snippet choices could - // involve trigger chars, so this is skipped in that case. - if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER && self.snippet_stack.is_empty() - { - let menu_is_open = matches!( - self.context_menu.borrow().as_ref(), - Some(CodeContextMenu::Completions(_)) - ); - if menu_is_open { - self.hide_context_menu(window, cx); - } - } - - if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { - if filter_completions { - menu.filter(query.clone(), provider.clone(), window, cx); + if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { + if filter_completions { + menu.filter(query.clone(), provider.clone(), window, cx); } // When `is_incomplete` is false, no need to re-query completions when the current query // is a suffix of the initial query. @@ -5509,8 +5394,31 @@ impl Editor { } }; + let trigger_kind = match trigger { + Some(trigger) if buffer.read(cx).completion_triggers().contains(trigger) => { + CompletionTriggerKind::TRIGGER_CHARACTER + } + _ => CompletionTriggerKind::INVOKED, + }; + let completion_context = CompletionContext { + trigger_character: trigger.and_then(|trigger| { + if trigger_kind == CompletionTriggerKind::TRIGGER_CHARACTER { + Some(String::from(trigger)) + } else { + None + } + }), + trigger_kind, + }; + + let Anchor { + excerpt_id: buffer_excerpt_id, + text_anchor: buffer_position, + .. + } = buffer_position; + let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) = - buffer_snapshot.surrounding_word(buffer_position, false) + buffer_snapshot.surrounding_word(buffer_position, None) { let word_to_exclude = buffer_snapshot .text_for_range(word_range.clone()) @@ -5528,8 +5436,9 @@ impl Editor { .language_at(buffer_position) .map(|language| language.name()); - let completion_settings = - language_settings(language.clone(), buffer_snapshot.file(), cx).completions; + let completion_settings = language_settings(language.clone(), buffer_snapshot.file(), cx) + .completions + .clone(); let show_completion_documentation = buffer_snapshot .settings_at(buffer_position, cx) @@ -5552,12 +5461,19 @@ impl Editor { let skip_digits = query .as_ref() - .map_or(true, |query| !query.chars().any(|c| c.is_digit(10))); + .is_none_or(|query| !query.chars().any(|c| c.is_digit(10))); + + let omit_word_completions = !self.word_completions_enabled + || (!ignore_word_threshold + && match &query { + Some(query) => query.chars().count() < completion_settings.words_min_length, + None => completion_settings.words_min_length != 0, + }); let (mut words, provider_responses) = match &provider { Some(provider) => { let provider_responses = provider.completions( - position.excerpt_id, + buffer_excerpt_id, &buffer, buffer_position, completion_context, @@ -5565,9 +5481,11 @@ impl Editor { cx, ); - let words = match completion_settings.words { - WordsCompletionMode::Disabled => Task::ready(BTreeMap::default()), - WordsCompletionMode::Enabled | WordsCompletionMode::Fallback => cx + let words = match (omit_word_completions, completion_settings.words) { + (true, _) | (_, WordsCompletionMode::Disabled) => { + Task::ready(BTreeMap::default()) + } + (false, WordsCompletionMode::Enabled | WordsCompletionMode::Fallback) => cx .background_spawn(async move { buffer_snapshot.words_in_range(WordsQuery { fuzzy_contents: None, @@ -5579,16 +5497,20 @@ impl Editor { (words, provider_responses) } - None => ( - cx.background_spawn(async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, + None => { + let words = if omit_word_completions { + Task::ready(BTreeMap::default()) + } else { + cx.background_spawn(async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) }) - }), - Task::ready(Ok(Vec::new())), - ), + }; + (words, Task::ready(Ok(Vec::new()))) + } }; let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; @@ -5605,17 +5527,25 @@ impl Editor { // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. let mut completions = Vec::new(); let mut is_incomplete = false; - if let Some(provider_responses) = provider_responses.await.log_err() { - if !provider_responses.is_empty() { - for response in provider_responses { - completions.extend(response.completions); - is_incomplete = is_incomplete || response.is_incomplete; - } - if completion_settings.words == WordsCompletionMode::Fallback { - words = Task::ready(BTreeMap::default()); + let mut display_options: Option = None; + if let Some(provider_responses) = provider_responses.await.log_err() + && !provider_responses.is_empty() + { + for response in provider_responses { + completions.extend(response.completions); + is_incomplete = is_incomplete || response.is_incomplete; + match display_options.as_mut() { + None => { + display_options = Some(response.display_options); + } + Some(options) => options.merge(&response.display_options), } } + if completion_settings.words == WordsCompletionMode::Fallback { + words = Task::ready(BTreeMap::default()); + } } + let display_options = display_options.unwrap_or_default(); let mut words = words.await; if let Some(word_to_exclude) = &word_to_exclude { @@ -5657,6 +5587,7 @@ impl Editor { is_incomplete, buffer.clone(), completions.into(), + display_options, snippet_sort_order, languages, language, @@ -5678,34 +5609,31 @@ impl Editor { let Ok(()) = editor.update_in(cx, |editor, window, cx| { // Newer menu already set, so exit. - match editor.context_menu.borrow().as_ref() { - Some(CodeContextMenu::Completions(prev_menu)) => { - if prev_menu.id > id { - return; - } - } - _ => {} + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow().as_ref() + && prev_menu.id > id + { + return; }; // Only valid to take prev_menu because it the new menu is immediately set // below, or the menu is hidden. - match editor.context_menu.borrow_mut().take() { - Some(CodeContextMenu::Completions(prev_menu)) => { - let position_matches = - if prev_menu.initial_position == menu.initial_position { - true - } else { - let snapshot = editor.buffer.read(cx).read(cx); - prev_menu.initial_position.to_offset(&snapshot) - == menu.initial_position.to_offset(&snapshot) - }; - if position_matches { - // Preserve markdown cache before `set_filter_results` because it will - // try to populate the documentation cache. - menu.preserve_markdown_cache(prev_menu); - } + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow_mut().take() + { + let position_matches = + if prev_menu.initial_position == menu.initial_position { + true + } else { + let snapshot = editor.buffer.read(cx).read(cx); + prev_menu.initial_position.to_offset(&snapshot) + == menu.initial_position.to_offset(&snapshot) + }; + if position_matches { + // Preserve markdown cache before `set_filter_results` because it will + // try to populate the documentation cache. + menu.preserve_markdown_cache(prev_menu); } - _ => {} }; menu.set_filter_results(matches, provider, window, cx); @@ -5718,21 +5646,21 @@ impl Editor { editor .update_in(cx, |editor, window, cx| { - if editor.focus_handle.is_focused(window) { - if let Some(menu) = menu { - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::Completions(menu)); - - crate::hover_popover::hide_hover(editor, cx); - if editor.show_edit_predictions_in_menu() { - editor.update_visible_edit_prediction(window, cx); - } else { - editor.discard_edit_prediction(false, cx); - } + if editor.focus_handle.is_focused(window) + && let Some(menu) = menu + { + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::Completions(menu)); - cx.notify(); - return; + crate::hover_popover::hide_hover(editor, cx); + if editor.show_edit_predictions_in_menu() { + editor.update_visible_edit_prediction(window, cx); + } else { + editor.discard_edit_prediction(false, cx); } + + cx.notify(); + return; } if editor.completion_tasks.len() <= 1 { @@ -5865,17 +5793,10 @@ impl Editor { let snapshot = self.buffer.read(cx).snapshot(cx); let newest_anchor = self.selections.newest_anchor(); let replace_range_multibuffer = { - let excerpt = snapshot.excerpt_containing(newest_anchor.range()).unwrap(); - let multibuffer_anchor = snapshot - .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.start)) - .unwrap() - ..snapshot - .anchor_in_excerpt(excerpt.id(), buffer.anchor_before(replace_range.end)) - .unwrap(); - multibuffer_anchor.start.to_offset(&snapshot) - ..multibuffer_anchor.end.to_offset(&snapshot) + let mut excerpt = snapshot.excerpt_containing(newest_anchor.range()).unwrap(); + excerpt.map_range_from_buffer(replace_range.clone()) }; - if newest_anchor.head().buffer_id != Some(buffer.remote_id()) { + if snapshot.buffer_id_for_anchor(newest_anchor.head()) != Some(buffer.remote_id()) { return None; } @@ -5893,7 +5814,7 @@ impl Editor { let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; let suffix = &old_text[lookbehind.min(old_text.len())..]; - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let mut ranges = Vec::new(); let mut linked_edits = HashMap::<_, Vec<_>>::default(); @@ -5981,12 +5902,12 @@ impl Editor { editor.refresh_edit_prediction(true, false, window, cx); }); - self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), &snapshot); + self.invalidate_autoclose_regions(&self.selections.disjoint_anchors_arc(), &snapshot); let show_new_completions_on_confirm = completion .confirm .as_ref() - .map_or(false, |confirm| confirm(intent, window, cx)); + .is_some_and(|confirm| confirm(intent, window, cx)); if show_new_completions_on_confirm { self.show_completions(&ShowCompletions { trigger: None }, window, cx); } @@ -6045,10 +5966,13 @@ impl Editor { Some(CodeActionSource::Indicator(row)) | Some(CodeActionSource::RunMenu(row)) => { DisplayPoint::new(*row, 0).to_point(&snapshot) } - _ => self.selections.newest::(cx).head(), + _ => self + .selections + .newest::(&snapshot.display_snapshot) + .head(), }; let Some((buffer, buffer_row)) = snapshot - .buffer_snapshot + .buffer_snapshot() .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) .and_then(|(buffer_snapshot, range)| { self.buffer() @@ -6079,11 +6003,11 @@ impl Editor { Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), _ => { let mut task_context_task = Task::ready(None); - if let Some(tasks) = &tasks { - if let Some(project) = project { - task_context_task = - Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); - } + if let Some(tasks) = &tasks + && let Some(project) = project + { + task_context_task = + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx); } cx.spawn_in(window, { @@ -6096,7 +6020,7 @@ impl Editor { .zip(task_context.clone()) .map(|(tasks, task_context)| ResolvedTasks { templates: tasks.resolve(&task_context).collect(), - position: snapshot.buffer_snapshot.anchor_before(Point::new( + position: snapshot.buffer_snapshot().anchor_before(Point::new( multibuffer_point.row, tasks.column, )), @@ -6118,10 +6042,10 @@ impl Editor { let spawn_straight_away = quick_launch && resolved_tasks .as_ref() - .map_or(false, |tasks| tasks.templates.len() == 1) + .is_some_and(|tasks| tasks.templates.len() == 1) && code_actions .as_ref() - .map_or(true, |actions| actions.is_empty()) + .is_none_or(|actions| actions.is_empty()) && debug_scenarios.is_empty(); editor.update_in(cx, |editor, window, cx| { @@ -6148,14 +6072,14 @@ impl Editor { deployed_from, })); cx.notify(); - if spawn_straight_away { - if let Some(task) = editor.confirm_code_action( + if spawn_straight_away + && let Some(task) = editor.confirm_code_action( &ConfirmCodeAction { item_ix: Some(0) }, window, cx, - ) { - return task; - } + ) + { + return task; } Task::ready(Ok(())) @@ -6196,12 +6120,11 @@ impl Editor { } }); Some(cx.background_spawn(async move { - let scenarios = futures::future::join_all(scenarios) + futures::future::join_all(scenarios) .await .into_iter() .flatten() - .collect::>(); - scenarios + .collect::>() })) }) .unwrap_or_else(|| Task::ready(vec![])) @@ -6299,7 +6222,7 @@ impl Editor { })) } CodeActionsItem::DebugScenario(scenario) => { - let context = actions_menu.actions.context.clone(); + let context = actions_menu.actions.context; workspace.update(cx, |workspace, cx| { dap::send_telemetry(&scenario, TelemetrySpawnLocation::Gutter, cx); @@ -6318,7 +6241,7 @@ impl Editor { } pub async fn open_project_transaction( - this: &WeakEntity, + editor: &WeakEntity, workspace: WeakEntity, transaction: ProjectTransaction, title: String, @@ -6330,38 +6253,36 @@ impl Editor { buffer.read(cx).file().map(|f| f.path().clone()) }); })?; + if entries.is_empty() { + return Ok(()); + } // If the project transaction's edits are all contained within this editor, then // avoid opening a new editor to display them. - if let Some((buffer, transaction)) = entries.first() { - if entries.len() == 1 { - let excerpt = this.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .excerpt_containing(editor.selections.newest_anchor().head(), cx) + if let [(buffer, transaction)] = &*entries { + let excerpt = editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .excerpt_containing(editor.selections.newest_anchor().head(), cx) + })?; + if let Some((_, excerpted_buffer, excerpt_range)) = excerpt + && excerpted_buffer == *buffer + { + let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { + let excerpt_range = excerpt_range.to_offset(buffer); + buffer + .edited_ranges_for_transaction::(transaction) + .all(|range| { + excerpt_range.start <= range.start && excerpt_range.end >= range.end + }) })?; - if let Some((_, excerpted_buffer, excerpt_range)) = excerpt { - if excerpted_buffer == *buffer { - let all_edits_within_excerpt = buffer.read_with(cx, |buffer, _| { - let excerpt_range = excerpt_range.to_offset(buffer); - buffer - .edited_ranges_for_transaction::(transaction) - .all(|range| { - excerpt_range.start <= range.start - && excerpt_range.end >= range.end - }) - })?; - if all_edits_within_excerpt { - return Ok(()); - } - } + if all_edits_within_excerpt { + return Ok(()); } } - } else { - return Ok(()); } let mut ranges_to_highlight = Vec::new(); @@ -6376,7 +6297,7 @@ impl Editor { PathKey::for_buffer(buffer_handle, cx), buffer_handle.clone(), edited_ranges, - DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); @@ -6464,7 +6385,7 @@ impl Editor { .when(show_tooltip, |this| { this.tooltip({ let focus_handle = self.focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Toggle Code Actions", &ToggleCodeActions { @@ -6472,7 +6393,6 @@ impl Editor { quick_launch: false, }, &focus_handle, - window, cx, ) } @@ -6498,26 +6418,34 @@ impl Editor { &self.context_menu } - fn refresh_code_actions(&mut self, window: &mut Window, cx: &mut Context) -> Option<()> { - let newest_selection = self.selections.newest_anchor().clone(); - let newest_selection_adjusted = self.selections.newest_adjusted(cx).clone(); - let buffer = self.buffer.read(cx); - if newest_selection.head().diff_base_anchor.is_some() { - return None; - } - let (start_buffer, start) = - buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; - if start_buffer != end_buffer { - return None; - } - + fn refresh_code_actions(&mut self, window: &mut Window, cx: &mut Context) { self.code_actions_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) .await; + let (start_buffer, start, _, end, newest_selection) = this + .update(cx, |this, cx| { + let newest_selection = this.selections.newest_anchor().clone(); + if newest_selection.head().diff_base_anchor.is_some() { + return None; + } + let display_snapshot = this.display_snapshot(cx); + let newest_selection_adjusted = + this.selections.newest_adjusted(&display_snapshot); + let buffer = this.buffer.read(cx); + + let (start_buffer, start) = + buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; + let (end_buffer, end) = + buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; + + Some((start_buffer, start, end_buffer, end, newest_selection)) + })? + .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer) + .context( + "Expected selection to lie in a single buffer when refreshing code actions", + )?; let (providers, tasks) = this.update_in(cx, |this, window, cx| { let providers = this.code_action_providers.clone(); let tasks = this @@ -6558,7 +6486,6 @@ impl Editor { cx.notify(); }) })); - None } fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { @@ -6580,8 +6507,11 @@ impl Editor { pub fn blame_hover(&mut self, _: &BlameHover, window: &mut Window, cx: &mut Context) { let snapshot = self.snapshot(window, cx); - let cursor = self.selections.newest::(cx).head(); - let Some((buffer, point, _)) = snapshot.buffer_snapshot.point_to_buffer_point(cursor) + let cursor = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let Some((buffer, point, _)) = snapshot.buffer_snapshot().point_to_buffer_point(cursor) else { return; }; @@ -6595,7 +6525,7 @@ impl Editor { buffer_row: Some(point.row), ..Default::default() }; - let Some(blame_entry) = blame + let Some((buffer, blame_entry)) = blame .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) .flatten() else { @@ -6605,12 +6535,19 @@ impl Editor { let anchor = self.selections.newest_anchor().head(); let position = self.to_pixel_point(anchor, &snapshot, window); if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { - self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx); + self.show_blame_popover( + buffer, + &blame_entry, + position + last_bounds.origin, + true, + cx, + ); }; } fn show_blame_popover( &mut self, + buffer: BufferId, blame_entry: &BlameEntry, position: gpui::Point, ignore_timeout: bool, @@ -6619,7 +6556,7 @@ impl Editor { if let Some(state) = &mut self.inline_blame_popover { state.hide_task.take(); } else { - let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; + let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let blame_entry = blame_entry.clone(); let show_task = cx.spawn(async move |editor, cx| { if !ignore_timeout { @@ -6634,7 +6571,7 @@ impl Editor { return; }; let blame = blame.read(cx); - let details = blame.details_for_entry(&blame_entry); + let details = blame.details_for_entry(buffer, &blame_entry); let markdown = cx.new(|cx| { Markdown::new( details @@ -6665,13 +6602,15 @@ impl Editor { } } - fn hide_blame_popover(&mut self, cx: &mut Context) { + fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { self.inline_blame_popover_show_task.take(); if let Some(state) = &mut self.inline_blame_popover { let hide_task = cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(100)) - .await; + if !ignore_timeout { + cx.background_executor() + .timer(std::time::Duration::from_millis(100)) + .await; + } editor .update(cx, |editor, cx| { editor.inline_blame_popover.take(); @@ -6680,6 +6619,9 @@ impl Editor { .ok(); }); state.hide_task = Some(hide_task); + true + } else { + false } } @@ -6701,8 +6643,8 @@ impl Editor { } let snapshot = cursor_buffer.read(cx).snapshot(); - let (start_word_range, _) = snapshot.surrounding_word(cursor_buffer_position, false); - let (end_word_range, _) = snapshot.surrounding_word(tail_buffer_position, false); + let (start_word_range, _) = snapshot.surrounding_word(cursor_buffer_position, None); + let (end_word_range, _) = snapshot.surrounding_word(tail_buffer_position, None); if start_word_range != end_word_range { self.document_highlights_task.take(); self.clear_background_highlights::(cx); @@ -6710,7 +6652,7 @@ impl Editor { return None; } - let debounce = EditorSettings::get_global(cx).lsp_highlight_debounce; + let debounce = EditorSettings::get_global(cx).lsp_highlight_debounce.0; self.document_highlights_task = Some(cx.spawn(async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(debounce)) @@ -6734,11 +6676,10 @@ impl Editor { return; } - let buffer_id = cursor_position.buffer_id; let buffer = this.buffer.read(cx); - if !buffer + if buffer .text_anchor_for_position(cursor_position, cx) - .map_or(false, |(buffer, _)| buffer == cursor_buffer) + .is_none_or(|(buffer, _)| buffer != cursor_buffer) { return; } @@ -6747,8 +6688,8 @@ impl Editor { let mut write_ranges = Vec::new(); let mut read_ranges = Vec::new(); for highlight in highlights { - for (excerpt_id, excerpt_range) in - buffer.excerpts_for_buffer(cursor_buffer.read(cx).remote_id(), cx) + let buffer_id = cursor_buffer.read(cx).remote_id(); + for (excerpt_id, excerpt_range) in buffer.excerpts_for_buffer(buffer_id, cx) { let start = highlight .range @@ -6762,17 +6703,8 @@ impl Editor { continue; } - let range = Anchor { - buffer_id, - excerpt_id, - text_anchor: start, - diff_base_anchor: None, - }..Anchor { - buffer_id, - excerpt_id, - text_anchor: end, - diff_base_anchor: None, - }; + let range = + Anchor::range_in_buffer(excerpt_id, buffer_id, *start..*end); if highlight.kind == lsp::DocumentHighlightKind::WRITE { write_ranges.push(range); } else { @@ -6801,24 +6733,29 @@ impl Editor { fn prepare_highlight_query_from_selection( &mut self, + window: &Window, cx: &mut Context, ) -> Option<(String, Range)> { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { return None; } if !EditorSettings::get_global(cx).selection_highlight { return None; } - if self.selections.count() != 1 || self.selections.line_mode { + if self.selections.count() != 1 || self.selections.line_mode() { return None; } - let selection = self.selections.newest::(cx); - if selection.is_empty() || selection.start.row != selection.end.row { + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest::(&snapshot); + // If the selection spans multiple rows OR it is empty + if selection.start.row != selection.end.row + || selection.start.column == selection.end.column + { return None; } - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let selection_anchor_range = selection.range().to_anchors(&multi_buffer_snapshot); - let query = multi_buffer_snapshot + let selection_anchor_range = selection.range().to_anchors(snapshot.buffer_snapshot()); + let query = snapshot + .buffer_snapshot() .text_for_range(selection_anchor_range.clone()) .collect::(); if query.trim().is_empty() { @@ -6861,10 +6798,11 @@ impl Editor { ) else { return Vec::default(); }; + let query_range = query_range.to_anchors(&multi_buffer_snapshot); for (buffer_snapshot, search_range, excerpt_id) in buffer_ranges { match_ranges.extend( regex - .search(&buffer_snapshot, Some(search_range.clone())) + .search(buffer_snapshot, Some(search_range.clone())) .await .into_iter() .filter_map(|match_range| { @@ -6906,7 +6844,7 @@ impl Editor { return; } let snapshot = self.snapshot(window, cx); - if snapshot.buffer_snapshot.max_point().row == 0 { + if snapshot.buffer_snapshot().max_point().row == 0 { return; } let task = cx.background_spawn(async move { @@ -6915,8 +6853,8 @@ impl Editor { .filter_map(|(c, i)| { if c == '\n' { Some( - snapshot.buffer_snapshot.anchor_after(i) - ..snapshot.buffer_snapshot.anchor_before(i + 1), + snapshot.buffer_snapshot().anchor_after(i) + ..snapshot.buffer_snapshot().anchor_before(i + 1), ) } else { None @@ -6924,7 +6862,7 @@ impl Editor { }) .collect::>(); let existing_newlines = snapshot - .folds_in_range(0..snapshot.buffer_snapshot.len()) + .folds_in_range(0..snapshot.buffer_snapshot().len()) .filter_map(|fold| { if fold.placeholder.type_tag == Some(type_id) { Some(fold.range.start..fold.range.end) @@ -6976,7 +6914,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let Some((query_text, query_range)) = self.prepare_highlight_query_from_selection(cx) + let Some((query_text, query_range)) = + self.prepare_highlight_query_from_selection(window, cx) else { self.clear_background_highlights::(cx); self.quick_selection_highlight_task.take(); @@ -6988,9 +6927,7 @@ impl Editor { || self .quick_selection_highlight_task .as_ref() - .map_or(true, |(prev_anchor_range, _)| { - prev_anchor_range != &query_range - }) + .is_none_or(|(prev_anchor_range, _)| prev_anchor_range != &query_range) { let multi_buffer_visible_start = self .scroll_manager @@ -7019,9 +6956,7 @@ impl Editor { || self .debounced_selection_highlight_task .as_ref() - .map_or(true, |(prev_anchor_range, _)| { - prev_anchor_range != &query_range - }) + .is_none_or(|(prev_anchor_range, _)| prev_anchor_range != &query_range) { let multi_buffer_start = multi_buffer_snapshot .anchor_before(0) @@ -7065,6 +7000,8 @@ impl Editor { return None; } + self.update_visible_edit_prediction(window, cx); + if !user_requested && (!self.should_show_edit_predictions() || !self.is_focused(window) @@ -7074,14 +7011,7 @@ impl Editor { return None; } - self.update_visible_edit_prediction(window, cx); - provider.refresh( - self.project.clone(), - buffer, - cursor_buffer_position, - debounce, - cx, - ); + provider.refresh(buffer, cursor_buffer_position, debounce, cx); Some(()) } @@ -7156,9 +7086,7 @@ impl Editor { && self .edit_prediction_provider .as_ref() - .map_or(false, |provider| { - provider.provider.show_completions_in_menu() - }); + .is_some_and(|provider| provider.provider.show_completions_in_menu()); let preview_requires_modifier = all_language_settings(file, cx).edit_predictions_mode() == EditPredictionsMode::Subtle; @@ -7192,7 +7120,7 @@ impl Editor { } pub fn supports_minimap(&self, cx: &App) -> bool { - !self.minimap_visibility.disabled() && self.is_singleton(cx) + !self.minimap_visibility.disabled() && self.buffer_kind(cx) == ItemBufferKind::Singleton } fn edit_predictions_enabled_in_buffer( @@ -7206,7 +7134,7 @@ impl Editor { return Some(false); } let provider = self.edit_prediction_provider()?; - if !provider.is_enabled(&buffer, buffer_position, cx) { + if !provider.is_enabled(buffer, buffer_position, cx) { return Some(false); } let buffer = buffer.read(cx); @@ -7326,10 +7254,8 @@ impl Editor { return; }; - self.report_edit_prediction_event(active_edit_prediction.completion_id.clone(), true, cx); - match &active_edit_prediction.completion { - EditPrediction::Move { target, .. } => { + EditPrediction::MoveWithin { target, .. } => { let target = *target; if let Some(position_map) = &self.last_position_map { @@ -7371,7 +7297,19 @@ impl Editor { } } } + EditPrediction::MoveOutside { snapshot, target } => { + if let Some(workspace) = self.workspace() { + Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) + .detach_and_log_err(cx); + } + } EditPrediction::Edit { edits, .. } => { + self.report_edit_prediction_event( + active_edit_prediction.completion_id.clone(), + true, + cx, + ); + if let Some(provider) = self.edit_prediction_provider() { provider.accept(cx); } @@ -7390,7 +7328,7 @@ impl Editor { s.select_anchor_ranges([last_edit_end..last_edit_end]); }); - let selections = self.selections.disjoint_anchors(); + let selections = self.selections.disjoint_anchors_arc(); if let Some(transaction_id_now) = self.buffer.read(cx).last_transaction_id(cx) { let has_new_transaction = transaction_id_prev != Some(transaction_id_now); if has_new_transaction { @@ -7424,10 +7362,8 @@ impl Editor { return; } - self.report_edit_prediction_event(active_edit_prediction.completion_id.clone(), true, cx); - match &active_edit_prediction.completion { - EditPrediction::Move { target, .. } => { + EditPrediction::MoveWithin { target, .. } => { let target = *target; self.change_selections( SelectionEffects::scroll(Autoscroll::newest()), @@ -7438,10 +7374,25 @@ impl Editor { }, ); } + EditPrediction::MoveOutside { snapshot, target } => { + if let Some(workspace) = self.workspace() { + Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) + .detach_and_log_err(cx); + } + } EditPrediction::Edit { edits, .. } => { + self.report_edit_prediction_event( + active_edit_prediction.completion_id.clone(), + true, + cx, + ); + // Find an insertion that starts at the cursor position. let snapshot = self.buffer.read(cx).snapshot(cx); - let cursor_offset = self.selections.newest::(cx).head(); + let cursor_offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); let insertion = edits.iter().find_map(|(range, text)| { let range = range.to_offset(&snapshot); if range.is_empty() && range.start == cursor_offset { @@ -7518,7 +7469,7 @@ impl Editor { let extension = buffer .read(cx) .file() - .and_then(|file| Some(file.path().extension()?.to_string_lossy().to_string())); + .and_then(|file| Some(file.path().extension()?.to_string())); let event_type = match accepted { true => "Edit Prediction Accepted", @@ -7533,6 +7484,36 @@ impl Editor { ); } + fn open_editor_at_anchor( + snapshot: &language::BufferSnapshot, + target: language::Anchor, + workspace: &Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + workspace.update(cx, |workspace, cx| { + let path = snapshot.file().map(|file| file.full_path(cx)); + let Some(path) = + path.and_then(|path| workspace.project().read(cx).find_project_path(path, cx)) + else { + return Task::ready(Err(anyhow::anyhow!("Project path not found"))); + }; + let target = text::ToPoint::to_point(&target, snapshot); + let item = workspace.open_path(path, None, true, window, cx); + window.spawn(cx, async move |cx| { + let Some(editor) = item.await?.downcast::() else { + return Ok(()); + }; + editor + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(target, window, cx); + }) + .ok(); + anyhow::Ok(()) + }) + }) + } + pub fn has_active_edit_prediction(&self) -> bool { self.active_edit_prediction.is_some() } @@ -7635,7 +7616,7 @@ impl Editor { let Some(mode) = Self::columnar_selection_mode(modifiers, cx) else { return; }; - if self.selections.pending.is_none() { + if self.selections.pending_anchor().is_none() { return; } @@ -7667,16 +7648,16 @@ impl Editor { .keystroke() { modifiers_held = modifiers_held - || (&accept_keystroke.modifiers == modifiers - && accept_keystroke.modifiers.modified()); + || (accept_keystroke.modifiers() == modifiers + && accept_keystroke.modifiers().modified()); }; if let Some(accept_partial_keystroke) = self .accept_edit_prediction_keybind(true, window, cx) .keystroke() { modifiers_held = modifiers_held - || (&accept_partial_keystroke.modifiers == modifiers - && accept_partial_keystroke.modifiers.modified()); + || (accept_partial_keystroke.modifiers() == modifiers + && accept_partial_keystroke.modifiers().modified()); } if modifiers_held { @@ -7726,6 +7707,11 @@ impl Editor { return None; } + if self.ime_transaction.is_some() { + self.discard_edit_prediction(false, cx); + return None; + } + let selection = self.selections.newest_anchor(); let cursor = selection.head(); let multibuffer = self.buffer.read(cx).snapshot(cx); @@ -7742,8 +7728,11 @@ impl Editor { || self .active_edit_prediction .as_ref() - .map_or(false, |completion| { - let invalidation_range = completion.invalidation_range.to_offset(&multibuffer); + .is_some_and(|completion| { + let Some(invalidation_range) = completion.invalidation_range.as_ref() else { + return false; + }; + let invalidation_range = invalidation_range.to_offset(&multibuffer); let invalidation_range = invalidation_range.start..=invalidation_range.end; !invalidation_range.contains(&offset_selection.head()) }) @@ -7771,21 +7760,45 @@ impl Editor { let indents = multibuffer.suggested_indents(cursor_point.row..cursor_point.row + 1, cx); - if let Some((_, indent)) = indents.iter().next() { - if indent.len == cursor_point.column { - self.edit_prediction_indent_conflict = false; - } + if let Some((_, indent)) = indents.iter().next() + && indent.len == cursor_point.column + { + self.edit_prediction_indent_conflict = false; } } let edit_prediction = provider.suggest(&buffer, cursor_buffer_position, cx)?; - let edits = edit_prediction - .edits + + let (completion_id, edits, edit_preview) = match edit_prediction { + edit_prediction::EditPrediction::Local { + id, + edits, + edit_preview, + } => (id, edits, edit_preview), + edit_prediction::EditPrediction::Jump { + id, + snapshot, + target, + } => { + self.stale_edit_prediction_in_menu = None; + self.active_edit_prediction = Some(EditPredictionState { + inlay_ids: vec![], + completion: EditPrediction::MoveOutside { snapshot, target }, + completion_id: id, + invalidation_range: None, + }); + cx.notify(); + return Some(()); + } + }; + + let edits = edits .into_iter() .flat_map(|(range, new_text)| { - let start = multibuffer.anchor_in_excerpt(excerpt_id, range.start)?; - let end = multibuffer.anchor_in_excerpt(excerpt_id, range.end)?; - Some((start..end, new_text)) + Some(( + multibuffer.anchor_range_in_excerpt(excerpt_id, range)?, + new_text, + )) }) .collect::>(); if edits.is_empty() { @@ -7825,7 +7838,7 @@ impl Editor { invalidation_row_range = move_invalidation_row_range.unwrap_or(edit_start_row..edit_end_row); let target = first_edit_start; - EditPrediction::Move { target, snapshot } + EditPrediction::MoveWithin { target, snapshot } } else { let show_completions_in_buffer = !self.edit_prediction_visible_in_cursor_popover(true) && !self.edit_predictions_hidden_for_vim_mode; @@ -7874,7 +7887,7 @@ impl Editor { EditPrediction::Edit { edits, - edit_preview: edit_prediction.edit_preview, + edit_preview, display_mode, snapshot, } @@ -7891,8 +7904,8 @@ impl Editor { self.active_edit_prediction = Some(EditPredictionState { inlay_ids, completion, - completion_id: edit_prediction.id, - invalidation_range, + completion_id, + invalidation_range: Some(invalidation_range), }); cx.notify(); @@ -7936,7 +7949,7 @@ impl Editor { let snapshot = self.snapshot(window, cx); - let multi_buffer_snapshot = &snapshot.display_snapshot.buffer_snapshot; + let multi_buffer_snapshot = snapshot.buffer_snapshot(); let Some(project) = self.project() else { return breakpoint_display_points; }; @@ -8220,8 +8233,6 @@ impl Editor { .icon_color(color) .style(ButtonStyle::Transparent) .on_click(cx.listener({ - let breakpoint = breakpoint.clone(); - move |editor, event: &ClickEvent, window, cx| { let edit_action = if event.modifiers().platform || breakpoint.is_disabled() { BreakpointEditAction::InvertState @@ -8247,13 +8258,12 @@ impl Editor { cx, ); })) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta_in( primary_action_text, Some(&ToggleBreakpoint), meta.clone(), &focus_handle, - window, cx, ) }) @@ -8337,7 +8347,11 @@ impl Editor { &mut self, cx: &mut Context, ) -> Option<(Entity, u32, Arc)> { - let cursor_row = self.selections.newest_adjusted(cx).head().row; + let cursor_row = self + .selections + .newest_adjusted(&self.display_snapshot(cx)) + .head() + .row; let ((buffer_id, row), tasks) = self .tasks @@ -8354,7 +8368,10 @@ impl Editor { cx: &mut Context, ) -> Option<(Entity, u32, Arc)> { let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self.selections.newest::(cx).head(); + let offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); let excerpt = snapshot.excerpt_containing(offset..offset)?; let buffer_id = excerpt.buffer().remote_id(); @@ -8435,7 +8452,7 @@ impl Editor { .context_menu .borrow() .as_ref() - .map_or(false, |menu| menu.visible()) + .is_some_and(|menu| menu.visible()) } pub fn context_menu_origin(&self) -> Option { @@ -8449,8 +8466,8 @@ impl Editor { self.context_menu_options = Some(options); } - const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = Pixels(24.); - const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = Pixels(2.); + const EDIT_PREDICTION_POPOVER_PADDING_X: Pixels = px(24.); + const EDIT_PREDICTION_POPOVER_PADDING_Y: Pixels = px(2.); fn render_edit_prediction_popover( &mut self, @@ -8459,11 +8476,12 @@ impl Editor { right_margin: Pixels, editor_snapshot: &EditorSnapshot, visible_row_range: Range, - scroll_top: f32, - scroll_bottom: f32, + scroll_top: ScrollOffset, + scroll_bottom: ScrollOffset, line_layouts: &[LineWithInvisibles], line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, newest_selection_head: Option, editor_width: Pixels, style: &EditorStyle, @@ -8480,7 +8498,7 @@ impl Editor { } match &active_edit_prediction.completion { - EditPrediction::Move { target, .. } => { + EditPrediction::MoveWithin { target, .. } => { let target_display_point = target.to_display_point(editor_snapshot); if self.edit_prediction_requires_modifier() { @@ -8555,6 +8573,7 @@ impl Editor { visible_row_range, line_layouts, line_height, + scroll_position, scroll_pixel_position, newest_selection_head, editor_width, @@ -8565,6 +8584,28 @@ impl Editor { window, cx, ), + EditPrediction::MoveOutside { snapshot, .. } => { + let file_name = snapshot + .file() + .map(|file| file.file_name(cx)) + .unwrap_or("untitled"); + let mut element = self + .render_edit_prediction_line_popover( + format!("Jump to {file_name}"), + Some(IconName::ZedPredict), + window, + cx, + ) + .into_any(); + + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); + let origin_x = text_bounds.size.width / 2. - size.width / 2.; + let origin_y = text_bounds.size.height - size.height - px(30.); + let origin = text_bounds.origin + gpui::Point::new(origin_x, origin_y); + element.prepaint_at(origin, window, cx); + + Some((element, origin)) + } } } @@ -8575,14 +8616,14 @@ impl Editor { visible_row_range: Range, line_layouts: &[LineWithInvisibles], line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, newest_selection_head: Option, target_display_point: DisplayPoint, window: &mut Window, cx: &mut App, ) -> Option<(AnyElement, gpui::Point)> { let scrolled_content_origin = - content_origin - gpui::Point::new(scroll_pixel_position.x, Pixels(0.0)); + content_origin - gpui::Point::new(scroll_pixel_position.x.into(), Pixels::ZERO); const SCROLL_PADDING_Y: Pixels = px(12.); @@ -8617,8 +8658,8 @@ impl Editor { let target_column = target_display_point.column() as usize; let target_x = line_layout.x_for_index(target_column); - let target_y = - (target_display_point.row().as_f32() * line_height) - scroll_pixel_position.y; + let target_y = (target_display_point.row().as_f64() * f64::from(line_height)) + - scroll_pixel_position.y; let flag_on_right = target_x < text_bounds.size.width / 2.; @@ -8629,13 +8670,13 @@ impl Editor { .items_end() .when(flag_on_right, |el| el.items_start()) .child(if flag_on_right { - self.render_edit_prediction_line_popover("Jump", None, window, cx)? + self.render_edit_prediction_line_popover("Jump", None, window, cx) .rounded_bl(px(0.)) .rounded_tl(px(0.)) .border_l_2() .border_color(border_color) } else { - self.render_edit_prediction_line_popover("Jump", None, window, cx)? + self.render_edit_prediction_line_popover("Jump", None, window, cx) .rounded_br(px(0.)) .rounded_tr(px(0.)) .border_r_2() @@ -8646,7 +8687,7 @@ impl Editor { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let mut origin = scrolled_content_origin + point(target_x, target_y) + let mut origin = scrolled_content_origin + point(target_x, target_y.into()) - point( if flag_on_right { POLE_WIDTH @@ -8675,7 +8716,7 @@ impl Editor { cx: &mut App, ) -> Option<(AnyElement, gpui::Point)> { let mut element = self - .render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx)? + .render_edit_prediction_line_popover("Scroll", Some(scroll_icon), window, cx) .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -8699,23 +8740,23 @@ impl Editor { content_origin: gpui::Point, editor_snapshot: &EditorSnapshot, visible_row_range: Range, - scroll_top: f32, - scroll_bottom: f32, + scroll_top: ScrollOffset, + scroll_bottom: ScrollOffset, line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, target_display_point: DisplayPoint, editor_width: Pixels, window: &mut Window, cx: &mut App, ) -> Option<(AnyElement, gpui::Point)> { - if target_display_point.row().as_f32() < scroll_top { + if target_display_point.row().as_f64() < scroll_top { let mut element = self .render_edit_prediction_line_popover( "Jump to Edit", Some(IconName::ArrowUp), window, cx, - )? + ) .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -8727,14 +8768,14 @@ impl Editor { let origin = text_bounds.origin + offset; element.prepaint_at(origin, window, cx); Some((element, origin)) - } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom { + } else if (target_display_point.row().as_f64() + 1.) > scroll_bottom { let mut element = self .render_edit_prediction_line_popover( "Jump to Edit", Some(IconName::ArrowDown), window, cx, - )? + ) .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -8769,7 +8810,7 @@ impl Editor { visible_row_range: Range, target_display_point: DisplayPoint, line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, content_origin: gpui::Point, editor_width: Pixels, window: &mut Window, @@ -8781,14 +8822,14 @@ impl Editor { ); let mut element = self - .render_edit_prediction_line_popover(label, None, window, cx)? + .render_edit_prediction_line_popover(label, None, window, cx) .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let line_origin = self.display_to_pixel_point(target_line_end, editor_snapshot, window)?; - let start_point = content_origin - point(scroll_pixel_position.x, Pixels::ZERO); + let start_point = content_origin - point(scroll_pixel_position.x.into(), Pixels::ZERO); let mut origin = start_point + line_origin + point(Self::EDIT_PREDICTION_POPOVER_PADDING_X, Pixels::ZERO); @@ -8808,7 +8849,7 @@ impl Editor { }; element = self - .render_edit_prediction_line_popover(label, Some(icon), window, cx)? + .render_edit_prediction_line_popover(label, Some(icon), window, cx) .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -8829,7 +8870,8 @@ impl Editor { visible_row_range: Range, line_layouts: &[LineWithInvisibles], line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, newest_selection_head: Option, editor_width: Pixels, style: &EditorStyle, @@ -8859,7 +8901,7 @@ impl Editor { } let highlighted_edits = if let Some(edit_preview) = edit_preview.as_ref() { - crate::edit_prediction_edit_text(&snapshot, edits, edit_preview, false, cx) + crate::edit_prediction_edit_text(snapshot, edits, edit_preview, false, cx) } else { // Fallback for providers without edit_preview crate::edit_prediction_fallback_text(edits, cx) @@ -8942,9 +8984,11 @@ impl Editor { ..Default::default() }); - let x_after_longest = - text_bounds.origin.x + longest_line_width + Self::EDIT_PREDICTION_POPOVER_PADDING_X - - scroll_pixel_position.x; + let x_after_longest = Pixels::from( + ScrollPixelOffset::from( + text_bounds.origin.x + longest_line_width + Self::EDIT_PREDICTION_POPOVER_PADDING_X, + ) - scroll_pixel_position.x, + ); let element_bounds = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -8956,8 +9000,11 @@ impl Editor { let mut origin = if can_position_to_the_right { point( x_after_longest, - text_bounds.origin.y + edit_start.row().as_f32() * line_height - - scroll_pixel_position.y, + text_bounds.origin.y + + Pixels::from( + edit_start.row().as_f64() * ScrollPixelOffset::from(line_height) + - scroll_pixel_position.y, + ), ) } else { let cursor_row = newest_selection_head.map(|head| head.row()); @@ -8981,15 +9028,16 @@ impl Editor { let end_row = start_row + line_count as u32; visible_row_range.contains(&start_row) && visible_row_range.contains(&end_row) - && cursor_row.map_or(true, |cursor_row| { - !((start_row..end_row).contains(&cursor_row)) - }) + && cursor_row + .is_none_or(|cursor_row| !((start_row..end_row).contains(&cursor_row))) })?; content_origin + point( - -scroll_pixel_position.x, - row_target.as_f32() * line_height - scroll_pixel_position.y, + Pixels::from(-scroll_pixel_position.x), + Pixels::from( + (row_target.as_f64() - scroll_position.y) * f64::from(line_height), + ), ) }; @@ -9016,14 +9064,14 @@ impl Editor { fn render_edit_prediction_accept_keybind( &self, window: &mut Window, - cx: &App, + cx: &mut App, ) -> Option { let accept_binding = self.accept_edit_prediction_keybind(false, window, cx); let accept_keystroke = accept_binding.keystroke()?; let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; - let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { + let modifiers_color = if *accept_keystroke.modifiers() == window.modifiers() { Color::Accent } else { Color::Muted @@ -9035,19 +9083,19 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(modifiers_color), Some(IconSize::XSmall.rems().into()), true, ))) .when(is_platform_style_mac, |parent| { - parent.child(accept_keystroke.key.clone()) + parent.child(accept_keystroke.key().to_string()) }) .when(!is_platform_style_mac, |parent| { parent.child( Key::new( - util::capitalize(&accept_keystroke.key), + util::capitalize(accept_keystroke.key()), Some(Color::Default), ) .size(Some(IconSize::XSmall.rems().into())), @@ -9062,14 +9110,14 @@ impl Editor { label: impl Into, icon: Option, window: &mut Window, - cx: &App, - ) -> Option> { + cx: &mut App, + ) -> Stateful
{ let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; let keybind = self.render_edit_prediction_accept_keybind(window, cx); let has_keybind = keybind.is_some(); - let result = h_flex() + h_flex() .id("ep-line-popover") .py_0p5() .pl_1() @@ -9115,9 +9163,7 @@ impl Editor { .mt(px(1.5)) .child(Icon::new(icon).size(IconSize::Small)), ) - }); - - Some(result) + }) } fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla { @@ -9150,52 +9196,13 @@ impl Editor { max_width: Pixels, cursor_point: Point, style: &EditorStyle, - accept_keystroke: Option<&gpui::Keystroke>, + accept_keystroke: Option<&gpui::KeybindingKeystroke>, _window: &Window, cx: &mut Context, ) -> Option { let provider = self.edit_prediction_provider.as_ref()?; let provider_icon = Self::get_prediction_provider_icon_name(&self.edit_prediction_provider); - if provider.provider.needs_terms_acceptance(cx) { - return Some( - h_flex() - .min_w(min_width) - .flex_1() - .px_2() - .py_1() - .gap_3() - .elevation_2(cx) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .id("accept-terms") - .cursor_pointer() - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .on_click(cx.listener(|this, _event, window, cx| { - cx.stop_propagation(); - this.report_editor_event(ReportEditorEvent::ZetaTosClicked, None, cx); - window.dispatch_action( - zed_actions::OpenZedPredictOnboarding.boxed_clone(), - cx, - ); - })) - .child( - h_flex() - .flex_1() - .gap_2() - .child(Icon::new(provider_icon)) - .child(Label::new("Accept Terms of Service")) - .child(div().w_full()) - .child( - Icon::new(IconName::ArrowUpRight) - .color(Color::Muted) - .size(IconSize::Small), - ) - .into_any_element(), - ) - .into_any(), - ); - } - let is_refreshing = provider.provider.is_refreshing(cx); fn pending_completion_container(icon: IconName) -> Div { @@ -9220,15 +9227,19 @@ impl Editor { .rounded_tl(px(0.)) .overflow_hidden() .child(div().px_1p5().child(match &prediction.completion { - EditPrediction::Move { target, snapshot } => { + EditPrediction::MoveWithin { target, snapshot } => { use text::ToPoint as _; - if target.text_anchor.to_point(&snapshot).row > cursor_point.row + if target.text_anchor.to_point(snapshot).row > cursor_point.row { Icon::new(IconName::ZedPredictDown) } else { Icon::new(IconName::ZedPredictUp) } } + EditPrediction::MoveOutside { .. } => { + // TODO [zeta2] custom icon for external jump? + Icon::new(provider_icon) + } EditPrediction::Edit { .. } => Icon::new(provider_icon), })) .child( @@ -9267,7 +9278,7 @@ impl Editor { accept_keystroke.as_ref(), |el, accept_keystroke| { el.child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(Color::Default), Some(IconSize::XSmall.rems().into()), @@ -9337,7 +9348,7 @@ impl Editor { .child(completion), ) .when_some(accept_keystroke, |el, accept_keystroke| { - if !accept_keystroke.modifiers.modified() { + if !accept_keystroke.modifiers().modified() { return el; } @@ -9356,7 +9367,7 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + accept_keystroke.modifiers(), PlatformStyle::platform(), Some(if !has_completion { Color::Muted @@ -9411,7 +9422,7 @@ impl Editor { .unwrap_or(true); match &completion.completion { - EditPrediction::Move { + EditPrediction::MoveWithin { target, snapshot, .. } => { if !supports_jump { @@ -9424,7 +9435,7 @@ impl Editor { .gap_2() .flex_1() .child( - if target.text_anchor.to_point(&snapshot).row > cursor_point.row { + if target.text_anchor.to_point(snapshot).row > cursor_point.row { Icon::new(IconName::ZedPredictDown) } else { Icon::new(IconName::ZedPredictUp) @@ -9433,21 +9444,34 @@ impl Editor { .child(Label::new("Jump to Edit")), ) } - + EditPrediction::MoveOutside { snapshot, .. } => { + let file_name = snapshot + .file() + .map(|file| file.file_name(cx)) + .unwrap_or("untitled"); + Some( + h_flex() + .px_2() + .gap_2() + .flex_1() + .child(Icon::new(IconName::ZedPredict)) + .child(Label::new(format!("Jump to {file_name}"))), + ) + } EditPrediction::Edit { edits, edit_preview, snapshot, display_mode: _, } => { - let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row; + let first_edit_row = edits.first()?.0.start.text_anchor.to_point(snapshot).row; let (highlighted_edits, has_more_lines) = if let Some(edit_preview) = edit_preview.as_ref() { - crate::edit_prediction_edit_text(&snapshot, &edits, edit_preview, true, cx) + crate::edit_prediction_edit_text(snapshot, edits, edit_preview, true, cx) .first_line_preview() } else { - crate::edit_prediction_fallback_text(&edits, cx).first_line_preview() + crate::edit_prediction_fallback_text(edits, cx).first_line_preview() }; let styled_text = gpui::StyledText::new(highlighted_edits.text) @@ -9523,10 +9547,10 @@ impl Editor { let context_menu = self.context_menu.borrow_mut().take(); self.stale_edit_prediction_in_menu.take(); self.update_visible_edit_prediction(window, cx); - if let Some(CodeContextMenu::Completions(_)) = &context_menu { - if let Some(completion_provider) = &self.completion_provider { - completion_provider.selection_changed(None, window, cx); - } + if let Some(CodeContextMenu::Completions(_)) = &context_menu + && let Some(completion_provider) = &self.completion_provider + { + completion_provider.selection_changed(None, window, cx); } context_menu } @@ -9537,18 +9561,22 @@ impl Editor { selection: Range, cx: &mut Context, ) { - let buffer_id = match (&selection.start.buffer_id, &selection.end.buffer_id) { - (Some(a), Some(b)) if a == b => a, - _ => { - log::error!("expected anchor range to have matching buffer IDs"); - return; - } + let Some((_, buffer, _)) = self + .buffer() + .read(cx) + .excerpt_containing(selection.start, cx) + else { + return; }; - let multi_buffer = self.buffer().read(cx); - let Some(buffer) = multi_buffer.buffer(*buffer_id) else { + let Some((_, end_buffer, _)) = self.buffer().read(cx).excerpt_containing(selection.end, cx) + else { return; }; - + if buffer != end_buffer { + log::error!("expected anchor range to have matching buffer IDs"); + return; + } + let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( @@ -9593,7 +9621,7 @@ impl Editor { .tabstops .iter() .map(|tabstop| { - let is_end_tabstop = tabstop.ranges.first().map_or(false, |tabstop| { + let is_end_tabstop = tabstop.ranges.first().is_some_and(|tabstop| { tabstop.is_empty() && tabstop.start == snippet.text.len() as isize }); let mut tabstop_ranges = tabstop @@ -9631,10 +9659,10 @@ impl Editor { s.select_ranges(tabstop.ranges.iter().rev().cloned()); }); - if let Some(choices) = &tabstop.choices { - if let Some(selection) = tabstop.ranges.first() { - self.show_snippet_choices(choices, selection.clone(), cx) - } + if let Some(choices) = &tabstop.choices + && let Some(selection) = tabstop.ranges.first() + { + self.show_snippet_choices(choices, selection.clone(), cx) } // If we're already at the last tabstop and it's at the end of the snippet, @@ -9660,8 +9688,7 @@ impl Editor { // Check whether the just-entered snippet ends with an auto-closable bracket. if self.autoclose_regions.is_empty() { let snapshot = self.buffer.read(cx).snapshot(cx); - let mut all_selections = self.selections.all::(cx); - for selection in &mut all_selections { + for selection in &mut self.selections.all::(&self.display_snapshot(cx)) { let selection_head = selection.head(); let Some(scope) = snapshot.language_scope_at(selection_head) else { continue; @@ -9768,10 +9795,10 @@ impl Editor { s.select_ranges(current_ranges.iter().rev().cloned()) }); - if let Some(choices) = &snippet.choices[snippet.active_index] { - if let Some(selection) = current_ranges.first() { - self.show_snippet_choices(&choices, selection.clone(), cx); - } + if let Some(choices) = &snippet.choices[snippet.active_index] + && let Some(selection) = current_ranges.first() + { + self.show_snippet_choices(choices, selection.clone(), cx); } // If snippet state is not at the last tabstop, push it back on the stack @@ -9793,12 +9820,18 @@ impl Editor { } pub fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); self.transact(window, cx, |this, window, cx| { this.select_autoclose_pair(window, cx); + + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut linked_ranges = HashMap::<_, Vec<_>>::default(); if !this.linked_edit_ranges.is_empty() { - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&display_map); let snapshot = this.buffer.read(cx).snapshot(cx); for selection in selections.iter() { @@ -9817,8 +9850,7 @@ impl Editor { } } - let mut selections = this.selections.all::(cx); - let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut selections = this.selections.all::(&display_map); for selection in &mut selections { if selection.is_empty() { let old_head = selection.head(); @@ -9826,7 +9858,7 @@ impl Editor { movement::left(&display_map, old_head.to_display_point(&display_map)) .to_point(&display_map); if let Some((buffer, line_buffer_range)) = display_map - .buffer_snapshot + .buffer_snapshot() .buffer_line_for_row(MultiBufferRow(old_head.row)) { let indent_size = buffer.indent_size_for_line(line_buffer_range.start.row); @@ -9881,11 +9913,14 @@ impl Editor { }) } this.refresh_edit_prediction(true, false, window, cx); - linked_editing_ranges::refresh_linked_ranges(this, window, cx); + refresh_linked_ranges(this, window, cx); }); } pub fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); self.transact(window, cx, |this, window, cx| { this.change_selections(Default::default(), window, cx, |s| { @@ -9916,6 +9951,38 @@ impl Editor { self.outdent(&Outdent, window, cx); } + pub fn next_snippet_tabstop( + &mut self, + _: &NextSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + return; + } + + if self.move_to_next_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + } + + pub fn previous_snippet_tabstop( + &mut self, + _: &PreviousSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + return; + } + + if self.move_to_prev_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + } + pub fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { cx.propagate(); @@ -9930,7 +9997,7 @@ impl Editor { return; } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let mut selections = self.selections.all_adjusted(cx); + let mut selections = self.selections.all_adjusted(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx); let snapshot = buffer.snapshot(cx); let rows_iter = selections.iter().map(|s| s.head().row); @@ -10046,7 +10113,7 @@ impl Editor { } self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&self.display_snapshot(cx)); let mut prev_edited_row = 0; let mut row_delta = 0; let mut edits = Vec::new(); @@ -10155,7 +10222,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut deletion_ranges = Vec::new(); let mut last_outdent = None; { @@ -10168,10 +10235,10 @@ impl Editor { // Avoid re-outdenting a row that has already been outdented by a // previous selection. - if let Some(last_row) = last_outdent { - if last_row == rows.start { - rows.start = rows.start.next_row(); - } + if let Some(last_row) = last_outdent + && last_row == rows.start + { + rows.start = rows.start.next_row(); } let has_multiple_rows = rows.len() > 1; for row in rows.iter_rows() { @@ -10216,7 +10283,7 @@ impl Editor { cx, ); }); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); }); } @@ -10233,7 +10300,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let selections = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() .map(|s| s.range()); @@ -10241,7 +10308,7 @@ impl Editor { this.buffer.update(cx, |buffer, cx| { buffer.autoindent_ranges(selections, cx); }); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); }); } @@ -10249,14 +10316,13 @@ impl Editor { pub fn delete_line(&mut self, _: &DeleteLine, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut new_cursors = Vec::new(); let mut edit_ranges = Vec::new(); let mut selections = selections.iter().peekable(); while let Some(selection) = selections.next() { let mut rows = selection.spanned_rows(false, &display_map); - let goal_display_column = selection.head().to_display_point(&display_map).column(); // Accumulate contiguous regions of rows that we want to delete. while let Some(next_selection) = selections.peek() { @@ -10269,30 +10335,35 @@ impl Editor { } } - let buffer = &display_map.buffer_snapshot; - let mut edit_start = Point::new(rows.start.0, 0).to_offset(buffer); - let edit_end; - let cursor_buffer_row; - if buffer.max_point().row >= rows.end.0 { + let buffer = display_map.buffer_snapshot(); + let mut edit_start = ToOffset::to_offset(&Point::new(rows.start.0, 0), buffer); + let (edit_end, target_row) = if buffer.max_point().row >= rows.end.0 { // If there's a line after the range, delete the \n from the end of the row range - // and position the cursor on the next line. - edit_end = Point::new(rows.end.0, 0).to_offset(buffer); - cursor_buffer_row = rows.end; + ( + ToOffset::to_offset(&Point::new(rows.end.0, 0), buffer), + rows.end, + ) } else { // If there isn't a line after the range, delete the \n from the line before the - // start of the row range and position the cursor there. + // start of the row range edit_start = edit_start.saturating_sub(1); - edit_end = buffer.len(); - cursor_buffer_row = rows.start.previous_row(); - } + (buffer.len(), rows.start.previous_row()) + }; - let mut cursor = Point::new(cursor_buffer_row.0, 0).to_display_point(&display_map); - *cursor.column_mut() = - cmp::min(goal_display_column, display_map.line_len(cursor.row())); + let text_layout_details = self.text_layout_details(window); + let x = display_map.x_for_display_point( + selection.head().to_display_point(&display_map), + &text_layout_details, + ); + let row = Point::new(target_row.0, 0) + .to_display_point(&display_map) + .row(); + let column = display_map.display_column_for_x(row, x, &text_layout_details); new_cursors.push(( selection.id, - buffer.anchor_after(cursor.to_point(&display_map)), + buffer.anchor_after(DisplayPoint::new(row, column).to_point(&display_map)), + SelectionGoal::None, )); edit_ranges.push(edit_start..edit_end); } @@ -10311,14 +10382,14 @@ impl Editor { }); let new_selections = new_cursors .into_iter() - .map(|(id, cursor)| { + .map(|(id, cursor, goal)| { let cursor = cursor.to_point(&buffer); Selection { id, start: cursor, end: cursor, reversed: false, - goal: SelectionGoal::None, + goal, } }) .collect(); @@ -10339,7 +10410,7 @@ impl Editor { return; } let mut row_ranges = Vec::>::new(); - for selection in self.selections.all::(cx) { + for selection in self.selections.all::(&self.display_snapshot(cx)) { let start = MultiBufferRow(selection.start.row); // Treat single line selections as if they include the next line. Otherwise this action // would do nothing for single line selections individual cursors. @@ -10349,11 +10420,11 @@ impl Editor { MultiBufferRow(selection.end.row) }; - if let Some(last_row_range) = row_ranges.last_mut() { - if start <= last_row_range.end { - last_row_range.end = end; - continue; - } + if let Some(last_row_range) = row_ranges.last_mut() + && start <= last_row_range.end + { + last_row_range.end = end; + continue; } row_ranges.push(start..end); } @@ -10455,6 +10526,90 @@ impl Editor { }) } + fn enable_wrap_selections_in_tag(&self, cx: &App) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + for selection in self.selections.disjoint_anchors_arc().iter() { + if snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.as_ref()) + .is_some() + { + return true; + } + } + false + } + + fn wrap_selections_in_tag( + &mut self, + _: &WrapSelectionsInTag, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + + let snapshot = self.buffer.read(cx).snapshot(cx); + + let mut edits = Vec::new(); + let mut boundaries = Vec::new(); + + for selection in self + .selections + .all_adjusted(&self.display_snapshot(cx)) + .iter() + { + let Some(wrap_config) = snapshot + .language_at(selection.start) + .and_then(|lang| lang.config().wrap_characters.clone()) + else { + continue; + }; + + let open_tag = format!("{}{}", wrap_config.start_prefix, wrap_config.start_suffix); + let close_tag = format!("{}{}", wrap_config.end_prefix, wrap_config.end_suffix); + + let start_before = snapshot.anchor_before(selection.start); + let end_after = snapshot.anchor_after(selection.end); + + edits.push((start_before..start_before, open_tag)); + edits.push((end_after..end_after, close_tag)); + + boundaries.push(( + start_before, + end_after, + wrap_config.start_prefix.len(), + wrap_config.end_suffix.len(), + )); + } + + if edits.is_empty() { + return; + } + + self.transact(window, cx, |this, window, cx| { + let buffer = this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + buffer.snapshot(cx) + }); + + let mut new_selections = Vec::with_capacity(boundaries.len() * 2); + for (start_before, end_after, start_prefix_len, end_suffix_len) in + boundaries.into_iter() + { + let open_offset = start_before.to_offset(&buffer) + start_prefix_len; + let close_offset = end_after.to_offset(&buffer).saturating_sub(end_suffix_len); + new_selections.push(open_offset..open_offset); + new_selections.push(close_offset..close_offset); + } + + this.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(new_selections); + }); + + this.request_autoscroll(Autoscroll::fit(), cx); + }); + } + pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context) { let Some(project) = self.project.clone() else { return; @@ -10472,7 +10627,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let mut buffer_ids = HashSet::default(); let snapshot = self.buffer().read(cx).snapshot(cx); - for selection in self.selections.all::(cx) { + for selection in self.selections.all::(&self.display_snapshot(cx)) { buffer_ids.extend(snapshot.buffer_ids_for_range(selection.range())) } @@ -10489,7 +10644,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let selections = self .selections - .all(cx) + .all(&self.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect(); @@ -10523,6 +10678,20 @@ impl Editor { } } + pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + if let Some(status) = self + .addons + .iter() + .find_map(|(_, addon)| addon.override_status_for_buffer_id(buffer_id, cx)) + { + return Some(status); + } + self.project + .as_ref()? + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } + pub fn open_active_item_in_terminal( &mut self, _: &OpenInTerminal, @@ -10623,7 +10792,7 @@ impl Editor { cx: &mut Context, ) -> Option<(Anchor, Breakpoint)> { let snapshot = self.snapshot(window, cx); - let breakpoint_position = snapshot.buffer_snapshot.anchor_before(Point::new(row, 0)); + let breakpoint_position = snapshot.buffer_snapshot().anchor_before(Point::new(row, 0)); self.breakpoint_at_anchor(breakpoint_position, &snapshot, cx) } @@ -10634,29 +10803,24 @@ impl Editor { snapshot: &EditorSnapshot, cx: &mut Context, ) -> Option<(Anchor, Breakpoint)> { - let project = self.project.clone()?; - - let buffer_id = breakpoint_position.buffer_id.or_else(|| { - snapshot - .buffer_snapshot - .buffer_id_for_excerpt(breakpoint_position.excerpt_id) - })?; + let buffer = self + .buffer + .read(cx) + .buffer_for_anchor(breakpoint_position, cx)?; let enclosing_excerpt = breakpoint_position.excerpt_id; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; let buffer_snapshot = buffer.read(cx).snapshot(); let row = buffer_snapshot .summary_for_anchor::(&breakpoint_position.text_anchor) .row; - let line_len = snapshot.buffer_snapshot.line_len(MultiBufferRow(row)); + let line_len = snapshot.buffer_snapshot().line_len(MultiBufferRow(row)); let anchor_end = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(Point::new(row, line_len)); - let bp = self - .breakpoint_store + self.breakpoint_store .as_ref()? .read_with(cx, |breakpoint_store, cx| { breakpoint_store @@ -10674,15 +10838,14 @@ impl Editor { if breakpoint_row == row { snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_in_excerpt(enclosing_excerpt, bp.position) .map(|position| (position, bp.bp.clone())) } else { None } }) - }); - bp + }) } pub fn edit_log_breakpoint( @@ -10717,10 +10880,10 @@ impl Editor { let snapshot = self.snapshot(window, cx); let cursors = self .selections - .disjoint_anchors() - .into_iter() + .disjoint_anchors_arc() + .iter() .map(|selection| { - let cursor_position: Point = selection.head().to_point(&snapshot.buffer_snapshot); + let cursor_position: Point = selection.head().to_point(&snapshot.buffer_snapshot()); let breakpoint_position = self .breakpoint_at_row(cursor_position.row, window, cx) @@ -10728,7 +10891,7 @@ impl Editor { .unwrap_or_else(|| { snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(Point::new(cursor_position.row, 0)) }); @@ -10818,21 +10981,11 @@ impl Editor { return; }; - let Some(buffer_id) = breakpoint_position.buffer_id.or_else(|| { - if breakpoint_position == Anchor::min() { - self.buffer() - .read(cx) - .excerpt_buffer_ids() - .into_iter() - .next() - } else { - None - } - }) else { - return; - }; - - let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + let Some(buffer) = self + .buffer + .read(cx) + .buffer_for_anchor(breakpoint_position, cx) + else { return; }; @@ -10895,7 +11048,7 @@ impl Editor { } pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context) { - self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut thread_rng())) + self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) } fn manipulate_lines( @@ -10913,7 +11066,7 @@ impl Editor { let mut edits = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11060,7 +11213,7 @@ impl Editor { let mut col = 0; let mut changed = false; - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { match ch { ' ' => { reindented_line.push(' '); @@ -11116,7 +11269,7 @@ impl Editor { let mut first_non_indent_char = None; let mut changed = false; - while let Some(ch) = chars.next() { + for ch in chars.by_ref() { match ch { ' ' => { // Keep track of spaces. Append \t when we reach tab_size @@ -11315,14 +11468,17 @@ impl Editor { let mut edits = Vec::new(); let mut selection_adjustment = 0i32; - for selection in self.selections.all::(cx) { + for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { let selection_is_empty = selection.is_empty(); let (start, end) = if selection_is_empty { - let (word_range, _) = buffer.surrounding_word(selection.start, false); + let (word_range, _) = buffer.surrounding_word(selection.start, None); (word_range.start, word_range.end) } else { - (selection.start, selection.end) + ( + buffer.point_to_offset(selection.start), + buffer.point_to_offset(selection.end), + ) }; let text = buffer.text_for_range(start..end).collect::(); @@ -11333,7 +11489,8 @@ impl Editor { start: (start as i32 - selection_adjustment) as usize, end: ((start + text.len()) as i32 - selection_adjustment) as usize, goal: SelectionGoal::None, - ..selection + id: selection.id, + reversed: selection.reversed, }); selection_adjustment += old_length - text.len() as i32; @@ -11363,7 +11520,7 @@ impl Editor { cx: &mut Context, ) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; + let buffer = display_map.buffer_snapshot(); let mut edits = Vec::new(); let insert_point = display_map .clip_point(target, Bias::Left) @@ -11402,8 +11559,8 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let selections = self.selections.all::(cx); + let buffer = display_map.buffer_snapshot(); + let selections = self.selections.all::(&display_map); let mut edits = Vec::new(); let mut selections_iter = selections.iter().peekable(); @@ -11429,13 +11586,26 @@ impl Editor { rows.end.previous_row().0, buffer.line_len(rows.end.previous_row()), ); - let text = buffer - .text_for_range(start..end) - .chain(Some("\n")) - .collect::(); + + let mut text = buffer.text_for_range(start..end).collect::(); + let insert_location = if upwards { - Point::new(rows.end.0, 0) + // When duplicating upward, we need to insert before the current line. + // If we're on the last line and it doesn't end with a newline, + // we need to add a newline before the duplicated content. + let needs_leading_newline = rows.end.0 >= buffer.max_point().row + && buffer.max_point().column > 0 + && !text.ends_with('\n'); + + if needs_leading_newline { + text.insert(0, '\n'); + end + } else { + text.push('\n'); + Point::new(rows.start.0, 0) + } } else { + text.push('\n'); start }; edits.push((insert_location..insert_location, text)); @@ -11448,11 +11618,57 @@ impl Editor { } } - self.transact(window, cx, |this, _, cx| { + self.transact(window, cx, |this, window, cx| { this.buffer.update(cx, |buffer, cx| { buffer.edit(edits, None, cx); }); + // When duplicating upward with whole lines, move the cursor to the duplicated line + if upwards && whole_lines { + let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + + this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + let mut new_ranges = Vec::new(); + let selections = s.all::(&display_map); + let mut selections_iter = selections.iter().peekable(); + + while let Some(first_selection) = selections_iter.next() { + // Group contiguous selections together to find the total row span + let mut group_selections = vec![first_selection]; + let mut rows = first_selection.spanned_rows(false, &display_map); + + while let Some(next_selection) = selections_iter.peek() { + let next_rows = next_selection.spanned_rows(false, &display_map); + if next_rows.start < rows.end { + rows.end = next_rows.end; + group_selections.push(selections_iter.next().unwrap()); + } else { + break; + } + } + + let row_count = rows.end.0 - rows.start.0; + + // Move all selections in this group up by the total number of duplicated rows + for selection in group_selections { + let new_start = Point::new( + selection.start.row.saturating_sub(row_count), + selection.start.column, + ); + + let new_end = Point::new( + selection.end.row.saturating_sub(row_count), + selection.end.column, + ); + + new_ranges.push(new_start..new_end); + } + } + + s.select_ranges(new_ranges); + }); + } + this.request_autoscroll(Autoscroll::fit(), cx); }); } @@ -11498,7 +11714,7 @@ impl Editor { let mut unfold_ranges = Vec::new(); let mut refold_creases = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11609,7 +11825,7 @@ impl Editor { let mut unfold_ranges = Vec::new(); let mut refold_creases = Vec::new(); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut selections = selections.iter().peekable(); let mut contiguous_row_selections = Vec::new(); let mut new_selections = Vec::new(); @@ -11704,7 +11920,7 @@ impl Editor { let mut transpose_offset = head.to_offset(display_map, Bias::Right); if head.column() == display_map.line_len(head.row()) { transpose_offset = display_map - .buffer_snapshot + .buffer_snapshot() .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); } @@ -11722,14 +11938,16 @@ impl Editor { selection.collapse_to(head, goal); let transpose_start = display_map - .buffer_snapshot + .buffer_snapshot() .clip_offset(transpose_offset.saturating_sub(1), Bias::Left); - if edits.last().map_or(true, |e| e.0.end <= transpose_start) { + if edits.last().is_none_or(|e| e.0.end <= transpose_start) { let transpose_end = display_map - .buffer_snapshot + .buffer_snapshot() .clip_offset(transpose_offset + 1, Bias::Right); - if let Some(ch) = - display_map.buffer_snapshot.chars_at(transpose_start).next() + if let Some(ch) = display_map + .buffer_snapshot() + .chars_at(transpose_start) + .next() { edits.push((transpose_start..transpose_offset, String::new())); edits.push((transpose_end..transpose_end, ch.to_string())); @@ -11740,7 +11958,7 @@ impl Editor { }); this.buffer .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| { s.select(selections); }); @@ -11759,7 +11977,19 @@ impl Editor { pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { let buffer = self.buffer.read(cx).snapshot(cx); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); + + #[derive(Clone, Debug, PartialEq)] + enum CommentFormat { + /// single line comment, with prefix for line + Line(String), + /// single line within a block comment, with prefix for line + BlockLine(String), + /// a single line of a block comment that includes the initial delimiter + BlockCommentWithStart(BlockCommentConfig), + /// a single line of a block comment that includes the ending delimiter + BlockCommentWithEnd(BlockCommentConfig), + } // Split selections to respect paragraph, indent, and comment prefix boundaries. let wrap_ranges = selections.into_iter().flat_map(|selection| { @@ -11777,37 +12007,75 @@ impl Editor { let language_scope = buffer.language_scope_at(selection.head()); let indent_and_prefix_for_row = - |row: u32| -> (IndentSize, Option, Option) { + |row: u32| -> (IndentSize, Option, Option) { let indent = buffer.indent_size_for_line(MultiBufferRow(row)); - let (comment_prefix, rewrap_prefix) = - if let Some(language_scope) = &language_scope { - let indent_end = Point::new(row, indent.len); - let comment_prefix = language_scope + let (comment_prefix, rewrap_prefix) = if let Some(language_scope) = + &language_scope + { + let indent_end = Point::new(row, indent.len); + let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + let line_text_after_indent = buffer + .text_for_range(indent_end..line_end) + .collect::(); + + let is_within_comment_override = buffer + .language_scope_at(indent_end) + .is_some_and(|scope| scope.override_name() == Some("comment")); + let comment_delimiters = if is_within_comment_override { + // we are within a comment syntax node, but we don't + // yet know what kind of comment: block, doc or line + match ( + language_scope.documentation_comment(), + language_scope.block_comment(), + ) { + (Some(config), _) | (_, Some(config)) + if buffer.contains_str_at(indent_end, &config.start) => + { + Some(CommentFormat::BlockCommentWithStart(config.clone())) + } + (Some(config), _) | (_, Some(config)) + if line_text_after_indent.ends_with(config.end.as_ref()) => + { + Some(CommentFormat::BlockCommentWithEnd(config.clone())) + } + (Some(config), _) | (_, Some(config)) + if buffer.contains_str_at(indent_end, &config.prefix) => + { + Some(CommentFormat::BlockLine(config.prefix.to_string())) + } + (_, _) => language_scope + .line_comment_prefixes() + .iter() + .find(|prefix| buffer.contains_str_at(indent_end, prefix)) + .map(|prefix| CommentFormat::Line(prefix.to_string())), + } + } else { + // we not in an overridden comment node, but we may + // be within a non-overridden line comment node + language_scope .line_comment_prefixes() .iter() .find(|prefix| buffer.contains_str_at(indent_end, prefix)) - .map(|prefix| prefix.to_string()); - let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row))); - let line_text_after_indent = buffer - .text_for_range(indent_end..line_end) - .collect::(); - let rewrap_prefix = language_scope - .rewrap_prefixes() - .iter() - .find_map(|prefix_regex| { - prefix_regex.find(&line_text_after_indent).map(|mat| { - if mat.start() == 0 { - Some(mat.as_str().to_string()) - } else { - None - } - }) - }) - .flatten(); - (comment_prefix, rewrap_prefix) - } else { - (None, None) + .map(|prefix| CommentFormat::Line(prefix.to_string())) }; + + let rewrap_prefix = language_scope + .rewrap_prefixes() + .iter() + .find_map(|prefix_regex| { + prefix_regex.find(&line_text_after_indent).map(|mat| { + if mat.start() == 0 { + Some(mat.as_str().to_string()) + } else { + None + } + }) + }) + .flatten(); + (comment_delimiters, rewrap_prefix) + } else { + (None, None) + }; (indent, comment_prefix, rewrap_prefix) }; @@ -11818,22 +12086,22 @@ impl Editor { let mut prev_row = first_row; let ( mut current_range_indent, - mut current_range_comment_prefix, + mut current_range_comment_delimiters, mut current_range_rewrap_prefix, ) = indent_and_prefix_for_row(first_row); for row in non_blank_rows_iter.skip(1) { let has_paragraph_break = row > prev_row + 1; - let (row_indent, row_comment_prefix, row_rewrap_prefix) = + let (row_indent, row_comment_delimiters, row_rewrap_prefix) = indent_and_prefix_for_row(row); let has_indent_change = row_indent != current_range_indent; - let has_comment_change = row_comment_prefix != current_range_comment_prefix; + let has_comment_change = row_comment_delimiters != current_range_comment_delimiters; let has_boundary_change = has_comment_change || row_rewrap_prefix.is_some() - || (has_indent_change && current_range_comment_prefix.is_some()); + || (has_indent_change && current_range_comment_delimiters.is_some()); if has_paragraph_break || has_boundary_change { ranges.push(( @@ -11841,13 +12109,13 @@ impl Editor { Point::new(current_range_start, 0) ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), current_range_indent, - current_range_comment_prefix.clone(), + current_range_comment_delimiters.clone(), current_range_rewrap_prefix.clone(), from_empty_selection, )); current_range_start = row; current_range_indent = row_indent; - current_range_comment_prefix = row_comment_prefix; + current_range_comment_delimiters = row_comment_delimiters; current_range_rewrap_prefix = row_rewrap_prefix; } prev_row = row; @@ -11858,7 +12126,7 @@ impl Editor { Point::new(current_range_start, 0) ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), current_range_indent, - current_range_comment_prefix, + current_range_comment_delimiters, current_range_rewrap_prefix, from_empty_selection, )); @@ -11872,7 +12140,7 @@ impl Editor { for ( language_settings, wrap_range, - indent_size, + mut indent_size, comment_prefix, rewrap_prefix, from_empty_selection, @@ -11892,16 +12160,26 @@ impl Editor { let tab_size = language_settings.tab_size; + let (line_prefix, inside_comment) = match &comment_prefix { + Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => { + (Some(prefix.as_str()), true) + } + Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => { + (Some(prefix.as_ref()), true) + } + Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig { + start: _, + end: _, + prefix, + tab_size, + })) => { + indent_size.len += tab_size; + (Some(prefix.as_ref()), true) + } + None => (None, false), + }; let indent_prefix = indent_size.chars().collect::(); - let mut line_prefix = indent_prefix.clone(); - let mut inside_comment = false; - if let Some(prefix) = &comment_prefix { - line_prefix.push_str(prefix); - inside_comment = true; - } - if let Some(prefix) = &rewrap_prefix { - line_prefix.push_str(prefix); - } + let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or("")); let allow_rewrap_based_on_language = match language_settings.allow_rewrap { RewrapBehavior::InComments => inside_comment, @@ -11943,9 +12221,11 @@ impl Editor { } let start = Point::new(start_row, 0); - let start_offset = start.to_offset(&buffer); + let start_offset = ToOffset::to_offset(&start, &buffer); let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); let selection_text = buffer.text_for_range(start..end).collect::(); + let mut first_line_delimiter = None; + let mut last_line_delimiter = None; let Some(lines_without_prefixes) = selection_text .lines() .enumerate() @@ -11953,6 +12233,46 @@ impl Editor { let line_trimmed = line.trim_start(); if rewrap_prefix.is_some() && ix > 0 { Ok(line_trimmed) + } else if let Some( + CommentFormat::BlockCommentWithStart(BlockCommentConfig { + start, + prefix, + end, + tab_size, + }) + | CommentFormat::BlockCommentWithEnd(BlockCommentConfig { + start, + prefix, + end, + tab_size, + }), + ) = &comment_prefix + { + let line_trimmed = line_trimmed + .strip_prefix(start.as_ref()) + .map(|s| { + let mut indent_size = indent_size; + indent_size.len -= tab_size; + let indent_prefix: String = indent_size.chars().collect(); + first_line_delimiter = Some((indent_prefix, start)); + s.trim_start() + }) + .unwrap_or(line_trimmed); + let line_trimmed = line_trimmed + .strip_suffix(end.as_ref()) + .map(|s| { + last_line_delimiter = Some(end); + s.trim_end() + }) + .unwrap_or(line_trimmed); + let line_trimmed = line_trimmed + .strip_prefix(prefix.as_ref()) + .unwrap_or(line_trimmed); + Ok(line_trimmed) + } else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix { + line_trimmed.strip_prefix(prefix).with_context(|| { + format!("line did not start with prefix {prefix:?}: {line:?}") + }) } else { line_trimmed .strip_prefix(&line_prefix.trim_start()) @@ -11979,14 +12299,25 @@ impl Editor { line_prefix.clone() }; - let wrapped_text = wrap_with_prefix( - line_prefix, - subsequent_lines_prefix, - lines_without_prefixes.join("\n"), - wrap_column, - tab_size, - options.preserve_existing_whitespace, - ); + let wrapped_text = { + let mut wrapped_text = wrap_with_prefix( + line_prefix, + subsequent_lines_prefix, + lines_without_prefixes.join("\n"), + wrap_column, + tab_size, + options.preserve_existing_whitespace, + ); + + if let Some((indent, delimiter)) = first_line_delimiter { + wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}"); + } + if let Some(last_line) = last_line_delimiter { + wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}"); + } + + wrapped_text + }; // TODO: should always use char-based diff while still supporting cursor behavior that // matches vim. @@ -12014,16 +12345,22 @@ impl Editor { .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); } - pub fn cut_common(&mut self, window: &mut Window, cx: &mut Context) -> ClipboardItem { + pub fn cut_common( + &mut self, + cut_no_selection_line: bool, + window: &mut Window, + cx: &mut Context, + ) -> ClipboardItem { let mut text = String::new(); let buffer = self.buffer.read(cx).snapshot(cx); - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all::(&self.display_snapshot(cx)); let mut clipboard_selections = Vec::with_capacity(selections.len()); { let max_point = buffer.max_point(); let mut is_first = true; for selection in &mut selections { - let is_entire_line = selection.is_empty() || self.selections.line_mode; + let is_entire_line = + (selection.is_empty() && cut_no_selection_line) || self.selections.line_mode(); if is_entire_line { selection.start = Point::new(selection.start.row, 0); if !selection.is_empty() && selection.end.column == 0 { @@ -12064,7 +12401,7 @@ impl Editor { pub fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); - let item = self.cut_common(window, cx); + let item = self.cut_common(true, window, cx); cx.write_to_clipboard(item); } @@ -12073,11 +12410,14 @@ impl Editor { self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|snapshot, sel| { if sel.is_empty() { - sel.end = DisplayPoint::new(sel.end.row(), snapshot.line_len(sel.end.row())) + sel.end = DisplayPoint::new(sel.end.row(), snapshot.line_len(sel.end.row())); + } + if sel.is_empty() { + sel.end = DisplayPoint::new(sel.end.row() + 1_u32, 0); } }); }); - let item = self.cut_common(window, cx); + let item = self.cut_common(false, window, cx); cx.set_global(KillRing(item)) } @@ -12109,7 +12449,7 @@ impl Editor { } fn do_copy(&self, strip_leading_indents: bool, cx: &mut Context) { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let mut text = String::new(); @@ -12120,10 +12460,19 @@ impl Editor { for selection in &selections { let mut start = selection.start; let mut end = selection.end; - let is_entire_line = selection.is_empty() || self.selections.line_mode; + let is_entire_line = selection.is_empty() || self.selections.line_mode(); + let mut add_trailing_newline = false; if is_entire_line { start = Point::new(start.row, 0); - end = cmp::min(max_point, Point::new(end.row + 1, 0)); + let next_line_start = Point::new(end.row + 1, 0); + if next_line_start <= max_point { + end = next_line_start; + } else { + // We're on the last line without a trailing newline. + // Copy to the end of the line and add a newline afterwards. + end = Point::new(end.row, buffer.line_len(MultiBufferRow(end.row))); + add_trailing_newline = true; + } } let mut trimmed_selections = Vec::new(); @@ -12174,6 +12523,10 @@ impl Editor { text.push_str(chunk); len += chunk.len(); } + if add_trailing_newline { + text.push('\n'); + len += 1; + } clipboard_selections.push(ClipboardSelection { len, is_entire_line, @@ -12203,13 +12556,15 @@ impl Editor { return; } - let clipboard_text = Cow::Borrowed(text); + let clipboard_text = Cow::Borrowed(text.as_str()); self.transact(window, cx, |this, window, cx| { let had_active_edit_prediction = this.has_active_edit_prediction(); + let display_map = this.display_snapshot(cx); + let old_selections = this.selections.all::(&display_map); + let cursor_offset = this.selections.last::(&display_map).head(); if let Some(mut clipboard_selections) = clipboard_selections { - let old_selections = this.selections.all::(cx); let all_selections_were_entire_line = clipboard_selections.iter().all(|s| s.is_entire_line); let first_selection_indent_column = @@ -12217,7 +12572,6 @@ impl Editor { if clipboard_selections.len() != old_selections.len() { clipboard_selections.drain(..); } - let cursor_offset = this.selections.last::(cx).head(); let mut auto_indent_on_paste = true; this.buffer.update(cx, |buffer, cx| { @@ -12240,22 +12594,36 @@ impl Editor { start_offset = end_offset + 1; original_indent_column = Some(clipboard_selection.first_line_indent); } else { - to_insert = clipboard_text.as_str(); + to_insert = &*clipboard_text; entire_line = all_selections_were_entire_line; original_indent_column = first_selection_indent_column } - // If the corresponding selection was empty when this slice of the - // clipboard text was written, then the entire line containing the - // selection was copied. If this selection is also currently empty, - // then paste the line before the current line of the buffer. - let range = if selection.is_empty() && handle_entire_lines && entire_line { - let column = selection.start.to_point(&snapshot).column as usize; - let line_start = selection.start - column; - line_start..line_start - } else { - selection.range() - }; + let (range, to_insert) = + if selection.is_empty() && handle_entire_lines && entire_line { + // If the corresponding selection was empty when this slice of the + // clipboard text was written, then the entire line containing the + // selection was copied. If this selection is also currently empty, + // then paste the line before the current line of the buffer. + let column = selection.start.to_point(&snapshot).column as usize; + let line_start = selection.start - column; + (line_start..line_start, Cow::Borrowed(to_insert)) + } else { + let language = snapshot.language_at(selection.head()); + let range = selection.range(); + if let Some(language) = language + && language.name() == "Markdown".into() + { + edit_for_markdown_paste( + &snapshot, + range, + to_insert, + url::Url::parse(to_insert).ok(), + ) + } else { + (range, Cow::Borrowed(to_insert)) + } + }; edits.push((range, to_insert)); original_indent_columns.push(original_indent_column); @@ -12275,30 +12643,76 @@ impl Editor { ); }); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); } else { - this.insert(&clipboard_text, window, cx); - } - - let trigger_in_words = - this.show_edit_predictions_in_menu() || !had_active_edit_prediction; + let url = url::Url::parse(&clipboard_text).ok(); - this.trigger_completion_on_input(&text, trigger_in_words, window, cx); - }); - } + let auto_indent_mode = if !clipboard_text.is_empty() { + Some(AutoindentMode::Block { + original_indent_columns: Vec::new(), + }) + } else { + None + }; - pub fn diff_clipboard_with_selection( - &mut self, - _: &DiffClipboardWithSelection, - window: &mut Window, - cx: &mut Context, - ) { - let selections = self.selections.all::(cx); + let selection_anchors = this.buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); - if selections.is_empty() { - log::warn!("There should always be at least one selection in Zed. This is a bug."); - return; + let anchors = old_selections + .iter() + .map(|s| { + let anchor = snapshot.anchor_after(s.head()); + s.map(|_| anchor) + }) + .collect::>(); + + let mut edits = Vec::new(); + + for selection in old_selections.iter() { + let language = snapshot.language_at(selection.head()); + let range = selection.range(); + + let (edit_range, edit_text) = if let Some(language) = language + && language.name() == "Markdown".into() + { + edit_for_markdown_paste(&snapshot, range, &clipboard_text, url.clone()) + } else { + (range, clipboard_text.clone()) + }; + + edits.push((edit_range, edit_text)); + } + + drop(snapshot); + buffer.edit(edits, auto_indent_mode, cx); + + anchors + }); + + this.change_selections(Default::default(), window, cx, |s| { + s.select_anchors(selection_anchors); + }); + } + + let trigger_in_words = + this.show_edit_predictions_in_menu() || !had_active_edit_prediction; + + this.trigger_completion_on_input(text, trigger_in_words, window, cx); + }); + } + + pub fn diff_clipboard_with_selection( + &mut self, + _: &DiffClipboardWithSelection, + window: &mut Window, + cx: &mut Context, + ) { + let selections = self.selections.all::(&self.display_snapshot(cx)); + + if selections.is_empty() { + log::warn!("There should always be at least one selection in Zed. This is a bug."); + return; }; let clipboard_text = match cx.read_from_clipboard() { @@ -12452,7 +12866,7 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { s.move_heads_with(|map, head, _| (movement::right(map, head), SelectionGoal::None)); - }) + }); } pub fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { @@ -12638,7 +13052,7 @@ impl Editor { return; } - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -12762,7 +13176,7 @@ impl Editor { return; } - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -12964,11 +13378,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = if action.ignore_newlines { + let mut cursor = if action.ignore_newlines { movement::previous_word_start(map, selection.head()) } else { movement::previous_word_start_or_newline(map, selection.head()) }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); @@ -12989,7 +13409,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = movement::previous_subword_start(map, selection.head()); + let mut cursor = movement::previous_subword_start(map, selection.head()); + cursor = + movement::adjust_greedy_deletion(map, selection.head(), cursor, false); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13065,11 +13487,17 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = if action.ignore_newlines { + let mut cursor = if action.ignore_newlines { movement::next_word_end(map, selection.head()) } else { movement::next_word_end_or_newline(map, selection.head()) }; + cursor = movement::adjust_greedy_deletion( + map, + selection.head(), + cursor, + action.ignore_brackets, + ); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13089,7 +13517,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { if selection.is_empty() { - let cursor = movement::next_subword_end(map, selection.head()); + let mut cursor = movement::next_subword_end(map, selection.head()); + cursor = + movement::adjust_greedy_deletion(map, selection.head(), cursor, false); selection.set_head(cursor, SelectionGoal::None); } }); @@ -13223,7 +13653,7 @@ impl Editor { pub fn cut_to_end_of_line( &mut self, - _: &CutToEndOfLine, + action: &CutToEndOfLine, window: &mut Window, cx: &mut Context, ) { @@ -13236,7 +13666,18 @@ impl Editor { window, cx, ); - this.cut(&Cut, window, cx); + if !action.stop_at_newlines { + this.change_selections(Default::default(), window, cx, |s| { + s.move_with(|_, sel| { + if sel.is_empty() { + sel.end = DisplayPoint::new(sel.end.row() + 1_u32, 0); + } + }); + }); + } + this.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + let item = this.cut_common(false, window, cx); + cx.write_to_clipboard(item); }); } @@ -13246,7 +13687,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13267,7 +13708,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13288,7 +13729,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13309,7 +13750,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13330,7 +13771,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13355,7 +13796,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13380,7 +13821,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13405,7 +13846,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13430,7 +13871,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13451,7 +13892,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13472,7 +13913,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13493,7 +13934,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13514,7 +13955,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13530,7 +13971,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let mut selection = self.selections.last::(cx); + let mut selection = self.selections.last::(&self.display_snapshot(cx)); selection.set_head(Point::zero(), SelectionGoal::None); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { @@ -13539,7 +13980,7 @@ impl Editor { } pub fn move_to_end(&mut self, _: &MoveToEnd, window: &mut Window, cx: &mut Context) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -13609,7 +14050,7 @@ impl Editor { pub fn select_to_end(&mut self, _: &SelectToEnd, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let mut selection = self.selections.first::(cx); + let mut selection = self.selections.first::(&self.display_snapshot(cx)); selection.set_head(buffer.len(), SelectionGoal::None); self.change_selections(Default::default(), window, cx, |s| { s.select(vec![selection]); @@ -13627,8 +14068,8 @@ impl Editor { pub fn select_line(&mut self, _: &SelectLine, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let mut selections = self.selections.all::(cx); - let max_point = display_map.buffer_snapshot.max_point(); + let mut selections = self.selections.all::(&display_map); + let max_point = display_map.buffer_snapshot().max_point(); for selection in &mut selections { let rows = selection.spanned_rows(true, &display_map); selection.start = Point::new(rows.start.0, 0); @@ -13648,7 +14089,7 @@ impl Editor { ) { let selections = self .selections - .all::(cx) + .all::(&self.display_snapshot(cx)) .into_iter() .map(|selection| selection.start..selection.end) .collect::>(); @@ -13701,27 +14142,33 @@ impl Editor { pub fn add_selection_above( &mut self, - _: &AddSelectionAbove, + action: &AddSelectionAbove, window: &mut Window, cx: &mut Context, ) { - self.add_selection(true, window, cx); + self.add_selection(true, action.skip_soft_wrap, window, cx); } pub fn add_selection_below( &mut self, - _: &AddSelectionBelow, + action: &AddSelectionBelow, window: &mut Window, cx: &mut Context, ) { - self.add_selection(false, window, cx); + self.add_selection(false, action.skip_soft_wrap, window, cx); } - fn add_selection(&mut self, above: bool, window: &mut Window, cx: &mut Context) { + fn add_selection( + &mut self, + above: bool, + skip_soft_wrap: bool, + window: &mut Window, + cx: &mut Context, + ) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let all_selections = self.selections.all::(cx); + let all_selections = self.selections.all::(&display_map); let text_layout_details = self.text_layout_details(window); let (mut columnar_selections, new_selections_to_columnarize) = { @@ -13794,7 +14241,7 @@ impl Editor { let mut row = range.start.row(); let positions = if let SelectionGoal::HorizontalRange { start, end } = selection.goal { - px(start)..px(end) + Pixels::from(start)..Pixels::from(end) } else { let start_x = display_map.x_for_display_point(range.start, &text_layout_details); @@ -13804,12 +14251,19 @@ impl Editor { }; let mut maybe_new_selection = None; + let direction = if above { -1 } else { 1 }; + while row != end_row { - if above { + if skip_soft_wrap { + row = display_map + .start_of_relative_buffer_row(DisplayPoint::new(row, 0), direction) + .row(); + } else if above { row.0 -= 1; } else { row.0 += 1; } + if let Some(new_selection) = self.selections.build_columnar_selection( &display_map, row, @@ -13848,7 +14302,7 @@ impl Editor { let final_selection_ids: HashSet<_> = self .selections - .all::(cx) + .all::(&display_map) .iter() .map(|s| s.id) .collect(); @@ -13905,8 +14359,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Result<()> { - let buffer = &display_map.buffer_snapshot; - let mut selections = self.selections.all::(cx); + let buffer = display_map.buffer_snapshot(); + let mut selections = self.selections.all::(&display_map); if let Some(mut select_next_state) = self.select_next_state.take() { let query = &select_next_state.query; if !select_next_state.done { @@ -13932,14 +14386,16 @@ impl Editor { start_offset + query_match.start()..start_offset + query_match.end(); if !select_next_state.wordwise - || (!buffer.is_inside_word(offset_range.start, false) - && !buffer.is_inside_word(offset_range.end, false)) + || (!buffer.is_inside_word(offset_range.start, None) + && !buffer.is_inside_word(offset_range.end, None)) { - // TODO: This is n^2, because we might check all the selections - if !selections - .iter() - .any(|selection| selection.range().overlaps(&offset_range)) - { + let idx = selections + .partition_point(|selection| selection.end <= offset_range.start); + let overlaps = selections + .get(idx) + .map_or(false, |selection| selection.start < offset_range.end); + + if !overlaps { next_selected_range = Some(offset_range); break; } @@ -13997,7 +14453,7 @@ impl Editor { if only_carets { for selection in &mut selections { - let (word_range, _) = buffer.surrounding_word(selection.start, false); + let (word_range, _) = buffer.surrounding_word(selection.start, None); selection.start = word_range.start; selection.end = word_range.end; selection.goal = SelectionGoal::None; @@ -14067,8 +14523,8 @@ impl Editor { let mut new_selections = Vec::new(); - let reversed = self.selections.oldest::(cx).reversed; - let buffer = &display_map.buffer_snapshot; + let reversed = self.selections.oldest::(&display_map).reversed; + let buffer = display_map.buffer_snapshot(); let query_matches = select_next_state .query .stream_find_iter(buffer.bytes_in_range(0..buffer.len())); @@ -14082,8 +14538,8 @@ impl Editor { }; if !select_next_state.wordwise - || (!buffer.is_inside_word(offset_range.start, false) - && !buffer.is_inside_word(offset_range.end, false)) + || (!buffer.is_inside_word(offset_range.start, None) + && !buffer.is_inside_word(offset_range.end, None)) { new_selections.push(offset_range.start..offset_range.end); } @@ -14130,8 +14586,8 @@ impl Editor { ) -> Result<()> { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let mut selections = self.selections.all::(cx); + let buffer = display_map.buffer_snapshot(); + let mut selections = self.selections.all::(&display_map); if let Some(mut select_prev_state) = self.select_prev_state.take() { let query = &select_prev_state.query; if !select_prev_state.done { @@ -14157,8 +14613,8 @@ impl Editor { end_offset - query_match.end()..end_offset - query_match.start(); if !select_prev_state.wordwise - || (!buffer.is_inside_word(offset_range.start, false) - && !buffer.is_inside_word(offset_range.end, false)) + || (!buffer.is_inside_word(offset_range.start, None) + && !buffer.is_inside_word(offset_range.end, None)) { next_selected_range = Some(offset_range); break; @@ -14216,7 +14672,7 @@ impl Editor { if only_carets { for selection in &mut selections { - let (word_range, _) = buffer.surrounding_word(selection.start, false); + let (word_range, _) = buffer.surrounding_word(selection.start, None); selection.start = word_range.start; selection.end = word_range.end; selection.goal = SelectionGoal::None; @@ -14265,7 +14721,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Result<()> { - let selections = self.selections.disjoint_anchors(); + let selections = self.selections.disjoint_anchors_arc(); match selections.first() { Some(first) if selections.len() >= 2 => { self.change_selections(Default::default(), window, cx, |s| { @@ -14289,7 +14745,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Result<()> { - let selections = self.selections.disjoint_anchors(); + let selections = self.selections.disjoint_anchors_arc(); match selections.last() { Some(last) if selections.len() >= 2 => { self.change_selections(Default::default(), window, cx, |s| { @@ -14319,7 +14775,9 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let text_layout_details = &self.text_layout_details(window); self.transact(window, cx, |this, window, cx| { - let mut selections = this.selections.all::(cx); + let mut selections = this + .selections + .all::(&this.display_snapshot(cx)); let mut edits = Vec::new(); let mut selection_edit_ranges = Vec::new(); let mut last_toggled_row = None; @@ -14550,7 +15008,7 @@ impl Editor { // Adjust selections so that they end before any comment suffixes that // were inserted. let mut suffixes_inserted = suffixes_inserted.into_iter().peekable(); - let mut selections = this.selections.all::(cx); + let mut selections = this.selections.all::(&this.display_snapshot(cx)); let snapshot = this.buffer.read(cx).read(cx); for selection in &mut selections { while let Some((row, suffix_len)) = suffixes_inserted.peek().copied() { @@ -14576,7 +15034,7 @@ impl Editor { drop(snapshot); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); let selections_on_single_row = selections.windows(2).all(|selections| { selections[0].start.row == selections[1].start.row && selections[0].end.row == selections[1].end.row @@ -14588,7 +15046,7 @@ impl Editor { let advance_downwards = action.advance_downwards && selections_on_single_row && !selections_selecting - && !matches!(this.mode, EditorMode::SingleLine { .. }); + && !matches!(this.mode, EditorMode::SingleLine); if advance_downwards { let snapshot = this.buffer.read(cx).snapshot(cx); @@ -14620,7 +15078,10 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections = self.selections.all::(cx).into_boxed_slice(); + let old_selections = self + .selections + .all::(&self.display_snapshot(cx)) + .into_boxed_slice(); fn update_selection( selection: &Selection, @@ -14675,7 +15136,10 @@ impl Editor { let Some(visible_row_count) = self.visible_row_count() else { return; }; - let old_selections: Box<[_]> = self.selections.all::(cx).into(); + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); if old_selections.is_empty() { return; } @@ -14694,11 +15158,10 @@ impl Editor { if let Some((node, _)) = buffer.syntax_ancestor(old_range.clone()) { // manually select word at selection if ["string_content", "inline"].contains(&node.kind()) { - let (word_range, _) = buffer.surrounding_word(old_range.start, false); + let (word_range, _) = buffer.surrounding_word(old_range.start, None); // ignore if word is already selected if !word_range.is_empty() && old_range != word_range { - let (last_word_range, _) = - buffer.surrounding_word(old_range.end, false); + let (last_word_range, _) = buffer.surrounding_word(old_range.end, None); // only select word if start and end point belongs to same word if word_range == last_word_range { selected_larger_node = true; @@ -14715,13 +15178,11 @@ impl Editor { } let mut new_range = old_range.clone(); - while let Some((_node, containing_range)) = - buffer.syntax_ancestor(new_range.clone()) - { - new_range = match containing_range { - MultiOrSingleBufferOffsetRange::Single(_) => break, - MultiOrSingleBufferOffsetRange::Multi(range) => range, - }; + while let Some((node, range)) = buffer.syntax_ancestor(new_range.clone()) { + new_range = range; + if !node.is_named() { + continue; + } if !display_map.intersects_fold(new_range.start) && !display_map.intersects_fold(new_range.end) { @@ -14834,70 +15295,176 @@ impl Editor { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let buffer = self.buffer.read(cx).snapshot(cx); - let old_selections: Box<[_]> = self.selections.all::(cx).into(); + let selections = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + // subtracting the offset requires sorting + .sorted_by_key(|i| i.start); - let edits = old_selections - .iter() - // only consider the first selection for now - .take(1) - .map(|selection| { - // Only requires two branches once if-let-chains stabilize (#53667) - let selection_range = if !selection.is_empty() { - selection.range() - } else if let Some((_, ancestor_range)) = - buffer.syntax_ancestor(selection.start..selection.end) + let full_edits = selections + .into_iter() + .filter_map(|selection| { + let child = if selection.is_empty() + && let Some((_, ancestor_range)) = + buffer.syntax_ancestor(selection.start..selection.end) { - match ancestor_range { - MultiOrSingleBufferOffsetRange::Single(range) => range, - MultiOrSingleBufferOffsetRange::Multi(range) => range, - } + ancestor_range } else { selection.range() }; - let mut new_range = selection_range.clone(); - while let Some((_, ancestor_range)) = buffer.syntax_ancestor(new_range.clone()) { - new_range = match ancestor_range { - MultiOrSingleBufferOffsetRange::Single(range) => range, - MultiOrSingleBufferOffsetRange::Multi(range) => range, - }; - if new_range.start < selection_range.start - || new_range.end > selection_range.end - { + let mut parent = child.clone(); + while let Some((_, ancestor_range)) = buffer.syntax_ancestor(parent.clone()) { + parent = ancestor_range; + if parent.start < child.start || parent.end > child.end { break; } } - (selection, selection_range, new_range) + if parent == child { + return None; + } + let text = buffer.text_for_range(child).collect::(); + Some((selection.id, parent, text)) }) .collect::>(); + if full_edits.is_empty() { + return; + } - self.transact(window, cx, |editor, window, cx| { - for (_, child, parent) in &edits { - let text = buffer.text_for_range(child.clone()).collect::(); - editor.replace_text_in_range(Some(parent.clone()), &text, window, cx); - } + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit( + full_edits + .iter() + .map(|(_, p, t)| (p.clone(), t.clone())) + .collect::>(), + None, + cx, + ); + }); + this.change_selections(Default::default(), window, cx, |s| { + let mut offset = 0; + let mut selections = vec![]; + for (id, parent, text) in full_edits { + let start = parent.start - offset; + offset += parent.len() - text.len(); + selections.push(Selection { + id, + start, + end: start + text.len(), + reversed: false, + goal: Default::default(), + }); + } + s.select(selections); + }); + }); + } + + pub fn select_next_syntax_node( + &mut self, + _: &SelectNextSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); + if old_selections.is_empty() { + return; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; - editor.change_selections( + if let Some(node) = buffer.syntax_next_sibling(old_range) { + let new_range = node.byte_range(); + selected_sibling = true; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + selection.clone() + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( SelectionEffects::scroll(Autoscroll::fit()), window, cx, |s| { - s.select( - edits - .iter() - .map(|(s, old, new)| Selection { - id: s.id, - start: new.start, - end: new.start + old.len(), - goal: SelectionGoal::None, - reversed: s.reversed, - }) - .collect(), - ); + s.select(new_selections); }, ); - }); + } + } + + pub fn select_prev_syntax_node( + &mut self, + _: &SelectPreviousSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); + if old_selections.is_empty() { + return; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; + + if let Some(node) = buffer.syntax_prev_sibling(old_range) { + let new_range = node.byte_range(); + selected_sibling = true; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + selection.clone() + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( + SelectionEffects::scroll(Autoscroll::fit()), + window, + cx, + |s| { + s.select(new_selections); + }, + ); + } } fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { @@ -14920,10 +15487,7 @@ impl Editor { }; let hide_runnables = project - .update(cx, |project, cx| { - // Do not display any test indicators in non-dev server remote projects. - project.is_via_collab() && project.ssh_connection_string(cx).is_none() - }) + .update(cx, |project, _| project.is_via_collab()) .unwrap_or(true); if hide_runnables { return; @@ -14954,11 +15518,11 @@ impl Editor { .fold(HashMap::default(), |mut acc, (kind, location, task)| { let buffer = location.target.buffer; let buffer_snapshot = buffer.read(cx).snapshot(); - let offset = display_snapshot.buffer_snapshot.excerpts().find_map( + let offset = display_snapshot.buffer_snapshot().excerpts().find_map( |(excerpt_id, snapshot, _)| { if snapshot.remote_id() == buffer_snapshot.remote_id() { display_snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_in_excerpt(excerpt_id, location.target.range.start) } else { None @@ -15026,7 +15590,7 @@ impl Editor { snapshot: &DisplaySnapshot, range: Range, ) -> Vec { - snapshot.buffer_snapshot.runnable_ranges(range).collect() + snapshot.buffer_snapshot().runnable_ranges(range).collect() } fn runnable_rows( @@ -15056,9 +15620,12 @@ impl Editor { continue; } - let point = runnable.run_range.start.to_point(&snapshot.buffer_snapshot); + let point = runnable + .run_range + .start + .to_point(&snapshot.buffer_snapshot()); let Some(row) = snapshot - .buffer_snapshot + .buffer_snapshot() .buffer_line_for_row(MultiBufferRow(point.row)) .map(|(_, range)| range.start.row) else { @@ -15072,7 +15639,7 @@ impl Editor { RunnableTasks { templates: tasks, offset: snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(runnable.run_range.start), context_range, column: point.column, @@ -15282,7 +15849,7 @@ impl Editor { cx: &mut Context, ) { - let selections = self.selections.disjoint_anchors(); + let selections = self.selections.disjoint_anchors_arc(); let lines = if lines == 0 { EditorSettings::get_global(cx).expand_excerpt_lines @@ -15311,32 +15878,43 @@ impl Editor { ) { let current_scroll_position = self.scroll_position(cx); let lines_to_expand = EditorSettings::get_global(cx).expand_excerpt_lines; - let mut should_scroll_up = false; + let mut scroll = None; if direction == ExpandExcerptDirection::Down { let multi_buffer = self.buffer.read(cx); let snapshot = multi_buffer.snapshot(cx); - if let Some(buffer_id) = snapshot.buffer_id_for_excerpt(excerpt) { - if let Some(buffer) = multi_buffer.buffer(buffer_id) { - if let Some(excerpt_range) = snapshot.buffer_range_for_excerpt(excerpt) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let excerpt_end_row = - Point::from_anchor(&excerpt_range.end, &buffer_snapshot).row; - let last_row = buffer_snapshot.max_point().row; - let lines_below = last_row.saturating_sub(excerpt_end_row); - should_scroll_up = lines_below >= lines_to_expand; - } + if let Some(buffer_id) = snapshot.buffer_id_for_excerpt(excerpt) + && let Some(buffer) = multi_buffer.buffer(buffer_id) + && let Some(excerpt_range) = snapshot.context_range_for_excerpt(excerpt) + { + let buffer_snapshot = buffer.read(cx).snapshot(); + let excerpt_end_row = Point::from_anchor(&excerpt_range.end, &buffer_snapshot).row; + let last_row = buffer_snapshot.max_point().row; + let lines_below = last_row.saturating_sub(excerpt_end_row); + if lines_below >= lines_to_expand { + scroll = Some( + current_scroll_position + + gpui::Point::new(0.0, lines_to_expand as ScrollOffset), + ); } } } + if direction == ExpandExcerptDirection::Up + && self + .buffer + .read(cx) + .snapshot(cx) + .excerpt_before(excerpt) + .is_none() + { + scroll = Some(current_scroll_position); + } self.buffer.update(cx, |buffer, cx| { buffer.expand_excerpts([excerpt], lines_to_expand, direction, cx) }); - if should_scroll_up { - let new_scroll_position = - current_scroll_position + gpui::Point::new(0.0, lines_to_expand as f32); + if let Some(new_scroll_position) = scroll { self.set_scroll_position(new_scroll_position, window, cx); } } @@ -15408,20 +15986,20 @@ impl Editor { cx: &mut Context, ) { let buffer = self.buffer.read(cx).snapshot(cx); - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let mut active_group_id = None; - if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics { - if active_group.active_range.start.to_offset(&buffer) == selection.start { - active_group_id = Some(active_group.group_id); - } + if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics + && active_group.active_range.start.to_offset(&buffer) == selection.start + { + active_group_id = Some(active_group.group_id); } - fn filtered( + fn filtered<'a>( snapshot: EditorSnapshot, severity: GoToDiagnosticSeverityFilter, - diagnostics: impl Iterator>, - ) -> impl Iterator> { + diagnostics: impl Iterator>, + ) -> impl Iterator> { diagnostics .filter(move |entry| severity.matches(entry.diagnostic.severity)) .filter(|entry| entry.range.start != entry.range.end) @@ -15445,7 +16023,7 @@ impl Editor { .filter(|entry| entry.range.start >= selection.start), ); - let mut found: Option> = None; + let mut found: Option> = None; if direction == Direction::Prev { 'outer: for prev_diagnostics in [before.collect::>(), after.collect::>()] { @@ -15473,7 +16051,8 @@ impl Editor { return; }; - let Some(buffer_id) = buffer.anchor_after(next_diagnostic.range.start).buffer_id else { + let next_diagnostic_start = buffer.anchor_after(next_diagnostic.range.start); + let Some(buffer_id) = buffer.buffer_id_for_anchor(next_diagnostic_start) else { return; }; self.change_selections(Default::default(), window, cx, |s| { @@ -15488,7 +16067,7 @@ impl Editor { pub fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); self.go_to_hunk_before_or_after_position( &snapshot, selection.head(), @@ -15530,12 +16109,12 @@ impl Editor { position: Point, ) -> Option { snapshot - .buffer_snapshot - .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) + .buffer_snapshot() + .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) .find(|hunk| hunk.row_range.start.0 > position.row) .or_else(|| { snapshot - .buffer_snapshot + .buffer_snapshot() .diff_hunks_in_range(Point::zero()..position) .find(|hunk| hunk.row_range.end.0 < position.row) }) @@ -15549,7 +16128,7 @@ impl Editor { ) { self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); let snapshot = self.snapshot(window, cx); - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&snapshot.display_snapshot); self.go_to_hunk_before_or_after_position( &snapshot, selection.head(), @@ -15565,9 +16144,9 @@ impl Editor { position: Point, ) -> Option { snapshot - .buffer_snapshot + .buffer_snapshot() .diff_hunk_before(position) - .or_else(|| snapshot.buffer_snapshot.diff_hunk_before(Point::MAX)) + .or_else(|| snapshot.buffer_snapshot().diff_hunk_before(Point::MAX)) } fn go_to_next_change( @@ -15612,6 +16191,90 @@ impl Editor { } } + pub fn go_to_next_document_highlight( + &mut self, + _: &GoToNextDocumentHighlight, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_document_highlight_before_or_after_position(Direction::Next, window, cx); + } + + pub fn go_to_prev_document_highlight( + &mut self, + _: &GoToPreviousDocumentHighlight, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_document_highlight_before_or_after_position(Direction::Prev, window, cx); + } + + pub fn go_to_document_highlight_before_or_after_position( + &mut self, + direction: Direction, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + let snapshot = self.snapshot(window, cx); + let buffer = &snapshot.buffer_snapshot(); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let anchor_position = buffer.anchor_after(position); + + // Get all document highlights (both read and write) + let mut all_highlights = Vec::new(); + + if let Some((_, read_highlights)) = self + .background_highlights + .get(&HighlightKey::Type(TypeId::of::())) + { + all_highlights.extend(read_highlights.iter()); + } + + if let Some((_, write_highlights)) = self + .background_highlights + .get(&HighlightKey::Type(TypeId::of::())) + { + all_highlights.extend(write_highlights.iter()); + } + + if all_highlights.is_empty() { + return; + } + + // Sort highlights by position + all_highlights.sort_by(|a, b| a.start.cmp(&b.start, buffer)); + + let target_highlight = match direction { + Direction::Next => { + // Find the first highlight after the current position + all_highlights + .iter() + .find(|highlight| highlight.start.cmp(&anchor_position, buffer).is_gt()) + } + Direction::Prev => { + // Find the last highlight before the current position + all_highlights + .iter() + .rev() + .find(|highlight| highlight.end.cmp(&anchor_position, buffer).is_lt()) + } + }; + + if let Some(highlight) = target_highlight { + let destination = highlight.start.to_point(buffer); + let autoscroll = Autoscroll::center(); + + self.unfold_ranges(&[destination..destination], false, false, cx); + self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| { + s.select_ranges([destination..destination]); + }); + } + } + fn go_to_line( &mut self, position: Anchor, @@ -15620,13 +16283,13 @@ impl Editor { cx: &mut Context, ) { let snapshot = self.snapshot(window, cx).display_snapshot; - let position = position.to_point(&snapshot.buffer_snapshot); + let position = position.to_point(&snapshot.buffer_snapshot()); let start = snapshot - .buffer_snapshot + .buffer_snapshot() .clip_point(Point::new(position.row, 0), Bias::Left); let end = start + Point::new(1, 0); - let start = snapshot.buffer_snapshot.anchor_before(start); - let end = snapshot.buffer_snapshot.anchor_before(end); + let start = snapshot.buffer_snapshot().anchor_before(start); + let end = snapshot.buffer_snapshot().anchor_before(end); self.highlight_rows::( start..end, @@ -15741,7 +16404,10 @@ impl Editor { let Some(provider) = self.semantics_provider.clone() else { return Task::ready(Ok(Navigated::No)); }; - let head = self.selections.newest::(cx).head(); + let head = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); let buffer = self.buffer.read(cx); let Some((buffer, head)) = buffer.text_anchor_for_position(head, cx) else { return Task::ready(Ok(Navigated::No)); @@ -15751,7 +16417,9 @@ impl Editor { }; cx.spawn_in(window, async move |editor, cx| { - let definitions = definitions.await?; + let Some(definitions) = definitions.await? else { + return Ok(Navigated::No); + }; let navigated = editor .update_in(cx, |editor, window, cx| { editor.navigate_to_hover_links( @@ -15793,7 +16461,7 @@ impl Editor { None }; - let url_finder = cx.spawn_in(window, async move |editor, cx| { + let url_finder = cx.spawn_in(window, async move |_editor, cx| { let url = if let Some(end_pos) = end_position { find_url_from_range(&buffer, start_position..end_pos, cx.clone()) } else { @@ -15801,12 +16469,16 @@ impl Editor { }; if let Some(url) = url { - editor.update(cx, |_, cx| { - cx.open_url(&url); - }) - } else { - Ok(()) + cx.update(|window, cx| { + if parse_zed_link(&url, cx).is_some() { + window.dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + } else { + cx.open_url(&url); + } + })?; } + + anyhow::Ok(()) }); url_finder.detach(); @@ -15879,15 +16551,30 @@ impl Editor { let workspace = self.workspace(); - cx.spawn_in(window, async move |editor, acx| { - let mut locations: Vec = future::join_all(definitions) + cx.spawn_in(window, async move |editor, cx| { + let locations: Vec = future::join_all(definitions) .await .into_iter() .filter_map(|location| location.transpose()) .collect::>() .context("location tasks")?; + let mut locations = cx.update(|_, cx| { + locations + .into_iter() + .map(|location| { + let buffer = location.buffer.read(cx); + (location.buffer, location.range.to_point(buffer)) + }) + .into_group_map() + })?; + let mut num_locations = 0; + for ranges in locations.values_mut() { + ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.dedup(); + num_locations += ranges.len(); + } - if locations.len() > 1 { + if num_locations > 1 { let Some(workspace) = workspace else { return Ok(Navigated::No); }; @@ -15899,14 +16586,14 @@ impl Editor { Some(GotoDefinitionKind::Type) => "Types", }; let title = editor - .update_in(acx, |_, _, cx| { + .update_in(cx, |_, _, cx| { let target = locations .iter() - .map(|location| { - location - .buffer + .flat_map(|(k, v)| iter::repeat(k.clone()).zip(v)) + .map(|(buffer, location)| { + buffer .read(cx) - .text_for_range(location.range.clone()) + .text_for_range(location.clone()) .collect::() }) .filter(|text| !text.contains('\n')) @@ -15922,7 +16609,7 @@ impl Editor { .context("buffer title")?; let opened = workspace - .update_in(acx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { Self::open_locations_in_multibuffer( workspace, locations, @@ -15936,11 +16623,11 @@ impl Editor { .is_ok(); anyhow::Ok(Navigated::from_bool(opened)) - } else if locations.is_empty() { - // If there is one definition, just open it directly + } else if num_locations == 0 { + // If there is one url or file, open it directly match first_url_or_file { Some(Either::Left(url)) => { - acx.update(|_, cx| cx.open_url(&url))?; + cx.update(|_, cx| cx.open_url(&url))?; Ok(Navigated::Yes) } Some(Either::Right(path)) => { @@ -15949,7 +16636,7 @@ impl Editor { }; workspace - .update_in(acx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_resolved_path(path, window, cx) })? .await?; @@ -15962,19 +16649,20 @@ impl Editor { return Ok(Navigated::No); }; - let target = locations.pop().unwrap(); - editor.update_in(acx, |editor, window, cx| { - let pane = workspace.read(cx).active_pane().clone(); + let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); + let target_range = target_ranges.first().unwrap().clone(); - let range = target.range.to_point(target.buffer.read(cx)); + editor.update_in(cx, |editor, window, cx| { + let range = target_range.to_point(target_buffer.read(cx)); let range = editor.range_for_match(&range); let range = collapse_multiline_range(range); if !split - && Some(&target.buffer) == editor.buffer.read(cx).as_singleton().as_ref() + && Some(&target_buffer) == editor.buffer.read(cx).as_singleton().as_ref() { - editor.go_to_singleton_buffer_range(range.clone(), window, cx); + editor.go_to_singleton_buffer_range(range, window, cx); } else { + let pane = workspace.read(cx).active_pane().clone(); window.defer(cx, move |window, cx| { let target_editor: Entity = workspace.update(cx, |workspace, cx| { @@ -15986,7 +16674,7 @@ impl Editor { workspace.open_project_item( pane, - target.buffer.clone(), + target_buffer.clone(), true, true, window, @@ -16022,49 +16710,168 @@ impl Editor { cx.spawn_in(window, async move |editor, cx| { let location_task = editor.update(cx, |_, cx| { project.update(cx, |project, cx| { - let language_server_name = project - .language_server_statuses(cx) - .find(|(id, _)| server_id == *id) - .map(|(_, status)| status.name.clone()); - language_server_name.map(|language_server_name| { - project.open_local_buffer_via_lsp( - lsp_location.uri.clone(), - server_id, - language_server_name, - cx, - ) - }) + project.open_local_buffer_via_lsp(lsp_location.uri.clone(), server_id, cx) }) })?; - let location = match location_task { - Some(task) => Some({ - let target_buffer_handle = task.await.context("open local buffer")?; - let range = target_buffer_handle.read_with(cx, |target_buffer, _| { - let target_start = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); - let target_end = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); - target_buffer.anchor_after(target_start) - ..target_buffer.anchor_before(target_end) - })?; - Location { - buffer: target_buffer_handle, - range, - } - }), - None => None, - }; + let location = Some({ + let target_buffer_handle = location_task.await.context("open local buffer")?; + let range = target_buffer_handle.read_with(cx, |target_buffer, _| { + let target_start = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); + let target_end = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); + target_buffer.anchor_after(target_start) + ..target_buffer.anchor_before(target_end) + })?; + Location { + buffer: target_buffer_handle, + range, + } + }); Ok(location) }) } + fn go_to_next_reference( + &mut self, + _: &GoToNextReference, + window: &mut Window, + cx: &mut Context, + ) { + let task = self.go_to_reference_before_or_after_position(Direction::Next, 1, window, cx); + if let Some(task) = task { + task.detach(); + }; + } + + fn go_to_prev_reference( + &mut self, + _: &GoToPreviousReference, + window: &mut Window, + cx: &mut Context, + ) { + let task = self.go_to_reference_before_or_after_position(Direction::Prev, 1, window, cx); + if let Some(task) = task { + task.detach(); + }; + } + + pub fn go_to_reference_before_or_after_position( + &mut self, + direction: Direction, + count: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let selection = self.selections.newest_anchor(); + let head = selection.head(); + + let multi_buffer = self.buffer.read(cx); + + let (buffer, text_head) = multi_buffer.text_anchor_for_position(head, cx)?; + let workspace = self.workspace()?; + let project = workspace.read(cx).project().clone(); + let references = + project.update(cx, |project, cx| project.references(&buffer, text_head, cx)); + Some(cx.spawn_in(window, async move |editor, cx| -> Result<()> { + let Some(locations) = references.await? else { + return Ok(()); + }; + + if locations.is_empty() { + // totally normal - the cursor may be on something which is not + // a symbol (e.g. a keyword) + log::info!("no references found under cursor"); + return Ok(()); + } + + let multi_buffer = editor.read_with(cx, |editor, _| editor.buffer().clone())?; + + let multi_buffer_snapshot = + multi_buffer.read_with(cx, |multi_buffer, cx| multi_buffer.snapshot(cx))?; + + let (locations, current_location_index) = + multi_buffer.update(cx, |multi_buffer, cx| { + let mut locations = locations + .into_iter() + .filter_map(|loc| { + let start = multi_buffer.buffer_anchor_to_anchor( + &loc.buffer, + loc.range.start, + cx, + )?; + let end = multi_buffer.buffer_anchor_to_anchor( + &loc.buffer, + loc.range.end, + cx, + )?; + Some(start..end) + }) + .collect::>(); + + // There is an O(n) implementation, but given this list will be + // small (usually <100 items), the extra O(log(n)) factor isn't + // worth the (surprisingly large amount of) extra complexity. + locations + .sort_unstable_by(|l, r| l.start.cmp(&r.start, &multi_buffer_snapshot)); + + let head_offset = head.to_offset(&multi_buffer_snapshot); + + let current_location_index = locations.iter().position(|loc| { + loc.start.to_offset(&multi_buffer_snapshot) <= head_offset + && loc.end.to_offset(&multi_buffer_snapshot) >= head_offset + }); + + (locations, current_location_index) + })?; + + let Some(current_location_index) = current_location_index else { + // This indicates something has gone wrong, because we already + // handle the "no references" case above + log::error!( + "failed to find current reference under cursor. Total references: {}", + locations.len() + ); + return Ok(()); + }; + + let destination_location_index = match direction { + Direction::Next => (current_location_index + count) % locations.len(), + Direction::Prev => { + (current_location_index + locations.len() - count % locations.len()) + % locations.len() + } + }; + + // TODO(cameron): is this needed? + // the thinking is to avoid "jumping to the current location" (avoid + // polluting "jumplist" in vim terms) + if current_location_index == destination_location_index { + return Ok(()); + } + + let Range { start, end } = locations[destination_location_index]; + + editor.update_in(cx, |editor, window, cx| { + let effects = SelectionEffects::default(); + + editor.unfold_ranges(&[start..end], false, false, cx); + editor.change_selections(effects, window, cx, |s| { + s.select_ranges([start..start]); + }); + })?; + + Ok(()) + })) + } + pub fn find_all_references( &mut self, _: &FindAllReferences, window: &mut Window, cx: &mut Context, ) -> Option>> { - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let multi_buffer = self.buffer.read(cx); let head = selection.head(); @@ -16107,19 +16914,34 @@ impl Editor { } }); - let locations = references.await?; + let Some(locations) = references.await? else { + return anyhow::Ok(Navigated::No); + }; + let mut locations = cx.update(|_, cx| { + locations + .into_iter() + .map(|location| { + let buffer = location.buffer.read(cx); + (location.buffer, location.range.to_point(buffer)) + }) + .into_group_map() + })?; if locations.is_empty() { return anyhow::Ok(Navigated::No); } + for ranges in locations.values_mut() { + ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.dedup(); + } workspace.update_in(cx, |workspace, window, cx| { let target = locations .iter() - .map(|location| { - location - .buffer + .flat_map(|(k, v)| iter::repeat(k.clone()).zip(v)) + .map(|(buffer, location)| { + buffer .read(cx) - .text_for_range(location.range.clone()) + .text_for_range(location.clone()) .collect::() }) .filter(|text| !text.contains('\n')) @@ -16148,7 +16970,7 @@ impl Editor { /// Opens a multibuffer with the given project locations in it pub fn open_locations_in_multibuffer( workspace: &mut Workspace, - mut locations: Vec, + locations: std::collections::HashMap, Vec>>, title: String, split: bool, multibuffer_selection_mode: MultibufferSelectionMode, @@ -16160,35 +16982,23 @@ impl Editor { return; } - // If there are multiple definitions, open them in a multibuffer - locations.sort_by_key(|location| location.buffer.read(cx).remote_id()); - let mut locations = locations.into_iter().peekable(); - let mut ranges: Vec> = Vec::new(); let capability = workspace.project().read(cx).capability(); + let mut ranges = >>::new(); + // a key to find existing multibuffer editors with the same set of locations + // to prevent us from opening more and more multibuffer tabs for searches and the like + let mut key = (title.clone(), vec![]); let excerpt_buffer = cx.new(|cx| { + let key = &mut key.1; let mut multibuffer = MultiBuffer::new(capability); - while let Some(location) = locations.next() { - let buffer = location.buffer.read(cx); - let mut ranges_for_buffer = Vec::new(); - let range = location.range.to_point(buffer); - ranges_for_buffer.push(range.clone()); - - while let Some(next_location) = locations.peek() { - if next_location.buffer == location.buffer { - ranges_for_buffer.push(next_location.range.to_point(buffer)); - locations.next(); - } else { - break; - } - } - + for (buffer, mut ranges_for_buffer) in locations { ranges_for_buffer.sort_by_key(|range| (range.start, Reverse(range.end))); + key.push((buffer.read(cx).remote_id(), ranges_for_buffer.clone())); let (new_ranges, _) = multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&location.buffer, cx), - location.buffer.clone(), + PathKey::for_buffer(&buffer, cx), + buffer.clone(), ranges_for_buffer, - DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); ranges.extend(new_ranges) @@ -16196,73 +17006,80 @@ impl Editor { multibuffer.with_title(title) }); - - let editor = cx.new(|cx| { - Editor::for_multibuffer( - excerpt_buffer, - Some(workspace.project().clone()), - window, - cx, - ) + let existing = workspace.active_pane().update(cx, |pane, cx| { + pane.items() + .filter_map(|item| item.downcast::()) + .find(|editor| { + editor + .read(cx) + .lookup_key + .as_ref() + .and_then(|it| { + it.downcast_ref::<(String, Vec<(BufferId, Vec>)>)>() + }) + .is_some_and(|it| *it == key) + }) + }); + let editor = existing.unwrap_or_else(|| { + cx.new(|cx| { + let mut editor = Editor::for_multibuffer( + excerpt_buffer, + Some(workspace.project().clone()), + window, + cx, + ); + editor.lookup_key = Some(Box::new(key)); + editor + }) }); - editor.update(cx, |editor, cx| { - match multibuffer_selection_mode { - MultibufferSelectionMode::First => { - if let Some(first_range) = ranges.first() { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - selections.clear_disjoint(); - selections - .select_anchor_ranges(std::iter::once(first_range.clone())); - }, - ); - } - editor.highlight_background::( - &ranges, - |theme| theme.colors().editor_highlighted_line_background, - cx, - ); - } - MultibufferSelectionMode::All => { + editor.update(cx, |editor, cx| match multibuffer_selection_mode { + MultibufferSelectionMode::First => { + if let Some(first_range) = ranges.first() { editor.change_selections( SelectionEffects::no_scroll(), window, cx, |selections| { selections.clear_disjoint(); - selections.select_anchor_ranges(ranges); + selections.select_anchor_ranges(std::iter::once(first_range.clone())); }, ); } + editor.highlight_background::( + &ranges, + |theme| theme.colors().editor_highlighted_line_background, + cx, + ); + } + MultibufferSelectionMode::All => { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(ranges); + }); } - editor.register_buffers_with_language_servers(cx); }); let item = Box::new(editor); let item_id = item.item_id(); if split { - workspace.split_item(SplitDirection::Right, item.clone(), window, cx); - } else { - if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { - let (preview_item_id, preview_item_idx) = - workspace.active_pane().read_with(cx, |pane, _| { - (pane.preview_item_id(), pane.preview_item_idx()) - }); + let pane = workspace.adjacent_pane(window, cx); + workspace.add_item(pane, item, None, true, true, window, cx); + } else if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { + let (preview_item_id, preview_item_idx) = + workspace.active_pane().read_with(cx, |pane, _| { + (pane.preview_item_id(), pane.preview_item_idx()) + }); - workspace.add_item_to_active_pane(item.clone(), preview_item_idx, true, window, cx); + workspace.add_item_to_active_pane(item, preview_item_idx, true, window, cx); - if let Some(preview_item_id) = preview_item_id { - workspace.active_pane().update(cx, |pane, cx| { - pane.remove_item(preview_item_id, false, false, window, cx); - }); - } - } else { - workspace.add_item_to_active_pane(item.clone(), None, true, window, cx); + if let Some(preview_item_id) = preview_item_id { + workspace.active_pane().update(cx, |pane, cx| { + pane.remove_item(preview_item_id, false, false, window, cx); + }); } + } else { + workspace.add_item_to_active_pane(item, None, true, window, cx); } workspace.active_pane().update(cx, |pane, cx| { pane.set_preview_item_id(Some(item_id), cx); @@ -16529,7 +17346,10 @@ impl Editor { if moving_cursor { let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| { - editor.selections.newest::(cx).head() + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head() }); // Update the selection to match the position of the selection inside @@ -16592,7 +17412,7 @@ impl Editor { let ranges = self .selections - .all_adjusted(cx) + .all_adjusted(&self.display_snapshot(cx)) .into_iter() .map(|selection| selection.range()) .collect_vec(); @@ -16652,10 +17472,7 @@ impl Editor { .transaction(transaction_id_prev) .map(|t| t.0.clone()) }) - .unwrap_or_else(|| { - log::info!("Failed to determine selections from before format. Falling back to selections when format was initiated"); - self.selections.disjoint_anchors() - }); + .unwrap_or_else(|| self.selections.disjoint_anchors_arc()); let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse(); let format = project.update(cx, |project, cx| { @@ -16673,10 +17490,10 @@ impl Editor { buffer .update(cx, |buffer, cx| { - if let Some(transaction) = transaction { - if !buffer.is_singleton() { - buffer.push_transaction(&transaction.0, cx); - } + if let Some(transaction) = transaction + && !buffer.is_singleton() + { + buffer.push_transaction(&transaction.0, cx); } cx.notify(); }) @@ -16742,10 +17559,10 @@ impl Editor { buffer .update(cx, |buffer, cx| { // check if we need this - if let Some(transaction) = transaction { - if !buffer.is_singleton() { - buffer.push_transaction(&transaction.0, cx); - } + if let Some(transaction) = transaction + && !buffer.is_singleton() + { + buffer.push_transaction(&transaction.0, cx); } cx.notify(); }) @@ -16787,9 +17604,9 @@ impl Editor { HashSet::default(), cx, ); - cx.emit(project::Event::RefreshInlayHints); }); }); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } } @@ -16862,7 +17679,7 @@ impl Editor { fn activate_diagnostics( &mut self, buffer_id: BufferId, - diagnostic: DiagnosticEntry, + diagnostic: DiagnosticEntryRef<'_, usize>, window: &mut Window, cx: &mut Context, ) { @@ -16924,6 +17741,10 @@ impl Editor { self.inline_diagnostics.clear(); } + pub fn disable_word_completions(&mut self) { + self.word_completions_enabled = false; + } + pub fn diagnostics_enabled(&self) -> bool { self.diagnostics_enabled && self.mode.is_full() } @@ -17047,7 +17868,7 @@ impl Editor { .map(|(line, _)| line) .map(SharedString::new) .unwrap_or_else(|| { - SharedString::from(diagnostic_entry.diagnostic.message) + SharedString::new(&*diagnostic_entry.diagnostic.message) }); let start_anchor = snapshot.anchor_before(diagnostic_entry.range.start); let (Ok(i) | Err(i)) = inline_diagnostics @@ -17085,7 +17906,7 @@ impl Editor { window: &Window, cx: &mut Context, ) -> Option<()> { - if !self.mode().is_full() { + if self.ignore_lsp_data() { return None; } let pull_diagnostics_settings = ProjectSettings::get_global(cx) @@ -17097,8 +17918,14 @@ impl Editor { let project = self.project()?.downgrade(); let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); let mut buffers = self.buffer.read(cx).all_buffers(); - if let Some(buffer_id) = buffer_id { - buffers.retain(|buffer| buffer.read(cx).remote_id() == buffer_id); + buffers.retain(|buffer| { + let buffer_id_to_retain = buffer.read(cx).remote_id(); + buffer_id.is_none_or(|buffer_id| buffer_id == buffer_id_to_retain) + && self.registered_buffers.contains_key(&buffer_id_to_retain) + }); + if buffers.is_empty() { + self.pull_diagnostics_task = Task::ready(()); + return None; } self.pull_diagnostics_task = cx.spawn_in(window, async move |editor, cx| { @@ -17191,7 +18018,7 @@ impl Editor { .update(cx, |buffer, cx| buffer.start_transaction_at(now, cx)) { self.selection_history - .insert_transaction(tx_id, self.selections.disjoint_anchors()); + .insert_transaction(tx_id, self.selections.disjoint_anchors_arc()); cx.emit(EditorEvent::TransactionBegun { transaction_id: tx_id, }); @@ -17213,7 +18040,7 @@ impl Editor { if let Some((_, end_selections)) = self.selection_history.transaction_mut(transaction_id) { - *end_selections = Some(self.selections.disjoint_anchors()); + *end_selections = Some(self.selections.disjoint_anchors_arc()); } else { log::error!("unexpectedly ended a transaction that wasn't started by this editor"); } @@ -17283,10 +18110,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if self.is_singleton(cx) { - let selection = self.selections.newest::(cx); - + if self.buffer_kind(cx) == ItemBufferKind::Singleton { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selection = self.selections.newest::(&display_map); + let range = if selection.is_empty() { let point = selection.head().to_display_point(&display_map); let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); @@ -17329,7 +18156,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let range = if selection.is_empty() { @@ -17349,10 +18176,10 @@ impl Editor { } pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { - if self.is_singleton(cx) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); + let selections = self.selections.all_adjusted(&display_map); for selection in selections { let range = selection.range().sorted(); @@ -17377,12 +18204,12 @@ impl Editor { } for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - if row <= range.start.row { - break; - } + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + && crease.range().end.row >= buffer_start_row + { + to_fold.push(crease); + if row <= range.start.row { + break; } } } @@ -17411,7 +18238,7 @@ impl Editor { if self.buffer.read(cx).is_singleton() { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let has_folds = display_map - .folds_in_range(0..display_map.buffer_snapshot.len()) + .folds_in_range(0..display_map.buffer_snapshot().len()) .next() .is_some(); @@ -17457,6 +18284,13 @@ impl Editor { let mut to_fold = Vec::new(); let mut stack = vec![(0, snapshot.max_row().0, 1)]; + let row_ranges_to_keep: Vec> = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|sel| sel.start.row..sel.end.row) + .collect(); + while let Some((mut start_row, end_row, current_level)) = stack.pop() { while start_row < end_row { match self @@ -17470,7 +18304,13 @@ impl Editor { if current_level < fold_at_level { stack.push((nested_start_row, nested_end_row, current_level + 1)); } else if current_level == fold_at_level { - to_fold.push(crease); + // Fold iff there is no selection completely contained within the fold region + if !row_ranges_to_keep.iter().any(|selection| { + selection.end >= nested_start_row + && selection.start <= nested_end_row + }) { + to_fold.push(crease); + } } start_row = nested_end_row + 1; @@ -17483,6 +18323,87 @@ impl Editor { self.fold_creases(to_fold, true, window, cx); } + pub fn fold_at_level_1( + &mut self, + _: &actions::FoldAtLevel1, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(1), window, cx); + } + + pub fn fold_at_level_2( + &mut self, + _: &actions::FoldAtLevel2, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(2), window, cx); + } + + pub fn fold_at_level_3( + &mut self, + _: &actions::FoldAtLevel3, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(3), window, cx); + } + + pub fn fold_at_level_4( + &mut self, + _: &actions::FoldAtLevel4, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(4), window, cx); + } + + pub fn fold_at_level_5( + &mut self, + _: &actions::FoldAtLevel5, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(5), window, cx); + } + + pub fn fold_at_level_6( + &mut self, + _: &actions::FoldAtLevel6, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(6), window, cx); + } + + pub fn fold_at_level_7( + &mut self, + _: &actions::FoldAtLevel7, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(7), window, cx); + } + + pub fn fold_at_level_8( + &mut self, + _: &actions::FoldAtLevel8, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(8), window, cx); + } + + pub fn fold_at_level_9( + &mut self, + _: &actions::FoldAtLevel9, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(9), window, cx); + } + pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { if self.buffer.read(cx).is_singleton() { let mut fold_ranges = Vec::new(); @@ -17540,7 +18461,7 @@ impl Editor { ) { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); + let selections = self.selections.all_adjusted(&display_map); for selection in selections { let range = selection.range().sorted(); @@ -17584,7 +18505,7 @@ impl Editor { if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { let autoscroll = self .selections - .all::(cx) + .all::(&display_map) .iter() .any(|selection| crease.range().overlaps(&selection.range())); @@ -17593,10 +18514,10 @@ impl Editor { } pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { - if self.is_singleton(cx) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let selections = self.selections.all::(cx); + let buffer = display_map.buffer_snapshot(); + let selections = self.selections.all::(&display_map); let ranges = selections .iter() .map(|s| { @@ -17630,7 +18551,7 @@ impl Editor { cx: &mut Context, ) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let ranges = selections .iter() .map(|s| { @@ -17657,12 +18578,12 @@ impl Editor { let intersection_range = Point::new(buffer_row.0, 0) ..Point::new( buffer_row.0, - display_map.buffer_snapshot.line_len(buffer_row), + display_map.buffer_snapshot().line_len(buffer_row), ); let autoscroll = self .selections - .all::(cx) + .all::(&display_map) .iter() .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); @@ -17677,7 +18598,7 @@ impl Editor { ) { if self.buffer.read(cx).is_singleton() { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges(&[0..display_map.buffer_snapshot.len()], true, true, cx); + self.unfold_ranges(&[0..display_map.buffer_snapshot().len()], true, true, cx); } else { self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { editor @@ -17697,8 +18618,8 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let selections = self.selections.all_adjusted(cx); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); let ranges = selections .into_iter() .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) @@ -17877,13 +18798,29 @@ impl Editor { }); } + pub fn collapse_all_diff_hunks( + &mut self, + _: &CollapseAllDiffHunks, + _window: &mut Window, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + buffer.collapse_diff_hunks(vec![Anchor::min()..Anchor::max()], cx) + }); + } + pub fn toggle_selected_diff_hunks( &mut self, _: &ToggleSelectedDiffHunks, _window: &mut Window, cx: &mut Context, ) { - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); self.toggle_diff_hunks_in_ranges(ranges, cx); } @@ -17910,7 +18847,7 @@ impl Editor { ranges: &[Range], snapshot: &MultiBufferSnapshot, ) -> bool { - let mut hunks = self.diff_hunks_in_ranges(ranges, &snapshot); + let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot); hunks.any(|hunk| hunk.status().has_secondary_hunk()) } @@ -17921,7 +18858,12 @@ impl Editor { cx: &mut Context, ) { let snapshot = self.buffer.read(cx).snapshot(cx); - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); self.stage_or_unstage_diff_hunks(stage, ranges, cx); } @@ -18021,10 +18963,13 @@ impl Editor { self.stage_or_unstage_diff_hunks(stage, ranges, cx); let snapshot = self.snapshot(window, cx); - let position = self.selections.newest::(cx).head(); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); let mut row = snapshot - .buffer_snapshot - .diff_hunks_in_range(position..snapshot.buffer_snapshot.max_point()) + .buffer_snapshot() + .diff_hunks_in_range(position..snapshot.buffer_snapshot().max_point()) .find(|hunk| hunk.row_range.start.0 > position.row) .map(|hunk| hunk.row_range.start); @@ -18033,7 +18978,7 @@ impl Editor { if !all_diff_hunks_expanded { row = row.or_else(|| { snapshot - .buffer_snapshot + .buffer_snapshot() .diff_hunks_in_range(Point::zero()..position) .find(|hunk| hunk.row_range.end.0 < position.row) .map(|hunk| hunk.row_range.start) @@ -18085,7 +19030,12 @@ impl Editor { } pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { - let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect(); + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); self.buffer .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) } @@ -18162,7 +19112,12 @@ impl Editor { ) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); let snapshot = self.snapshot(window, cx); - let hunks = snapshot.hunks_for_ranges(self.selections.ranges(cx)); + let hunks = snapshot.hunks_for_ranges( + self.selections + .all(&snapshot.display_snapshot) + .into_iter() + .map(|selection| selection.range()), + ); let mut ranges_by_buffer = HashMap::default(); self.transact(window, cx, |editor, _window, cx| { for hunk in hunks { @@ -18354,7 +19309,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option> { - (minimap_settings.minimap_enabled() && self.is_singleton(cx)) + (minimap_settings.minimap_enabled() && self.buffer_kind(cx) == ItemBufferKind::Singleton) .then(|| self.initialize_new_minimap(minimap_settings, window, cx)) } @@ -18460,24 +19415,20 @@ impl Editor { } /// called by the Element so we know what style we were most recently rendered with. - pub(crate) fn set_style( - &mut self, - style: EditorStyle, - window: &mut Window, - cx: &mut Context, - ) { + pub fn set_style(&mut self, style: EditorStyle, window: &mut Window, cx: &mut Context) { // We intentionally do not inform the display map about the minimap style // so that wrapping is not recalculated and stays consistent for the editor // and its linked minimap. if !self.mode.is_minimap() { - let rem_size = window.rem_size(); - self.display_map.update(cx, |map, cx| { - map.set_font( - style.text.font(), - style.text.font_size.to_pixels(rem_size), - cx, - ) - }); + let font = style.text.font(); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let display_map = self + .placeholder_display_map + .as_ref() + .filter(|_| self.is_empty(cx)) + .unwrap_or(&self.display_map); + + display_map.update(cx, |map, cx| map.set_font(font, font_size, cx)); } self.style = Some(style); } @@ -18489,8 +19440,16 @@ impl Editor { // Called by the element. This method is not designed to be called outside of the editor // element's layout code because it does not notify when rewrapping is computed synchronously. pub(crate) fn set_wrap_width(&self, width: Option, cx: &mut App) -> bool { - self.display_map - .update(cx, |map, cx| map.set_wrap_width(width, cx)) + if self.is_empty(cx) { + self.placeholder_display_map + .as_ref() + .map_or(false, |display_map| { + display_map.update(cx, |map, cx| map.set_wrap_width(width, cx)) + }) + } else { + self.display_map + .update(cx, |map, cx| map.set_wrap_width(width, cx)) + } } pub fn set_soft_wrap(&mut self) { @@ -18519,8 +19478,8 @@ impl Editor { }; let fs = workspace.read(cx).app_state().fs.clone(); let current_show = TabBarSettings::get_global(cx).show; - update_settings_file::(fs, cx, move |setting, _| { - setting.show = Some(!current_show); + update_settings_file(fs, cx, move |setting, _| { + setting.tab_bar.get_or_insert_default().show = Some(!current_show); }); } @@ -18692,14 +19651,10 @@ impl Editor { pub fn working_directory(&self, cx: &App) -> Option { if let Some(buffer) = self.buffer().read(cx).as_singleton() { - if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { - if let Some(dir) = file.abs_path(cx).parent() { - return Some(dir.to_owned()); - } - } - - if let Some(project_path) = buffer.read(cx).project_path(cx) { - return Some(project_path.path.to_path_buf()); + if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) + && let Some(dir) = file.abs_path(cx).parent() + { + return Some(dir.to_owned()); } } @@ -18728,16 +19683,6 @@ impl Editor { }) } - fn target_file_path(&self, cx: &mut Context) -> Option { - self.active_excerpt(cx).and_then(|(_, buffer, _)| { - let project_path = buffer.read(cx).project_path(cx)?; - let project = self.project()?.read(cx); - let entry = project.entry_for_path(&project_path, cx)?; - let path = entry.path.to_path_buf(); - Some(path) - }) - } - pub fn reveal_in_finder( &mut self, _: &RevealInFileManager, @@ -18755,10 +19700,12 @@ impl Editor { _window: &mut Window, cx: &mut Context, ) { - if let Some(path) = self.target_file_abs_path(cx) { - if let Some(path) = path.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } + if let Some(path) = self.target_file_abs_path(cx) + && let Some(path) = path.to_str() + { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); } } @@ -18768,13 +19715,20 @@ impl Editor { _window: &mut Window, cx: &mut Context, ) { - if let Some(path) = self.target_file_path(cx) { - if let Some(path) = path.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } + if let Some(path) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { + let project = self.project()?.read(cx); + let path = buffer.read(cx).file()?.path(); + let path = path.display(project.path_style(cx)); + Some(path) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); } } + /// Returns the project path for the editor's buffer, if any buffer is + /// opened in the editor. pub fn project_path(&self, cx: &App) -> Option { if let Some(buffer) = self.buffer.read(cx).as_singleton() { buffer.read(cx).project_path(cx) @@ -18840,22 +19794,18 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - if let Some(file) = self.target_file(cx) { - if let Some(file_stem) = file.path().file_stem() { - if let Some(name) = file_stem.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - } + if let Some(file) = self.target_file(cx) + && let Some(file_stem) = file.path().file_stem() + { + cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); } } pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { - if let Some(file) = self.target_file(cx) { - if let Some(file_name) = file.path().file_name() { - if let Some(name) = file_name.to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - } + if let Some(file) = self.target_file(cx) + && let Some(name) = file.path().file_name() + { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); } } @@ -18900,9 +19850,12 @@ impl Editor { ) -> Option<()> { let blame = self.blame.as_ref()?; let snapshot = self.snapshot(window, cx); - let cursor = self.selections.newest::(cx).head(); - let (buffer, point, _) = snapshot.buffer_snapshot.point_to_buffer_point(cursor)?; - let blame_entry = blame + let cursor = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let (buffer, point, _) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?; + let (_, blame_entry) = blame .update(cx, |blame, cx| { blame .blame_for_rows( @@ -18917,7 +19870,7 @@ impl Editor { }) .flatten()?; let renderer = cx.global::().0.clone(); - let repo = blame.read(cx).repository(cx)?; + let repo = blame.read(cx).repository(cx, buffer.remote_id())?; let workspace = self.workspace()?.downgrade(); renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); None @@ -18953,18 +19906,17 @@ impl Editor { cx: &mut Context, ) { if let Some(project) = self.project() { - let Some(buffer) = self.buffer().read(cx).as_singleton() else { - return; - }; - - if buffer.read(cx).file().is_none() { + if let Some(buffer) = self.buffer().read(cx).as_singleton() + && buffer.read(cx).file().is_none() + { return; } let focused = self.focus_handle(cx).contains_focused(window, cx); let project = project.clone(); - let blame = cx.new(|cx| GitBlame::new(buffer, project, user_triggered, focused, cx)); + let blame = cx + .new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx)); self.blame_subscription = Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); self.blame = Some(blame); @@ -19029,7 +19981,7 @@ impl Editor { fn has_blame_entries(&self, cx: &App) -> bool { self.blame() - .map_or(false, |blame| blame.read(cx).has_generated_entries()) + .is_some_and(|blame| blame.read(cx).has_generated_entries()) } fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool { @@ -19043,7 +19995,7 @@ impl Editor { fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { let buffer_and_selection = maybe!({ - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let selection_range = selection.range(); let multi_buffer = self.buffer().read(cx); @@ -19056,12 +20008,9 @@ impl Editor { buffer_ranges.last() }?; - let selection = text::ToPoint::to_point(&range.start, &buffer).row - ..text::ToPoint::to_point(&range.end, &buffer).row; - Some(( - multi_buffer.buffer(buffer.remote_id()).unwrap().clone(), - selection, - )) + let selection = text::ToPoint::to_point(&range.start, buffer).row + ..text::ToPoint::to_point(&range.end, buffer).row; + Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)) }); let Some((buffer, selection)) = buffer_and_selection else { @@ -19124,11 +20073,15 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - let selection = self.selections.newest::(cx).start.row + 1; + let selection = self + .selections + .newest::(&self.display_snapshot(cx)) + .start + .row + + 1; if let Some(file) = self.target_file(cx) { - if let Some(path) = file.path().to_str() { - cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}"))); - } + let path = file.path().display(file.path_style(cx)); + cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}"))); } } @@ -19196,7 +20149,7 @@ impl Editor { self.transact(window, cx, |this, window, cx| { let edits = this .selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .into_iter() .map(|selection| { let uuid = match version { @@ -19232,12 +20185,15 @@ impl Editor { let locations = self .selections .all_anchors(cx) - .into_iter() - .map(|selection| Location { - buffer: buffer.clone(), - range: selection.start.text_anchor..selection.end.text_anchor, + .iter() + .map(|selection| { + ( + buffer.clone(), + (selection.start.text_anchor..selection.end.text_anchor) + .to_point(buffer.read(cx)), + ) }) - .collect::>(); + .into_group_map(); cx.spawn_in(window, async move |_, cx| { workspace.update_in(cx, |workspace, window, cx| { @@ -19303,7 +20259,7 @@ impl Editor { row_highlights.insert( ix, RowHighlight { - range: range.clone(), + range, index, color, options, @@ -19614,10 +20570,27 @@ impl Editor { cx: &mut Context, ) -> Vec<(Range, Hsla)> { let snapshot = self.snapshot(window, cx); - let buffer = &snapshot.buffer_snapshot; + let buffer = &snapshot.buffer_snapshot(); let start = buffer.anchor_before(0); let end = buffer.anchor_after(buffer.len()); - self.background_highlights_in_range(start..end, &snapshot, cx.theme()) + self.sorted_background_highlights_in_range(start..end, &snapshot, cx.theme()) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn sorted_background_highlights_in_range( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + theme: &Theme, + ) -> Vec<(Range, Hsla)> { + let mut res = self.background_highlights_in_range(search_range, display_snapshot, theme); + res.sort_by(|a, b| { + a.0.start + .cmp(&b.0.start) + .then_with(|| a.0.end.cmp(&b.0.end)) + .then_with(|| a.1.cmp(&b.1)) + }); + res } #[cfg(feature = "test-support")] @@ -19679,9 +20652,12 @@ impl Editor { pub fn has_background_highlights(&self) -> bool { self.background_highlights .get(&HighlightKey::Type(TypeId::of::())) - .map_or(false, |(_, highlights)| !highlights.is_empty()) + .is_some_and(|(_, highlights)| !highlights.is_empty()) } + /// Returns all background highlights for a given range. + /// + /// The order of highlights is not deterministic, do sort the ranges if needed for the logic. pub fn background_highlights_in_range( &self, search_range: Range, @@ -19694,7 +20670,7 @@ impl Editor { let start_ix = match ranges.binary_search_by(|probe| { let cmp = probe .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + .cmp(&search_range.start, &display_snapshot.buffer_snapshot()); if cmp.is_gt() { Ordering::Greater } else { @@ -19706,7 +20682,7 @@ impl Editor { for range in &ranges[start_ix..] { if range .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .cmp(&search_range.end, &display_snapshot.buffer_snapshot()) .is_ge() { break; @@ -19720,84 +20696,6 @@ impl Editor { results } - pub fn background_highlight_row_ranges( - &self, - search_range: Range, - display_snapshot: &DisplaySnapshot, - count: usize, - ) -> Vec> { - let mut results = Vec::new(); - let Some((_, ranges)) = self - .background_highlights - .get(&HighlightKey::Type(TypeId::of::())) - else { - return vec![]; - }; - - let start_ix = match ranges.binary_search_by(|probe| { - let cmp = probe - .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); - if cmp.is_gt() { - Ordering::Greater - } else { - Ordering::Less - } - }) { - Ok(i) | Err(i) => i, - }; - let mut push_region = |start: Option, end: Option| { - if let (Some(start_display), Some(end_display)) = (start, end) { - results.push( - start_display.to_display_point(display_snapshot) - ..=end_display.to_display_point(display_snapshot), - ); - } - }; - let mut start_row: Option = None; - let mut end_row: Option = None; - if ranges.len() > count { - return Vec::new(); - } - for range in &ranges[start_ix..] { - if range - .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) - .is_ge() - { - break; - } - let end = range.end.to_point(&display_snapshot.buffer_snapshot); - if let Some(current_row) = &end_row { - if end.row == current_row.row { - continue; - } - } - let start = range.start.to_point(&display_snapshot.buffer_snapshot); - if start_row.is_none() { - assert_eq!(end_row, None); - start_row = Some(start); - end_row = Some(end); - continue; - } - if let Some(current_end) = end_row.as_mut() { - if start.row > current_end.row + 1 { - push_region(start_row, end_row); - start_row = Some(start); - end_row = Some(end); - } else { - // Merge two hunks. - *current_end = end; - } - } else { - unreachable!(); - } - } - // We might still have a hunk that was not rendered (if there was a search hit on the last line) - push_region(start_row, end_row); - results - } - pub fn gutter_highlights_in_range( &self, search_range: Range, @@ -19810,7 +20708,7 @@ impl Editor { let start_ix = match ranges.binary_search_by(|probe| { let cmp = probe .end - .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + .cmp(&search_range.start, &display_snapshot.buffer_snapshot()); if cmp.is_gt() { Ordering::Greater } else { @@ -19822,7 +20720,7 @@ impl Editor { for range in &ranges[start_ix..] { if range .start - .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .cmp(&search_range.end, &display_snapshot.buffer_snapshot()) .is_ge() { break; @@ -19844,7 +20742,7 @@ impl Editor { cx: &App, ) -> Vec> { display_snapshot - .buffer_snapshot + .buffer_snapshot() .redacted_ranges(search_range, |file| { if let Some(file) = file { file.is_private() @@ -19877,33 +20775,21 @@ impl Editor { self.display_map.update(cx, |map, _| { map.highlight_text( HighlightKey::TypePlus(TypeId::of::(), key), - ranges, - style, - ); - }); - cx.notify(); - } - - pub fn highlight_text( - &mut self, - ranges: Vec>, - style: HighlightStyle, - cx: &mut Context, - ) { - self.display_map.update(cx, |map, _| { - map.highlight_text(HighlightKey::Type(TypeId::of::()), ranges, style) + ranges, + style, + ); }); cx.notify(); } - pub(crate) fn highlight_inlays( + pub fn highlight_text( &mut self, - highlights: Vec, + ranges: Vec>, style: HighlightStyle, cx: &mut Context, ) { self.display_map.update(cx, |map, _| { - map.highlight_inlays(TypeId::of::(), highlights, style) + map.highlight_text(HighlightKey::Type(TypeId::of::()), ranges, style) }); cx.notify(); } @@ -19944,11 +20830,8 @@ impl Editor { event: &SessionEvent, cx: &mut Context, ) { - match event { - SessionEvent::InvalidateInlineValue => { - self.refresh_inline_values(cx); - } - _ => {} + if let SessionEvent::InvalidateInlineValue = event { + self.refresh_inline_values(cx); } } @@ -20025,7 +20908,7 @@ impl Editor { Anchor::in_buffer(excerpt_id, buffer_id, hint.position), hint.text(), ); - if !inlay.text.chars().contains(&'\n') { + if !inlay.text().chars().contains(&'\n') { new_inlays.push(inlay); } }); @@ -20049,80 +20932,44 @@ impl Editor { cx: &mut Context, ) { match event { - multi_buffer::Event::Edited { - singleton_buffer_edited, - edited_buffer, - } => { + multi_buffer::Event::Edited { edited_buffer } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); self.refresh_code_actions(window, cx); self.refresh_selected_text_highlights(true, window, cx); self.refresh_single_line_folds(window, cx); - refresh_matching_bracket_highlights(self, window, cx); + self.refresh_matching_bracket_highlights(window, cx); if self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); } - if let Some(project) = self.project.as_ref() { - if let Some(edited_buffer) = edited_buffer { - project.update(cx, |project, cx| { - self.registered_buffers - .entry(edited_buffer.read(cx).remote_id()) - .or_insert_with(|| { - project - .register_buffer_with_language_servers(&edited_buffer, cx) - }); - }); - } - } - cx.emit(EditorEvent::BufferEdited); - cx.emit(SearchEvent::MatchesInvalidated); if let Some(buffer) = edited_buffer { - self.update_lsp_data(false, Some(buffer.read(cx).remote_id()), window, cx); - } - - if *singleton_buffer_edited { - if let Some(buffer) = edited_buffer { - if buffer.read(cx).file().is_none() { - cx.emit(EditorEvent::TitleChanged); - } + if buffer.read(cx).file().is_none() { + cx.emit(EditorEvent::TitleChanged); } - if let Some(project) = &self.project { - #[allow(clippy::mutable_key_type)] - let languages_affected = multibuffer.update(cx, |multibuffer, cx| { - multibuffer - .all_buffers() - .into_iter() - .filter_map(|buffer| { - buffer.update(cx, |buffer, cx| { - let language = buffer.language()?; - let should_discard = project.update(cx, |project, cx| { - project.is_local() - && !project.has_language_servers_for(buffer, cx) - }); - should_discard.not().then_some(language.clone()) - }) - }) - .collect::>() - }); - if !languages_affected.is_empty() { - self.refresh_inlay_hints( - InlayHintRefreshReason::BufferEdited(languages_affected), - cx, - ); - } + + if self.project.is_some() { + let buffer_id = buffer.read(cx).remote_id(); + self.register_buffer(buffer_id, cx); + self.update_lsp_data(Some(buffer_id), window, cx); + self.refresh_inlay_hints( + InlayHintRefreshReason::BufferEdited(buffer_id), + cx, + ); } } + cx.emit(EditorEvent::BufferEdited); + cx.emit(SearchEvent::MatchesInvalidated); + let Some(project) = &self.project else { return }; let (telemetry, is_via_ssh) = { let project = project.read(cx); let telemetry = project.client().telemetry().clone(); - let is_via_ssh = project.is_via_ssh(); + let is_via_ssh = project.is_via_remote_server(); (telemetry, is_via_ssh) }; - refresh_linked_ranges(self, window, cx); telemetry.log_edit_event("editor", is_via_ssh); } multi_buffer::Event::ExcerptsAdded { @@ -20132,34 +20979,37 @@ impl Editor { } => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); let buffer_id = buffer.read(cx).remote_id(); - if self.buffer.read(cx).diff_for(buffer_id).is_none() { - if let Some(project) = &self.project { - update_uncommitted_diff_for_buffer( - cx.entity(), - project, - [buffer.clone()], - self.buffer.clone(), - cx, - ) - .detach(); - } + if self.buffer.read(cx).diff_for(buffer_id).is_none() + && let Some(project) = &self.project + { + update_uncommitted_diff_for_buffer( + cx.entity(), + project, + [buffer.clone()], + self.buffer.clone(), + cx, + ) + .detach(); } - self.update_lsp_data(false, Some(buffer_id), window, cx); + self.update_lsp_data(Some(buffer_id), window, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); cx.emit(EditorEvent::ExcerptsAdded { buffer: buffer.clone(), predecessor: *predecessor, excerpts: excerpts.clone(), }); - self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } multi_buffer::Event::ExcerptsRemoved { ids, removed_buffer_ids, } => { + if let Some(inlay_hints) = &mut self.inlay_hints { + inlay_hints.remove_inlay_chunk_data(removed_buffer_ids); + } self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); - let buffer = self.buffer.read(cx); - self.registered_buffers - .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); + for buffer_id in removed_buffer_ids { + self.registered_buffers.remove(buffer_id); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone(), @@ -20179,6 +21029,7 @@ impl Editor { } multi_buffer::Event::ExcerptsExpanded { ids } => { self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.refresh_document_highlights(cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { @@ -20191,7 +21042,7 @@ impl Editor { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); } multi_buffer::Event::LanguageChanged(buffer_id) => { - linked_editing_ranges::refresh_linked_ranges(self, window, cx); + self.registered_buffers.remove(&buffer_id); jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); cx.notify(); @@ -20201,7 +21052,6 @@ impl Editor { multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded | multi_buffer::Event::BufferDiffChanged => cx.emit(EditorEvent::TitleChanged), - multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), multi_buffer::Event::DiagnosticsUpdated => { self.update_diagnostics_state(window, cx); } @@ -20299,7 +21149,7 @@ impl Editor { if self.mode.is_full() { let show_inline_diagnostics = project_settings.diagnostics.inline.enabled; - let inline_blame_enabled = project_settings.git.inline_blame_enabled(); + let inline_blame_enabled = project_settings.git.inline_blame.enabled; if self.show_inline_diagnostics != show_inline_diagnostics { self.show_inline_diagnostics = show_inline_diagnostics; self.refresh_inline_diagnostics(false, window, cx); @@ -20330,10 +21180,10 @@ impl Editor { if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| { colors.render_mode_updated(EditorSettings::get_global(cx).lsp_document_colors) }) { - if !inlay_splice.to_insert.is_empty() || !inlay_splice.to_remove.is_empty() { + if !inlay_splice.is_empty() { self.splice_inlays(&inlay_splice.to_remove, inlay_splice.to_insert, cx); } - self.refresh_colors(false, None, window, cx); + self.refresh_colors_for_visible_range(None, window, cx); } cx.notify(); @@ -20347,65 +21197,6 @@ impl Editor { self.searchable } - fn open_proposed_changes_editor( - &mut self, - _: &OpenProposedChangesEditor, - window: &mut Window, - cx: &mut Context, - ) { - let Some(workspace) = self.workspace() else { - cx.propagate(); - return; - }; - - let selections = self.selections.all::(cx); - let multi_buffer = self.buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let mut new_selections_by_buffer = HashMap::default(); - for selection in selections { - for (buffer, range, _) in - multi_buffer_snapshot.range_to_buffer_ranges(selection.start..selection.end) - { - let mut range = range.to_point(buffer); - range.start.column = 0; - range.end.column = buffer.line_len(range.end.row); - new_selections_by_buffer - .entry(multi_buffer.buffer(buffer.remote_id()).unwrap()) - .or_insert(Vec::new()) - .push(range) - } - } - - let proposed_changes_buffers = new_selections_by_buffer - .into_iter() - .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges }) - .collect::>(); - let proposed_changes_editor = cx.new(|cx| { - ProposedChangesEditor::new( - "Proposed changes", - proposed_changes_buffers, - self.project.clone(), - window, - cx, - ) - }); - - window.defer(cx, move |window, cx| { - workspace.update(cx, |workspace, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item( - Box::new(proposed_changes_editor), - true, - true, - None, - window, - cx, - ); - }); - }); - }); - } - pub fn open_excerpts_in_split( &mut self, _: &OpenExcerptsSplit, @@ -20482,7 +21273,7 @@ impl Editor { } } None => { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); let multi_buffer = self.buffer.read(cx); for selection in selections { for (snapshot, range, _, anchor) in multi_buffer @@ -20490,11 +21281,8 @@ impl Editor { .range_to_buffer_ranges_with_deleted_hunks(selection.range()) { if let Some(anchor) = anchor { - // selection is in a deleted hunk - let Some(buffer_id) = anchor.buffer_id else { - continue; - }; - let Some(buffer_handle) = multi_buffer.buffer(buffer_id) else { + let Some(buffer_handle) = multi_buffer.buffer_for_anchor(anchor, cx) + else { continue; }; let offset = text::ToOffset::to_offset( @@ -20602,7 +21390,7 @@ impl Editor { // For now, don't allow opening excerpts in buffers that aren't backed by // regular project files. fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { - file.map_or(true, |file| project::File::from_dyn(Some(file)).is_some()) + file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some()) } fn marked_text_ranges(&self, cx: &App) -> Option>> { @@ -20623,7 +21411,9 @@ impl Editor { range: Range, cx: &mut App, ) -> Vec> { - let selections = self.selections.all::(cx); + let selections = self + .selections + .all::(&self.display_snapshot(cx)); let newest_selection = selections .iter() .max_by_key(|selection| selection.id) @@ -20690,7 +21480,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); } else { telemetry::event!( @@ -20700,7 +21490,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); }; } @@ -20726,7 +21516,10 @@ impl Editor { if selection.range.is_empty() { None } else { - Some(selection.range) + Some( + snapshot.offset_utf16_to_offset(OffsetUtf16(selection.range.start)) + ..snapshot.offset_utf16_to_offset(OffsetUtf16(selection.range.end)), + ) } }) .unwrap_or_else(|| 0..snapshot.len()); @@ -20746,11 +21539,11 @@ impl Editor { let mut chunk_lines = chunk.text.split('\n').peekable(); while let Some(text) = chunk_lines.next() { let mut merged_with_last_token = false; - if let Some(last_token) = line.back_mut() { - if last_token.highlight == highlight { - last_token.text.push_str(text); - merged_with_last_token = true; - } + if let Some(last_token) = line.back_mut() + && last_token.highlight == highlight + { + last_token.text.push_str(text); + merged_with_last_token = true; } if !merged_with_last_token { @@ -20786,14 +21579,13 @@ impl Editor { cx: &mut Context, ) { self.request_autoscroll(Autoscroll::newest(), cx); - let position = self.selections.newest_display(cx).start; + let position = self + .selections + .newest_display(&self.display_snapshot(cx)) + .start; mouse_context_menu::deploy_context_menu(self, None, position, window, cx); } - pub fn inlay_hint_cache(&self) -> &InlayHintCache { - &self.inlay_hint_cache - } - pub fn replay_insert_event( &mut self, text: &str, @@ -20806,7 +21598,9 @@ impl Editor { return; } if let Some(relative_utf16_range) = relative_utf16_range { - let selections = self.selections.all::(cx); + let selections = self + .selections + .all::(&self.display_snapshot(cx)); self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { let new_ranges = selections.into_iter().map(|range| { let start = OffsetUtf16( @@ -20830,21 +21624,6 @@ impl Editor { self.handle_input(text, window, cx); } - pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { - let Some(provider) = self.semantics_provider.as_ref() else { - return false; - }; - - let mut supports = false; - self.buffer().update(cx, |this, cx| { - this.for_each_buffer(|buffer| { - supports |= provider.supports_inlay_hints(buffer, cx); - }); - }); - - supports - } - pub fn is_focused(&self, window: &Window) -> bool { self.focus_handle.is_focused(window) } @@ -20869,8 +21648,8 @@ impl Editor { buffer.finalize_last_transaction(cx); if self.leader_id.is_none() { buffer.set_active_selections( - &self.selections.disjoint_anchors(), - self.selections.line_mode, + &self.selections.disjoint_anchors_arc(), + self.selections.line_mode(), self.cursor_shape, cx, ); @@ -20915,7 +21694,7 @@ impl Editor { { self.hide_context_menu(window, cx); } - self.discard_edit_prediction(false, cx); + self.take_active_edit_prediction(cx); cx.emit(EditorEvent::Blurred); cx.notify(); } @@ -20940,13 +21719,13 @@ impl Editor { let existing_pending = self .text_highlights::(cx) - .map(|(_, ranges)| ranges.iter().cloned().collect::>()); + .map(|(_, ranges)| ranges.to_vec()); if existing_pending.is_none() && pending.is_empty() { return; } let transaction = self.transact(window, cx, |this, window, cx| { - let selections = this.selections.all::(cx); + let selections = this.selections.all::(&this.display_snapshot(cx)); let edits = selections .iter() .map(|selection| (selection.end..selection.end, pending.clone())); @@ -20965,12 +21744,12 @@ impl Editor { let snapshot = self.snapshot(window, cx); let ranges = self .selections - .all::(cx) + .all::(&snapshot.display_snapshot) .into_iter() .map(|selection| { - snapshot.buffer_snapshot.anchor_after(selection.end) + snapshot.buffer_snapshot().anchor_after(selection.end) ..snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(selection.end + pending.len()) }) .collect(); @@ -21093,7 +21872,7 @@ impl Editor { }; if let Some((workspace, path)) = workspace.as_ref().zip(path) { let Some(task) = cx - .update_window_entity(&workspace, |workspace, window, cx| { + .update_window_entity(workspace, |workspace, window, cx| { workspace .open_path_preview(path, None, false, false, false, window, cx) }) @@ -21135,17 +21914,17 @@ impl Editor { .scroll_position(editor_snapshot) .y; - if source.row().as_f32() < scroll_top.floor() { + if source.row().as_f64() < scroll_top.floor() { return None; } let source_x = editor_snapshot.x_for_display_point(source, &text_layout_details); - let source_y = line_height * (source.row().as_f32() - scroll_top); + let source_y = line_height * (source.row().as_f64() - scroll_top) as f32; Some(gpui::Point::new(source_x, source_y)) } pub fn has_visible_completions_menu(&self) -> bool { !self.edit_prediction_preview_is_active() - && self.context_menu.borrow().as_ref().map_or(false, |menu| { + && self.context_menu.borrow().as_ref().is_some_and(|menu| { menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) }) } @@ -21203,45 +21982,43 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if self.is_singleton(cx) + if self.buffer_kind(cx) == ItemBufferKind::Singleton && !self.mode.is_minimap() && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None { let buffer_snapshot = OnceCell::new(); - if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err() { - if !folds.is_empty() { - let snapshot = - buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); - self.fold_ranges( - folds - .into_iter() - .map(|(start, end)| { - snapshot.clip_offset(start, Bias::Left) - ..snapshot.clip_offset(end, Bias::Right) - }) - .collect(), - false, - window, - cx, - ); - } - } - - if let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() { - if !selections.is_empty() { - let snapshot = - buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); - // skip adding the initial selection to selection history - self.selection_history.mode = SelectionHistoryMode::Skipping; - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(selections.into_iter().map(|(start, end)| { + if let Some(folds) = DB.get_editor_folds(item_id, workspace_id).log_err() + && !folds.is_empty() + { + let snapshot = buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); + self.fold_ranges( + folds + .into_iter() + .map(|(start, end)| { snapshot.clip_offset(start, Bias::Left) ..snapshot.clip_offset(end, Bias::Right) - })); - }); - self.selection_history.mode = SelectionHistoryMode::Normal; - } + }) + .collect(), + false, + window, + cx, + ); + } + + if let Some(selections) = DB.get_editor_selections(item_id, workspace_id).log_err() + && !selections.is_empty() + { + let snapshot = buffer_snapshot.get_or_init(|| self.buffer.read(cx).snapshot(cx)); + // skip adding the initial selection to selection history + self.selection_history.mode = SelectionHistoryMode::Skipping; + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(selections.into_iter().map(|(start, end)| { + snapshot.clip_offset(start, Bias::Left) + ..snapshot.clip_offset(end, Bias::Right) + })); + }); + self.selection_history.mode = SelectionHistoryMode::Normal; }; } @@ -21250,21 +22027,71 @@ impl Editor { fn update_lsp_data( &mut self, - ignore_cache: bool, for_buffer: Option, window: &mut Window, cx: &mut Context<'_, Self>, ) { self.pull_diagnostics(for_buffer, window, cx); - self.refresh_colors(ignore_cache, for_buffer, window, cx); + self.refresh_colors_for_visible_range(for_buffer, window, cx); + } + + fn register_visible_buffers(&mut self, cx: &mut Context) { + if self.ignore_lsp_data() { + return; + } + for (_, (visible_buffer, _, _)) in self.visible_excerpts(cx) { + self.register_buffer(visible_buffer.read(cx).remote_id(), cx); + } + } + + fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if !self.registered_buffers.contains_key(&buffer_id) + && let Some(project) = self.project.as_ref() + { + if let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) { + project.update(cx, |project, cx| { + self.registered_buffers.insert( + buffer_id, + project.register_buffer_with_language_servers(&buffer, cx), + ); + }); + } else { + self.registered_buffers.remove(&buffer_id); + } + } + } + + fn ignore_lsp_data(&self) -> bool { + // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, + // skip any LSP updates for it. + self.active_diagnostics == ActiveDiagnostic::All || !self.mode().is_full() } } +fn edit_for_markdown_paste<'a>( + buffer: &MultiBufferSnapshot, + range: Range, + to_insert: &'a str, + url: Option, +) -> (Range, Cow<'a, str>) { + if url.is_none() { + return (range, Cow::Borrowed(to_insert)); + }; + + let old_text = buffer.text_for_range(range.clone()).collect::(); + + let new_text = if range.is_empty() || url::Url::parse(&old_text).is_ok() { + Cow::Borrowed(to_insert) + } else { + Cow::Owned(format!("[{old_text}]({to_insert})")) + }; + (range, new_text) +} + fn vim_enabled(cx: &App) -> bool { - cx.global::() - .raw_user_settings() - .get("vim_mode") - == Some(&serde_json::Value::Bool(true)) + vim_mode_setting::VimModeSetting::try_get(cx) + .map(|vim_mode| vim_mode.0) + .unwrap_or(false) } fn process_completion_for_edit( @@ -21277,23 +22104,25 @@ fn process_completion_for_edit( let buffer = buffer.read(cx); let buffer_snapshot = buffer.snapshot(); let (snippet, new_text) = if completion.is_snippet() { + let mut snippet_source = completion.new_text.clone(); // Workaround for typescript language server issues so that methods don't expand within // strings and functions with type expressions. The previous point is used because the query // for function identifier doesn't match when the cursor is immediately after. See PR #30312 - let mut snippet_source = completion.new_text.clone(); - let mut previous_point = text::ToPoint::to_point(cursor_position, buffer); - previous_point.column = previous_point.column.saturating_sub(1); - if let Some(scope) = buffer_snapshot.language_scope_at(previous_point) { - if scope.prefers_label_for_snippet_in_completion() { - if let Some(label) = completion.label() { - if matches!( - completion.kind(), - Some(CompletionItemKind::FUNCTION) | Some(CompletionItemKind::METHOD) - ) { - snippet_source = label; - } - } - } + let previous_point = text::ToPoint::to_point(cursor_position, &buffer_snapshot); + let previous_point = if previous_point.column > 0 { + cursor_position.to_previous_offset(&buffer_snapshot) + } else { + cursor_position.to_offset(&buffer_snapshot) + }; + if let Some(scope) = buffer_snapshot.language_scope_at(previous_point) + && scope.prefers_label_for_snippet_in_completion() + && let Some(label) = completion.label() + && matches!( + completion.kind(), + Some(CompletionItemKind::FUNCTION) | Some(CompletionItemKind::METHOD) + ) + { + snippet_source = label; } match Snippet::parse(&snippet_source).log_err() { Some(parsed_snippet) => (Some(parsed_snippet.clone()), parsed_snippet.text), @@ -21317,14 +22146,14 @@ fn process_completion_for_edit( debug_assert!( insert_range .start - .cmp(&cursor_position, &buffer_snapshot) + .cmp(cursor_position, &buffer_snapshot) .is_le(), "insert_range should start before or at cursor position" ); debug_assert!( replace_range .start - .cmp(&cursor_position, &buffer_snapshot) + .cmp(cursor_position, &buffer_snapshot) .is_le(), "replace_range should start before or at cursor position" ); @@ -21347,10 +22176,10 @@ fn process_completion_for_edit( ); let mut current_needle = text_to_replace.next(); for haystack_ch in completion.label.text.chars() { - if let Some(needle_ch) = current_needle { - if haystack_ch.eq_ignore_ascii_case(&needle_ch) { - current_needle = text_to_replace.next(); - } + if let Some(needle_ch) = current_needle + && haystack_ch.eq_ignore_ascii_case(&needle_ch) + { + current_needle = text_to_replace.next(); } } current_needle.is_none() @@ -21358,7 +22187,7 @@ fn process_completion_for_edit( LspInsertMode::ReplaceSuffix => { if replace_range .end - .cmp(&cursor_position, &buffer_snapshot) + .cmp(cursor_position, &buffer_snapshot) .is_gt() { let range_after_cursor = *cursor_position..replace_range.end; @@ -21394,7 +22223,7 @@ fn process_completion_for_edit( if range_to_replace .end - .cmp(&cursor_position, &buffer_snapshot) + .cmp(cursor_position, &buffer_snapshot) .is_lt() { range_to_replace.end = *cursor_position; @@ -21402,7 +22231,7 @@ fn process_completion_for_edit( CompletionEdit { new_text, - replace_range: range_to_replace.to_offset(&buffer), + replace_range: range_to_replace.to_offset(buffer), snippet, } } @@ -21572,9 +22401,9 @@ fn is_grapheme_whitespace(text: &str) -> bool { } fn should_stay_with_preceding_ideograph(text: &str) -> bool { - text.chars().next().map_or(false, |ch| { - matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…') - }) + text.chars() + .next() + .is_some_and(|ch| matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…')) } #[derive(PartialEq, Eq, Debug, Clone, Copy)] @@ -21604,20 +22433,20 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> { offset += first_grapheme.len(); grapheme_len += 1; if is_grapheme_ideographic(first_grapheme) && !is_whitespace { - if let Some(grapheme) = iter.peek().copied() { - if should_stay_with_preceding_ideograph(grapheme) { - offset += grapheme.len(); - grapheme_len += 1; - } + if let Some(grapheme) = iter.peek().copied() + && should_stay_with_preceding_ideograph(grapheme) + { + offset += grapheme.len(); + grapheme_len += 1; } } else { let mut words = self.input[offset..].split_word_bound_indices().peekable(); let mut next_word_bound = words.peek().copied(); - if next_word_bound.map_or(false, |(i, _)| i == 0) { + if next_word_bound.is_some_and(|(i, _)| i == 0) { next_word_bound = words.next(); } while let Some(grapheme) = iter.peek().copied() { - if next_word_bound.map_or(false, |(i, _)| i == offset) { + if next_word_bound.is_some_and(|(i, _)| i == offset) { break; }; if is_grapheme_whitespace(grapheme) != is_whitespace @@ -21738,7 +22567,7 @@ fn wrap_with_prefix( let subsequent_lines_prefix_len = char_len_with_expanded_tabs(0, &subsequent_lines_prefix, tab_size); let mut wrapped_text = String::new(); - let mut current_line = first_line_prefix.clone(); + let mut current_line = first_line_prefix; let mut is_first_line = true; let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); @@ -21778,7 +22607,14 @@ fn wrap_with_prefix( continue; } if !preserve_existing_whitespace { - token = " "; + // Keep a single whitespace grapheme as-is + if let Some(first) = + unicode_segmentation::UnicodeSegmentation::graphemes(token, true).next() + { + token = first; + } else { + token = " "; + } grapheme_len = 1; } let current_prefix_len = if is_first_line { @@ -21880,6 +22716,17 @@ fn test_wrap_with_prefix() { ), "这是什\n么 钢\n笔" ); + assert_eq!( + wrap_with_prefix( + String::new(), + String::new(), + format!("foo{}bar", '\u{2009}'), // thin space + 80, + NonZeroU32::new(4).unwrap(), + false, + ), + format!("foo{}bar", '\u{2009}') + ); } pub trait CollaborationHub { @@ -21910,7 +22757,7 @@ pub trait SemanticsProvider { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Option>>; + ) -> Option>>>; fn inline_values( &self, @@ -21919,20 +22766,23 @@ pub trait SemanticsProvider { cx: &mut App, ) -> Option>>>; - fn inlay_hints( + fn applicable_inlay_chunks( &self, - buffer_handle: Entity, - range: Range, + buffer: &Entity, + ranges: &[Range], cx: &mut App, - ) -> Option>>>; + ) -> Vec>; - fn resolve_inlay_hint( + fn invalidate_inlay_hints(&self, for_buffers: &HashSet, cx: &mut App); + + fn inlay_hints( &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, + invalidate: InvalidationStrategy, + buffer: Entity, + ranges: Vec>, + known_chunks: Option<(clock::Global, HashSet>)>, cx: &mut App, - ) -> Option>>; + ) -> Option, Task>>>; fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool; @@ -21949,7 +22799,7 @@ pub trait SemanticsProvider { position: text::Anchor, kind: GotoDefinitionKind, cx: &mut App, - ) -> Option>>>; + ) -> Option>>>>; fn range_for_rename( &self, @@ -22062,7 +22912,13 @@ impl CodeActionProvider for Entity { Ok(code_lens_actions .context("code lens fetch")? .into_iter() - .chain(code_actions.context("code action fetch")?) + .flatten() + .chain( + code_actions + .context("code action fetch")? + .into_iter() + .flatten(), + ) .collect()) }) }) @@ -22109,30 +22965,35 @@ fn snippet_completions( if scopes.is_empty() { return Task::ready(Ok(CompletionResponse { completions: vec![], + display_options: CompletionDisplayOptions::default(), is_incomplete: false, })); } let snapshot = buffer.read(cx).text_snapshot(); - let chars: String = snapshot - .reversed_chars_for_range(text::Anchor::MIN..buffer_position) - .collect(); let executor = cx.background_executor().clone(); cx.background_spawn(async move { let mut is_incomplete = false; let mut completions: Vec = Vec::new(); for (scope, snippets) in scopes.into_iter() { - let classifier = CharClassifier::new(Some(scope)).for_completion(true); - let mut last_word = chars - .chars() + let classifier = + CharClassifier::new(Some(scope)).scope_context(Some(CharScopeContext::Completion)); + + const MAX_WORD_PREFIX_LEN: usize = 128; + let last_word: String = snapshot + .reversed_chars_for_range(text::Anchor::MIN..buffer_position) + .take(MAX_WORD_PREFIX_LEN) .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); + .collect::() + .chars() + .rev() + .collect(); if last_word.is_empty() { return Ok(CompletionResponse { completions: vec![], + display_options: CompletionDisplayOptions::default(), is_incomplete: true, }); } @@ -22151,7 +23012,7 @@ fn snippet_completions( snippet .prefix .iter() - .map(move |prefix| StringMatchCandidate::new(ix, &prefix)) + .map(move |prefix| StringMatchCandidate::new(ix, prefix)) }) .collect::>(); @@ -22233,11 +23094,7 @@ fn snippet_completions( }), lsp_defaults: None, }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, + label: CodeLabel::plain(matching_prefix.clone(), None), icon_path: None, documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line: snippet.name.clone().into(), @@ -22254,6 +23111,7 @@ fn snippet_completions( Ok(CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), is_incomplete, }) }) @@ -22342,7 +23200,9 @@ impl CompletionProvider for Entity { if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { return false; } - let classifier = snapshot.char_classifier_at(position).for_completion(true); + let classifier = snapshot + .char_classifier_at(position) + .scope_context(Some(CharScopeContext::Completion)); if trigger_in_words && classifier.is_word(char) { return true; } @@ -22357,7 +23217,7 @@ impl SemanticsProvider for Entity { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Option>> { + ) -> Option>>> { Some(self.update(cx, |project, cx| project.hover(buffer, position, cx))) } @@ -22378,12 +23238,12 @@ impl SemanticsProvider for Entity { position: text::Anchor, kind: GotoDefinitionKind, cx: &mut App, - ) -> Option>>> { + ) -> Option>>>> { Some(self.update(cx, |project, cx| match kind { - GotoDefinitionKind::Symbol => project.definitions(&buffer, position, cx), - GotoDefinitionKind::Declaration => project.declarations(&buffer, position, cx), - GotoDefinitionKind::Type => project.type_definitions(&buffer, position, cx), - GotoDefinitionKind::Implementation => project.implementations(&buffer, position, cx), + GotoDefinitionKind::Symbol => project.definitions(buffer, position, cx), + GotoDefinitionKind::Declaration => project.declarations(buffer, position, cx), + GotoDefinitionKind::Type => project.type_definitions(buffer, position, cx), + GotoDefinitionKind::Implementation => project.implementations(buffer, position, cx), })) } @@ -22415,26 +23275,33 @@ impl SemanticsProvider for Entity { }) } - fn inlay_hints( + fn applicable_inlay_chunks( &self, - buffer_handle: Entity, - range: Range, + buffer: &Entity, + ranges: &[Range], cx: &mut App, - ) -> Option>>> { - Some(self.update(cx, |project, cx| { - project.inlay_hints(buffer_handle, range, cx) - })) + ) -> Vec> { + self.read(cx).lsp_store().update(cx, |lsp_store, cx| { + lsp_store.applicable_inlay_chunks(buffer, ranges, cx) + }) } - fn resolve_inlay_hint( + fn invalidate_inlay_hints(&self, for_buffers: &HashSet, cx: &mut App) { + self.read(cx).lsp_store().update(cx, |lsp_store, _| { + lsp_store.invalidate_inlay_hints(for_buffers) + }); + } + + fn inlay_hints( &self, - hint: InlayHint, - buffer_handle: Entity, - server_id: LanguageServerId, + invalidate: InvalidationStrategy, + buffer: Entity, + ranges: Vec>, + known_chunks: Option<(clock::Global, HashSet>)>, cx: &mut App, - ) -> Option>> { - Some(self.update(cx, |project, cx| { - project.resolve_inlay_hint(hint, buffer_handle, server_id, cx) + ) -> Option, Task>>> { + Some(self.read(cx).lsp_store().update(cx, |lsp_store, cx| { + lsp_store.inlay_hints(invalidate, buffer, ranges, known_chunks, cx) })) } @@ -22455,7 +23322,7 @@ impl SemanticsProvider for Entity { // Fallback on using TreeSitter info to determine identifier range buffer.read_with(cx, |buffer, _| { let snapshot = buffer.snapshot(); - let (range, kind) = snapshot.surrounding_word(position, false); + let (range, kind) = snapshot.surrounding_word(position, None); if kind != Some(CharKind::Word) { return None; } @@ -22483,16 +23350,6 @@ impl SemanticsProvider for Entity { } } -fn inlay_hint_settings( - location: Anchor, - snapshot: &MultiBufferSnapshot, - cx: &mut Context, -) -> InlayHintSettings { - let file = snapshot.file_at(location); - let language = snapshot.language_at(location).map(|l| l.name()); - language_settings(language, file, cx).inlay_hints -} - fn consume_contiguous_rows( contiguous_row_selections: &mut Vec>, selection: &Selection, @@ -22544,10 +23401,10 @@ impl EditorSnapshot { .values() .map(|collaborator| (collaborator.replica_id, collaborator)) .collect::>(); - self.buffer_snapshot + self.buffer_snapshot() .selections_in_range(range, false) .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| { - if replica_id == AGENT_REPLICA_ID { + if replica_id == ReplicaId::AGENT { Some(RemoteSelection { replica_id, selection, @@ -22588,7 +23445,7 @@ impl EditorSnapshot { for query_range in ranges { let query_rows = MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); - for hunk in self.buffer_snapshot.diff_hunks_in_range( + for hunk in self.buffer_snapshot().diff_hunks_in_range( Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), ) { // Include deleted hunks that are adjacent to the query range, because @@ -22622,7 +23479,7 @@ impl EditorSnapshot { let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(self); let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(self); - self.buffer_snapshot + self.buffer_snapshot() .diff_hunks_in_range(buffer_start..buffer_end) .filter_map(|hunk| { if folded_buffers.contains(&hunk.buffer_id) { @@ -22663,18 +23520,22 @@ impl EditorSnapshot { } pub fn language_at(&self, position: T) -> Option<&Arc> { - self.display_snapshot.buffer_snapshot.language_at(position) + self.display_snapshot + .buffer_snapshot() + .language_at(position) } pub fn is_focused(&self) -> bool { self.is_focused } - pub fn placeholder_text(&self) -> Option<&Arc> { - self.placeholder_text.as_ref() + pub fn placeholder_text(&self) -> Option { + self.placeholder_display_snapshot + .as_ref() + .map(|display_map| display_map.text()) } - pub fn scroll_position(&self) -> gpui::Point { + pub fn scroll_position(&self) -> gpui::Point { self.scroll_anchor.scroll_position(&self.display_snapshot) } @@ -22695,7 +23556,7 @@ impl EditorSnapshot { let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { matches!( ProjectSettings::get_global(cx).git.git_gutter, - Some(GitGutterSetting::TrackedFiles) + GitGutterSetting::TrackedFiles ) }); let gutter_settings = EditorSettings::get_global(cx).gutter; @@ -22732,7 +23593,7 @@ impl EditorSnapshot { ch_advance * max_char_count }); - let is_singleton = self.buffer_snapshot.is_singleton(); + let is_singleton = self.buffer_snapshot().is_singleton(); let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); left_padding += if !is_singleton { @@ -22781,7 +23642,7 @@ impl EditorSnapshot { if let Some(crease) = self .crease_snapshot - .query_row(buffer_row, &self.buffer_snapshot) + .query_row(buffer_row, self.buffer_snapshot()) { is_foldable = true; match crease { @@ -22840,7 +23701,7 @@ impl EditorSnapshot { let folded = self.is_line_folded(buffer_row); if let Crease::Inline { render_trailer, .. } = self .crease_snapshot - .query_row(buffer_row, &self.buffer_snapshot)? + .query_row(buffer_row, self.buffer_snapshot())? { let render_trailer = render_trailer.as_ref()?; Some(render_trailer(buffer_row, folded, window, cx)) @@ -22897,7 +23758,6 @@ pub enum EditorEvent { DirtyChanged, Saved, TitleChanged, - DiffBaseChanged, SelectionsChanged { local: bool, }, @@ -22905,14 +23765,12 @@ pub enum EditorEvent { local: bool, autoscroll: bool, }, - Closed, TransactionUndone { transaction_id: clock::Lamport, }, TransactionBegun { transaction_id: clock::Lamport, }, - Reloaded, CursorShapeChanged, BreadcrumbsChanged, PushedToNavHistory { @@ -22934,7 +23792,7 @@ impl Render for Editor { let settings = ThemeSettings::get_global(cx); let mut text_style = match self.mode { - EditorMode::SingleLine { .. } | EditorMode::AutoHeight { .. } => TextStyle { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { color: cx.theme().colors().editor_foreground, font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), @@ -22960,7 +23818,7 @@ impl Render for Editor { } let background = match self.mode { - EditorMode::SingleLine { .. } => cx.theme().system().transparent, + EditorMode::SingleLine => cx.theme().system().transparent, EditorMode::AutoHeight { .. } => cx.theme().system().transparent, EditorMode::Full { .. } => cx.theme().colors().editor_background, EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), @@ -23014,7 +23872,9 @@ impl EntityInputHandler for Editor { return None; } - let selection = self.selections.newest::(cx); + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); let range = selection.range(); Some(UTF16Selection { @@ -23057,7 +23917,7 @@ impl EntityInputHandler for Editor { let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { let newest_selection_id = this.selections.newest_anchor().id; this.selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .iter() .zip(ranges_to_replace.iter()) .find_map(|(selection, range)| { @@ -23132,7 +23992,7 @@ impl EntityInputHandler for Editor { let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { let newest_selection_id = this.selections.newest_anchor().id; this.selections - .all::(cx) + .all::(&this.display_snapshot(cx)) .iter() .zip(ranges_to_replace.iter()) .find_map(|(selection, range)| { @@ -23161,7 +24021,7 @@ impl EntityInputHandler for Editor { let marked_ranges = { let snapshot = this.buffer.read(cx).read(cx); this.selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .map(|selection| { selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot) @@ -23243,12 +24103,16 @@ impl EntityInputHandler for Editor { let snapshot = self.snapshot(window, cx); let scroll_position = snapshot.scroll_position(); - let scroll_left = scroll_position.x * em_advance; + let scroll_left = scroll_position.x * ScrollOffset::from(em_advance); let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); - let x = snapshot.x_for_display_point(start, &text_layout_details) - scroll_left - + self.gutter_dimensions.full_width(); - let y = line_height * (start.row().as_f32() - scroll_position.y); + let x = Pixels::from( + ScrollOffset::from( + snapshot.x_for_display_point(start, &text_layout_details) + + self.gutter_dimensions.full_width(), + ) - scroll_left, + ); + let y = line_height * (start.row().as_f64() - scroll_position.y) as f32; Some(Bounds { origin: element_bounds.origin + point(x, y), @@ -23270,7 +24134,7 @@ impl EntityInputHandler for Editor { let anchor = position_map .snapshot .display_point_to_anchor(display_point, Bias::Left); - let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot); + let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot()); Some(utf16_offset.0) } } @@ -23288,11 +24152,11 @@ impl SelectionExt for Selection { fn display_range(&self, map: &DisplaySnapshot) -> Range { let start = self .start - .to_point(&map.buffer_snapshot) + .to_point(map.buffer_snapshot()) .to_display_point(map); let end = self .end - .to_point(&map.buffer_snapshot) + .to_point(map.buffer_snapshot()) .to_display_point(map); if self.reversed { end..start @@ -23306,8 +24170,8 @@ impl SelectionExt for Selection { include_end_if_at_line_start: bool, map: &DisplaySnapshot, ) -> Range { - let start = self.start.to_point(&map.buffer_snapshot); - let mut end = self.end.to_point(&map.buffer_snapshot); + let start = self.start.to_point(map.buffer_snapshot()); + let mut end = self.end.to_point(map.buffer_snapshot()); if !include_end_if_at_line_start && start.row != end.row && end.column == 0 { end.row -= 1; } @@ -23449,8 +24313,7 @@ pub fn styled_runs_for_code_label<'a>( } else { return Default::default(); }; - let mut muted_style = style; - muted_style.highlight(fade_out); + let muted_style = style.highlight(fade_out); let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); if range.start >= label.filter_range.end { @@ -23498,7 +24361,7 @@ pub trait RangeToAnchorExt: Sized { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; fn to_display_points(self, snapshot: &EditorSnapshot) -> Range { - let anchor_range = self.to_anchors(&snapshot.buffer_snapshot); + let anchor_range = self.to_anchors(&snapshot.buffer_snapshot()); anchor_range.start.to_display_point(snapshot)..anchor_range.end.to_display_point(snapshot) } } @@ -23516,7 +24379,7 @@ impl RangeToAnchorExt for Range { } pub trait RowExt { - fn as_f32(&self) -> f32; + fn as_f64(&self) -> f64; fn next_row(&self) -> Self; @@ -23526,8 +24389,8 @@ pub trait RowExt { } impl RowExt for DisplayRow { - fn as_f32(&self) -> f32 { - self.0 as f32 + fn as_f64(&self) -> f64 { + self.0 as _ } fn next_row(&self) -> Self { @@ -23544,8 +24407,8 @@ impl RowExt for DisplayRow { } impl RowExt for MultiBufferRow { - fn as_f32(&self) -> f32 { - self.0 as f32 + fn as_f64(&self) -> f64 { + self.0 as _ } fn next_row(&self) -> Self { @@ -23665,6 +24528,7 @@ impl BreakpointPromptEditor { BreakpointPromptEditAction::Condition => "Condition when a breakpoint is hit. Expressions within {} are interpolated.", BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", }, + window, cx, ); @@ -23792,7 +24656,7 @@ fn all_edits_insertions_or_deletions( let mut all_deletions = true; for (range, new_text) in edits.iter() { - let range_is_empty = range.to_offset(&snapshot).is_empty(); + let range_is_empty = range.to_offset(snapshot).is_empty(); let text_is_empty = new_text.is_empty(); if range_is_empty != text_is_empty { @@ -23815,8 +24679,8 @@ fn all_edits_insertions_or_deletions( struct MissingEditPredictionKeybindingTooltip; impl Render for MissingEditPredictionKeybindingTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - ui::tooltip_container(window, cx, |container, _, cx| { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + ui::tooltip_container(cx, |container, cx| { container .flex_shrink_0() .max_w_80() @@ -23836,7 +24700,7 @@ impl Render for MissingEditPredictionKeybindingTooltip { .items_end() .w_full() .child(Button::new("open-keymap", "Assign Keybinding").size(ButtonSize::Compact).on_click(|_ev, window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx) + window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx) })) .child(Button::new("see-docs", "See Docs").size(ButtonSize::Compact).on_click(|_ev, _window, cx| { cx.open_url("https://zed.dev/docs/completions#edit-predictions-missing-keybinding"); @@ -23889,12 +24753,11 @@ fn render_diff_hunk_controls( .alpha(if status.is_pending() { 0.66 } else { 1.0 }) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Stage Hunk", &::git::ToggleStaged, &focus_handle, - window, cx, ) } @@ -23916,12 +24779,11 @@ fn render_diff_hunk_controls( .alpha(if status.is_pending() { 0.66 } else { 1.0 }) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Unstage Hunk", &::git::ToggleStaged, &focus_handle, - window, cx, ) } @@ -23943,14 +24805,8 @@ fn render_diff_hunk_controls( Button::new(("restore", row as u64), "Restore") .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Restore Hunk", - &::git::Restore, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Restore Hunk", &::git::Restore, &focus_handle, cx) } }) .on_click({ @@ -23958,7 +24814,7 @@ fn render_diff_hunk_controls( move |_event, window, cx| { editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); - let point = hunk_range.start.to_point(&snapshot.buffer_snapshot); + let point = hunk_range.start.to_point(&snapshot.buffer_snapshot()); editor.restore_hunks_in_ranges(vec![point..point], window, cx); }); } @@ -23975,14 +24831,8 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - window, - cx, - ) + move |_window, cx| { + Tooltip::for_action_in("Next Hunk", &GoToHunk, &focus_handle, cx) } }) .on_click({ @@ -23991,7 +24841,7 @@ fn render_diff_hunk_controls( editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let position = - hunk_range.end.to_point(&snapshot.buffer_snapshot); + hunk_range.end.to_point(&snapshot.buffer_snapshot()); editor.go_to_hunk_before_or_after_position( &snapshot, position, @@ -24011,12 +24861,11 @@ fn render_diff_hunk_controls( // .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Previous Hunk", &GoToPreviousHunk, &focus_handle, - window, cx, ) } @@ -24027,7 +24876,7 @@ fn render_diff_hunk_controls( editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let point = - hunk_range.start.to_point(&snapshot.buffer_snapshot); + hunk_range.start.to_point(&snapshot.buffer_snapshot()); editor.go_to_hunk_before_or_after_position( &snapshot, point, @@ -24044,3 +24893,10 @@ fn render_diff_hunk_controls( ) .into_any_element() } + +pub fn multibuffer_context_lines(cx: &App) -> u32 { + EditorSettings::try_get(cx) + .map(|settings| settings.excerpt_context_lines) + .unwrap_or(2) + .min(32) +} diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index d3a21c7642e2d5eb11a75e06c5466210dd68f63c..dc67ab3ed6c8cfdbe88809e32d615789c01eef60 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -1,32 +1,34 @@ use core::num; -use std::num::NonZeroU32; use gpui::App; use language::CursorShape; use project::project_settings::DiagnosticSeverity; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources, VsCodeSettings}; -use util::serde::default_true; +use settings::Settings; +pub use settings::{ + CurrentLineHighlight, DelayMs, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer, + GoToDefinitionFallback, HideMouseMode, MinimapThumb, MinimapThumbBorder, MultiCursorModifier, + ScrollBeyondLastLine, ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap, SnippetSortOrder, +}; +use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; /// Imports from the VSCode settings at /// https://code.visualstudio.com/docs/reference/default-settings -#[derive(Deserialize, Clone)] +#[derive(Clone)] pub struct EditorSettings { pub cursor_blink: bool, pub cursor_shape: Option, pub current_line_highlight: CurrentLineHighlight, pub selection_highlight: bool, - pub lsp_highlight_debounce: u64, + pub rounded_selection: bool, + pub lsp_highlight_debounce: DelayMs, pub hover_popover_enabled: bool, - pub hover_popover_delay: u64, - pub status_bar: StatusBar, + pub hover_popover_delay: DelayMs, pub toolbar: Toolbar, pub scrollbar: Scrollbar, pub minimap: Minimap, pub gutter: Gutter, pub scroll_beyond_last_line: ScrollBeyondLastLine, - pub vertical_scroll_margin: f32, + pub vertical_scroll_margin: f64, pub autoscroll_on_clicks: bool, pub horizontal_scroll_margin: f32, pub scroll_sensitivity: f32, @@ -37,79 +39,24 @@ pub struct EditorSettings { pub multi_cursor_modifier: MultiCursorModifier, pub redact_private_values: bool, pub expand_excerpt_lines: u32, + pub excerpt_context_lines: u32, pub middle_click_paste: bool, - #[serde(default)] pub double_click_in_multibuffer: DoubleClickInMultibuffer, pub search_wrap: bool, - #[serde(default)] pub search: SearchSettings, pub auto_signature_help: bool, pub show_signature_help_after_edits: bool, - #[serde(default)] pub go_to_definition_fallback: GoToDefinitionFallback, pub jupyter: Jupyter, pub hide_mouse: Option, pub snippet_sort_order: SnippetSortOrder, - #[serde(default)] pub diagnostics_max_severity: Option, pub inline_code_actions: bool, pub drag_and_drop_selection: DragAndDropSelection, pub lsp_document_colors: DocumentColorsRenderMode, + pub minimum_contrast_for_highlights: f32, } - -/// How to render LSP `textDocument/documentColor` colors in the editor. -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DocumentColorsRenderMode { - /// Do not query and render document colors. - None, - /// Render document colors as inlay hints near the color text. - #[default] - Inlay, - /// Draw a border around the color text. - Border, - /// Draw a background behind the color text. - Background, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum CurrentLineHighlight { - // Don't highlight the current line. - None, - // Highlight the gutter area. - Gutter, - // Highlight the editor area. - Line, - // Highlight the full line. - All, -} - -/// When to populate a new search's query based on the text under the cursor. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SeedQuerySetting { - /// Always populate the search query with the word under the cursor. - Always, - /// Only populate the search query when there is text selected. - Selection, - /// Never populate the search query - Never, -} - -/// What to do when multibuffer is double clicked in some of its excerpts (parts of singleton buffers). -#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DoubleClickInMultibuffer { - /// Behave as a regular buffer and select the whole word. - #[default] - Select, - /// Open the excerpt clicked as a new buffer in the new tab, if no `alt` modifier was pressed during double click. - /// Otherwise, behave as a regular buffer and select the whole word. - Open, -} - -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct Jupyter { /// Whether the Jupyter feature is enabled. /// @@ -117,28 +64,7 @@ pub struct Jupyter { pub enabled: bool, } -#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct JupyterContent { - /// Whether the Jupyter feature is enabled. - /// - /// Default: true - pub enabled: Option, -} - -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct StatusBar { - /// Whether to display the active language button in the status bar. - /// - /// Default: true - pub active_language_button: bool, - /// Whether to show the cursor position button in the status bar. - /// - /// Default: true - pub cursor_position_button: bool, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, pub quick_actions: bool, @@ -147,7 +73,7 @@ pub struct Toolbar { pub code_actions: bool, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Scrollbar { pub show: ShowScrollbar, pub git_diff: bool, @@ -159,7 +85,7 @@ pub struct Scrollbar { pub axes: ScrollbarAxes, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub struct Minimap { pub show: ShowMinimap, pub display_in: DisplayIn, @@ -187,7 +113,7 @@ impl Minimap { } } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct Gutter { pub min_line_number_digits: usize, pub line_numbers: bool, @@ -196,86 +122,8 @@ pub struct Gutter { pub folds: bool, } -/// When to show the scrollbar in the editor. -/// -/// Default: auto -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ShowScrollbar { - /// Show the scrollbar if there's important information or - /// follow the system's configured behavior. - Auto, - /// Match the system's configured behavior. - System, - /// Always show the scrollbar. - Always, - /// Never show the scrollbar. - Never, -} - -/// When to show the minimap in the editor. -/// -/// Default: never -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ShowMinimap { - /// Follow the visibility of the scrollbar. - Auto, - /// Always show the minimap. - Always, - /// Never show the minimap. - #[default] - Never, -} - -/// Where to show the minimap in the editor. -/// -/// Default: all_editors -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DisplayIn { - /// Show on all open editors. - AllEditors, - /// Show the minimap on the active editor only. - #[default] - ActiveEditor, -} - -/// When to show the minimap thumb. -/// -/// Default: always -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MinimapThumb { - /// Show the minimap thumb only when the mouse is hovering over the minimap. - Hover, - /// Always show the minimap thumb. - #[default] - Always, -} - -/// Defines the border style for the minimap's scrollbar thumb. -/// -/// Default: left_open -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MinimapThumbBorder { - /// Displays a border on all sides of the thumb. - Full, - /// Displays a border on all sides except the left side of the thumb. - #[default] - LeftOpen, - /// Displays a border on all sides except the right side of the thumb. - RightOpen, - /// Displays a border only on the left side of the thumb. - LeftOnly, - /// Displays the thumb without any border. - None, -} - /// Forcefully enable or disable the scrollbar for each axis -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct ScrollbarAxes { /// When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. /// @@ -289,654 +137,135 @@ pub struct ScrollbarAxes { } /// Whether to allow drag and drop text selection in buffer. -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] pub struct DragAndDropSelection { /// When true, enables drag and drop text selection in buffer. /// /// Default: true - #[serde(default = "default_true")] pub enabled: bool, /// The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. /// /// Default: 300 - #[serde(default = "default_drag_and_drop_selection_delay_ms")] - pub delay: u64, -} - -fn default_drag_and_drop_selection_delay_ms() -> u64 { - 300 -} - -/// Which diagnostic indicators to show in the scrollbar. -/// -/// Default: all -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ScrollbarDiagnostics { - /// Show all diagnostic levels: hint, information, warnings, error. - All, - /// Show only the following diagnostic levels: information, warning, error. - Information, - /// Show only the following diagnostic levels: warning, error. - Warning, - /// Show only the following diagnostic level: error. - Error, - /// Do not show diagnostics. - None, -} - -/// The key to use for adding multiple cursors -/// -/// Default: alt -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MultiCursorModifier { - Alt, - #[serde(alias = "cmd", alias = "ctrl")] - CmdOrCtrl, -} - -/// Whether the editor will scroll beyond the last line. -/// -/// Default: one_page -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ScrollBeyondLastLine { - /// The editor will not scroll beyond the last line. - Off, - - /// The editor will scroll beyond the last line by one page. - OnePage, - - /// The editor will scroll beyond the last line by the same number of lines as vertical_scroll_margin. - VerticalScrollMargin, + pub delay: DelayMs, } /// Default options for buffer and project search items. -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] pub struct SearchSettings { /// Whether to show the project search button in the status bar. - #[serde(default = "default_true")] pub button: bool, - #[serde(default)] pub whole_word: bool, - #[serde(default)] pub case_sensitive: bool, - #[serde(default)] pub include_ignored: bool, - #[serde(default)] pub regex: bool, } -/// What to do when go to definition yields no results. -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum GoToDefinitionFallback { - /// Disables the fallback. - None, - /// Looks up references of the same symbol instead. - #[default] - FindAllReferences, -} - -/// Determines when the mouse cursor should be hidden in an editor or input box. -/// -/// Default: on_typing_and_movement -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum HideMouseMode { - /// Never hide the mouse cursor - Never, - /// Hide only when typing - OnTyping, - /// Hide on both typing and cursor movement - #[default] - OnTypingAndMovement, -} - -/// Determines how snippets are sorted relative to other completion items. -/// -/// Default: inline -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SnippetSortOrder { - /// Place snippets at the top of the completion list - Top, - /// Sort snippets normally using the default comparison logic - #[default] - Inline, - /// Place snippets at the bottom of the completion list - Bottom, - /// Do not show snippets in the completion list - None, -} - -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] -pub struct EditorSettingsContent { - /// Whether the cursor blinks in the editor. - /// - /// Default: true - pub cursor_blink: Option, - /// Cursor shape for the default editor. - /// Can be "bar", "block", "underline", or "hollow". - /// - /// Default: None - pub cursor_shape: Option, - /// Determines when the mouse cursor should be hidden in an editor or input box. - /// - /// Default: on_typing_and_movement - pub hide_mouse: Option, - /// Determines how snippets are sorted relative to other completion items. - /// - /// Default: inline - pub snippet_sort_order: Option, - /// How to highlight the current line in the editor. - /// - /// Default: all - pub current_line_highlight: Option, - /// Whether to highlight all occurrences of the selected text in an editor. - /// - /// Default: true - pub selection_highlight: Option, - /// The debounce delay before querying highlights from the language - /// server based on the current cursor location. - /// - /// Default: 75 - pub lsp_highlight_debounce: Option, - /// Whether to show the informational hover box when moving the mouse - /// over symbols in the editor. - /// - /// Default: true - pub hover_popover_enabled: Option, - /// Time to wait in milliseconds before showing the informational hover box. - /// - /// Default: 300 - pub hover_popover_delay: Option, - /// Status bar related settings - pub status_bar: Option, - /// Toolbar related settings - pub toolbar: Option, - /// Scrollbar related settings - pub scrollbar: Option, - /// Minimap related settings - pub minimap: Option, - /// Gutter related settings - pub gutter: Option, - /// Whether the editor will scroll beyond the last line. - /// - /// Default: one_page - pub scroll_beyond_last_line: Option, - /// The number of lines to keep above/below the cursor when auto-scrolling. - /// - /// Default: 3. - pub vertical_scroll_margin: Option, - /// Whether to scroll when clicking near the edge of the visible text area. - /// - /// Default: false - pub autoscroll_on_clicks: Option, - /// The number of characters to keep on either side when scrolling with the mouse. - /// - /// Default: 5. - pub horizontal_scroll_margin: Option, - /// Scroll sensitivity multiplier. This multiplier is applied - /// to both the horizontal and vertical delta values while scrolling. - /// - /// Default: 1.0 - pub scroll_sensitivity: Option, - /// Scroll sensitivity multiplier for fast scrolling. This multiplier is applied - /// to both the horizontal and vertical delta values while scrolling. Fast scrolling - /// happens when a user holds the alt or option key while scrolling. - /// - /// Default: 4.0 - pub fast_scroll_sensitivity: Option, - /// Whether the line numbers on editors gutter are relative or not. - /// - /// Default: false - pub relative_line_numbers: Option, - /// When to populate a new search's query based on the text under the cursor. - /// - /// Default: always - pub seed_search_query_from_cursor: Option, - pub use_smartcase_search: Option, - /// Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. - /// - /// Default: alt - pub multi_cursor_modifier: Option, - /// Hide the values of variables in `private` files, as defined by the - /// private_files setting. This only changes the visual representation, - /// the values are still present in the file and can be selected / copied / pasted - /// - /// Default: false - pub redact_private_values: Option, - - /// How many lines to expand the multibuffer excerpts by default - /// - /// Default: 3 - pub expand_excerpt_lines: Option, - - /// Whether to enable middle-click paste on Linux - /// - /// Default: true - pub middle_click_paste: Option, - - /// What to do when multibuffer is double clicked in some of its excerpts - /// (parts of singleton buffers). - /// - /// Default: select - pub double_click_in_multibuffer: Option, - /// Whether the editor search results will loop - /// - /// Default: true - pub search_wrap: Option, - - /// Defaults to use when opening a new buffer and project search items. - /// - /// Default: nothing is enabled - pub search: Option, - - /// Whether to automatically show a signature help pop-up or not. - /// - /// Default: false - pub auto_signature_help: Option, - - /// Whether to show the signature help pop-up after completions or bracket pairs inserted. - /// - /// Default: false - pub show_signature_help_after_edits: Option, - - /// Whether to follow-up empty go to definition responses from the language server or not. - /// `FindAllReferences` allows to look up references of the same symbol instead. - /// `None` disables the fallback. - /// - /// Default: FindAllReferences - pub go_to_definition_fallback: Option, - - /// Jupyter REPL settings. - pub jupyter: Option, - - /// Which level to use to filter out diagnostics displayed in the editor. - /// - /// Affects the editor rendering only, and does not interrupt - /// the functionality of diagnostics fetching and project diagnostics editor. - /// Which files containing diagnostic errors/warnings to mark in the tabs. - /// Diagnostics are only shown when file icons are also active. - /// - /// Shows all diagnostics if not specified. - /// - /// Default: warning - #[serde(default)] - pub diagnostics_max_severity: Option, - - /// Whether to show code action button at start of buffer line. - /// - /// Default: true - pub inline_code_actions: Option, - - /// Drag and drop related settings - pub drag_and_drop_selection: Option, - - /// How to render LSP `textDocument/documentColor` colors in the editor. - /// - /// Default: [`DocumentColorsRenderMode::Inlay`] - pub lsp_document_colors: Option, -} - -// Status bar related settings -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct StatusBarContent { - /// Whether to display the active language button in the status bar. - /// - /// Default: true - pub active_language_button: Option, - /// Whether to show the cursor position button in the status bar. - /// - /// Default: true - pub cursor_position_button: Option, -} - -// Toolbar related settings -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct ToolbarContent { - /// Whether to display breadcrumbs in the editor toolbar. - /// - /// Default: true - pub breadcrumbs: Option, - /// Whether to display quick action buttons in the editor toolbar. - /// - /// Default: true - pub quick_actions: Option, - /// Whether to show the selections menu in the editor toolbar. - /// - /// Default: true - pub selections_menu: Option, - /// Whether to display Agent review buttons in the editor toolbar. - /// Only applicable while reviewing a file edited by the Agent. - /// - /// Default: true - pub agent_review: Option, - /// Whether to display code action buttons in the editor toolbar. - /// - /// Default: false - pub code_actions: Option, -} - -/// Scrollbar related settings -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] -pub struct ScrollbarContent { - /// When to show the scrollbar in the editor. - /// - /// Default: auto - pub show: Option, - /// Whether to show git diff indicators in the scrollbar. - /// - /// Default: true - pub git_diff: Option, - /// Whether to show buffer search result indicators in the scrollbar. - /// - /// Default: true - pub search_results: Option, - /// Whether to show selected text occurrences in the scrollbar. - /// - /// Default: true - pub selected_text: Option, - /// Whether to show selected symbol occurrences in the scrollbar. - /// - /// Default: true - pub selected_symbol: Option, - /// Which diagnostic indicators to show in the scrollbar: - /// - /// Default: all - pub diagnostics: Option, - /// Whether to show cursor positions in the scrollbar. - /// - /// Default: true - pub cursors: Option, - /// Forcefully enable or disable the scrollbar for each axis - pub axes: Option, -} - -/// Minimap related settings -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] -pub struct MinimapContent { - /// When to show the minimap in the editor. - /// - /// Default: never - pub show: Option, - - /// Where to show the minimap in the editor. - /// - /// Default: [`DisplayIn::ActiveEditor`] - pub display_in: Option, - - /// When to show the minimap thumb. - /// - /// Default: always - pub thumb: Option, - - /// Defines the border style for the minimap's scrollbar thumb. - /// - /// Default: left_open - pub thumb_border: Option, - - /// How to highlight the current line in the minimap. - /// - /// Default: inherits editor line highlights setting - pub current_line_highlight: Option>, - - /// Maximum number of columns to display in the minimap. - /// - /// Default: 80 - pub max_width_columns: Option, -} - -/// Forcefully enable or disable the scrollbar for each axis -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] -pub struct ScrollbarAxesContent { - /// When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. - /// - /// Default: true - horizontal: Option, - - /// When false, forcefully disables the vertical scrollbar. Otherwise, obey other settings. - /// - /// Default: true - vertical: Option, -} - -/// Gutter related settings -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct GutterContent { - /// Whether to show line numbers in the gutter. - /// - /// Default: true - pub line_numbers: Option, - /// Minimum number of characters to reserve space for in the gutter. - /// - /// Default: 4 - pub min_line_number_digits: Option, - /// Whether to show runnable buttons in the gutter. - /// - /// Default: true - pub runnables: Option, - /// Whether to show breakpoints in the gutter. - /// - /// Default: true - pub breakpoints: Option, - /// Whether to show fold buttons in the gutter. - /// - /// Default: true - pub folds: Option, -} - impl EditorSettings { pub fn jupyter_enabled(cx: &App) -> bool { EditorSettings::get_global(cx).jupyter.enabled } } -impl Settings for EditorSettings { - const KEY: Option<&'static str> = None; - - type FileContent = EditorSettingsContent; - - fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { - sources.json_merge() +impl ScrollbarVisibility for EditorSettings { + fn visibility(&self, _cx: &App) -> ShowScrollbar { + self.scrollbar.show } +} - fn import_from_vscode(vscode: &VsCodeSettings, current: &mut Self::FileContent) { - vscode.enum_setting( - "editor.cursorBlinking", - &mut current.cursor_blink, - |s| match s { - "blink" | "phase" | "expand" | "smooth" => Some(true), - "solid" => Some(false), - _ => None, - }, - ); - vscode.enum_setting( - "editor.cursorStyle", - &mut current.cursor_shape, - |s| match s { - "block" => Some(CursorShape::Block), - "block-outline" => Some(CursorShape::Hollow), - "line" | "line-thin" => Some(CursorShape::Bar), - "underline" | "underline-thin" => Some(CursorShape::Underline), - _ => None, - }, - ); - - vscode.enum_setting( - "editor.renderLineHighlight", - &mut current.current_line_highlight, - |s| match s { - "gutter" => Some(CurrentLineHighlight::Gutter), - "line" => Some(CurrentLineHighlight::Line), - "all" => Some(CurrentLineHighlight::All), - _ => None, - }, - ); - - vscode.bool_setting( - "editor.selectionHighlight", - &mut current.selection_highlight, - ); - vscode.bool_setting("editor.hover.enabled", &mut current.hover_popover_enabled); - vscode.u64_setting("editor.hover.delay", &mut current.hover_popover_delay); - - let mut gutter = GutterContent::default(); - vscode.enum_setting( - "editor.showFoldingControls", - &mut gutter.folds, - |s| match s { - "always" | "mouseover" => Some(true), - "never" => Some(false), - _ => None, +impl Settings for EditorSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let editor = content.editor.clone(); + let scrollbar = editor.scrollbar.unwrap(); + let minimap = editor.minimap.unwrap(); + let gutter = editor.gutter.unwrap(); + let axes = scrollbar.axes.unwrap(); + let toolbar = editor.toolbar.unwrap(); + let search = editor.search.unwrap(); + let drag_and_drop_selection = editor.drag_and_drop_selection.unwrap(); + Self { + cursor_blink: editor.cursor_blink.unwrap(), + cursor_shape: editor.cursor_shape.map(Into::into), + current_line_highlight: editor.current_line_highlight.unwrap(), + selection_highlight: editor.selection_highlight.unwrap(), + rounded_selection: editor.rounded_selection.unwrap(), + lsp_highlight_debounce: editor.lsp_highlight_debounce.unwrap(), + hover_popover_enabled: editor.hover_popover_enabled.unwrap(), + hover_popover_delay: editor.hover_popover_delay.unwrap(), + toolbar: Toolbar { + breadcrumbs: toolbar.breadcrumbs.unwrap(), + quick_actions: toolbar.quick_actions.unwrap(), + selections_menu: toolbar.selections_menu.unwrap(), + agent_review: toolbar.agent_review.unwrap(), + code_actions: toolbar.code_actions.unwrap(), }, - ); - vscode.enum_setting( - "editor.lineNumbers", - &mut gutter.line_numbers, - |s| match s { - "on" | "relative" => Some(true), - "off" => Some(false), - _ => None, + scrollbar: Scrollbar { + show: scrollbar.show.map(Into::into).unwrap(), + git_diff: scrollbar.git_diff.unwrap(), + selected_text: scrollbar.selected_text.unwrap(), + selected_symbol: scrollbar.selected_symbol.unwrap(), + search_results: scrollbar.search_results.unwrap(), + diagnostics: scrollbar.diagnostics.unwrap(), + cursors: scrollbar.cursors.unwrap(), + axes: ScrollbarAxes { + horizontal: axes.horizontal.unwrap(), + vertical: axes.vertical.unwrap(), + }, }, - ); - if let Some(old_gutter) = current.gutter.as_mut() { - if gutter.folds.is_some() { - old_gutter.folds = gutter.folds - } - if gutter.line_numbers.is_some() { - old_gutter.line_numbers = gutter.line_numbers - } - } else { - if gutter != GutterContent::default() { - current.gutter = Some(gutter) - } - } - if let Some(b) = vscode.read_bool("editor.scrollBeyondLastLine") { - current.scroll_beyond_last_line = Some(if b { - ScrollBeyondLastLine::OnePage - } else { - ScrollBeyondLastLine::Off - }) - } - - let mut scrollbar_axes = ScrollbarAxesContent::default(); - vscode.enum_setting( - "editor.scrollbar.horizontal", - &mut scrollbar_axes.horizontal, - |s| match s { - "auto" | "visible" => Some(true), - "hidden" => Some(false), - _ => None, + minimap: Minimap { + show: minimap.show.unwrap(), + display_in: minimap.display_in.unwrap(), + thumb: minimap.thumb.unwrap(), + thumb_border: minimap.thumb_border.unwrap(), + current_line_highlight: minimap.current_line_highlight, + max_width_columns: minimap.max_width_columns.unwrap(), }, - ); - vscode.enum_setting( - "editor.scrollbar.vertical", - &mut scrollbar_axes.horizontal, - |s| match s { - "auto" | "visible" => Some(true), - "hidden" => Some(false), - _ => None, + gutter: Gutter { + min_line_number_digits: gutter.min_line_number_digits.unwrap(), + line_numbers: gutter.line_numbers.unwrap(), + runnables: gutter.runnables.unwrap(), + breakpoints: gutter.breakpoints.unwrap(), + folds: gutter.folds.unwrap(), }, - ); - - if scrollbar_axes != ScrollbarAxesContent::default() { - let scrollbar_settings = current.scrollbar.get_or_insert_default(); - let axes_settings = scrollbar_settings.axes.get_or_insert_default(); - - if let Some(vertical) = scrollbar_axes.vertical { - axes_settings.vertical = Some(vertical); - } - if let Some(horizontal) = scrollbar_axes.horizontal { - axes_settings.horizontal = Some(horizontal); - } - } - - // TODO: check if this does the int->float conversion? - vscode.f32_setting( - "editor.cursorSurroundingLines", - &mut current.vertical_scroll_margin, - ); - vscode.f32_setting( - "editor.mouseWheelScrollSensitivity", - &mut current.scroll_sensitivity, - ); - vscode.f32_setting( - "editor.fastScrollSensitivity", - &mut current.fast_scroll_sensitivity, - ); - if Some("relative") == vscode.read_string("editor.lineNumbers") { - current.relative_line_numbers = Some(true); - } - - vscode.enum_setting( - "editor.find.seedSearchStringFromSelection", - &mut current.seed_search_query_from_cursor, - |s| match s { - "always" => Some(SeedQuerySetting::Always), - "selection" => Some(SeedQuerySetting::Selection), - "never" => Some(SeedQuerySetting::Never), - _ => None, + scroll_beyond_last_line: editor.scroll_beyond_last_line.unwrap(), + vertical_scroll_margin: editor.vertical_scroll_margin.unwrap() as f64, + autoscroll_on_clicks: editor.autoscroll_on_clicks.unwrap(), + horizontal_scroll_margin: editor.horizontal_scroll_margin.unwrap(), + scroll_sensitivity: editor.scroll_sensitivity.unwrap(), + fast_scroll_sensitivity: editor.fast_scroll_sensitivity.unwrap(), + relative_line_numbers: editor.relative_line_numbers.unwrap(), + seed_search_query_from_cursor: editor.seed_search_query_from_cursor.unwrap(), + use_smartcase_search: editor.use_smartcase_search.unwrap(), + multi_cursor_modifier: editor.multi_cursor_modifier.unwrap(), + redact_private_values: editor.redact_private_values.unwrap(), + expand_excerpt_lines: editor.expand_excerpt_lines.unwrap(), + excerpt_context_lines: editor.excerpt_context_lines.unwrap(), + middle_click_paste: editor.middle_click_paste.unwrap(), + double_click_in_multibuffer: editor.double_click_in_multibuffer.unwrap(), + search_wrap: editor.search_wrap.unwrap(), + search: SearchSettings { + button: search.button.unwrap(), + whole_word: search.whole_word.unwrap(), + case_sensitive: search.case_sensitive.unwrap(), + include_ignored: search.include_ignored.unwrap(), + regex: search.regex.unwrap(), }, - ); - vscode.bool_setting("search.smartCase", &mut current.use_smartcase_search); - vscode.enum_setting( - "editor.multiCursorModifier", - &mut current.multi_cursor_modifier, - |s| match s { - "ctrlCmd" => Some(MultiCursorModifier::CmdOrCtrl), - "alt" => Some(MultiCursorModifier::Alt), - _ => None, + auto_signature_help: editor.auto_signature_help.unwrap(), + show_signature_help_after_edits: editor.show_signature_help_after_edits.unwrap(), + go_to_definition_fallback: editor.go_to_definition_fallback.unwrap(), + jupyter: Jupyter { + enabled: editor.jupyter.unwrap().enabled.unwrap(), }, - ); - - vscode.bool_setting( - "editor.parameterHints.enabled", - &mut current.auto_signature_help, - ); - vscode.bool_setting( - "editor.parameterHints.enabled", - &mut current.show_signature_help_after_edits, - ); - - if let Some(use_ignored) = vscode.read_bool("search.useIgnoreFiles") { - let search = current.search.get_or_insert_default(); - search.include_ignored = use_ignored; - } - - let mut minimap = MinimapContent::default(); - let minimap_enabled = vscode.read_bool("editor.minimap.enabled").unwrap_or(true); - let autohide = vscode.read_bool("editor.minimap.autohide"); - let mut max_width_columns: Option = None; - vscode.u32_setting("editor.minimap.maxColumn", &mut max_width_columns); - if minimap_enabled { - if let Some(false) = autohide { - minimap.show = Some(ShowMinimap::Always); - } else { - minimap.show = Some(ShowMinimap::Auto); - } - } else { - minimap.show = Some(ShowMinimap::Never); - } - if let Some(max_width_columns) = max_width_columns { - minimap.max_width_columns = NonZeroU32::new(max_width_columns); - } - - vscode.enum_setting( - "editor.minimap.showSlider", - &mut minimap.thumb, - |s| match s { - "always" => Some(MinimapThumb::Always), - "mouseover" => Some(MinimapThumb::Hover), - _ => None, + hide_mouse: editor.hide_mouse, + snippet_sort_order: editor.snippet_sort_order.unwrap(), + diagnostics_max_severity: editor.diagnostics_max_severity.map(Into::into), + inline_code_actions: editor.inline_code_actions.unwrap(), + drag_and_drop_selection: DragAndDropSelection { + enabled: drag_and_drop_selection.enabled.unwrap(), + delay: drag_and_drop_selection.delay.unwrap(), }, - ); - - if minimap != MinimapContent::default() { - current.minimap = Some(minimap) + lsp_document_colors: editor.lsp_document_colors.unwrap(), + minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0, } } } diff --git a/crates/editor/src/editor_settings_controls.rs b/crates/editor/src/editor_settings_controls.rs deleted file mode 100644 index dc5557b05277da972ea36ba43ffdf08a565edda9..0000000000000000000000000000000000000000 --- a/crates/editor/src/editor_settings_controls.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::sync::Arc; - -use gpui::{App, FontFeatures, FontWeight}; -use project::project_settings::{InlineBlameSettings, ProjectSettings}; -use settings::{EditableSettingControl, Settings}; -use theme::{FontFamilyCache, FontFamilyName, ThemeSettings}; -use ui::{ - CheckboxWithLabel, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer, SettingsGroup, - prelude::*, -}; - -use crate::EditorSettings; - -#[derive(IntoElement)] -pub struct EditorSettingsControls {} - -impl Default for EditorSettingsControls { - fn default() -> Self { - Self::new() - } -} - -impl EditorSettingsControls { - pub fn new() -> Self { - Self {} - } -} - -impl RenderOnce for EditorSettingsControls { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - SettingsContainer::new() - .child( - SettingsGroup::new("Font") - .child( - h_flex() - .gap_2() - .justify_between() - .child(BufferFontFamilyControl) - .child(BufferFontWeightControl), - ) - .child(BufferFontSizeControl) - .child(BufferFontLigaturesControl), - ) - .child(SettingsGroup::new("Editor").child(InlineGitBlameControl)) - .child( - SettingsGroup::new("Gutter").child( - h_flex() - .gap_2() - .justify_between() - .child(LineNumbersControl) - .child(RelativeLineNumbersControl), - ), - ) - } -} - -#[derive(IntoElement)] -struct BufferFontFamilyControl; - -impl EditableSettingControl for BufferFontFamilyControl { - type Value = SharedString; - type Settings = ThemeSettings; - - fn name(&self) -> SharedString { - "Buffer Font Family".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = ThemeSettings::get_global(cx); - settings.buffer_font.family.clone() - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - settings.buffer_font_family = Some(FontFamilyName(value.into())); - } -} - -impl RenderOnce for BufferFontFamilyControl { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - h_flex() - .gap_2() - .child(Icon::new(IconName::Font)) - .child(DropdownMenu::new( - "buffer-font-family", - value.clone(), - ContextMenu::build(window, cx, |mut menu, _, cx| { - let font_family_cache = FontFamilyCache::global(cx); - - for font_name in font_family_cache.list_font_families(cx) { - menu = menu.custom_entry( - { - let font_name = font_name.clone(); - move |_window, _cx| Label::new(font_name.clone()).into_any_element() - }, - { - let font_name = font_name.clone(); - move |_window, cx| { - Self::write(font_name.clone(), cx); - } - }, - ) - } - - menu - }), - )) - } -} - -#[derive(IntoElement)] -struct BufferFontSizeControl; - -impl EditableSettingControl for BufferFontSizeControl { - type Value = Pixels; - type Settings = ThemeSettings; - - fn name(&self) -> SharedString { - "Buffer Font Size".into() - } - - fn read(cx: &App) -> Self::Value { - ThemeSettings::get_global(cx).buffer_font_size(cx) - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - settings.buffer_font_size = Some(value.into()); - } -} - -impl RenderOnce for BufferFontSizeControl { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - h_flex() - .gap_2() - .child(Icon::new(IconName::FontSize)) - .child(NumericStepper::new( - "buffer-font-size", - value.to_string(), - move |_, _, cx| { - Self::write(value - px(1.), cx); - }, - move |_, _, cx| { - Self::write(value + px(1.), cx); - }, - )) - } -} - -#[derive(IntoElement)] -struct BufferFontWeightControl; - -impl EditableSettingControl for BufferFontWeightControl { - type Value = FontWeight; - type Settings = ThemeSettings; - - fn name(&self) -> SharedString { - "Buffer Font Weight".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = ThemeSettings::get_global(cx); - settings.buffer_font.weight - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - settings.buffer_font_weight = Some(value.0); - } -} - -impl RenderOnce for BufferFontWeightControl { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - h_flex() - .gap_2() - .child(Icon::new(IconName::FontWeight)) - .child(DropdownMenu::new( - "buffer-font-weight", - value.0.to_string(), - ContextMenu::build(window, cx, |mut menu, _window, _cx| { - for weight in FontWeight::ALL { - menu = menu.custom_entry( - move |_window, _cx| Label::new(weight.0.to_string()).into_any_element(), - { - move |_, cx| { - Self::write(weight, cx); - } - }, - ) - } - - menu - }), - )) - } -} - -#[derive(IntoElement)] -struct BufferFontLigaturesControl; - -impl EditableSettingControl for BufferFontLigaturesControl { - type Value = bool; - type Settings = ThemeSettings; - - fn name(&self) -> SharedString { - "Buffer Font Ligatures".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = ThemeSettings::get_global(cx); - settings - .buffer_font - .features - .is_calt_enabled() - .unwrap_or(true) - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - let value = if value { 1 } else { 0 }; - - let mut features = settings - .buffer_font_features - .as_ref() - .map(|features| features.tag_value_list().to_vec()) - .unwrap_or_default(); - - if let Some(calt_index) = features.iter().position(|(tag, _)| tag == "calt") { - features[calt_index].1 = value; - } else { - features.push(("calt".into(), value)); - } - - settings.buffer_font_features = Some(FontFeatures(Arc::new(features))); - } -} - -impl RenderOnce for BufferFontLigaturesControl { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - CheckboxWithLabel::new( - "buffer-font-ligatures", - Label::new(self.name()), - value.into(), - |selection, _, cx| { - Self::write( - match selection { - ToggleState::Selected => true, - ToggleState::Unselected | ToggleState::Indeterminate => false, - }, - cx, - ); - }, - ) - } -} - -#[derive(IntoElement)] -struct InlineGitBlameControl; - -impl EditableSettingControl for InlineGitBlameControl { - type Value = bool; - type Settings = ProjectSettings; - - fn name(&self) -> SharedString { - "Inline Git Blame".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = ProjectSettings::get_global(cx); - settings.git.inline_blame_enabled() - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - if let Some(inline_blame) = settings.git.inline_blame.as_mut() { - inline_blame.enabled = value; - } else { - settings.git.inline_blame = Some(InlineBlameSettings { - enabled: false, - ..Default::default() - }); - } - } -} - -impl RenderOnce for InlineGitBlameControl { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - CheckboxWithLabel::new( - "inline-git-blame", - Label::new(self.name()), - value.into(), - |selection, _, cx| { - Self::write( - match selection { - ToggleState::Selected => true, - ToggleState::Unselected | ToggleState::Indeterminate => false, - }, - cx, - ); - }, - ) - } -} - -#[derive(IntoElement)] -struct LineNumbersControl; - -impl EditableSettingControl for LineNumbersControl { - type Value = bool; - type Settings = EditorSettings; - - fn name(&self) -> SharedString { - "Line Numbers".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = EditorSettings::get_global(cx); - settings.gutter.line_numbers - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - if let Some(gutter) = settings.gutter.as_mut() { - gutter.line_numbers = Some(value); - } else { - settings.gutter = Some(crate::editor_settings::GutterContent { - line_numbers: Some(value), - ..Default::default() - }); - } - } -} - -impl RenderOnce for LineNumbersControl { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - CheckboxWithLabel::new( - "line-numbers", - Label::new(self.name()), - value.into(), - |selection, _, cx| { - Self::write( - match selection { - ToggleState::Selected => true, - ToggleState::Unselected | ToggleState::Indeterminate => false, - }, - cx, - ); - }, - ) - } -} - -#[derive(IntoElement)] -struct RelativeLineNumbersControl; - -impl EditableSettingControl for RelativeLineNumbersControl { - type Value = bool; - type Settings = EditorSettings; - - fn name(&self) -> SharedString { - "Relative Line Numbers".into() - } - - fn read(cx: &App) -> Self::Value { - let settings = EditorSettings::get_global(cx); - settings.relative_line_numbers - } - - fn apply( - settings: &mut ::FileContent, - value: Self::Value, - _cx: &App, - ) { - settings.relative_line_numbers = Some(value); - } -} - -impl RenderOnce for RelativeLineNumbersControl { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let value = Self::read(cx); - - DropdownMenu::new( - "relative-line-numbers", - if value { "Relative" } else { "Ascending" }, - ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.custom_entry( - |_window, _cx| Label::new("Ascending").into_any_element(), - move |_, cx| Self::write(false, cx), - ) - .custom_entry( - |_window, _cx| Label::new("Relative").into_any_element(), - move |_, cx| Self::write(true, cx), - ) - }), - ) - } -} diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ef2bdc5da390e662332a4f0444b4149f3b1debfd..06fbd9d3381f70955049ddde1c7a395945d67c66 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -13,7 +13,8 @@ use crate::{ }, }; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind}; -use futures::StreamExt; +use collections::HashMap; +use futures::{StreamExt, channel::oneshot}; use gpui::{ BackgroundExecutor, DismissEvent, Rgba, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext, WindowBounds, WindowOptions, div, @@ -22,15 +23,15 @@ use indoc::indoc; use language::{ BracketPairConfig, Capability::ReadWrite, - DiagnosticSourceKind, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageMatcher, - LanguageName, Override, Point, + DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings, LanguageConfig, + LanguageConfigOverride, LanguageMatcher, LanguageName, Override, Point, language_settings::{ - AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings, FormatterList, - LanguageSettingsContent, LspInsertMode, PrettierSettings, SelectedFormatter, + CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode, }, tree_sitter_python, }; -use language_settings::{Formatter, IndentGuideSettings}; +use language_settings::Formatter; +use languages::rust_lang; use lsp::CompletionParams; use multi_buffer::{IndentGuide, PathKey}; use parking_lot::Mutex; @@ -38,26 +39,33 @@ use pretty_assertions::{assert_eq, assert_ne}; use project::{ FakeFs, debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, - project_settings::{LspSettings, ProjectSettings}, + project_settings::LspSettings, }; use serde_json::{self, json}; +use settings::{ + AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring, + ProjectSettingsContent, +}; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ iter, sync::atomic::{self, AtomicUsize}, }; -use test::{build_editor_with_project, editor_lsp_test_context::rust_lang}; +use test::build_editor_with_project; use text::ToPoint as _; use unindent::Unindent; use util::{ assert_set_eq, path, + rel_path::rel_path, test::{TextRangeMarker, marked_text_ranges, marked_text_ranges_by, sample_text}, uri, }; use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry, OpenOptions, ViewId, + invalid_item_view::InvalidItemView, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, + register_project_item, }; #[gpui::test] @@ -212,7 +220,10 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { editor.insert("cd", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cd56"); - assert_eq!(editor.selections.ranges(cx), vec![4..4]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![4..4] + ); editor.start_transaction_at(now, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -221,7 +232,10 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { editor.insert("e", window, cx); editor.end_transaction_at(now, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![5..5] + ); now += group_interval + Duration::from_millis(1); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -237,30 +251,45 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { }); assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![3..3] + ); // Last transaction happened past the group interval in a different editor. // Undo it individually and don't restore selections. editor.undo(&Undo, window, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![2..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![2..2] + ); // First two transactions happened within the group interval in this editor. // Undo them together and restore selections. editor.undo(&Undo, window, cx); editor.undo(&Undo, window, cx); // Undo stack is empty here, so this is a no-op. assert_eq!(editor.text(cx), "123456"); - assert_eq!(editor.selections.ranges(cx), vec![0..0]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![0..0] + ); // Redo the first two transactions together. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "12cde6"); - assert_eq!(editor.selections.ranges(cx), vec![5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![5..5] + ); // Redo the last transaction on its own. editor.redo(&Redo, window, cx); assert_eq!(editor.text(cx), "ab2cde6"); - assert_eq!(editor.selections.ranges(cx), vec![6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + vec![6..6] + ); // Test empty transactions. editor.start_transaction_at(now, window, cx); @@ -611,6 +640,93 @@ fn test_movement_actions_with_pending_selection(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_extending_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaa bbb ccc ddd eee", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), false, 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 5)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 10)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + editor.end_selection(window, cx); + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 2, window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 5)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 1, window, cx); + editor.end_selection(window, cx); + editor.begin_selection(DisplayPoint::new(DisplayRow(0), 5), true, 2, window, cx); + editor.end_selection(window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 11)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 6), + 0, + gpui::Point::::default(), + window, + cx, + ); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 7)] + ); + + editor.update_selection( + DisplayPoint::new(DisplayRow(0), 1), + 0, + gpui::Point::::default(), + window, + cx, + ); + editor.end_selection(window, cx); + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + #[gpui::test] fn test_clone(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -676,10 +792,14 @@ fn test_clone(cx: &mut TestAppContext) { ); assert_set_eq!( cloned_editor - .update(cx, |editor, _, cx| editor.selections.ranges::(cx)) + .update(cx, |editor, _, cx| editor + .selections + .ranges::(&editor.display_snapshot(cx))) .unwrap(), editor - .update(cx, |editor, _, cx| editor.selections.ranges(cx)) + .update(cx, |editor, _, cx| editor + .selections + .ranges(&editor.display_snapshot(cx))) .unwrap() ); assert_set_eq!( @@ -708,7 +828,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) { _ = workspace.update(cx, |_v, window, cx| { cx.new(|cx| { let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); - let mut editor = build_editor(buffer.clone(), window, cx); + let mut editor = build_editor(buffer, window, cx); let handle = cx.entity(); editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); @@ -774,12 +894,12 @@ async fn test_navigation_history(cx: &mut TestAppContext) { assert!(pop_history(&mut editor, cx).is_none()); // Set scroll position to check later - editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), window, cx); + editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), window, cx); let original_scroll_position = editor.scroll_manager.anchor(); // Jump to the end of the document and adjust scroll editor.move_to_end(&MoveToEnd, window, cx); - editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), window, cx); + editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), window, cx); assert_ne!(editor.scroll_manager.anchor(), original_scroll_position); let nav_entry = pop_history(&mut editor, cx).unwrap(); @@ -809,7 +929,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) { ); assert_eq!( editor.scroll_position(cx), - gpui::Point::new(0., editor.max_point(cx).row().as_f32()) + gpui::Point::new(0., editor.max_point(cx).row().as_f64()) ); editor @@ -898,7 +1018,7 @@ fn test_fold_action(cx: &mut TestAppContext) { .unindent(), cx, ); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { @@ -989,7 +1109,7 @@ fn test_fold_action_whitespace_sensitive_language(cx: &mut TestAppContext) { .unindent(), cx, ); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { @@ -1074,7 +1194,7 @@ fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) { .unindent(), cx, ); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { @@ -1173,7 +1293,7 @@ fn test_fold_at_level(cx: &mut TestAppContext) { .unindent(), cx, ); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { @@ -1248,6 +1368,63 @@ fn test_fold_at_level(cx: &mut TestAppContext) { editor.display_text(cx), editor.buffer.read(cx).read(cx).text() ); + let (_, positions) = marked_text_ranges( + &" + class Foo: + # Hello! + + def a(): + print(1) + + def b(): + p«riˇ»nt(2) + + + class Bar: + # World! + + def a(): + «ˇprint(1) + + def b(): + print(2)» + + + " + .unindent(), + true, + ); + + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges(positions) + }); + + editor.fold_at_level(&FoldAtLevel(2), window, cx); + assert_eq!( + editor.display_text(cx), + " + class Foo: + # Hello! + + def a():⋯ + + def b(): + print(2) + + + class Bar: + # World! + + def a(): + print(1) + + def b(): + print(2) + + + " + .unindent(), + ); }); } @@ -1335,7 +1512,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("🟥🟧🟨🟩🟦🟪\nabcde\nαβγδε", cx); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); assert_eq!('🟥'.len_utf8(), 4); @@ -1452,7 +1629,7 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -2474,515 +2651,849 @@ async fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) { } #[gpui::test] -fn test_delete_to_word_boundary(cx: &mut TestAppContext) { +async fn test_delete_to_word_boundary(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let editor = cx.add_window(|window, cx| { - let buffer = MultiBuffer::build_simple("one two three four", cx); - build_editor(buffer.clone(), window, cx) - }); + let mut cx = EditorTestContext::new(cx).await; - _ = editor.update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([ - // an empty selection - the preceding word fragment is deleted - DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), - // characters selected - they are deleted - DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 12), - ]) - }); + // For an empty selection, the preceding word fragment is deleted. + // For non-empty selections, only selected characters are deleted. + cx.set_state("onˇe two t«hreˇ»e four"); + cx.update_editor(|editor, window, cx| { editor.delete_to_previous_word_start( &DeleteToPreviousWordStart { ignore_newlines: false, + ignore_brackets: false, }, window, cx, ); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "e two te four"); }); + cx.assert_editor_state("ˇe two tˇe four"); - _ = editor.update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([ - // an empty selection - the following word fragment is deleted - DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3), - // characters selected - they are deleted - DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 10), - ]) - }); + cx.set_state("e tˇwo te «fˇ»our"); + cx.update_editor(|editor, window, cx| { editor.delete_to_next_word_end( &DeleteToNextWordEnd { ignore_newlines: false, + ignore_brackets: false, }, window, cx, ); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "e t te our"); }); + cx.assert_editor_state("e tˇ te ˇour"); } #[gpui::test] -fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) { +async fn test_delete_whitespaces(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let editor = cx.add_window(|window, cx| { - let buffer = MultiBuffer::build_simple("one\n2\nthree\n4", cx); - build_editor(buffer.clone(), window, cx) - }); - let del_to_prev_word_start = DeleteToPreviousWordStart { - ignore_newlines: false, - }; - let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart { - ignore_newlines: true, - }; + let mut cx = EditorTestContext::new(cx).await; - _ = editor.update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1) - ]) - }); - editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree\n"); - editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree"); - editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\n"); - editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2"); - editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n"); - editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + cx.set_state("here is some text ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: true, + }, + window, + cx, + ); }); -} - -#[gpui::test] -fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { - init_test(cx, |_| {}); + // Continuous whitespace sequences are removed entirely, words behind them are not affected by the deletion action. + cx.assert_editor_state("here is some textˇwith a space"); - let editor = cx.add_window(|window, cx| { - let buffer = MultiBuffer::build_simple("\none\n two\nthree\n four", cx); - build_editor(buffer.clone(), window, cx) + cx.set_state("here is some text ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); }); - let del_to_next_word_end = DeleteToNextWordEnd { - ignore_newlines: false, - }; - let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd { - ignore_newlines: true, - }; + cx.assert_editor_state("here is some textˇwith a space"); - _ = editor.update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) - ]) - }); - editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); - assert_eq!( - editor.buffer.read(cx).read(cx).text(), - "one\n two\nthree\n four" - ); - editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); - assert_eq!( - editor.buffer.read(cx).read(cx).text(), - "\n two\nthree\n four" - ); - editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); - assert_eq!( - editor.buffer.read(cx).read(cx).text(), - "two\nthree\n four" + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: true, + }, + window, + cx, ); - editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "\nthree\n four"); - editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four"); - editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); - assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); }); -} - -#[gpui::test] -fn test_newline(cx: &mut TestAppContext) { - init_test(cx, |_| {}); + // Same happens in the other direction. + cx.assert_editor_state("here is some textˇwith a space"); - let editor = cx.add_window(|window, cx| { - let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); - build_editor(buffer.clone(), window, cx) + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: false, + }, + window, + cx, + ); }); + cx.assert_editor_state("here is some textˇwith a space"); - _ = editor.update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), - DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), - DisplayPoint::new(DisplayRow(1), 6)..DisplayPoint::new(DisplayRow(1), 6), - ]) - }); - - editor.newline(&Newline, window, cx); - assert_eq!(editor.text(cx), "aa\naa\n \n bb\n bb\n"); + cx.set_state("here is some textˇ with a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); }); -} - -#[gpui::test] -fn test_newline_with_old_selections(cx: &mut TestAppContext) { - init_test(cx, |_| {}); - - let editor = cx.add_window(|window, cx| { - let buffer = MultiBuffer::build_simple( - " - a - b( - X - ) - c( - X - ) - " - .unindent() - .as_str(), + cx.assert_editor_state("here is some textˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, cx, ); - let mut editor = build_editor(buffer.clone(), window, cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([ - Point::new(2, 4)..Point::new(2, 5), - Point::new(5, 4)..Point::new(5, 5), - ]) - }); - editor }); - - _ = editor.update(cx, |editor, window, cx| { - // Edit the buffer directly, deleting ranges surrounding the editor's selections - editor.buffer.update(cx, |buffer, cx| { - buffer.edit( - [ - (Point::new(1, 2)..Point::new(3, 0), ""), - (Point::new(4, 2)..Point::new(6, 0), ""), - ], - None, - cx, - ); - assert_eq!( - buffer.read(cx).text(), - " - a - b() - c() - " - .unindent() - ); - }); - assert_eq!( - editor.selections.ranges(cx), - &[ - Point::new(1, 2)..Point::new(1, 2), - Point::new(2, 2)..Point::new(2, 2), - ], + cx.assert_editor_state("here is some ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); - - editor.newline(&Newline, window, cx); - assert_eq!( - editor.text(cx), - " - a - b( - ) - c( - ) - " - .unindent() + }); + // Single whitespaces are removed with the word behind them. + cx.assert_editor_state("here is ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); - - // The selections are moved after the inserted newlines - assert_eq!( - editor.selections.ranges(cx), - &[ - Point::new(2, 0)..Point::new(2, 0), - Point::new(4, 0)..Point::new(4, 0), - ], + }); + cx.assert_editor_state("here ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇwith a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Same happens in the other direction. + cx.assert_editor_state("ˇ a space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ space"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state("ˇ"); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); }); + cx.assert_editor_state("ˇ"); } #[gpui::test] -async fn test_newline_above(cx: &mut TestAppContext) { - init_test(cx, |settings| { - settings.defaults.tab_size = NonZeroU32::new(4) - }); +async fn test_delete_to_bracket(cx: &mut TestAppContext) { + init_test(cx, |_| {}); let language = Arc::new( Language::new( - LanguageConfig::default(), + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: true, + }, + ], + ..BracketPairConfig::default() + }, + ..LanguageConfig::default() + }, Some(tree_sitter_rust::LANGUAGE.into()), ) - .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .with_brackets_query( + r#" + ("(" @open ")" @close) + ("\"" @open "\"" @close) + "#, + ) .unwrap(), ); let mut cx = EditorTestContext::new(cx).await; cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" - const a: ˇA = ( - (ˇ - «const_functionˇ»(ˇ), - so«mˇ»et«hˇ»ing_ˇelse,ˇ - )ˇ - ˇ);ˇ - "}); - cx.update_editor(|e, window, cx| e.newline_above(&NewlineAbove, window, cx)); - cx.assert_editor_state(indoc! {" - ˇ - const a: A = ( - ˇ - ( - ˇ - ˇ - const_function(), - ˇ - ˇ - ˇ - ˇ - something_else, - ˇ - ) - ˇ - ˇ + cx.set_state(r#"macro!("// ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + // Deletion stops before brackets if asked to not ignore them. + cx.assert_editor_state(r#"macro!("ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); - "}); -} - -#[gpui::test] -async fn test_newline_below(cx: &mut TestAppContext) { - init_test(cx, |settings| { - settings.defaults.tab_size = NonZeroU32::new(4) }); + // Deletion has to remove a single bracket and then stop again. + cx.assert_editor_state(r#"macro!(ˇCOMMENT");"#); - let language = Arc::new( - Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_indents_query(r#"(_ "(" ")" @end) @indent"#) - .unwrap(), - ); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"macro!ˇCOMMENT");"#); - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" - const a: ˇA = ( - (ˇ - «const_functionˇ»(ˇ), - so«mˇ»et«hˇ»ing_ˇelse,ˇ - )ˇ - ˇ);ˇ - "}); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇCOMMENT");"#); - cx.update_editor(|e, window, cx| e.newline_below(&NewlineBelow, window, cx)); - cx.assert_editor_state(indoc! {" - const a: A = ( - ˇ - ( - ˇ - const_function(), - ˇ - ˇ - something_else, - ˇ - ˇ - ˇ - ˇ - ) - ˇ + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); - ˇ - ˇ - "}); -} + }); + cx.assert_editor_state(r#"ˇCOMMENT");"#); -#[gpui::test] -async fn test_newline_comments(cx: &mut TestAppContext) { - init_test(cx, |settings| { - settings.defaults.tab_size = NonZeroU32::new(4) + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); }); + // Brackets on the right are not paired anymore, hence deletion does not stop at them + cx.assert_editor_state(r#"ˇ");"#); - let language = Arc::new(Language::new( - LanguageConfig { - line_comments: vec!["// ".into()], - ..LanguageConfig::default() - }, - None, - )); - { - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" - // Fooˇ - "}); + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, + ); + }); + cx.assert_editor_state(r#"ˇ"#); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - // Foo - // ˇ - "}); - // Ensure that we add comment prefix when existing line contains space - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state( - indoc! {" - // Foo - //s - // ˇ - "} - .replace("s", " ") // s is used as space placeholder to prevent format on save - .as_str(), + cx.update_editor(|editor, window, cx| { + editor.delete_to_next_word_end( + &DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }, + window, + cx, ); - // Ensure that we add comment prefix when existing line does not contain space - cx.set_state(indoc! {" - // Foo - //ˇ - "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - // Foo - // - // ˇ - "}); - // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. - cx.set_state(indoc! {" - ˇ// Foo - "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" + }); + cx.assert_editor_state(r#"ˇ"#); - ˇ// Foo - "}); - } - // Ensure that comment continuations can be disabled. - update_test_language_settings(cx, |settings| { - settings.defaults.extend_comment_on_newline = Some(false); + cx.set_state(r#"macro!("// ˇCOMMENT");"#); + cx.update_editor(|editor, window, cx| { + editor.delete_to_previous_word_start( + &DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: true, + }, + window, + cx, + ); }); - let mut cx = EditorTestContext::new(cx).await; - cx.set_state(indoc! {" - // Fooˇ - "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - // Foo - ˇ - "}); + cx.assert_editor_state(r#"macroˇCOMMENT");"#); } #[gpui::test] -async fn test_newline_comments_with_multiple_delimiters(cx: &mut TestAppContext) { - init_test(cx, |settings| { - settings.defaults.tab_size = NonZeroU32::new(4) - }); +fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); - let language = Arc::new(Language::new( - LanguageConfig { - line_comments: vec!["// ".into(), "/// ".into()], - ..LanguageConfig::default() - }, - None, - )); - { - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" - //ˇ - "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - // - // ˇ - "}); + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("one\n2\nthree\n4", cx); + build_editor(buffer, window, cx) + }); + let del_to_prev_word_start = DeleteToPreviousWordStart { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_prev_word_start_ignore_newlines = DeleteToPreviousWordStart { + ignore_newlines: true, + ignore_brackets: false, + }; - cx.set_state(indoc! {" - ///ˇ - "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - /// - /// ˇ - "}); - } + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1) + ]) + }); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\nthree"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n2"); + editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "one\n"); + editor.delete_to_previous_word_start(&del_to_prev_word_start_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); } #[gpui::test] -async fn test_newline_documentation_comments(cx: &mut TestAppContext) { - init_test(cx, |settings| { - settings.defaults.tab_size = NonZeroU32::new(4) +fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\none\n two\nthree\n four", cx); + build_editor(buffer, window, cx) }); + let del_to_next_word_end = DeleteToNextWordEnd { + ignore_newlines: false, + ignore_brackets: false, + }; + let del_to_next_word_end_ignore_newlines = DeleteToNextWordEnd { + ignore_newlines: true, + ignore_brackets: false, + }; - let language = Arc::new( - Language::new( - LanguageConfig { - documentation_comment: Some(language::BlockCommentConfig { - start: "/**".into(), - end: "*/".into(), - prefix: "* ".into(), - tab_size: 1, - }), + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "one\n two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "\n two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!( + editor.buffer.read(cx).read(cx).text(), + "two\nthree\n four" + ); + editor.delete_to_next_word_end(&del_to_next_word_end, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\nthree\n four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "\n four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), "four"); + editor.delete_to_next_word_end(&del_to_next_word_end_ignore_newlines, window, cx); + assert_eq!(editor.buffer.read(cx).read(cx).text(), ""); + }); +} - ..LanguageConfig::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") +#[gpui::test] +fn test_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), + DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2), + DisplayPoint::new(DisplayRow(1), 6)..DisplayPoint::new(DisplayRow(1), 6), + ]) + }); + + editor.newline(&Newline, window, cx); + assert_eq!(editor.text(cx), "aa\naa\n \n bb\n bb\n"); + }); +} + +#[gpui::test] +fn test_newline_with_old_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple( + " + a + b( + X + ) + c( + X + ) + " + .unindent() + .as_str(), + cx, + ); + let mut editor = build_editor(buffer, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(2, 4)..Point::new(2, 5), + Point::new(5, 4)..Point::new(5, 5), + ]) + }); + editor + }); + + _ = editor.update(cx, |editor, window, cx| { + // Edit the buffer directly, deleting ranges surrounding the editor's selections + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 2)..Point::new(3, 0), ""), + (Point::new(4, 2)..Point::new(6, 0), ""), + ], + None, + cx, + ); + assert_eq!( + buffer.read(cx).text(), + " + a + b() + c() + " + .unindent() + ); + }); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(1, 2)..Point::new(1, 2), + Point::new(2, 2)..Point::new(2, 2), + ], + ); + + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + " + a + b( + ) + c( + ) + " + .unindent() + ); + + // The selections are moved after the inserted newlines + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(2, 0)..Point::new(2, 0), + Point::new(4, 0)..Point::new(4, 0), + ], + ); + }); +} + +#[gpui::test] +async fn test_newline_above(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) .unwrap(), ); - { - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" - /**ˇ + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + const a: ˇA = ( + (ˇ + «const_functionˇ»(ˇ), + so«mˇ»et«hˇ»ing_ˇelse,ˇ + )ˇ + ˇ);ˇ "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - /** - * ˇ + cx.update_editor(|e, window, cx| e.newline_above(&NewlineAbove, window, cx)); + cx.assert_editor_state(indoc! {" + ˇ + const a: A = ( + ˇ + ( + ˇ + ˇ + const_function(), + ˇ + ˇ + ˇ + ˇ + something_else, + ˇ + ) + ˇ + ˇ + ); "}); - // Ensure that if cursor is before the comment start, - // we do not actually insert a comment prefix. - cx.set_state(indoc! {" - ˇ/** +} + +#[gpui::test] +async fn test_newline_below(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query(r#"(_ "(" ")" @end) @indent"#) + .unwrap(), + ); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + const a: ˇA = ( + (ˇ + «const_functionˇ»(ˇ), + so«mˇ»et«hˇ»ing_ˇelse,ˇ + )ˇ + ˇ);ˇ "}); - cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); - cx.assert_editor_state(indoc! {" - ˇ/** + cx.update_editor(|e, window, cx| e.newline_below(&NewlineBelow, window, cx)); + cx.assert_editor_state(indoc! {" + const a: A = ( + ˇ + ( + ˇ + const_function(), + ˇ + ˇ + something_else, + ˇ + ˇ + ˇ + ˇ + ) + ˇ + ); + ˇ + ˇ "}); - // Ensure that if cursor is between it doesn't add comment prefix. +} + +#[gpui::test] +async fn test_newline_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into()], + ..LanguageConfig::default() + }, + None, + )); + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(indoc! {" - /*ˇ* + // Fooˇ "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" - /* - ˇ* + // Foo + // ˇ "}); - // Ensure that if suffix exists on same line after cursor it adds new line. + // Ensure that we add comment prefix when existing line contains space + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state( + indoc! {" + // Foo + //s + // ˇ + "} + .replace("s", " ") // s is used as space placeholder to prevent format on save + .as_str(), + ); + // Ensure that we add comment prefix when existing line does not contain space cx.set_state(indoc! {" - /**ˇ*/ + // Foo + //ˇ "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" - /** - * ˇ - */ + // Foo + // + // ˇ "}); - // Ensure that if suffix exists on same line after cursor with space it adds new line. + // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. cx.set_state(indoc! {" - /**ˇ */ + ˇ// Foo "}); cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); cx.assert_editor_state(indoc! {" - /** + + ˇ// Foo + "}); + } + // Ensure that comment continuations can be disabled. + update_test_language_settings(cx, |settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + // Fooˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // Foo + ˇ + "}); +} + +#[gpui::test] +async fn test_newline_comments_with_multiple_delimiters(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["// ".into(), "/// ".into()], + ..LanguageConfig::default() + }, + None, + )); + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + //ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + // + // ˇ + "}); + + cx.set_state(indoc! {" + ///ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /// + /// ˇ + "}); + } +} + +#[gpui::test] +async fn test_newline_documentation_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = NonZeroU32::new(4) + }); + + let language = Arc::new( + Language::new( + LanguageConfig { + documentation_comment: Some(language::BlockCommentConfig { + start: "/**".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); + + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" + /**ˇ + "}); + + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * ˇ + "}); + // Ensure that if cursor is before the comment start, + // we do not actually insert a comment prefix. + cx.set_state(indoc! {" + ˇ/** + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + + ˇ/** + "}); + // Ensure that if cursor is between it doesn't add comment prefix. + cx.set_state(indoc! {" + /*ˇ* + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /* + ˇ* + "}); + // Ensure that if suffix exists on same line after cursor it adds new line. + cx.set_state(indoc! {" + /**ˇ*/ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** + * ˇ + */ + "}); + // Ensure that if suffix exists on same line after cursor with space it adds new line. + cx.set_state(indoc! {" + /**ˇ */ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.assert_editor_state(indoc! {" + /** * ˇ */ "}); @@ -3175,7 +3686,7 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) { let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx); - let mut editor = build_editor(buffer.clone(), window, cx); + let mut editor = build_editor(buffer, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([3..4, 11..12, 19..20]) }); @@ -3188,13 +3699,19 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) { buffer.edit([(2..5, ""), (10..13, ""), (18..21, "")], None, cx); assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent()); }); - assert_eq!(editor.selections.ranges(cx), &[2..2, 7..7, 12..12],); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[2..2, 7..7, 12..12], + ); editor.insert("Z", window, cx); assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)"); // The selections are moved after the inserted characters - assert_eq!(editor.selections.ranges(cx), &[3..3, 9..9, 15..15],); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[3..3, 9..9, 15..15], + ); }); } @@ -3902,7 +4419,7 @@ fn test_delete_line(cx: &mut TestAppContext) { editor.selections.display_ranges(cx), vec![ DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), - DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1) + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1), ] ); }); @@ -3924,6 +4441,24 @@ fn test_delete_line(cx: &mut TestAppContext) { vec![DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1)] ); }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n\njkl\nmno", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(2), 1) + ]) + }); + editor.delete_line(&DeleteLine, window, cx); + assert_eq!(editor.display_text(cx), "\njkl\nmno"); + assert_eq!( + editor.selections.display_ranges(cx), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); } #[gpui::test] @@ -3936,7 +4471,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { let buffer = buffer.read(cx).as_singleton().unwrap(); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 0)..Point::new(0, 0)] ); @@ -3944,7 +4481,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 3)..Point::new(0, 3)] ); @@ -3955,7 +4494,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 11)..Point::new(0, 11)] ); @@ -3963,7 +4504,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.undo(&Undo, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), &[Point::new(0, 5)..Point::new(2, 2)] ); @@ -3974,7 +4517,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -3982,7 +4527,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -3990,7 +4537,9 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { editor.join_lines(&JoinLines, window, cx); assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [Point::new(2, 3)..Point::new(2, 3)] ); @@ -4047,7 +4596,9 @@ fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 7)..Point::new(0, 7), Point::new(1, 3)..Point::new(1, 3) @@ -4128,8 +4679,8 @@ async fn test_custom_newlines_cause_no_false_positive_diffs( let snapshot = editor.snapshot(window, cx); assert_eq!( snapshot - .buffer_snapshot - .diff_hunks_in_range(0..snapshot.buffer_snapshot.len()) + .buffer_snapshot() + .diff_hunks_in_range(0..snapshot.buffer_snapshot().len()) .collect::>(), Vec::new(), "Should not have any diffs for files with custom newlines" @@ -4401,6 +4952,129 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + "}); + + cx.set_state(indoc! {" + teˇst + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + te<«ˇ»>st + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let js_language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + wrap_characters: Some(language::WrapCharactersConfig { + start_prefix: "<".into(), + start_suffix: ">".into(), + end_prefix: "".into(), + }), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + «testˇ» «testˇ» + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + <«ˇ»>test <«ˇ»>test + <«ˇ»>test + "}); + + cx.set_state(indoc! {" + «test + testˇ» + «test + testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + <«ˇ»>test + test + <«ˇ»>test + test + "}); +} + +#[gpui::test] +async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let plaintext_language = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx)); + + cx.set_state(indoc! {" + «testˇ» + "}); + cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx)); + cx.assert_editor_state(indoc! {" + «testˇ» + "}); +} + #[gpui::test] async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -4904,6 +5578,20 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" «HeLlO, wOrLD!ˇ» "}); + + // Test selections with `line_mode() = true`. + cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); + cx.set_state(indoc! {" + «The quick brown + fox jumps over + tˇ»he lazy dog + "}); + cx.update_editor(|e, window, cx| e.convert_to_upper_case(&ConvertToUpperCase, window, cx)); + cx.assert_editor_state(indoc! {" + «THE QUICK BROWN + FOX JUMPS OVER + THE LAZY DOGˇ» + "}); } #[gpui::test] @@ -4958,8 +5646,8 @@ fn test_duplicate_line(cx: &mut TestAppContext) { ); }); - // With `move_upwards` the selections stay in place, except for - // the lines inserted above them + // With `duplicate_line_up` the selections move to the duplicated lines, + // which are inserted above the original lines let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx); build_editor(buffer, window, cx) @@ -4981,7 +5669,7 @@ fn test_duplicate_line(cx: &mut TestAppContext) { DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 1), DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2), DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0), - DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(6), 0), + DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); }); @@ -5206,7 +5894,7 @@ async fn test_selections_and_replace_blocks(cx: &mut TestAppContext) { // Create a four-line block that replaces three lines of text. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot; + let snapshot = &snapshot.buffer_snapshot(); let placement = BlockPlacement::Replace( snapshot.anchor_after(Point::new(1, 0))..=snapshot.anchor_after(Point::new(3, 0)), ); @@ -5268,15 +5956,24 @@ fn test_transpose(cx: &mut TestAppContext) { }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [2..2]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [2..2] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bca"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [3..3] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bac"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [3..3] + ); editor }); @@ -5289,22 +5986,34 @@ fn test_transpose(cx: &mut TestAppContext) { }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acb\nde"); - assert_eq!(editor.selections.ranges(cx), [3..3]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [3..3] + ); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([4..4]) }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [5..5] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbde\n"); - assert_eq!(editor.selections.ranges(cx), [6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [6..6] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "acbd\ne"); - assert_eq!(editor.selections.ranges(cx), [6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [6..6] + ); editor }); @@ -5317,23 +6026,38 @@ fn test_transpose(cx: &mut TestAppContext) { }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bacd\ne"); - assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [2..2, 3..3, 5..5] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); - + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [3..3, 4..4, 6..6] + ); + editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcda\ne"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [4..4, 6..6] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcade\n"); - assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [4..4, 6..6] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "bcaed\n"); - assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [5..5, 6..6] + ); editor }); @@ -5346,15 +6070,24 @@ fn test_transpose(cx: &mut TestAppContext) { }); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [8..8]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [8..8] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀✋🍐"); - assert_eq!(editor.selections.ranges(cx), [11..11]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [11..11] + ); editor.transpose(&Default::default(), window, cx); assert_eq!(editor.text(cx), "🏀🍐✋"); - assert_eq!(editor.selections.ranges(cx), [11..11]); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + [11..11] + ); editor }); @@ -5436,14 +6169,18 @@ async fn test_rewrap(cx: &mut TestAppContext) { }, None, )); - let rust_language = Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - line_comments: vec!["// ".into(), "/// ".into()], - ..LanguageConfig::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )); + let rust_language = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + line_comments: vec!["// ".into(), "/// ".into()], + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment)(block_comment)] @comment.inclusive") + .unwrap(), + ); let plaintext_language = Arc::new(Language::new( LanguageConfig { @@ -5562,7 +6299,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { # ˇThis is a long comment using a pound # sign. "}, - python_language.clone(), + python_language, &mut cx, ); @@ -5669,7 +6406,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { also very long and should not merge with the numbered item.ˇ» "}, - markdown_language.clone(), + markdown_language, &mut cx, ); @@ -5700,7 +6437,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { // This is the second long comment block // to be wrapped.ˇ» "}, - rust_language.clone(), + rust_language, &mut cx, ); @@ -5723,7 +6460,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { «\tThis is a very long indented line \tthat will be wrapped.ˇ» "}, - plaintext_language.clone(), + plaintext_language, &mut cx, ); @@ -5759,6 +6496,411 @@ async fn test_rewrap(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_rewrap_block_comments(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.languages.0.extend([( + "Rust".into(), + LanguageSettingsContent { + allow_rewrap: Some(language_settings::RewrapBehavior::InComments), + preferred_line_length: Some(40), + ..Default::default() + }, + )]) + }); + + let mut cx = EditorTestContext::new(cx).await; + + let rust_lang = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + line_comments: vec!["// ".into()], + block_comment: Some(BlockCommentConfig { + start: "/*".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + documentation_comment: Some(BlockCommentConfig { + start: "/**".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), + + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_override_query("[(line_comment) (block_comment)] @comment.inclusive") + .unwrap(), + ); + + // regular block comment + assert_rewrap( + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // indent is respected + assert_rewrap( + indoc! {" + {} + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + {} + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // short block comments with inline delimiters + assert_rewrap( + indoc! {" + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // multiline block comment with inline start/end delimiters + assert_rewrap( + indoc! {" + /*ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // block comment rewrap still respects paragraph bounds + assert_rewrap( + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + * + * Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + indoc! {" + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + * + * Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // documentation comments + assert_rewrap( + indoc! {" + /**ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /** + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + "}, + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // different, adjacent comments + assert_rewrap( + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /*ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + "}, + indoc! {" + /** + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + //ˇ Lorem ipsum dolor sit amet, + // consectetur adipiscing elit. + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ single short block comment + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // rewrapping a single comment w/ abutting comments + assert_rewrap( + indoc! {" + /* ˇLorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + indoc! {" + /* + * ˇLorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ non-abutting short block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + + /* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection of multiline block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // partial selection of multiline block comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet,ˇ» + * consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, + «* consectetur adipiscing elit. */ˇ» + "}, + indoc! {" + «/* + * Lorem ipsum dolor sit amet,ˇ» + * consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, + «* consectetur adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // selection w/ abutting short block comments + // TODO: should not be combined; should rewrap as 2 comments + assert_rewrap( + indoc! {" + «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + "}, + // desired behavior: + // indoc! {" + // «/* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ˇ» + // "}, + // actual behaviour: + indoc! {" + «/* + * Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. Lorem + * ipsum dolor sit amet, consectetur + * adipiscing elit. + */ˇ» + "}, + rust_lang.clone(), + &mut cx, + ); + + // TODO: same as above, but with delimiters on separate line + // assert_rewrap( + // indoc! {" + // «/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. + // */ + // /* + // * Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ˇ» + // "}, + // // desired: + // // indoc! {" + // // «/* + // // * Lorem ipsum dolor sit amet, + // // * consectetur adipiscing elit. + // // */ + // // /* + // // * Lorem ipsum dolor sit amet, + // // * consectetur adipiscing elit. + // // */ˇ» + // // "}, + // // actual: (but with trailing w/s on the empty lines) + // indoc! {" + // «/* + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // * + // */ + // /* + // * + // * Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ˇ» + // "}, + // rust_lang.clone(), + // &mut cx, + // ); + + // TODO these are unhandled edge cases; not correct, just documenting known issues + assert_rewrap( + indoc! {" + /* + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + */ + /* + //ˇ Lorem ipsum dolor sit amet, consectetur adipiscing elit. */ + /*ˇ Lorem ipsum dolor sit amet */ /* consectetur adipiscing elit. */ + "}, + // desired: + // indoc! {" + // /* + // *ˇ Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // *ˇ Lorem ipsum dolor sit amet, + // * consectetur adipiscing elit. + // */ + // /* + // *ˇ Lorem ipsum dolor sit amet + // */ /* consectetur adipiscing elit. */ + // "}, + // actual: + indoc! {" + /* + //ˇ Lorem ipsum dolor sit amet, + // consectetur adipiscing elit. + */ + /* + * //ˇ Lorem ipsum dolor sit amet, + * consectetur adipiscing elit. + */ + /* + *ˇ Lorem ipsum dolor sit amet */ /* + * consectetur adipiscing elit. + */ + "}, + rust_lang, + &mut cx, + ); + + #[track_caller] + fn assert_rewrap( + unwrapped_text: &str, + wrapped_text: &str, + language: Arc, + cx: &mut EditorTestContext, + ) { + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(unwrapped_text); + cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); + cx.assert_editor_state(wrapped_text); + } +} + #[gpui::test] async fn test_hard_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -5849,23 +6991,83 @@ async fn test_hard_wrap(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_clipboard(cx: &mut TestAppContext) { +async fn test_cut_line_ends(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); + cx.set_state(indoc! {"The quick brownˇ"}); + cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); + cx.assert_editor_state(indoc! {"The quick brownˇ"}); + + cx.set_state(indoc! {"The emacs foxˇ"}); + cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); + cx.assert_editor_state(indoc! {"The emacs foxˇ"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); - cx.assert_editor_state("ˇtwo ˇfour ˇsix "); + cx.assert_editor_state(indoc! {" + The quickˇ + ˇthe lazy dog"}); - // Paste with three cursors. Each cursor pastes one slice of the clipboard text. - cx.set_state("two ˇfour ˇsix ˇ"); - cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); - cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| e.cut_to_end_of_line(&CutToEndOfLine::default(), window, cx)); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇthe lazy dog"}); - // Paste again but with only two cursors. Since the number of cursors doesn't - // match the number of slices in the clipboard, the entire clipboard text - // is pasted at each cursor. + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| { + e.cut_to_end_of_line( + &CutToEndOfLine { + stop_at_newlines: true, + }, + window, + cx, + ) + }); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇ + the lazy dog"}); + + cx.set_state(indoc! {" + The quick« brownˇ» + fox jumps overˇ + the lazy dog"}); + cx.update_editor(|e, window, cx| e.kill_ring_cut(&KillRingCut, window, cx)); + cx.assert_editor_state(indoc! {" + The quickˇ + fox jumps overˇthe lazy dog"}); +} + +#[gpui::test] +async fn test_clipboard(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); + cx.update_editor(|e, window, cx| e.cut(&Cut, window, cx)); + cx.assert_editor_state("ˇtwo ˇfour ˇsix "); + + // Paste with three cursors. Each cursor pastes one slice of the clipboard text. + cx.set_state("two ˇfour ˇsix ˇ"); + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); + + // Paste again but with only two cursors. Since the number of cursors doesn't + // match the number of slices in the clipboard, the entire clipboard text + // is pasted at each cursor. cx.set_state("ˇtwo one✅ four three six five ˇ"); cx.update_editor(|e, window, cx| { e.handle_input("( ", window, cx); @@ -7324,7 +8526,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction { + provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { id: None, edits: vec![(edit_position..edit_position, "X".into())], edit_preview: None, @@ -7709,10 +8911,10 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { assert_text_with_selections( editor, indoc! {r#" - use mod1::mod2::{mod3, mo«ˇ»d4}; + use mod1::mod2::{mod3, moˇd4}; fn fn_1(para«ˇm1: bool, pa»ram2: &str) { - let var1 = "te«ˇ»xt"; + let var1 = "teˇxt"; } "#}, cx, @@ -7727,10 +8929,10 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) { assert_text_with_selections( editor, indoc! {r#" - use mod1::mod2::{mod3, mo«ˇ»d4}; + use mod1::mod2::{mod3, moˇd4}; fn fn_1(para«ˇm1: bool, pa»ram2: &str) { - let var1 = "te«ˇ»xt"; + let var1 = "teˇxt"; } "#}, cx, @@ -7834,6 +9036,184 @@ async fn test_select_larger_syntax_node_for_cursor_at_end(cx: &mut TestAppContex }); } +#[gpui::test] +async fn test_select_larger_syntax_node_for_cursor_at_symbol(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig { + name: "JavaScript".into(), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + + let text = r#" + let a = { + key: "value", + }; + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Cursor after '{' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = {ˇ + key: "value", + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 2: Cursor after ':' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 8)..DisplayPoint::new(DisplayRow(1), 8) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key:ˇ "value", + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + «ˇkey: "value"», + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 3: Cursor after ',' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 17)..DisplayPoint::new(DisplayRow(1), 17) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key: "value",ˇ + }; + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = «ˇ{ + key: "value", + }»; + "#}, + cx, + ); + }); + + // Test case 4: Cursor after ';' + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + let a = { + key: "value", + };ˇ + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + «ˇlet a = { + key: "value", + }; + »"#}, + cx, + ); + }); +} + #[gpui::test] async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -8015,7 +9395,7 @@ async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppConte } #[gpui::test] -async fn test_unwrap_syntax_node(cx: &mut gpui::TestAppContext) { +async fn test_unwrap_syntax_nodes(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; @@ -8029,21 +9409,70 @@ async fn test_unwrap_syntax_node(cx: &mut gpui::TestAppContext) { buffer.set_language(Some(language), cx); }); - cx.set_state( - &r#" - use mod1::mod2::{«mod3ˇ», mod4}; - "# - .unindent(), - ); + cx.set_state(indoc! { r#"use mod1::{mod2::{«mod3ˇ», mod4}, mod5::{mod6, «mod7ˇ»}};"# }); cx.update_editor(|editor, window, cx| { editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); }); - cx.assert_editor_state( - &r#" - use mod1::mod2::«mod3ˇ»; - "# - .unindent(), - ); + + cx.assert_editor_state(indoc! { r#"use mod1::{mod2::«mod3ˇ», mod5::«mod7ˇ»};"# }); + + cx.set_state(indoc! { r#"fn a() { + // what + // a + // ˇlong + // method + // I + // sure + // hope + // it + // works + }"# }); + + let buffer = cx.update_multibuffer(|multibuffer, _| multibuffer.as_singleton().unwrap()); + let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + cx.update(|_, cx| { + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + [Point::new(1, 0)..Point::new(1, 0)], + 3, + cx, + ); + }); + }); + + let editor2 = cx.new_window_entity(|window, cx| { + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor2, &mut cx).await; + cx.update_editor(|editor, window, cx| { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(3, 0)..Point::new(3, 0)]); + }) + }); + + cx.assert_editor_state(indoc! { " + fn a() { + // what + // a + ˇ // long + // method"}); + + cx.update_editor(|editor, window, cx| { + editor.unwrap_syntax_node(&UnwrapSyntaxNode, window, cx); + }); + + // Although we could potentially make the action work when the syntax node + // is half-hidden, it seems a bit dangerous as you can't easily tell what it + // did. Maybe we could also expand the excerpt to contain the range? + cx.assert_editor_state(indoc! { " + fn a() { + // what + // a + ˇ // long + // method"}); } #[gpui::test] @@ -8204,7 +9633,7 @@ async fn test_autoindent(cx: &mut TestAppContext) { editor.newline(&Newline, window, cx); assert_eq!(editor.text(cx), "fn a(\n \n) {\n \n}\n"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), &[ Point::new(1, 4)..Point::new(1, 4), Point::new(3, 4)..Point::new(3, 4), @@ -8215,19 +9644,229 @@ async fn test_autoindent(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_autoindent_selections(cx: &mut TestAppContext) { - init_test(cx, |_| {}); - - { - let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; - cx.set_state(indoc! {" - impl A { +async fn test_autoindent_disabled(cx: &mut TestAppContext) { + init_test(cx, |settings| settings.defaults.auto_indent = Some(false)); - fn b() {} + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); - «fn c() { + let text = "fn a() {}"; - }ˇ» + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([5..5, 8..8, 9..9]) + }); + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + indoc!( + " + fn a( + + ) { + + } + " + ) + ); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + &[ + Point::new(1, 0)..Point::new(1, 0), + Point::new(3, 0)..Point::new(3, 0), + Point::new(5, 0)..Point::new(5, 0) + ] + ); + }); +} + +#[gpui::test] +async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(true); + settings.languages.0.insert( + "python".into(), + LanguageSettingsContent { + auto_indent: Some(false), + ..Default::default() + }, + ); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let injected_language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: "python".into(), + ..Default::default() + }, + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: LanguageName::new("rust"), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + .with_injection_query( + r#" + (macro_invocation + macro: (identifier) @_macro_name + (token_tree) @injection.content + (#set! injection.language "python")) + "#, + ) + .unwrap(), + ); + + cx.language_registry().add(injected_language); + cx.language_registry().add(language.clone()); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state(r#"struct A {ˇ}"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + "struct A { + ˇ + }" + )); + + cx.set_state(r#"select_biased!(ˇ)"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + editor.handle_input("def ", window, cx); + editor.handle_input("(", window, cx); + editor.newline(&Default::default(), window, cx); + editor.handle_input("a", window, cx); + }); + + cx.assert_editor_state(indoc!( + "select_biased!( + def ( + aˇ + ) + )" + )); +} + +#[gpui::test] +async fn test_autoindent_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + { + let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; + cx.set_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» } "}); @@ -8688,7 +10327,7 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { )); cx.language_registry().add(html_language.clone()); - cx.language_registry().add(javascript_language.clone()); + cx.language_registry().add(javascript_language); cx.executor().run_until_parked(); cx.update_buffer(|buffer, cx| { @@ -8709,7 +10348,9 @@ async fn test_autoclose_with_embedded_language(cx: &mut TestAppContext) { // Precondition: different languages are active at different locations. cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); - let cursors = editor.selections.ranges::(cx); + let cursors = editor + .selections + .ranges::(&editor.display_snapshot(cx)); let languages = cursors .iter() .map(|c| snapshot.language_at(c.start).unwrap().name()) @@ -9154,7 +10795,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 4)..Point::new(0, 4), Point::new(1, 4)..Point::new(1, 4), @@ -9174,7 +10817,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 2)..Point::new(0, 2), Point::new(1, 2)..Point::new(1, 2), @@ -9193,7 +10838,9 @@ async fn test_delete_autoclose_pair(cx: &mut TestAppContext) { .unindent() ); assert_eq!( - editor.selections.ranges::(cx), + editor + .selections + .ranges::(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), @@ -9399,7 +11046,12 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { fn assert(editor: &mut Editor, cx: &mut Context, marked_text: &str) { let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges::(cx), selection_ranges); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + selection_ranges + ); } assert( @@ -9414,6 +11066,129 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + fn assert_state(editor: &mut Editor, cx: &mut Context, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + selection_ranges + ); + } + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state( + editor, + cx, + indoc! {" + type «» = ;• + "}, + ); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = «»;• + "}, + ); + + assert!( + !editor.context_menu_visible(), + "Context menu should be hidden after moving to next tabstop" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + }); + + _ = editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.backspace(&Backspace, window, cx); + + let snippet = Snippet::parse("fn ${1|,foo,bar|} = ${2:value}; $3").unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn = «valueˇ»;•"); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible again after returning to first tabstop" + ); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + }); +} + #[gpui::test] async fn test_snippets(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -9430,9 +11205,9 @@ async fn test_snippets(cx: &mut TestAppContext) { let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); let insertion_ranges = editor .selections - .all(cx) + .all(&editor.display_snapshot(cx)) .iter() - .map(|s| s.range().clone()) + .map(|s| s.range()) .collect::>(); editor .insert_snippet(&insertion_ranges, snippet, window, cx) @@ -9510,9 +11285,9 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) { .unwrap(); let insertion_ranges = editor .selections - .all(cx) + .all(&editor.display_snapshot(cx)) .iter() - .map(|s| s.range().clone()) + .map(|s| s.range()) .collect::>(); editor .insert_snippet(&insertion_ranges, snippet, window, cx) @@ -9583,7 +11358,7 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) { move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( @@ -9626,7 +11401,7 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) { move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() @@ -9674,7 +11449,7 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) { .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 8); Ok(Some(vec![])) @@ -9824,19 +11599,19 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { let buffer_1 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "other.rs"), cx) + project.open_buffer((worktree_id, rel_path("other.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "lib.rs"), cx) + project.open_buffer((worktree_id, rel_path("lib.rs")), cx) }) .await .unwrap(); @@ -10011,19 +11786,19 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { // Open three buffers let buffer_1 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "file1.rs"), cx) + project.open_buffer((worktree_id, rel_path("file1.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "file2.rs"), cx) + project.open_buffer((worktree_id, rel_path("file2.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "file3.rs"), cx) + project.open_buffer((worktree_id, rel_path("file3.rs")), cx) }) .await .unwrap(); @@ -10222,7 +11997,7 @@ async fn test_range_format_on_save_success(cx: &mut TestAppContext) { .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( @@ -10255,7 +12030,7 @@ async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) { move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() @@ -10348,7 +12123,7 @@ async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppC .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 8); Ok(Some(Vec::new())) @@ -10361,8 +12136,8 @@ async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppC #[gpui::test] async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Single( - Formatter::LanguageServer { name: None }, + settings.defaults.formatter = Some(FormatterList::Single(Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, ))) }); @@ -10386,10 +12161,7 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { update_test_language_settings(cx, |settings| { // Enable Prettier formatting for the same buffer, and ensure // LSP is called instead of Prettier. - settings.defaults.prettier = Some(PrettierSettings { - allowed: true, - ..PrettierSettings::default() - }); + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let mut fake_servers = language_registry.register_fake_lsp( "Rust", @@ -10435,7 +12207,7 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); assert_eq!(params.options.tab_size, 4); Ok(Some(vec![lsp::TextEdit::new( @@ -10460,7 +12232,7 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.rs")).unwrap() + lsp::Uri::from_file_path(path!("/file.rs")).unwrap() ); futures::future::pending::<()>().await; unreachable!() @@ -10490,17 +12262,11 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) { async fn test_multiple_formatters(cx: &mut TestAppContext) { init_test(cx, |settings| { settings.defaults.remove_trailing_whitespace_on_save = Some(true); - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None }, - Formatter::CodeActions( - [ - ("code-action-1".into(), true), - ("code-action-2".into(), true), - ] - .into_iter() - .collect(), - ), - ]))) + settings.defaults.formatter = Some(FormatterList::Vec(vec![ + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + Formatter::CodeAction("code-action-1".into()), + Formatter::CodeAction("code-action-2".into()), + ])) }); let fs = FakeFs::new(cx.executor()); @@ -10552,17 +12318,16 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { ); fake_server.set_request_handler::( move |params, _| async move { - assert_eq!( - params.context.only, - Some(vec!["code-action-1".into(), "code-action-2".into()]) - ); - let uri = lsp::Url::from_file_path(path!("/file.rs")).unwrap(); - Ok(Some(vec![ - lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { + let requested_code_actions = params.context.only.expect("Expected code action request"); + assert_eq!(requested_code_actions.len(), 1); + + let uri = lsp::Uri::from_file_path(path!("/file.rs")).unwrap(); + let code_action = match requested_code_actions[0].as_str() { + "code-action-1" => lsp::CodeAction { kind: Some("code-action-1".into()), edit: Some(lsp::WorkspaceEdit::new( [( - uri.clone(), + uri, vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "applied-code-action-1-edit\n".to_string(), @@ -10576,12 +12341,12 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { ..Default::default() }), ..Default::default() - }), - lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { + }, + "code-action-2" => lsp::CodeAction { kind: Some("code-action-2".into()), edit: Some(lsp::WorkspaceEdit::new( [( - uri.clone(), + uri, vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), "applied-code-action-2-edit\n".to_string(), @@ -10591,8 +12356,12 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { .collect(), )), ..Default::default() - }), - ])) + }, + req => panic!("Unexpected code action request: {:?}", req), + }; + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + code_action, + )])) }, ); @@ -10616,7 +12385,7 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { edit: lsp::WorkspaceEdit { changes: Some( [( - lsp::Url::from_file_path(path!("/file.rs")).unwrap(), + lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 0), @@ -10752,9 +12521,9 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) { #[gpui::test] async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None }, - ]))) + settings.defaults.formatter = Some(FormatterList::Vec(vec![Formatter::LanguageServer( + settings::LanguageServerFormatterSpecifier::Current, + )])) }); let fs = FakeFs::new(cx.executor()); @@ -10775,10 +12544,7 @@ async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); update_test_language_settings(cx, |settings| { - settings.defaults.prettier = Some(PrettierSettings { - allowed: true, - ..PrettierSettings::default() - }); + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let mut fake_servers = language_registry.register_fake_lsp( "TypeScript", @@ -10827,7 +12593,7 @@ async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { .set_request_handler::(move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.ts")).unwrap() + lsp::Uri::from_file_path(path!("/file.ts")).unwrap() ); Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { @@ -10875,7 +12641,7 @@ async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { move |params, _| async move { assert_eq!( params.text_document.uri, - lsp::Url::from_file_path(path!("/file.ts")).unwrap() + lsp::Uri::from_file_path(path!("/file.ts")).unwrap() ); futures::future::pending::<()>().await; unreachable!() @@ -10960,7 +12726,7 @@ async fn test_concurrent_format_requests(cx: &mut TestAppContext) { #[gpui::test] async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::Auto) + settings.defaults.formatter = Some(FormatterList::default()) }); let mut cx = EditorLspTestContext::new_rust( @@ -10972,22 +12738,6 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { ) .await; - // Set up a buffer white some trailing whitespace and no trailing newline. - cx.set_state( - &[ - "one ", // - "twoˇ", // - "three ", // - "four", // - ] - .join("\n"), - ); - - // Submit a format request. - let format = cx - .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) - .unwrap(); - // Record which buffer changes have been sent to the language server let buffer_changes = Arc::new(Mutex::new(Vec::new())); cx.lsp @@ -11002,34 +12752,34 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { ); } }); - // Handle formatting requests to the language server. cx.lsp .set_request_handler::({ let buffer_changes = buffer_changes.clone(); move |_, _| { - // When formatting is requested, trailing whitespace has already been stripped, - // and the trailing newline has already been added. - assert_eq!( - &buffer_changes.lock()[1..], - &[ - ( - lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), - "".into() - ), - ( - lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), - "".into() - ), - ( - lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), - "\n".into() - ), - ] - ); - + let buffer_changes = buffer_changes.clone(); // Insert blank lines between each line of the buffer. async move { + // When formatting is requested, trailing whitespace has already been stripped, + // and the trailing newline has already been added. + assert_eq!( + &buffer_changes.lock()[1..], + &[ + ( + lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)), + "".into() + ), + ( + lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)), + "".into() + ), + ( + lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)), + "\n".into() + ), + ] + ); + Ok(Some(vec![ lsp::TextEdit { range: lsp::Range::new( @@ -11050,10 +12800,29 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut TestAppContext) { } }); + // Set up a buffer white some trailing whitespace and no trailing newline. + cx.set_state( + &[ + "one ", // + "twoˇ", // + "three ", // + "four", // + ] + .join("\n"), + ); + cx.run_until_parked(); + + // Submit a format request. + let format = cx + .update_editor(|editor, window, cx| editor.format(&Format, window, cx)) + .unwrap(); + + cx.run_until_parked(); // After formatting the buffer, the trailing whitespace is stripped, // a newline is appended, and the edits provided by the language server // have been applied. format.await.unwrap(); + cx.assert_editor_state( &[ "one", // @@ -11089,8 +12858,8 @@ async fn test_handle_input_for_show_signature_help_auto_signature_help_true( cx.update(|cx| { cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, |settings| { - settings.auto_signature_help = Some(true); + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); }); }); }); @@ -11229,9 +12998,9 @@ async fn test_handle_input_with_different_show_signature_settings(cx: &mut TestA cx.update(|cx| { cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, |settings| { - settings.auto_signature_help = Some(false); - settings.show_signature_help_after_edits = Some(false); + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(false); + settings.editor.show_signature_help_after_edits = Some(false); }); }); }); @@ -11356,9 +13125,9 @@ async fn test_handle_input_with_different_show_signature_settings(cx: &mut TestA // Ensure that signature_help is called when enabled afte edits cx.update(|_, cx| { cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, |settings| { - settings.auto_signature_help = Some(false); - settings.show_signature_help_after_edits = Some(true); + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(false); + settings.editor.show_signature_help_after_edits = Some(true); }); }); }); @@ -11398,9 +13167,9 @@ async fn test_handle_input_with_different_show_signature_settings(cx: &mut TestA // Ensure that signature_help is called when auto signature help override is enabled cx.update(|_, cx| { cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, |settings| { - settings.auto_signature_help = Some(true); - settings.show_signature_help_after_edits = Some(false); + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); + settings.editor.show_signature_help_after_edits = Some(false); }); }); }); @@ -11442,8 +13211,8 @@ async fn test_signature_help(cx: &mut TestAppContext) { init_test(cx, |_| {}); cx.update(|cx| { cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, |settings| { - settings.auto_signature_help = Some(true); + settings.update_user_settings(cx, |settings| { + settings.editor.auto_signature_help = Some(true); }); }); }); @@ -12033,11 +13802,11 @@ async fn test_completion_mode(cx: &mut TestAppContext) { ); update_test_language_settings(&mut cx, |settings| { - settings.defaults.completions = Some(CompletionSettings { - lsp_insert_mode, - words: WordsCompletionMode::Disabled, - lsp: true, - lsp_fetch_timeout_ms: 0, + settings.defaults.completions = Some(CompletionSettingsContent { + lsp_insert_mode: Some(lsp_insert_mode), + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + ..Default::default() }); }); @@ -12092,12 +13861,12 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) let expected_with_replace_mode = "SubscriptionErrorˇ"; update_test_language_settings(&mut cx, |settings| { - settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Disabled, + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), // set the opposite here to ensure that the action is overriding the default behavior - lsp_insert_mode: LspInsertMode::Insert, - lsp: true, - lsp_fetch_timeout_ms: 0, + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -12109,7 +13878,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) let counter = Arc::new(AtomicUsize::new(0)); handle_completion_request_with_insert_and_replace( &mut cx, - &buffer_marked_text, + buffer_marked_text, vec![(completion_text, completion_text)], counter.clone(), ) @@ -12123,17 +13892,17 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) .confirm_completion_replace(&ConfirmCompletionReplace, window, cx) .unwrap() }); - cx.assert_editor_state(&expected_with_replace_mode); + cx.assert_editor_state(expected_with_replace_mode); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); update_test_language_settings(&mut cx, |settings| { - settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Disabled, + settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), // set the opposite here to ensure that the action is overriding the default behavior - lsp_insert_mode: LspInsertMode::Replace, - lsp: true, - lsp_fetch_timeout_ms: 0, + lsp_insert_mode: Some(LspInsertMode::Replace), + ..Default::default() }); }); @@ -12143,7 +13912,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) }); handle_completion_request_with_insert_and_replace( &mut cx, - &buffer_marked_text, + buffer_marked_text, vec![(completion_text, completion_text)], counter.clone(), ) @@ -12157,7 +13926,7 @@ async fn test_completion_with_mode_specified_by_action(cx: &mut TestAppContext) .confirm_completion_insert(&ConfirmCompletionInsert, window, cx) .unwrap() }); - cx.assert_editor_state(&expected_with_insert_mode); + cx.assert_editor_state(expected_with_insert_mode); handle_resolve_completion_request(&mut cx, None).await; apply_additional_edits.await.unwrap(); } @@ -12869,11 +14638,11 @@ async fn test_completion_reuse(cx: &mut TestAppContext) { async fn test_word_completion(cx: &mut TestAppContext) { let lsp_fetch_timeout_ms = 10; init_test(cx, |language_settings| { - language_settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Fallback, - lsp: true, - lsp_fetch_timeout_ms: 10, - lsp_insert_mode: LspInsertMode::Insert, + language_settings.defaults.completions = Some(CompletionSettingsContent { + words_min_length: Some(0), + lsp_fetch_timeout_ms: Some(10), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -12931,7 +14700,7 @@ async fn test_word_completion(cx: &mut TestAppContext) { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["first", "last"], "When LSP server is fast to reply, no fallback word completions are used" ); @@ -12954,7 +14723,7 @@ async fn test_word_completion(cx: &mut TestAppContext) { cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { - assert_eq!(completion_menu_entries(&menu), &["one", "three", "two"], + assert_eq!(completion_menu_entries(menu), &["one", "three", "two"], "When LSP server is slow, document words can be shown instead, if configured accordingly"); } else { panic!("expected completion menu to be open"); @@ -12965,11 +14734,11 @@ async fn test_word_completion(cx: &mut TestAppContext) { #[gpui::test] async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext) { init_test(cx, |language_settings| { - language_settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Enabled, - lsp: true, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::Insert, + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -13015,7 +14784,7 @@ async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["first", "last", "second"], "Word completions that has the same edit as the any of the LSP ones, should not be proposed" ); @@ -13028,11 +14797,11 @@ async fn test_word_completions_do_not_duplicate_lsp_ones(cx: &mut TestAppContext #[gpui::test] async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { init_test(cx, |language_settings| { - language_settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Disabled, - lsp: true, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::Insert, + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Disabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -13071,7 +14840,7 @@ async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["first", "last", "second"], "`ShowWordCompletions` action should show word completions" ); @@ -13088,7 +14857,7 @@ async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["last"], "After showing word completions, further editing should filter them and not query the LSP" ); @@ -13101,11 +14870,11 @@ async fn test_word_completions_continue_on_typing(cx: &mut TestAppContext) { #[gpui::test] async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { init_test(cx, |language_settings| { - language_settings.defaults.completions = Some(CompletionSettings { - words: WordsCompletionMode::Fallback, - lsp: false, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::Insert, + language_settings.defaults.completions = Some(CompletionSettingsContent { + words_min_length: Some(0), + lsp: Some(false), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() }); }); @@ -13127,7 +14896,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["let"], "With no digits in the completion query, no digits should be in the word completions" ); @@ -13152,7 +14921,7 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { - assert_eq!(completion_menu_entries(&menu), &["33", "35f32"], "The digit is in the completion query, \ + assert_eq!(completion_menu_entries(menu), &["33", "35f32"], "The digit is in the completion query, \ return matching words with digits (`33`, `35f32`) but exclude query duplicates (`3`)"); } else { panic!("expected completion menu to be open"); @@ -13160,6 +14929,118 @@ async fn test_word_completions_usually_skip_digits(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_word_completions_do_not_show_before_threshold(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(3), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion threshold is not met" + ); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["wowser", "wowen", "wow"], "Even though the threshold is not met, invoking word completions with an action should provide the completions"); + } else { + panic!("expected completion menu to be open after the word completions are called with an action"); + } + + editor.cancel(&Cancel, window, cx); + }); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!("expected completion menu to be hidden after canceling"); + } + }); + + cx.simulate_keystroke("o"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion threshold is not met still" + ); + } + }); + + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + assert_eq!(completion_menu_entries(menu), &["wowen", "wowser"], "After word completion threshold is met, matching words should be shown, excluding the already typed word"); + } else { + panic!("expected completion menu to be open after the word completions threshold is met"); + } + }); +} + +#[gpui::test] +async fn test_word_completions_disabled(cx: &mut TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.completions = Some(CompletionSettingsContent { + words: Some(WordsCompletionMode::Enabled), + words_min_length: Some(0), + lsp_insert_mode: Some(LspInsertMode::Insert), + ..Default::default() + }); + }); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + cx.update_editor(|editor, _, _| { + editor.disable_word_completions(); + }); + cx.set_state(indoc! {"ˇ + wow + wowen + wowser + "}); + cx.simulate_keystroke("w"); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden, as words completion are disabled for this editor" + ); + } + }); + + cx.update_editor(|editor, window, cx| { + editor.show_word_completions(&ShowWordCompletions, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, _, _| { + if editor.context_menu.borrow_mut().is_some() { + panic!( + "expected completion menu to be hidden even if called for explicitly, as words completion are disabled for this editor" + ); + } + }); +} + fn gen_text_edit(params: &CompletionParams, text: &str) -> Option { let position = || lsp::Position { line: params.text_document_position.position.line, @@ -13226,12 +15107,7 @@ async fn test_multiline_completion(cx: &mut TestAppContext) { } else { item.label.clone() }; - let len = text.len(); - Some(language::CodeLabel { - text, - runs: Vec::new(), - filter_range: 0..len, - }) + Some(language::CodeLabel::plain(text, None)) })), ..FakeLspAdapter::default() }, @@ -13253,7 +15129,7 @@ async fn test_multiline_completion(cx: &mut TestAppContext) { .unwrap(); let editor = workspace .update(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "main.ts"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("main.ts")), None, true, window, cx) }) .unwrap() .await @@ -13389,7 +15265,7 @@ async fn test_completion_page_up_down_keys(cx: &mut TestAppContext) { cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { - assert_eq!(completion_menu_entries(&menu), &["first", "last"]); + assert_eq!(completion_menu_entries(menu), &["first", "last"]); } else { panic!("expected completion menu to be open"); } @@ -14165,7 +16041,7 @@ async fn test_toggle_block_comment(cx: &mut TestAppContext) { )); cx.language_registry().add(html_language.clone()); - cx.language_registry().add(javascript_language.clone()); + cx.language_registry().add(javascript_language); cx.update_buffer(|buffer, cx| { buffer.set_language(Some(html_language), cx); }); @@ -14297,7 +16173,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.handle_input("X", window, cx); assert_eq!(editor.text(cx), "Xaaaa\nXbbbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), @@ -14311,7 +16187,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "Xa\nbbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(1, 0)..Point::new(1, 0)] ); @@ -14321,7 +16197,7 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { editor.backspace(&Default::default(), window, cx); assert_eq!(editor.text(cx), "X\nbb"); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 1)..Point::new(0, 1)] ); }); @@ -14342,7 +16218,7 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { ); let excerpt_ranges = markers.into_iter().map(|marker| { let context = excerpt_ranges.remove(&marker).unwrap()[0].clone(); - ExcerptRange::new(context.clone()) + ExcerptRange::new(context) }); let buffer = cx.new(|cx| Buffer::local(initial_text, cx)); let multibuffer = cx.new(|cx| { @@ -14379,7 +16255,10 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { false, ); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges(cx), expected_selections); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + expected_selections + ); editor.newline(&Newline, window, cx); let (expected_text, expected_selections) = marked_text_ranges( @@ -14396,7 +16275,10 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) { false, ); assert_eq!(editor.text(cx), expected_text); - assert_eq!(editor.selections.ranges(cx), expected_selections); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + expected_selections + ); }); } @@ -14437,7 +16319,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(1, 3)..Point::new(1, 3), Point::new(2, 1)..Point::new(2, 1), @@ -14450,7 +16332,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(1, 3)..Point::new(1, 3), Point::new(2, 1)..Point::new(2, 1), @@ -14464,7 +16346,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { // Removing an excerpt causes the first selection to become degenerate. assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 0)..Point::new(0, 0), Point::new(0, 1)..Point::new(0, 1) @@ -14475,7 +16357,7 @@ fn test_refresh_selections(cx: &mut TestAppContext) { // location. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [ Point::new(0, 1)..Point::new(0, 1), Point::new(0, 3)..Point::new(0, 3) @@ -14519,7 +16401,7 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) { cx, ); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(1, 3)..Point::new(1, 3)] ); editor @@ -14530,14 +16412,14 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) { }); _ = editor.update(cx, |editor, window, cx| { assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 0)..Point::new(0, 0)] ); // Ensure we don't panic when selections are refreshed and that the pending selection is finalized. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| s.refresh()); assert_eq!( - editor.selections.ranges(cx), + editor.selections.ranges(&editor.display_snapshot(cx)), [Point::new(0, 3)..Point::new(0, 3)] ); assert!(editor.selections.pending_anchor().is_some()); @@ -14627,7 +16509,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { let editor = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); - build_editor(buffer.clone(), window, cx) + build_editor(buffer, window, cx) }); _ = editor.update(cx, |editor, window, cx| { @@ -14661,37 +16543,34 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { ); let snapshot = editor.snapshot(window, cx); - let mut highlighted_ranges = editor.background_highlights_in_range( + let highlighted_ranges = editor.sorted_background_highlights_in_range( anchor_range(Point::new(3, 4)..Point::new(7, 4)), &snapshot, cx.theme(), ); - // Enforce a consistent ordering based on color without relying on the ordering of the - // highlight's `TypeId` which is non-executor. - highlighted_ranges.sort_unstable_by_key(|(_, color)| *color); assert_eq!( highlighted_ranges, &[ ( - DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4), - Hsla::red(), + DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5), + Hsla::green(), ), ( - DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), + DisplayPoint::new(DisplayRow(4), 2)..DisplayPoint::new(DisplayRow(4), 4), Hsla::red(), ), ( - DisplayPoint::new(DisplayRow(3), 2)..DisplayPoint::new(DisplayRow(3), 5), + DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6), Hsla::green(), ), ( - DisplayPoint::new(DisplayRow(5), 3)..DisplayPoint::new(DisplayRow(5), 6), - Hsla::green(), + DisplayPoint::new(DisplayRow(6), 3)..DisplayPoint::new(DisplayRow(6), 5), + Hsla::red(), ), ] ); assert_eq!( - editor.background_highlights_in_range( + editor.sorted_background_highlights_in_range( anchor_range(Point::new(5, 6)..Point::new(6, 4)), &snapshot, cx.theme(), @@ -14712,7 +16591,7 @@ async fn test_following(cx: &mut TestAppContext) { let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; let buffer = project.update(cx, |project, cx| { - let buffer = project.create_local_buffer(&sample_text(16, 8, 'a'), None, cx); + let buffer = project.create_local_buffer(&sample_text(16, 8, 'a'), None, false, cx); cx.new(|cx| MultiBuffer::singleton(buffer, cx)) }); let leader = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx)); @@ -14790,7 +16669,10 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![1..1]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![1..1] + ); }); assert!(*is_still_following.borrow()); assert_eq!(*follower_edit_event_count.borrow(), 0); @@ -14843,7 +16725,10 @@ async fn test_following(cx: &mut TestAppContext) { .unwrap(); _ = follower.update(cx, |follower, _, cx| { assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); - assert_eq!(follower.selections.ranges(cx), vec![0..0]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![0..0] + ); }); assert!(*is_still_following.borrow()); @@ -14867,7 +16752,10 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![0..0, 1..1]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![0..0, 1..1] + ); }); assert!(*is_still_following.borrow()); @@ -14888,7 +16776,10 @@ async fn test_following(cx: &mut TestAppContext) { .await .unwrap(); _ = follower.update(cx, |follower, _, cx| { - assert_eq!(follower.selections.ranges(cx), vec![0..2]); + assert_eq!( + follower.selections.ranges(&follower.display_snapshot(cx)), + vec![0..2] + ); }); // Scrolling locally breaks the follow @@ -14964,8 +16855,8 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { let (buffer_1, buffer_2) = project.update(cx, |project, cx| { ( - project.create_local_buffer("abc\ndef\nghi\njkl\n", None, cx), - project.create_local_buffer("mno\npqr\nstu\nvwx\n", None, cx), + project.create_local_buffer("abc\ndef\nghi\njkl\n", None, false, cx), + project.create_local_buffer("mno\npqr\nstu\nvwx\n", None, false, cx), ) }); @@ -14973,7 +16864,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { leader.update(cx, |leader, cx| { leader.buffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( - PathKey::namespaced(1, Arc::from(Path::new("b.txt"))), + PathKey::with_sort_prefix(1, rel_path("b.txt").into_arc()), buffer_1.clone(), vec![ Point::row_range(0..3), @@ -14984,7 +16875,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { cx, ); multibuffer.set_excerpts_for_path( - PathKey::namespaced(1, Arc::from(Path::new("a.txt"))), + PathKey::with_sort_prefix(1, rel_path("a.txt").into_arc()), buffer_2.clone(), vec![Point::row_range(0..6), Point::row_range(8..12)], 0, @@ -15095,7 +16986,7 @@ async fn go_to_prev_overlapping_diagnostic(executor: BackgroundExecutor, cx: &mu .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { @@ -15476,7 +17367,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { .unwrap(); let editor_handle = workspace .update(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "main.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) }) .unwrap() .await @@ -15491,7 +17382,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { |params, _| async move { assert_eq!( params.text_document_position.text_document.uri, - lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(), + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), ); assert_eq!( params.text_document_position.position, @@ -15549,8 +17440,7 @@ async fn test_on_type_formatting_is_applied_after_autoindent(cx: &mut TestAppCon cx.simulate_keystroke("\n"); cx.run_until_parked(); - let buffer_cloned = - cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap().clone()); + let buffer_cloned = cx.multibuffer(|multi_buffer, _| multi_buffer.as_singleton().unwrap()); let mut request = cx.set_request_handler::(move |_, _, mut cx| { let buffer_cloned = buffer_cloned.clone(); @@ -15637,7 +17527,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon let _fake_server = fake_servers.next().await.unwrap(); update_test_language_settings(cx, |language_settings| { language_settings.languages.0.insert( - language_name.clone(), + language_name.clone().0, LanguageSettingsContent { tab_size: NonZeroU32::new(8), ..Default::default() @@ -15661,6 +17551,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon "some other init value": false })), enable_lsp_tasks: false, + fetch: None, }, ); }); @@ -15681,6 +17572,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon "anotherInitValue": false })), enable_lsp_tasks: false, + fetch: None, }, ); }); @@ -15701,6 +17593,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon "anotherInitValue": false })), enable_lsp_tasks: false, + fetch: None, }, ); }); @@ -15719,6 +17612,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon settings: None, initialization_options: None, enable_lsp_tasks: false, + fetch: None, }, ); }); @@ -16017,7 +17911,7 @@ async fn test_context_menus_hide_hover_popover(cx: &mut gpui::TestAppContext) { edit: Some(lsp::WorkspaceEdit { changes: Some( [( - lsp::Url::from_file_path(path!("/file.rs")).unwrap(), + lsp::Uri::from_file_path(path!("/file.rs")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(5, 4), @@ -16492,7 +18386,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestA if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { assert_eq!( - completion_menu_entries(&menu), + completion_menu_entries(menu), &["bg-blue", "bg-red", "bg-yellow"] ); } else { @@ -16505,7 +18399,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestA cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { - assert_eq!(completion_menu_entries(&menu), &["bg-blue", "bg-yellow"]); + assert_eq!(completion_menu_entries(menu), &["bg-blue", "bg-yellow"]); } else { panic!("expected completion menu to be open"); } @@ -16519,7 +18413,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut TestA cx.update_editor(|editor, _, _| { if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() { - assert_eq!(completion_menu_entries(&menu), &["bg-yellow"]); + assert_eq!(completion_menu_entries(menu), &["bg-yellow"]); } else { panic!("expected completion menu to be open"); } @@ -16534,9 +18428,7 @@ fn completion_menu_entries(menu: &CompletionsMenu) -> Vec { #[gpui::test] async fn test_document_format_with_prettier(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::List(FormatterList::Single( - Formatter::Prettier, - ))) + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) }); let fs = FakeFs::new(cx.executor()); @@ -16557,10 +18449,7 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ))); update_test_language_settings(cx, |settings| { - settings.defaults.prettier = Some(PrettierSettings { - allowed: true, - ..PrettierSettings::default() - }); + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); }); let test_plugin = "test_plugin"; @@ -16606,7 +18495,7 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { ); update_test_language_settings(cx, |settings| { - settings.defaults.formatter = Some(SelectedFormatter::Auto) + settings.defaults.formatter = Some(FormatterList::default()) }); let format = editor.update_in(cx, |editor, window, cx| { editor.perform_format( @@ -17088,7 +18977,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) { (buffer_2.clone(), base_text_2), (buffer_3.clone(), base_text_3), ] { - let diff = cx.new(|cx| BufferDiff::new_with_base_text(&diff_base, &buffer, cx)); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(diff_base, &buffer, cx)); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); @@ -17256,8 +19145,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { let active_item = workspace .active_item(cx) .expect("should have an active item after adding the multi buffer"); - assert!( - !active_item.is_singleton(cx), + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Multibuffer, "A multi buffer was expected to active after adding" ); active_item.item_id() @@ -17285,8 +19175,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { first_item_id, multibuffer_item_id, "Should navigate into the 1st buffer and activate it" ); - assert!( - active_item.is_singleton(cx), + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( @@ -17316,7 +19207,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { multibuffer_item_id, "Should navigate back to the multi buffer" ); - assert!(!active_item.is_singleton(cx)); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }) .unwrap(); @@ -17344,8 +19235,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { second_item_id, first_item_id, "Should navigate into the 2nd buffer and activate it" ); - assert!( - active_item.is_singleton(cx), + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( @@ -17375,7 +19267,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { multibuffer_item_id, "Should navigate back from the 2nd buffer to the multi buffer" ); - assert!(!active_item.is_singleton(cx)); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }) .unwrap(); @@ -17401,8 +19293,9 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { ); assert_ne!(third_item_id, first_item_id); assert_ne!(third_item_id, second_item_id); - assert!( - active_item.is_singleton(cx), + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, "New active item should be a singleton buffer" ); assert_eq!( @@ -17430,7 +19323,7 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { multibuffer_item_id, "Should navigate back from the 3rd buffer to the multi buffer" ); - assert!(!active_item.is_singleton(cx)); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); }) .unwrap(); } @@ -17709,7 +19602,7 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut TestAppContext) { (buffer_2.clone(), file_2_old), (buffer_3.clone(), file_3_old), ] { - let diff = cx.new(|cx| BufferDiff::new_with_base_text(&diff_base, &buffer, cx)); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(diff_base, &buffer, cx)); editor .buffer .update(cx, |buffer, cx| buffer.add_diff(diff, cx)); @@ -17831,7 +19724,7 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut TestAppContext) { cx.executor().run_until_parked(); // When the start of a hunk coincides with the start of its excerpt, - // the hunk is expanded. When the start of a a hunk is earlier than + // the hunk is expanded. When the start of a hunk is earlier than // the start of its excerpt, the hunk is not expanded. cx.assert_state_with_diff( " @@ -18548,7 +20441,8 @@ fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) - enabled: true, line_width: 1, active_line_width: 1, - ..Default::default() + coloring: IndentGuideColoring::default(), + background_coloring: IndentGuideBackgroundColoring::default(), }, } } @@ -19192,7 +21086,7 @@ async fn test_indent_guide_with_expanded_diff_hunks(cx: &mut TestAppContext) { let mut actual_guides = cx.update_editor(|editor, window, cx| { editor .snapshot(window, cx) - .buffer_snapshot + .buffer_snapshot() .indent_guides_in_range(Anchor::min()..Anchor::max(), false, cx) .map(|guide| (guide.start_row..=guide.end_row, guide.depth)) .collect::>() @@ -19248,13 +21142,13 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor - .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot) + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range.clone())) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); @@ -19339,13 +21233,13 @@ async fn test_adjacent_diff_hunks(executor: BackgroundExecutor, cx: &mut TestApp let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor - .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot) + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range.clone())) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 2); @@ -19405,13 +21299,13 @@ async fn test_toggle_deletion_hunk_at_start_of_file( let hunk_ranges = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor - .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot) + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; let buffer_id = hunks[0].buffer_id; hunks .into_iter() - .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range.clone())) + .map(|hunk| Anchor::range_in_buffer(excerpt_id, buffer_id, hunk.buffer_range)) .collect::>() }); assert_eq!(hunk_ranges.len(), 1); @@ -19434,7 +21328,7 @@ async fn test_toggle_deletion_hunk_at_start_of_file( }); executor.run_until_parked(); - cx.assert_state_with_diff(hunk_expanded.clone()); + cx.assert_state_with_diff(hunk_expanded); } #[gpui::test] @@ -19456,9 +21350,9 @@ async fn test_display_diff_hunks(cx: &mut TestAppContext) { fs.set_head_for_repo( path!("/test/.git").as_ref(), &[ - ("file-1".into(), "one\n".into()), - ("file-2".into(), "two\n".into()), - ("file-3".into(), "three\n".into()), + ("file-1", "one\n".into()), + ("file-2", "two\n".into()), + ("file-3", "three\n".into()), ], "deadbeef", ); @@ -19482,10 +21376,10 @@ async fn test_display_diff_hunks(cx: &mut TestAppContext) { for buffer in &buffers { let snapshot = buffer.read(cx).snapshot(); multibuffer.set_excerpts_for_path( - PathKey::namespaced(0, buffer.read(cx).file().unwrap().path().clone()), + PathKey::with_sort_prefix(0, buffer.read(cx).file().unwrap().path().clone()), buffer.clone(), vec![text::Anchor::MIN.to_point(&snapshot)..text::Anchor::MAX.to_point(&snapshot)], - DEFAULT_MULTIBUFFER_CONTEXT, + 2, cx, ); } @@ -19570,7 +21464,7 @@ async fn test_partially_staged_hunk(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let hunks = editor - .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot) + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) .collect::>(); assert_eq!(hunks.len(), 1); assert_eq!( @@ -19969,48 +21863,201 @@ async fn test_goto_definition_no_fallback(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { +async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; - let language = Arc::new(Language::new( - LanguageConfig::default(), - Some(tree_sitter_rust::LANGUAGE.into()), - )); - - let text = r#" - #[cfg(test)] - mod tests() { - #[test] - fn runnable_1() { - let a = 1; - } - - #[test] - fn runnable_2() { - let a = 1; - let b = 2; - } + cx.set_state( + &r#" + fn one() { + let mut a = two(); } - "# - .unindent(); - let fs = FakeFs::new(cx.executor()); - fs.insert_file("/file.rs", Default::default()).await; + fn ˇtwo() {}"# + .unindent(), + ); + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(4, 4), lsp::Position::new(4, 7)), + }, + ])) + }); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences, window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = two(); + } - let project = Project::test(fs, ["/a".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); - let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + fn ˇtwo() {}"# + .unindent(), + ); - let editor = cx.new_window_entity(|window, cx| { - Editor::new( - EditorMode::full(), - multi_buffer, - Some(project.clone()), - window, - cx, - ) + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!(editors.len(), 2, "We should have opened a new multibuffer"); + }); + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(), + ); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences, window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + + fn two() {}"# + .unindent(), + ); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 2, + "should have re-used the previous multibuffer" + ); + }); + + cx.set_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + fn three() {} + fn two() {}"# + .unindent(), + ); + cx.lsp + .set_request_handler::(move |params, _| async move { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(0, 16), lsp::Position::new(0, 19)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(5, 4), lsp::Position::new(5, 7)), + }, + ])) + }); + let navigated = cx + .update_editor(|editor, window, cx| { + editor.find_all_references(&FindAllReferences, window, cx) + }) + .unwrap() + .await + .expect("Failed to navigate to references"); + assert_eq!( + navigated, + Navigated::Yes, + "Should have navigated to references from the FindAllReferences response" + ); + cx.assert_editor_state( + &r#"fn one() { + let mut a = ˇtwo(); + } + fn three() {} + fn two() {}"# + .unindent(), + ); + let editors = cx.update_workspace(|workspace, _, cx| { + workspace.items_of_type::(cx).collect::>() + }); + cx.update_editor(|_, _, _| { + assert_eq!( + editors.len(), + 3, + "should have used a new multibuffer as offsets changed" + ); + }); +} +#[gpui::test] +async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + #[cfg(test)] + mod tests() { + #[test] + fn runnable_1() { + let a = 1; + } + + #[test] + fn runnable_2() { + let a = 1; + let b = 2; + } + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let editor = cx.new_window_entity(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) }); editor.update_in(cx, |editor, window, cx| { @@ -20082,19 +22129,19 @@ async fn test_folding_buffers(cx: &mut TestAppContext) { let buffer_1 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "first.rs"), cx) + project.open_buffer((worktree_id, rel_path("first.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "second.rs"), cx) + project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "third.rs"), cx) + project.open_buffer((worktree_id, rel_path("third.rs")), cx) }) .await .unwrap(); @@ -20250,19 +22297,19 @@ async fn test_folding_buffers_with_one_excerpt(cx: &mut TestAppContext) { let buffer_1 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "first.rs"), cx) + project.open_buffer((worktree_id, rel_path("first.rs")), cx) }) .await .unwrap(); let buffer_2 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "second.rs"), cx) + project.open_buffer((worktree_id, rel_path("second.rs")), cx) }) .await .unwrap(); let buffer_3 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "third.rs"), cx) + project.open_buffer((worktree_id, rel_path("third.rs")), cx) }) .await .unwrap(); @@ -20385,7 +22432,7 @@ async fn test_folding_buffer_when_multibuffer_has_only_one_excerpt(cx: &mut Test let buffer_1 = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -20814,7 +22861,7 @@ async fn assert_highlighted_edits( cx.update(|_window, cx| { let highlighted_edits = edit_prediction_edit_text( - &snapshot.as_singleton().unwrap().2, + snapshot.as_singleton().unwrap().2, &edits, &edit_preview, include_deletions, @@ -20830,13 +22877,13 @@ fn assert_breakpoint( path: &Arc, expected: Vec<(u32, Breakpoint)>, ) { - if expected.len() == 0usize { + if expected.is_empty() { assert!(!breakpoints.contains_key(path), "{}", path.display()); } else { let mut breakpoint = breakpoints .get(path) .unwrap() - .into_iter() + .iter() .map(|breakpoint| { ( breakpoint.row, @@ -20865,23 +22912,17 @@ fn add_log_breakpoint_at_cursor( let (anchor, bp) = editor .breakpoints_at_cursors(window, cx) .first() - .and_then(|(anchor, bp)| { - if let Some(bp) = bp { - Some((*anchor, bp.clone())) - } else { - None - } - }) + .and_then(|(anchor, bp)| bp.as_ref().map(|bp| (*anchor, bp.clone()))) .unwrap_or_else(|| { - let cursor_position: Point = editor.selections.newest(cx).head(); + let snapshot = editor.snapshot(window, cx); + let cursor_position: Point = + editor.selections.newest(&snapshot.display_snapshot).head(); - let breakpoint_position = editor - .snapshot(window, cx) - .display_snapshot - .buffer_snapshot + let breakpoint_position = snapshot + .buffer_snapshot() .anchor_before(Point::new(cursor_position.row, 0)); - (breakpoint_position, Breakpoint::new_log(&log_message)) + (breakpoint_position, Breakpoint::new_log(log_message)) }); editor.edit_breakpoint_at_anchor( @@ -20930,7 +22971,7 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -20949,7 +22990,7 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) - .map(|path_buf| Arc::from(path_buf.to_owned())) + .map(Arc::from) .unwrap() }); @@ -20967,7 +23008,6 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_eq!(1, breakpoints.len()); @@ -20992,7 +23032,6 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_eq!(1, breakpoints.len()); @@ -21014,7 +23053,6 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_eq!(0, breakpoints.len()); @@ -21047,7 +23085,7 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -21066,7 +23104,7 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) - .map(|path_buf| Arc::from(path_buf.to_owned())) + .map(Arc::from) .unwrap() }); @@ -21081,7 +23119,6 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_breakpoint( @@ -21102,7 +23139,6 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_breakpoint(&breakpoints, &abs_path, vec![]); @@ -21122,7 +23158,6 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_breakpoint( @@ -21145,7 +23180,6 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_breakpoint( @@ -21168,7 +23202,6 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_breakpoint( @@ -21222,7 +23255,7 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { let buffer = project .update(cx, |project, cx| { - project.open_buffer((worktree_id, "main.rs"), cx) + project.open_buffer((worktree_id, rel_path("main.rs")), cx) }) .await .unwrap(); @@ -21241,7 +23274,7 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { let abs_path = project.read_with(cx, |project, cx| { project .absolute_path(&project_path, cx) - .map(|path_buf| Arc::from(path_buf.to_owned())) + .map(Arc::from) .unwrap() }); @@ -21261,7 +23294,6 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_eq!(1, breakpoints.len()); @@ -21293,7 +23325,6 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); let disable_breakpoint = { @@ -21329,7 +23360,6 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { .unwrap() .read(cx) .all_source_breakpoints(cx) - .clone() }); assert_eq!(1, breakpoints.len()); @@ -21702,7 +23732,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex edit: lsp::WorkspaceEdit { changes: Some( [( - lsp::Url::from_file_path(path!("/dir/a.ts")).unwrap(), + lsp::Uri::from_file_path(path!("/dir/a.ts")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 0), @@ -21813,7 +23843,7 @@ println!("5"); let editor_1 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane_1.downgrade()), true, window, @@ -21835,7 +23865,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -21856,7 +23886,7 @@ println!("5"); let editor_2 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane_2.downgrade()), true, window, @@ -21878,7 +23908,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -21895,7 +23925,7 @@ println!("5"); let _other_editor_1 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "lib.rs"), + (worktree_id, rel_path("lib.rs")), Some(pane_1.downgrade()), true, window, @@ -21931,7 +23961,7 @@ println!("5"); let _other_editor_2 = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "lib.rs"), + (worktree_id, rel_path("lib.rs")), Some(pane_2.downgrade()), true, window, @@ -21968,7 +23998,7 @@ println!("5"); let _editor_1_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane_1.downgrade()), true, window, @@ -21982,7 +24012,7 @@ println!("5"); let _editor_2_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane_2.downgrade()), true, window, @@ -22004,7 +24034,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -22030,7 +24060,7 @@ println!("5"); assert_eq!( editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.range()) .collect::>(), @@ -22076,7 +24106,7 @@ println!("5"); let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, @@ -22102,8 +24132,8 @@ println!("5"); }); cx.update_global(|store: &mut SettingsStore, cx| { - store.update_user_settings::(cx, |s| { - s.restore_on_file_reopen = Some(false); + store.update_user_settings(cx, |s| { + s.workspace.restore_on_file_reopen = Some(false); }); }); editor.update_in(cx, |editor, window, cx| { @@ -22127,15 +24157,15 @@ println!("5"); assert!(pane.active_item().is_none()); }); cx.update_global(|store: &mut SettingsStore, cx| { - store.update_user_settings::(cx, |s| { - s.restore_on_file_reopen = Some(true); + store.update_user_settings(cx, |s| { + s.workspace.restore_on_file_reopen = Some(true); }); }); let _editor_reopened = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, @@ -22216,6 +24246,28 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { }); } +fn set_linked_edit_ranges( + opening: (Point, Point), + closing: (Point, Point), + editor: &mut Editor, + cx: &mut Context, +) { + let Some((buffer, _)) = editor + .buffer + .read(cx) + .text_anchor_for_position(editor.selections.newest_anchor().start, cx) + else { + panic!("Failed to get buffer for selection position"); + }; + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + let opening_range = buffer.anchor_before(opening.0)..buffer.anchor_after(opening.1); + let closing_range = buffer.anchor_before(closing.0)..buffer.anchor_after(closing.1); + let mut linked_ranges = HashMap::default(); + linked_ranges.insert(buffer_id, vec![(opening_range, vec![closing_range])]); + editor.linked_edit_ranges = LinkedEditingRanges(linked_ranges); +} + #[gpui::test] async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -22280,7 +24332,7 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { .unwrap(); let editor = workspace .update(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "file.html"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("file.html")), None, true, window, cx) }) .unwrap() .await @@ -22294,25 +24346,12 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(0, 3)..Point::new(0, 3)]); }); - let Some((buffer, _)) = editor - .buffer - .read(cx) - .text_anchor_for_position(editor.selections.newest_anchor().start, cx) - else { - panic!("Failed to get buffer for selection position"); - }; - let buffer = buffer.read(cx); - let buffer_id = buffer.remote_id(); - let opening_range = - buffer.anchor_before(Point::new(0, 1))..buffer.anchor_after(Point::new(0, 3)); - let closing_range = - buffer.anchor_before(Point::new(0, 6))..buffer.anchor_after(Point::new(0, 8)); - let mut linked_ranges = HashMap::default(); - linked_ranges.insert( - buffer_id, - vec![(opening_range.clone(), vec![closing_range.clone()])], - ); - editor.linked_edit_ranges = LinkedEditingRanges(linked_ranges); + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 3)), + (Point::new(0, 6), Point::new(0, 8)), + editor, + cx, + ); }); let mut completion_handle = fake_server.set_request_handler::(move |_, _| async move { @@ -22356,6 +24395,77 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_linked_edits_on_typing_punctuation(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..LanguageMatcher::default() + }, + brackets: BracketPairConfig { + pairs: vec![BracketPair { + start: "<".into(), + end: ">".into(), + close: true, + ..Default::default() + }], + ..Default::default() + }, + linked_edit_characters: HashSet::from_iter(['.']), + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Test typing > does not extend linked pair + cx.set_state("
"); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 4)), + (Point::new(0, 11), Point::new(0, 14)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(">", window, cx); + }); + cx.assert_editor_state("
ˇ
"); + + // Test typing . do extend linked pair + cx.set_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 9)), + (Point::new(0, 12), Point::new(0, 20)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(".", window, cx); + }); + cx.assert_editor_state(""); + cx.update_editor(|editor, _, cx| { + set_linked_edit_ranges( + (Point::new(0, 1), Point::new(0, 10)), + (Point::new(0, 13), Point::new(0, 21)), + editor, + cx, + ); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("V", window, cx); + }); + cx.assert_editor_state(""); +} + #[gpui::test] async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -22416,7 +24526,7 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { let main_editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_path( - (worktree_id, "main.rs"), + (worktree_id, rel_path("main.rs")), Some(pane.downgrade()), true, window, @@ -22476,7 +24586,7 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { .await .unwrap(); pane.update_in(cx, |pane, window, cx| { - pane.navigate_backward(window, cx); + pane.navigate_backward(&Default::default(), window, cx); }); cx.run_until_parked(); pane.update(cx, |pane, cx| { @@ -23331,792 +25441,1664 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { editor.newline(&Newline, window, cx); }); cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - echo \"test\"; - ˇ - "}); + cx.assert_editor_state(indoc! {" + echo \"test\"; + ˇ + "}); +} + +fn empty_range(row: usize, column: usize) -> Range { + let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); + point..point +} + +#[track_caller] +fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context) { + let (text, ranges) = marked_text_ranges(marked_text, true); + assert_eq!(editor.text(cx), text); + assert_eq!( + editor.selections.ranges(&editor.display_snapshot(cx)), + ranges, + "Assert selections are {}", + marked_text + ); +} + +pub fn handle_signature_help_request( + cx: &mut EditorLspTestContext, + mocked_response: lsp::SignatureHelp, +) -> impl Future + use<> { + let mut request = + cx.set_request_handler::(move |_, _, _| { + let mocked_response = mocked_response.clone(); + async move { Ok(Some(mocked_response)) } + }); + + async move { + request.next().await; + } +} + +#[track_caller] +pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) { + cx.update_editor(|editor, _, _| { + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() { + let entries = menu.entries.borrow(); + let entries = entries + .iter() + .map(|entry| entry.string.as_str()) + .collect::>(); + assert_eq!(entries, expected); + } else { + panic!("Expected completions menu"); + } + }); +} + +/// Handle completion request passing a marked string specifying where the completion +/// should be triggered from using '|' character, what range should be replaced, and what completions +/// should be returned using '<' and '>' to delimit the range. +/// +/// Also see `handle_completion_request_with_insert_and_replace`. +#[track_caller] +pub fn handle_completion_request( + marked_string: &str, + completions: Vec<&'static str>, + is_incomplete: bool, + counter: Arc, + cx: &mut EditorLspTestContext, +) -> impl Future { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![complete_from_marker.clone(), replace_range_marker.clone()], + ); + + let complete_from_position = + cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let replace_range = + cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + + let mut request = + cx.set_request_handler::(move |url, params, _| { + let completions = completions.clone(); + counter.fetch_add(1, atomic::Ordering::Release); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, + complete_from_position + ); + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete, + item_defaults: None, + items: completions + .iter() + .map(|completion_text| lsp::CompletionItem { + label: completion_text.to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace_range, + new_text: completion_text.to_string(), + })), + ..Default::default() + }) + .collect(), + }))) + } + }); + + async move { + request.next().await; + } +} + +/// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be +/// given instead, which also contains an `insert` range. +/// +/// This function uses markers to define ranges: +/// - `|` marks the cursor position +/// - `<>` marks the replace range +/// - `[]` marks the insert range (optional, defaults to `replace_range.start..cursor_pos`which is what Rust-Analyzer provides) +pub fn handle_completion_request_with_insert_and_replace( + cx: &mut EditorLspTestContext, + marked_string: &str, + completions: Vec<(&'static str, &'static str)>, // (label, new_text) + counter: Arc, +) -> impl Future { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let insert_range_marker: TextRangeMarker = ('{', '}').into(); + + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![ + complete_from_marker.clone(), + replace_range_marker.clone(), + insert_range_marker.clone(), + ], + ); + + let complete_from_position = + cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let replace_range = + cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + + let insert_range = match marked_ranges.remove(&insert_range_marker) { + Some(ranges) if !ranges.is_empty() => cx.to_lsp_range(ranges[0].clone()), + _ => lsp::Range { + start: replace_range.start, + end: complete_from_position, + }, + }; + + let mut request = + cx.set_request_handler::(move |url, params, _| { + let completions = completions.clone(); + counter.fetch_add(1, atomic::Ordering::Release); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, complete_from_position, + "marker `|` position doesn't match", + ); + Ok(Some(lsp::CompletionResponse::Array( + completions + .iter() + .map(|(label, new_text)| lsp::CompletionItem { + label: label.to_string(), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + insert: insert_range, + replace: replace_range, + new_text: new_text.to_string(), + }, + )), + ..Default::default() + }) + .collect(), + ))) + } + }); + + async move { + request.next().await; + } +} + +fn handle_resolve_completion_request( + cx: &mut EditorLspTestContext, + edits: Option>, +) -> impl Future { + let edits = edits.map(|edits| { + edits + .iter() + .map(|(marked_string, new_text)| { + let (_, marked_ranges) = marked_text_ranges(marked_string, false); + let replace_range = cx.to_lsp_range(marked_ranges[0].clone()); + lsp::TextEdit::new(replace_range, new_text.to_string()) + }) + .collect::>() + }); + + let mut request = + cx.set_request_handler::(move |_, _, _| { + let edits = edits.clone(); + async move { + Ok(lsp::CompletionItem { + additional_text_edits: edits, + ..Default::default() + }) + } + }); + + async move { + request.next().await; + } +} + +pub(crate) fn update_test_language_settings( + cx: &mut TestAppContext, + f: impl Fn(&mut AllLanguageSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); + }); + }); +} + +pub(crate) fn update_test_project_settings( + cx: &mut TestAppContext, + f: impl Fn(&mut ProjectSettingsContent), +) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| f(&mut settings.project)); + }); + }); +} + +pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + crate::init(cx); + }); + zlog::init_test(); + update_test_language_settings(cx, f); +} + +#[track_caller] +fn assert_hunk_revert( + not_reverted_text_with_selections: &str, + expected_hunk_statuses_before: Vec, + expected_reverted_text_with_selections: &str, + base_text: &str, + cx: &mut EditorLspTestContext, +) { + cx.set_state(not_reverted_text_with_selections); + cx.set_head_text(base_text); + cx.executor().run_until_parked(); + + let actual_hunk_statuses_before = cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let reverted_hunk_statuses = snapshot + .buffer_snapshot() + .diff_hunks_in_range(0..snapshot.buffer_snapshot().len()) + .map(|hunk| hunk.status().kind) + .collect::>(); + + editor.git_restore(&Default::default(), window, cx); + reverted_hunk_statuses + }); + cx.executor().run_until_parked(); + cx.assert_editor_state(expected_reverted_text_with_selections); + assert_eq!(actual_hunk_statuses_before, expected_hunk_statuses_before); +} + +#[gpui::test(iterations = 10)] +async fn test_pulling_diagnostics(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let diagnostic_requests = Arc::new(AtomicUsize::new(0)); + let counter = diagnostic_requests.clone(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "first.rs": "fn main() { let a = 5; }", + "second.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( + lsp::DiagnosticOptions { + identifier: None, + inter_file_dependencies: true, + workspace_diagnostics: true, + work_done_progress_options: Default::default(), + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/first.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + let server_id = fake_server.server.server_id(); + let mut first_request = fake_server + .set_request_handler::(move |params, _| { + let new_result_id = counter.fetch_add(1, atomic::Ordering::Release) + 1; + let result_id = Some(new_result_id.to_string()); + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/first.rs")).unwrap() + ); + async move { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + items: Vec::new(), + result_id, + }, + }), + )) + } + }); + + let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { + project.update(cx, |project, cx| { + let buffer_id = editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("created a singleton buffer") + .read(cx) + .remote_id(); + let buffer_result_id = project + .lsp_store() + .read(cx) + .result_id(server_id, buffer_id, cx); + assert_eq!(expected, buffer_result_id); + }); + }; + + ensure_result_id(None, cx); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 1, + "Opening file should trigger diagnostic request" + ); + first_request + .next() + .await + .expect("should have sent the first diagnostics pull request"); + ensure_result_id(Some("1".to_string()), cx); + + // Editing should trigger diagnostics + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("2", window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 2, + "Editing should trigger diagnostic request" + ); + ensure_result_id(Some("2".to_string()), cx); + + // Moving cursor should not trigger diagnostic request + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + }); + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + assert_eq!( + diagnostic_requests.load(atomic::Ordering::Acquire), + 2, + "Cursor movement should not trigger diagnostic request" + ); + ensure_result_id(Some("2".to_string()), cx); + // Multiple rapid edits should be debounced + for _ in 0..5 { + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("x", window, cx) + }); + } + cx.executor().advance_clock(Duration::from_millis(60)); + cx.executor().run_until_parked(); + + let final_requests = diagnostic_requests.load(atomic::Ordering::Acquire); + assert!( + final_requests <= 4, + "Multiple rapid edits should be debounced (got {final_requests} requests)", + ); + ensure_result_id(Some(final_requests.to_string()), cx); +} + +#[gpui::test] +async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppContext) { + // Regression test for issue #11671 + // Previously, adding a cursor after moving multiple cursors would reset + // the cursor count instead of adding to the existing cursors. + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + // Create a simple buffer with cursor at start + cx.set_state(indoc! {" + ˇaaaa + bbbb + cccc + dddd + eeee + ffff + gggg + hhhh"}); + + // Add 2 cursors below (so we have 3 total) + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + editor.add_selection_below(&Default::default(), window, cx); + }); + + // Verify we have 3 cursors + let initial_count = cx.update_editor(|editor, _, _| editor.selections.count()); + assert_eq!( + initial_count, 3, + "Should have 3 cursors after adding 2 below" + ); + + // Move down one line + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + }); + + // Add another cursor below + cx.update_editor(|editor, window, cx| { + editor.add_selection_below(&Default::default(), window, cx); + }); + + // Should now have 4 cursors (3 original + 1 new) + let final_count = cx.update_editor(|editor, _, _| editor.selections.count()); + assert_eq!( + final_count, 4, + "Should have 4 cursors after moving and adding another" + ); +} + +#[gpui::test] +async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc!( + r#"ˇThis is a very long line that will be wrapped when soft wrapping is enabled + Second line here"# + )); + + cx.update_editor(|editor, window, cx| { + // Enable soft wrapping with a narrow width to force soft wrapping and + // confirm that more than 2 rows are being displayed. + editor.set_wrap_width(Some(100.0.into()), cx); + assert!(editor.display_text(cx).lines().count() > 2); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + editor.selections.display_ranges(cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(8), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: true, + }, + window, + cx, + ); + + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + + editor.add_selection_below( + &AddSelectionBelow { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + editor.selections.display_ranges(cx), + &[ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + ] + ); + + editor.add_selection_above( + &AddSelectionAbove { + skip_soft_wrap: false, + }, + window, + cx, + ); + + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_document_colors(cx: &mut TestAppContext) { + let expected_color = Rgba { + r: 0.33, + g: 0.33, + b: 0.33, + a: 0.33, + }; + + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "first.rs": "fn main() { let a = 5; }", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + color_provider: Some(lsp::ColorProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ); + let mut fake_servers_without_capabilities = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + color_provider: Some(lsp::ColorProviderCapability::Simple(false)), + ..lsp::ServerCapabilities::default() + }, + name: "not-rust-analyzer", + ..FakeLspAdapter::default() + }, + ); + + let editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/first.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_language_server = fake_servers.next().await.unwrap(); + let fake_language_server_without_capabilities = + fake_servers_without_capabilities.next().await.unwrap(); + let requests_made = Arc::new(AtomicUsize::new(0)); + let closure_requests_made = Arc::clone(&requests_made); + let mut color_request_handle = fake_language_server + .set_request_handler::(move |params, _| { + let requests_made = Arc::clone(&closure_requests_made); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/first.rs")).unwrap() + ); + requests_made.fetch_add(1, atomic::Ordering::Release); + Ok(vec![ + lsp::ColorInformation { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 0, + }, + end: lsp::Position { + line: 0, + character: 1, + }, + }, + color: lsp::Color { + red: 0.33, + green: 0.33, + blue: 0.33, + alpha: 0.33, + }, + }, + lsp::ColorInformation { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 0, + }, + end: lsp::Position { + line: 0, + character: 1, + }, + }, + color: lsp::Color { + red: 0.33, + green: 0.33, + blue: 0.33, + alpha: 0.33, + }, + }, + ]) + } + }); + + let _handle = fake_language_server_without_capabilities + .set_request_handler::(move |_, _| async move { + panic!("Should not be called"); + }); + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); + color_request_handle.next().await.unwrap(); + cx.run_until_parked(); + assert_eq!( + 1, + requests_made.load(atomic::Ordering::Acquire), + "Should query for colors once per editor open" + ); + editor.update_in(cx, |editor, _, cx| { + assert_eq!( + vec![expected_color], + extract_color_inlays(editor, cx), + "Should have an initial inlay" + ); + }); + + // opening another file in a split should not influence the LSP query counter + workspace + .update(cx, |workspace, window, cx| { + assert_eq!( + workspace.panes().len(), + 1, + "Should have one pane with one editor" + ); + workspace.move_item_to_pane_in_direction( + &MoveItemToPaneInDirection { + direction: SplitDirection::Right, + focus: false, + clone: true, + }, + window, + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + workspace + .update(cx, |workspace, _, cx| { + let panes = workspace.panes(); + assert_eq!(panes.len(), 2, "Should have two panes after splitting"); + for pane in panes { + let editor = pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("Should have opened an editor in each split"); + let editor_file = editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("test deals with singleton buffers") + .read(cx) + .file() + .expect("test buffese should have a file") + .path(); + assert_eq!( + editor_file.as_ref(), + rel_path("first.rs"), + "Both editors should be opened for the same file" + ) + } + }) + .unwrap(); + + cx.executor().advance_clock(Duration::from_millis(500)); + let save = editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input("dirty", window, cx); + editor.save( + SaveOptions { + format: true, + autosave: true, + }, + project.clone(), + window, + cx, + ) + }); + save.await.unwrap(); + + color_request_handle.next().await.unwrap(); + cx.run_until_parked(); + assert_eq!( + 2, + requests_made.load(atomic::Ordering::Acquire), + "Should query for colors once per save (deduplicated) and once per formatting after save" + ); + + drop(editor); + let close = workspace + .update(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .unwrap(); + close.await.unwrap(); + let close = workspace + .update(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .unwrap(); + close.await.unwrap(); + assert_eq!( + 2, + requests_made.load(atomic::Ordering::Acquire), + "After saving and closing all editors, no extra requests should be made" + ); + workspace + .update(cx, |workspace, _, cx| { + assert!( + workspace.active_item(cx).is_none(), + "Should close all editors" + ) + }) + .unwrap(); + + workspace + .update(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.navigate_backward(&workspace::GoBack, window, cx); + }) + }) + .unwrap(); + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + let editor = workspace + .update(cx, |workspace, _, cx| { + workspace + .active_item(cx) + .expect("Should have reopened the editor again after navigating back") + .downcast::() + .expect("Should be an editor") + }) + .unwrap(); + + assert_eq!( + 2, + requests_made.load(atomic::Ordering::Acquire), + "Cache should be reused on buffer close and reopen" + ); + editor.update(cx, |editor, cx| { + assert_eq!( + vec![expected_color], + extract_color_inlays(editor, cx), + "Should have an initial inlay" + ); + }); + + drop(color_request_handle); + let closure_requests_made = Arc::clone(&requests_made); + let mut empty_color_request_handle = fake_language_server + .set_request_handler::(move |params, _| { + let requests_made = Arc::clone(&closure_requests_made); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/first.rs")).unwrap() + ); + requests_made.fetch_add(1, atomic::Ordering::Release); + Ok(Vec::new()) + } + }); + let save = editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input("dirty_again", window, cx); + editor.save( + SaveOptions { + format: false, + autosave: true, + }, + project.clone(), + window, + cx, + ) + }); + save.await.unwrap(); + + cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT); + empty_color_request_handle.next().await.unwrap(); + cx.run_until_parked(); + assert_eq!( + 3, + requests_made.load(atomic::Ordering::Acquire), + "Should query for colors once per save only, as formatting was not requested" + ); + editor.update(cx, |editor, cx| { + assert_eq!( + Vec::::new(), + extract_color_inlays(editor, cx), + "Should clear all colors when the server returns an empty response" + ); + }); +} + +#[gpui::test] +async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let (editor, cx) = cx.add_window_view(Editor::single_line); + editor.update_in(cx, |editor, window, cx| { + editor.set_text("oops\n\nwow\n", window, cx) + }); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.display_text(cx), "oops⋯⋯wow⋯"); + }); + editor.update(cx, |editor, cx| editor.edit([(3..5, "")], cx)); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.display_text(cx), "oop⋯wow⋯"); + }); } -fn empty_range(row: usize, column: usize) -> Range { - let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); - point..point -} +#[gpui::test] +async fn test_non_utf_8_opens(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.update(|cx| { + register_project_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root1", json!({})).await; + fs.insert_file("/root1/one.pdf", vec![0xff, 0xfe, 0xfd]) + .await; + + let project = Project::test(fs, ["/root1".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let worktree_id = project.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let handle = workspace + .update_in(cx, |workspace, window, cx| { + let project_path = (worktree_id, rel_path("one.pdf")); + workspace.open_path(project_path, None, true, window, cx) + }) + .await + .unwrap(); -#[track_caller] -fn assert_selection_ranges(marked_text: &str, editor: &mut Editor, cx: &mut Context) { - let (text, ranges) = marked_text_ranges(marked_text, true); - assert_eq!(editor.text(cx), text); assert_eq!( - editor.selections.ranges(cx), - ranges, - "Assert selections are {}", - marked_text + handle.to_any().entity_type(), + TypeId::of::() ); } -pub fn handle_signature_help_request( - cx: &mut EditorLspTestContext, - mocked_response: lsp::SignatureHelp, -) -> impl Future + use<> { - let mut request = - cx.set_request_handler::(move |_, _, _| { - let mocked_response = mocked_response.clone(); - async move { Ok(Some(mocked_response)) } - }); +#[gpui::test] +async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { + init_test(cx, |_| {}); - async move { - request.next().await; - } -} + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); -#[track_caller] -pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorLspTestContext) { - cx.update_editor(|editor, _, _| { - if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow().as_ref() { - let entries = menu.entries.borrow(); - let entries = entries - .iter() - .map(|entry| entry.string.as_str()) - .collect::>(); - assert_eq!(entries, expected); - } else { - panic!("Expected completions menu"); + // Test hierarchical sibling navigation + let text = r#" + fn outer() { + if condition { + let a = 1; + } + let b = 2; } - }); -} -/// Handle completion request passing a marked string specifying where the completion -/// should be triggered from using '|' character, what range should be replaced, and what completions -/// should be returned using '<' and '>' to delimit the range. -/// -/// Also see `handle_completion_request_with_insert_and_replace`. -#[track_caller] -pub fn handle_completion_request( - marked_string: &str, - completions: Vec<&'static str>, - is_incomplete: bool, - counter: Arc, - cx: &mut EditorLspTestContext, -) -> impl Future { - let complete_from_marker: TextRangeMarker = '|'.into(); - let replace_range_marker: TextRangeMarker = ('<', '>').into(); - let (_, mut marked_ranges) = marked_text_ranges_by( - marked_string, - vec![complete_from_marker.clone(), replace_range_marker.clone()], - ); + fn another() { + let c = 3; + } + "#; - let complete_from_position = - cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); - let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); - let mut request = - cx.set_request_handler::(move |url, params, _| { - let completions = completions.clone(); - counter.fetch_add(1, atomic::Ordering::Release); - async move { - assert_eq!(params.text_document_position.text_document.uri, url.clone()); - assert_eq!( - params.text_document_position.position, - complete_from_position - ); - Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { - is_incomplete: is_incomplete, - item_defaults: None, - items: completions - .iter() - .map(|completion_text| lsp::CompletionItem { - label: completion_text.to_string(), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - range: replace_range, - new_text: completion_text.to_string(), - })), - ..Default::default() - }) - .collect(), - }))) - } + // Wait for parsing to complete + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + // Start by selecting "let a = 1;" inside the if block + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26) + ]); }); - async move { - request.next().await; - } -} + let initial_selection = editor.selections.display_ranges(cx); + assert_eq!(initial_selection.len(), 1, "Should have one selection"); -/// Similar to `handle_completion_request`, but a [`CompletionTextEdit::InsertAndReplace`] will be -/// given instead, which also contains an `insert` range. -/// -/// This function uses markers to define ranges: -/// - `|` marks the cursor position -/// - `<>` marks the replace range -/// - `[]` marks the insert range (optional, defaults to `replace_range.start..cursor_pos`which is what Rust-Analyzer provides) -pub fn handle_completion_request_with_insert_and_replace( - cx: &mut EditorLspTestContext, - marked_string: &str, - completions: Vec<(&'static str, &'static str)>, // (label, new_text) - counter: Arc, -) -> impl Future { - let complete_from_marker: TextRangeMarker = '|'.into(); - let replace_range_marker: TextRangeMarker = ('<', '>').into(); - let insert_range_marker: TextRangeMarker = ('{', '}').into(); + // Test select next sibling - should move up levels to find the next sibling + // Since "let a = 1;" has no siblings in the if block, it should move up + // to find "let b = 2;" which is a sibling of the if block + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let next_selection = editor.selections.display_ranges(cx); - let (_, mut marked_ranges) = marked_text_ranges_by( - marked_string, - vec![ - complete_from_marker.clone(), - replace_range_marker.clone(), - insert_range_marker.clone(), - ], - ); + // Should have a selection and it should be different from the initial + assert_eq!( + next_selection.len(), + 1, + "Should have one selection after next" + ); + assert_ne!( + next_selection[0], initial_selection[0], + "Next sibling selection should be different" + ); - let complete_from_position = - cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); - let replace_range = - cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + // Test hierarchical navigation by going to the end of the current function + // and trying to navigate to the next function + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22) + ]); + }); - let insert_range = match marked_ranges.remove(&insert_range_marker) { - Some(ranges) if !ranges.is_empty() => cx.to_lsp_range(ranges[0].clone()), - _ => lsp::Range { - start: replace_range.start, - end: complete_from_position, - }, - }; + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let function_next_selection = editor.selections.display_ranges(cx); - let mut request = - cx.set_request_handler::(move |url, params, _| { - let completions = completions.clone(); - counter.fetch_add(1, atomic::Ordering::Release); - async move { - assert_eq!(params.text_document_position.text_document.uri, url.clone()); - assert_eq!( - params.text_document_position.position, complete_from_position, - "marker `|` position doesn't match", - ); - Ok(Some(lsp::CompletionResponse::Array( - completions - .iter() - .map(|(label, new_text)| lsp::CompletionItem { - label: label.to_string(), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - insert: insert_range, - replace: replace_range, - new_text: new_text.to_string(), - }, - )), - ..Default::default() - }) - .collect(), - ))) - } - }); + // Should move to the next function + assert_eq!( + function_next_selection.len(), + 1, + "Should have one selection after function next" + ); - async move { - request.next().await; - } + // Test select previous sibling navigation + editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx); + let prev_selection = editor.selections.display_ranges(cx); + + // Should have a selection and it should be different + assert_eq!( + prev_selection.len(), + 1, + "Should have one selection after prev" + ); + assert_ne!( + prev_selection[0], function_next_selection[0], + "Previous sibling selection should be different from next" + ); + }); } -fn handle_resolve_completion_request( - cx: &mut EditorLspTestContext, - edits: Option>, -) -> impl Future { - let edits = edits.map(|edits| { - edits +#[gpui::test] +async fn test_next_prev_document_highlight(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + cx.set_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); + + // Set up document highlights manually (simulating LSP response) + cx.update_editor(|editor, _window, cx| { + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + + // Create highlights for "variable" occurrences + let highlight_ranges = [ + Point::new(0, 4)..Point::new(0, 12), // First "variable" + Point::new(1, 14)..Point::new(1, 22), // Second "variable" + Point::new(2, 13)..Point::new(2, 21), // Third "variable" + ]; + + let anchor_ranges: Vec<_> = highlight_ranges .iter() - .map(|(marked_string, new_text)| { - let (_, marked_ranges) = marked_text_ranges(marked_string, false); - let replace_range = cx.to_lsp_range(marked_ranges[0].clone()); - lsp::TextEdit::new(replace_range, new_text.to_string()) - }) - .collect::>() + .map(|range| range.clone().to_anchors(&buffer_snapshot)) + .collect(); + + editor.highlight_background::( + &anchor_ranges, + |theme| theme.colors().editor_document_highlight_read_background, + cx, + ); + }); + + // Go to next highlight - should move to second "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); }); + cx.assert_editor_state( + "let variable = 42; +let another = ˇvariable + 1; +let result = variable * 2;", + ); - let mut request = - cx.set_request_handler::(move |_, _, _| { - let edits = edits.clone(); - async move { - Ok(lsp::CompletionItem { - additional_text_edits: edits, - ..Default::default() - }) - } - }); + // Go to next highlight - should move to third "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = variable + 1; +let result = ˇvariable * 2;", + ); - async move { - request.next().await; - } -} + // Go to next highlight - should stay at third "variable" (no wrap-around) + cx.update_editor(|editor, window, cx| { + editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx); + }); + cx.assert_editor_state( + "let variable = 42; +let another = variable + 1; +let result = ˇvariable * 2;", + ); -pub(crate) fn update_test_language_settings( - cx: &mut TestAppContext, - f: impl Fn(&mut AllLanguageSettingsContent), -) { - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, f); - }); + // Now test going backwards from third position + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); -} + cx.assert_editor_state( + "let variable = 42; +let another = ˇvariable + 1; +let result = variable * 2;", + ); -pub(crate) fn update_test_project_settings( - cx: &mut TestAppContext, - f: impl Fn(&mut ProjectSettings), -) { - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::(cx, f); - }); + // Go to previous highlight - should move to first "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); -} + cx.assert_editor_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); -pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { - cx.update(|cx| { - assets::Assets.load_test_fonts(cx); - let store = SettingsStore::test(cx); - cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - crate::init(cx); + // Go to previous highlight - should stay on first "variable" + cx.update_editor(|editor, window, cx| { + editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx); }); - zlog::init_test(); - update_test_language_settings(cx, f); + cx.assert_editor_state( + "let ˇvariable = 42; +let another = variable + 1; +let result = variable * 2;", + ); } -#[track_caller] -fn assert_hunk_revert( - not_reverted_text_with_selections: &str, - expected_hunk_statuses_before: Vec, - expected_reverted_text_with_selections: &str, - base_text: &str, - cx: &mut EditorLspTestContext, +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, ) { - cx.set_state(not_reverted_text_with_selections); - cx.set_head_text(base_text); - cx.executor().run_until_parked(); + init_test(cx, |_| {}); - let actual_hunk_statuses_before = cx.update_editor(|editor, window, cx| { - let snapshot = editor.snapshot(window, cx); - let reverted_hunk_statuses = snapshot - .buffer_snapshot - .diff_hunks_in_range(0..snapshot.buffer_snapshot.len()) - .map(|hunk| hunk.status().kind) - .collect::>(); + let url = "https://zed.dev"; - editor.git_restore(&Default::default(), window, cx); - reverted_hunk_statuses + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); }); - cx.executor().run_until_parked(); - cx.assert_editor_state(expected_reverted_text_with_selections); - assert_eq!(actual_hunk_statuses_before, expected_hunk_statuses_before); + + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)" + )); } -#[gpui::test(iterations = 10)] -async fn test_pulling_diagnostics(cx: &mut TestAppContext) { +#[gpui::test] +async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( + cx: &mut gpui::TestAppContext, +) { init_test(cx, |_| {}); - let diagnostic_requests = Arc::new(AtomicUsize::new(0)); - let counter = diagnostic_requests.clone(); + let url = "https://zed.dev"; - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/a"), - json!({ - "first.rs": "fn main() { let a = 5; }", - "second.rs": "// Test file", - }), - ) - .await; + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state(&format!( + "Hello, editor.\nZed is great (see this link: )\n«{url}ˇ»" + )); - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: None, - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..Default::default() - }, - ..Default::default() - }, - ); + cx.update_editor(|editor, window, cx| { + editor.copy(&Copy, window, cx); + }); - let editor = workspace - .update(cx, |workspace, window, cx| { - workspace.open_abs_path( - PathBuf::from(path!("/a/first.rs")), - OpenOptions::default(), - window, - cx, - ) - }) - .unwrap() - .await - .unwrap() - .downcast::() - .unwrap(); - let fake_server = fake_servers.next().await.unwrap(); - let server_id = fake_server.server.server_id(); - let mut first_request = fake_server - .set_request_handler::(move |params, _| { - let new_result_id = counter.fetch_add(1, atomic::Ordering::Release) + 1; - let result_id = Some(new_result_id.to_string()); - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/first.rs")).unwrap() - ); - async move { - Ok(lsp::DocumentDiagnosticReportResult::Report( - lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport { - related_documents: None, - full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { - items: Vec::new(), - result_id, - }, - }), - )) - } - }); + cx.set_state(&format!( + "Hello, «editorˇ».\nZed is «ˇgreat» (see this link: ˇ)\n{url}" + )); - let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { - project.update(cx, |project, cx| { - let buffer_id = editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("created a singleton buffer") - .read(cx) - .remote_id(); - let buffer_result_id = project - .lsp_store() - .read(cx) - .result_id(server_id, buffer_id, cx); - assert_eq!(expected, buffer_result_id); - }); - }; + cx.update_editor(|editor, window, cx| { + editor.paste(&Paste, window, cx); + }); - ensure_result_id(None, cx); - cx.executor().advance_clock(Duration::from_millis(60)); - cx.executor().run_until_parked(); - assert_eq!( - diagnostic_requests.load(atomic::Ordering::Acquire), - 1, - "Opening file should trigger diagnostic request" - ); - first_request - .next() - .await - .expect("should have sent the first diagnostics pull request"); - ensure_result_id(Some("1".to_string()), cx); + cx.assert_editor_state(&format!( + "Hello, [editor]({url})ˇ.\nZed is [great]({url})ˇ (see this link: {url}ˇ)\n{url}" + )); +} - // Editing should trigger diagnostics - editor.update_in(cx, |editor, window, cx| { - editor.handle_input("2", window, cx) +#[gpui::test] +async fn test_paste_url_from_other_app_replaces_existing_url_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Please visit zed's homepage: «https://www.apple.comˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); }); - cx.executor().advance_clock(Duration::from_millis(60)); - cx.executor().run_until_parked(); - assert_eq!( - diagnostic_requests.load(atomic::Ordering::Acquire), - 2, - "Editing should trigger diagnostic request" - ); - ensure_result_id(Some("2".to_string()), cx); - // Moving cursor should not trigger diagnostic request - editor.update_in(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) - }); + cx.assert_editor_state(&format!("Please visit zed's homepage: {url}ˇ")); +} + +#[gpui::test] +async fn test_paste_plain_text_from_other_app_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + let text = "Awesome"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("Hello, «editorˇ».\nZed is «ˇgreat»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); }); - cx.executor().advance_clock(Duration::from_millis(60)); - cx.executor().run_until_parked(); - assert_eq!( - diagnostic_requests.load(atomic::Ordering::Acquire), - 2, - "Cursor movement should not trigger diagnostic request" - ); - ensure_result_id(Some("2".to_string()), cx); - // Multiple rapid edits should be debounced - for _ in 0..5 { - editor.update_in(cx, |editor, window, cx| { - editor.handle_input("x", window, cx) - }); - } - cx.executor().advance_clock(Duration::from_millis(60)); - cx.executor().run_until_parked(); - let final_requests = diagnostic_requests.load(atomic::Ordering::Acquire); - assert!( - final_requests <= 4, - "Multiple rapid edits should be debounced (got {final_requests} requests)", - ); - ensure_result_id(Some(final_requests.to_string()), cx); + cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); } #[gpui::test] -async fn test_add_selection_after_moving_with_multiple_cursors(cx: &mut TestAppContext) { - // Regression test for issue #11671 - // Previously, adding a cursor after moving multiple cursors would reset - // the cursor count instead of adding to the existing cursors. +async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( + cx: &mut gpui::TestAppContext, +) { init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; - // Create a simple buffer with cursor at start - cx.set_state(indoc! {" - ˇaaaa - bbbb - cccc - dddd - eeee - ffff - gggg - hhhh"}); + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("// Hello, «editorˇ».\n// Zed is «ˇgreat» (see this link: ˇ)"); - // Add 2 cursors below (so we have 3 total) cx.update_editor(|editor, window, cx| { - editor.add_selection_below(&Default::default(), window, cx); - editor.add_selection_below(&Default::default(), window, cx); + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); }); - // Verify we have 3 cursors - let initial_count = cx.update_editor(|editor, _, _| editor.selections.count()); - assert_eq!( - initial_count, 3, - "Should have 3 cursors after adding 2 below" - ); + cx.assert_editor_state(&format!( + "// Hello, {url}ˇ.\n// Zed is {url}ˇ (see this link: {url}ˇ)" + )); +} - // Move down one line - cx.update_editor(|editor, window, cx| { - editor.move_down(&MoveDown, window, cx); +#[gpui::test] +async fn test_paste_url_from_other_app_creates_markdown_link_selectively_in_multi_buffer( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let url = "https://zed.dev"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ("this will embed -> link", vec![Point::row_range(0..1)]), + ("this will replace -> link", vec![Point::row_range(0..1)]), + ], + cx, + ); + let mut editor = Editor::new(EditorMode::full(), multi_buffer.clone(), None, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![ + Point::new(0, 19)..Point::new(0, 23), + Point::new(1, 21)..Point::new(1, 25), + ]) + }); + let first_buffer_id = multi_buffer + .read(cx) + .excerpt_buffer_ids() + .into_iter() + .next() + .unwrap(); + let first_buffer = multi_buffer.read(cx).buffer(first_buffer_id).unwrap(); + first_buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown_language.clone()), cx); + }); + + editor }); + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; - // Add another cursor below cx.update_editor(|editor, window, cx| { - editor.add_selection_below(&Default::default(), window, cx); + cx.write_to_clipboard(ClipboardItem::new_string(url.to_string())); + editor.paste(&Paste, window, cx); }); - // Should now have 4 cursors (3 original + 1 new) - let final_count = cx.update_editor(|editor, _, _| editor.selections.count()); - assert_eq!( - final_count, 4, - "Should have 4 cursors after moving and adding another" - ); + cx.assert_editor_state(&format!( + "this will embed -> [link]({url})ˇ\nthis will replace -> {url}ˇ" + )); } -#[gpui::test(iterations = 10)] -async fn test_document_colors(cx: &mut TestAppContext) { - let expected_color = Rgba { - r: 0.33, - g: 0.33, - b: 0.33, - a: 0.33, - }; - +#[gpui::test] +async fn test_race_in_multibuffer_save(cx: &mut TestAppContext) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/a"), + path!("/project"), json!({ - "first.rs": "fn main() { let a = 5; }", + "first.rs": "# First Document\nSome content here.", + "second.rs": "Plain text content for second file.", }), ) .await; - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*workspace, cx); + let language = rust_lang(); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); + language_registry.add(language.clone()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - color_provider: Some(lsp::ColorProviderCapability::Simple(true)), - ..lsp::ServerCapabilities::default() - }, - name: "rust-analyzer", - ..FakeLspAdapter::default() - }, - ); - let mut fake_servers_without_capabilities = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - color_provider: Some(lsp::ColorProviderCapability::Simple(false)), - ..lsp::ServerCapabilities::default() - }, - name: "not-rust-analyzer", ..FakeLspAdapter::default() - }, - ); - - let editor = workspace - .update(cx, |workspace, window, cx| { - workspace.open_abs_path( - PathBuf::from(path!("/a/first.rs")), - OpenOptions::default(), - window, - cx, - ) - }) - .unwrap() - .await - .unwrap() - .downcast::() - .unwrap(); - let fake_language_server = fake_servers.next().await.unwrap(); - let fake_language_server_without_capabilities = - fake_servers_without_capabilities.next().await.unwrap(); - let requests_made = Arc::new(AtomicUsize::new(0)); - let closure_requests_made = Arc::clone(&requests_made); - let mut color_request_handle = fake_language_server - .set_request_handler::(move |params, _| { - let requests_made = Arc::clone(&closure_requests_made); - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/first.rs")).unwrap() - ); - requests_made.fetch_add(1, atomic::Ordering::Release); - Ok(vec![ - lsp::ColorInformation { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 0, - }, - end: lsp::Position { - line: 0, - character: 1, - }, - }, - color: lsp::Color { - red: 0.33, - green: 0.33, - blue: 0.33, - alpha: 0.33, - }, - }, - lsp::ColorInformation { - range: lsp::Range { - start: lsp::Position { - line: 0, - character: 0, - }, - end: lsp::Position { - line: 0, - character: 1, - }, - }, - color: lsp::Color { - red: 0.33, - green: 0.33, - blue: 0.33, - alpha: 0.33, - }, - }, - ]) - } - }); - - let _handle = fake_language_server_without_capabilities - .set_request_handler::(move |_, _| async move { - panic!("Should not be called"); - }); - cx.executor().advance_clock(Duration::from_millis(100)); - color_request_handle.next().await.unwrap(); - cx.run_until_parked(); - assert_eq!( - 1, - requests_made.load(atomic::Ordering::Acquire), - "Should query for colors once per editor open" - ); - editor.update_in(cx, |editor, _, cx| { - assert_eq!( - vec![expected_color], - extract_color_inlays(editor, cx), - "Should have an initial inlay" - ); - }); - - // opening another file in a split should not influence the LSP query counter - workspace - .update(cx, |workspace, window, cx| { - assert_eq!( - workspace.panes().len(), - 1, - "Should have one pane with one editor" - ); - workspace.move_item_to_pane_in_direction( - &MoveItemToPaneInDirection { - direction: SplitDirection::Right, - focus: false, - clone: true, - }, - window, - cx, - ); + }, + ); + + let buffer1 = project + .update(cx, |project, cx| { + project.open_local_buffer(PathBuf::from(path!("/project/first.rs")), cx) }) + .await .unwrap(); - cx.run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let panes = workspace.panes(); - assert_eq!(panes.len(), 2, "Should have two panes after splitting"); - for pane in panes { - let editor = pane - .read(cx) - .active_item() - .and_then(|item| item.downcast::()) - .expect("Should have opened an editor in each split"); - let editor_file = editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("test deals with singleton buffers") - .read(cx) - .file() - .expect("test buffese should have a file") - .path(); - assert_eq!( - editor_file.as_ref(), - Path::new("first.rs"), - "Both editors should be opened for the same file" - ) - } + let buffer2 = project + .update(cx, |project, cx| { + project.open_local_buffer(PathBuf::from(path!("/project/second.rs")), cx) }) + .await .unwrap(); - cx.executor().advance_clock(Duration::from_millis(500)); + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite); + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer1, cx), + buffer1.clone(), + [Point::zero()..buffer1.read(cx).max_point()], + 3, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::for_buffer(&buffer2, cx), + buffer2.clone(), + [Point::zero()..buffer1.read(cx).max_point()], + 3, + cx, + ); + multi_buffer + }); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + multi_buffer, + Some(project.clone()), + window, + cx, + ) + }); + + let fake_language_server = fake_servers.next().await.unwrap(); + + buffer1.update(cx, |buffer, cx| buffer.edit([(0..0, "hello!")], None, cx)); + let save = editor.update_in(cx, |editor, window, cx| { - editor.move_to_end(&MoveToEnd, window, cx); - editor.handle_input("dirty", window, cx); + assert!(editor.is_dirty(cx)); + editor.save( SaveOptions { format: true, autosave: true, }, - project.clone(), + project, window, cx, ) }); - save.await.unwrap(); + let (start_edit_tx, start_edit_rx) = oneshot::channel(); + let (done_edit_tx, done_edit_rx) = oneshot::channel(); + let mut done_edit_rx = Some(done_edit_rx); + let mut start_edit_tx = Some(start_edit_tx); - color_request_handle.next().await.unwrap(); - cx.run_until_parked(); - assert_eq!( - 3, - requests_made.load(atomic::Ordering::Acquire), - "Should query for colors once per save and once per formatting after save" - ); + fake_language_server.set_request_handler::(move |_, _| { + start_edit_tx.take().unwrap().send(()).unwrap(); + let done_edit_rx = done_edit_rx.take().unwrap(); + async move { + done_edit_rx.await.unwrap(); + Ok(None) + } + }); - drop(editor); - let close = workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.close_active_item(&CloseActiveItem::default(), window, cx) - }) - }) - .unwrap(); - close.await.unwrap(); - let close = workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.close_active_item(&CloseActiveItem::default(), window, cx) - }) - }) - .unwrap(); - close.await.unwrap(); - assert_eq!( - 3, - requests_made.load(atomic::Ordering::Acquire), - "After saving and closing all editors, no extra requests should be made" - ); - workspace - .update(cx, |workspace, _, cx| { - assert!( - workspace.active_item(cx).is_none(), - "Should close all editors" - ) - }) + start_edit_rx.await.unwrap(); + buffer2 + .update(cx, |buffer, cx| buffer.edit([(0..0, "world!")], None, cx)) .unwrap(); - workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.navigate_backward(window, cx); - }) - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis(100)); - cx.run_until_parked(); - let editor = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_item(cx) - .expect("Should have reopened the editor again after navigating back") - .downcast::() - .expect("Should be an editor") + done_edit_tx.send(()).unwrap(); + + save.await.unwrap(); + cx.update(|_, cx| assert!(editor.is_dirty(cx))); +} + +#[track_caller] +fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { + editor + .all_inlays(cx) + .into_iter() + .filter_map(|inlay| inlay.get_color()) + .map(Rgba::from) + .collect() +} + +#[gpui::test] +fn test_duplicate_line_up_on_last_line_without_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("line1\nline2", cx); + build_editor(buffer, window, cx) + }); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + + editor.duplicate_line_up(&DuplicateLineUp, window, cx); + + assert_eq!( + editor.display_text(cx), + "line1\nline2\nline2", + "Duplicating last line upward should create duplicate above, not on same line" + ); + + assert_eq!( + editor.selections.display_ranges(cx), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)], + "Selection should move to the duplicated line" + ); }) .unwrap(); - color_request_handle.next().await.unwrap(); +} + +#[gpui::test] +async fn test_copy_line_without_trailing_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + + cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx)); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| item.text().as_deref().map(str::to_string)); + assert_eq!( - 3, - requests_made.load(atomic::Ordering::Acquire), - "Cache should be reused on buffer close and reopen" + clipboard_text, + Some("line2\n".to_string()), + "Copying a line without trailing newline should include a newline" ); - editor.update(cx, |editor, cx| { - assert_eq!( - vec![expected_color], - extract_color_inlays(editor, cx), - "Should have an initial inlay" - ); - }); + + cx.set_state("line1\nˇ"); + + cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx)); + + cx.assert_editor_state("line1\nline2\nˇ"); } #[gpui::test] -async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) { +async fn test_end_of_editor_context(cx: &mut TestAppContext) { init_test(cx, |_| {}); - let (editor, cx) = cx.add_window_view(Editor::single_line); - editor.update_in(cx, |editor, window, cx| { - editor.set_text("oops\n\nwow\n", window, cx) + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("line1\nline2ˇ"); + cx.update_editor(|e, window, cx| { + e.set_mode(EditorMode::SingleLine); + assert!(e.key_context(window, cx).contains("end_of_input")); }); - cx.run_until_parked(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.display_text(cx), "oops⋯⋯wow⋯"); + cx.set_state("ˇline1\nline2"); + cx.update_editor(|e, window, cx| { + assert!(!e.key_context(window, cx).contains("end_of_input")); }); - editor.update(cx, |editor, cx| editor.edit([(3..5, "")], cx)); - cx.run_until_parked(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.display_text(cx), "oop⋯wow⋯"); + cx.set_state("line1ˇ\nline2"); + cx.update_editor(|e, window, cx| { + assert!(!e.key_context(window, cx).contains("end_of_input")); }); } -#[track_caller] -fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { - editor - .all_inlays(cx) - .into_iter() - .filter_map(|inlay| inlay.get_color()) - .map(Rgba::from) - .collect() +#[gpui::test] +async fn test_next_prev_reference(cx: &mut TestAppContext) { + const CYCLE_POSITIONS: &[&'static str] = &[ + indoc! {" + fn foo() { + let ˇabc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = ˇabc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = ˇabc + 2; + let z = abc + 2; + } + "}, + indoc! {" + fn foo() { + let abc = 123; + let x = abc + 1; + let y = abc + 2; + let z = ˇabc + 2; + } + "}, + ]; + + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + // importantly, the cursor is in the middle + cx.set_state(indoc! {" + fn foo() { + let aˇbc = 123; + let x = abc + 1; + let y = abc + 2; + let z = abc + 2; + } + "}); + + let reference_ranges = [ + lsp::Position::new(1, 8), + lsp::Position::new(2, 12), + lsp::Position::new(3, 12), + lsp::Position::new(4, 12), + ] + .map(|start| lsp::Range::new(start, lsp::Position::new(start.line, start.character + 3))); + + cx.lsp + .set_request_handler::(move |params, _cx| async move { + Ok(Some( + reference_ranges + .map(|range| lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range, + }) + .to_vec(), + )) + }); + + let _move = async |direction, count, cx: &mut EditorLspTestContext| { + cx.update_editor(|editor, window, cx| { + editor.go_to_reference_before_or_after_position(direction, count, window, cx) + }) + .unwrap() + .await + .unwrap() + }; + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + // loops back to the start + _move(Direction::Next, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + // loops back to the end + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[2]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); + + _move(Direction::Prev, 1, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[0]); + + _move(Direction::Next, 3, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[3]); + + _move(Direction::Prev, 2, &mut cx).await; + cx.assert_editor_state(CYCLE_POSITIONS[1]); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5edfd7df309fb5161ae865abefadda2747589dda..17b9ea9ced8d34396426e0a2640904b6e8df97a4 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -18,7 +18,7 @@ use crate::{ editor_settings::{ CurrentLineHighlight, DocumentColorsRenderMode, DoubleClickInMultibuffer, Minimap, MinimapThumb, MinimapThumbBorder, ScrollBeyondLastLine, ScrollbarAxes, - ScrollbarDiagnostics, ShowMinimap, ShowScrollbar, + ScrollbarDiagnostics, ShowMinimap, }, git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer}, hover_popover::{ @@ -28,7 +28,10 @@ use crate::{ inlay_hint_settings, items::BufferSearchHighlights, mouse_context_menu::{self, MenuPosition}, - scroll::{ActiveScrollbarState, ScrollbarThumbState, scroll_amount::ScrollAmount}, + scroll::{ + ActiveScrollbarState, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, + scroll_amount::ScrollAmount, + }, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use collections::{BTreeMap, HashMap}; @@ -40,19 +43,18 @@ use git::{ }; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, - Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, - Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, - HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length, - ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, - ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun, - TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, - linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, + Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, + DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, + KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, + ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, + Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, + linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, + transparent_black, }; use itertools::Itertools; -use language::language_settings::{ - IndentGuideBackgroundColoring, IndentGuideColoring, IndentGuideSettings, ShowWhitespaceSetting, -}; +use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; use markdown::Markdown; use multi_buffer::{ Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, @@ -60,11 +62,14 @@ use multi_buffer::{ }; use project::{ - ProjectPath, + Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, - project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings}, + project_settings::ProjectSettings, +}; +use settings::{ + GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, + Settings, }; -use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ any::TypeId, @@ -73,6 +78,7 @@ use std::{ fmt::{self, Write}, iter, mem, ops::{Deref, Range}, + path::{self, Path}, rc::Rc, sync::Arc, time::{Duration, Instant}, @@ -80,11 +86,19 @@ use std::{ use sum_tree::Bias; use text::{BufferId, SelectionGoal}; use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; -use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*}; +use ui::utils::ensure_minimum_contrast; +use ui::{ + ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*, + right_click_menu, scrollbars::ShowScrollbar, text_for_keystroke, +}; use unicode_segmentation::UnicodeSegmentation; use util::post_inc; use util::{RangeExt, ResultExt, debug_panic}; -use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; +use workspace::{ + CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, + item::{Item, ItemBufferKind}, + notifications::NotifyTaskExt, +}; /// Determines what kinds of highlights should be applied to a lines background. #[derive(Clone, Copy, Default)] @@ -108,6 +122,7 @@ struct SelectionLayout { struct InlineBlameLayout { element: AnyElement, bounds: Bounds, + buffer_id: BufferId, entry: BlameEntry, } @@ -121,7 +136,7 @@ impl SelectionLayout { is_local: bool, user_name: Option, ) -> Self { - let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot)); + let point_selection = selection.map(|p| p.to_point(map.buffer_snapshot())); let display_selection = point_selection.map(|p| p.to_display_point(map)); let mut range = display_selection.range(); let mut head = display_selection.head(); @@ -217,6 +232,8 @@ impl EditorElement { register_action(editor, window, Editor::blame_hover); register_action(editor, window, Editor::delete); register_action(editor, window, Editor::tab); + register_action(editor, window, Editor::next_snippet_tabstop); + register_action(editor, window, Editor::previous_snippet_tabstop); register_action(editor, window, Editor::backtab); register_action(editor, window, Editor::indent); register_action(editor, window, Editor::outdent); @@ -355,12 +372,14 @@ impl EditorElement { register_action(editor, window, Editor::toggle_comments); register_action(editor, window, Editor::select_larger_syntax_node); register_action(editor, window, Editor::select_smaller_syntax_node); + register_action(editor, window, Editor::select_next_syntax_node); + register_action(editor, window, Editor::select_prev_syntax_node); register_action(editor, window, Editor::unwrap_syntax_node); register_action(editor, window, Editor::select_enclosing_symbol); register_action(editor, window, Editor::move_to_enclosing_bracket); register_action(editor, window, Editor::undo_selection); register_action(editor, window, Editor::redo_selection); - if !editor.read(cx).is_singleton(cx) { + if editor.read(cx).buffer_kind(cx) == ItemBufferKind::Multibuffer { register_action(editor, window, Editor::expand_excerpts); register_action(editor, window, Editor::expand_excerpts_up); register_action(editor, window, Editor::expand_excerpts_down); @@ -369,6 +388,8 @@ impl EditorElement { register_action(editor, window, Editor::go_to_prev_diagnostic); register_action(editor, window, Editor::go_to_next_hunk); register_action(editor, window, Editor::go_to_prev_hunk); + register_action(editor, window, Editor::go_to_next_document_highlight); + register_action(editor, window, Editor::go_to_prev_document_highlight); register_action(editor, window, |editor, action, window, cx| { editor .go_to_definition(action, window, cx) @@ -413,6 +434,15 @@ impl EditorElement { register_action(editor, window, Editor::open_selected_filename); register_action(editor, window, Editor::fold); register_action(editor, window, Editor::fold_at_level); + register_action(editor, window, Editor::fold_at_level_1); + register_action(editor, window, Editor::fold_at_level_2); + register_action(editor, window, Editor::fold_at_level_3); + register_action(editor, window, Editor::fold_at_level_4); + register_action(editor, window, Editor::fold_at_level_5); + register_action(editor, window, Editor::fold_at_level_6); + register_action(editor, window, Editor::fold_at_level_7); + register_action(editor, window, Editor::fold_at_level_8); + register_action(editor, window, Editor::fold_at_level_9); register_action(editor, window, Editor::fold_all); register_action(editor, window, Editor::fold_function_bodies); register_action(editor, window, Editor::fold_recursive); @@ -430,7 +460,6 @@ impl EditorElement { register_action(editor, window, Editor::toggle_code_actions); register_action(editor, window, Editor::open_excerpts); register_action(editor, window, Editor::open_excerpts_in_split); - register_action(editor, window, Editor::open_proposed_changes_editor); register_action(editor, window, Editor::toggle_soft_wrap); register_action(editor, window, Editor::toggle_tab_bar); register_action(editor, window, Editor::toggle_line_numbers); @@ -465,8 +494,11 @@ impl EditorElement { register_action(editor, window, Editor::stage_and_next); register_action(editor, window, Editor::unstage_and_next); register_action(editor, window, Editor::expand_all_diff_hunks); + register_action(editor, window, Editor::collapse_all_diff_hunks); register_action(editor, window, Editor::go_to_previous_change); register_action(editor, window, Editor::go_to_next_change); + register_action(editor, window, Editor::go_to_prev_reference); + register_action(editor, window, Editor::go_to_next_reference); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) { @@ -577,6 +609,9 @@ impl EditorElement { register_action(editor, window, Editor::edit_log_breakpoint); register_action(editor, window, Editor::enable_breakpoint); register_action(editor, window, Editor::disable_breakpoint); + if editor.read(cx).enable_wrap_selections_in_tag(cx) { + register_action(editor, window, Editor::wrap_selections_in_tag); + } } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) { @@ -620,7 +655,6 @@ impl EditorElement { fn mouse_left_down( editor: &mut Editor, event: &MouseDownEvent, - hovered_hunk: Option>, position_map: &PositionMap, line_numbers: &HashMap, window: &mut Window, @@ -636,7 +670,20 @@ impl EditorElement { let mut click_count = event.click_count; let mut modifiers = event.modifiers; - if let Some(hovered_hunk) = hovered_hunk { + if let Some(hovered_hunk) = + position_map + .display_hunks + .iter() + .find_map(|(hunk, hunk_hitbox)| match hunk { + DisplayDiffHunk::Folded { .. } => None, + DisplayDiffHunk::Unfolded { + multi_buffer_range, .. + } => hunk_hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + .then(|| multi_buffer_range.clone()), + }) + { editor.toggle_single_diff_hunk(hovered_hunk, cx); cx.notify(); return; @@ -650,6 +697,7 @@ impl EditorElement { .drag_and_drop_selection .enabled && click_count == 1 + && !modifiers.shift { let newest_anchor = editor.selections.newest_anchor(); let snapshot = editor.snapshot(window, cx); @@ -678,11 +726,11 @@ impl EditorElement { // and run the selection logic. modifiers.alt = false; } else { - let scroll_position_row = - position_map.scroll_pixel_position.y / position_map.line_height; + let scroll_position_row = position_map.scroll_position.y; let display_row = (((event.position - gutter_hitbox.bounds.origin).y - + position_map.scroll_pixel_position.y) / position_map.line_height) + as f64 + + position_map.scroll_position.y) as u32; let multi_buffer_row = position_map .snapshot @@ -708,6 +756,35 @@ impl EditorElement { } } + if !is_singleton { + let display_row = (ScrollPixelOffset::from( + (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, + ) + position_map.scroll_position.y) as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) + .row; + if line_numbers + .get(&MultiBufferRow(multi_buffer_row)) + .and_then(|line_number| line_number.hitbox.as_ref()) + .is_some_and(|hitbox| hitbox.contains(&event.position)) + { + let line_offset_from_top = display_row - position_map.scroll_position.y as u32; + + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + modifiers.alt, + window, + cx, + ); + cx.stop_propagation(); + return; + } + } + let position = point_for_position.previous_valid; if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { editor.select( @@ -717,7 +794,7 @@ impl EditorElement { ColumnarMode::FromMouse => true, ColumnarMode::FromSelection => false, }, - mode: mode, + mode, goal_column: point_for_position.exact_unclipped.column(), }, window, @@ -745,36 +822,6 @@ impl EditorElement { ); } cx.stop_propagation(); - - if !is_singleton { - let display_row = (((event.position - gutter_hitbox.bounds.origin).y - + position_map.scroll_pixel_position.y) - / position_map.line_height) as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) - .row; - if line_numbers - .get(&MultiBufferRow(multi_buffer_row)) - .and_then(|line_number| line_number.hitbox.as_ref()) - .is_some_and(|hitbox| hitbox.contains(&event.position)) - { - let scroll_position_row = - position_map.scroll_pixel_position.y / position_map.line_height; - let line_offset_from_top = display_row - scroll_position_row as u32; - - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - modifiers.alt, - window, - cx, - ); - cx.stop_propagation(); - } - } } fn mouse_right_down( @@ -910,6 +957,11 @@ impl EditorElement { } else if cfg!(any(target_os = "linux", target_os = "freebsd")) && event.button == MouseButton::Middle { + #[allow( + clippy::collapsible_if, + clippy::needless_return, + reason = "The cfg-block below makes this a false positive" + )] if !text_hitbox.is_hovered(window) || editor.read_only(cx) { return; } @@ -1034,11 +1086,14 @@ impl EditorElement { ref mouse_down_time, } => { let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx).drag_and_drop_selection.delay, + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, ); if mouse_down_time.elapsed() >= drag_and_drop_delay { let drop_cursor = Selection { - id: post_inc(&mut editor.selections.next_selection_id), + id: post_inc(&mut editor.selections.next_selection_id()), start: drop_anchor, end: drop_anchor, reversed: false, @@ -1115,26 +1170,24 @@ impl EditorElement { let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control { Some(control_row) - } else { - if text_hovered { - let current_row = valid_point.row(); - position_map.display_hunks.iter().find_map(|(hunk, _)| { - if let DisplayDiffHunk::Unfolded { - display_row_range, .. - } = hunk - { - if display_row_range.contains(¤t_row) { - Some(display_row_range.start) - } else { - None - } + } else if text_hovered { + let current_row = valid_point.row(); + position_map.display_hunks.iter().find_map(|(hunk, _)| { + if let DisplayDiffHunk::Unfolded { + display_row_range, .. + } = hunk + { + if display_row_range.contains(¤t_row) { + Some(display_row_range.start) } else { None } - }) - } else { - None - } + } else { + None + } + }) + } else { + None }; if hovered_diff_hunk_row != editor.hovered_diff_hunk_row { @@ -1142,25 +1195,25 @@ impl EditorElement { cx.notify(); } - if let Some((bounds, blame_entry)) = &position_map.inline_blame_bounds { + if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds { let mouse_over_inline_blame = bounds.contains(&event.position); let mouse_over_popover = editor .inline_blame_popover .as_ref() .and_then(|state| state.popover_bounds) - .map_or(false, |bounds| bounds.contains(&event.position)); + .is_some_and(|bounds| bounds.contains(&event.position)); let keyboard_grace = editor .inline_blame_popover .as_ref() - .map_or(false, |state| state.keyboard_grace); + .is_some_and(|state| state.keyboard_grace); if mouse_over_inline_blame || mouse_over_popover { - editor.show_blame_popover(&blame_entry, event.position, false, cx); + editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); } else if !keyboard_grace { - editor.hide_blame_popover(cx); + editor.hide_blame_popover(false, cx); } } else { - editor.hide_blame_popover(cx); + editor.hide_blame_popover(false, cx); } let breakpoint_indicator = if gutter_hovered { @@ -1170,7 +1223,7 @@ impl EditorElement { if let Some((buffer_snapshot, file)) = position_map .snapshot - .buffer_snapshot + .buffer_snapshot() .buffer_for_excerpt(buffer_anchor.excerpt_id) .and_then(|buffer| buffer.file().map(|file| (buffer, file))) { @@ -1179,10 +1232,10 @@ impl EditorElement { let is_visible = editor .gutter_breakpoint_indicator .0 - .map_or(false, |indicator| indicator.is_active); + .is_some_and(|indicator| indicator.is_active); let has_existing_breakpoint = - editor.breakpoint_store.as_ref().map_or(false, |store| { + editor.breakpoint_store.as_ref().is_some_and(|store| { let Some(project) = &editor.project else { return false; }; @@ -1252,7 +1305,7 @@ impl EditorElement { if let Some(point) = point_for_position.as_valid() { let anchor = position_map .snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); hover_at(editor, Some(anchor), window, cx); Self::update_visible_cursor(editor, point, position_map, window, cx); @@ -1289,11 +1342,11 @@ impl EditorElement { ); let range = snapshot - .buffer_snapshot - .anchor_at(start.to_point(&snapshot.display_snapshot), Bias::Left) + .buffer_snapshot() + .anchor_before(start.to_point(&snapshot.display_snapshot)) ..snapshot - .buffer_snapshot - .anchor_at(end.to_point(&snapshot.display_snapshot), Bias::Right); + .buffer_snapshot() + .anchor_after(end.to_point(&snapshot.display_snapshot)); let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else { return; @@ -1343,14 +1396,14 @@ impl EditorElement { editor_with_selections.update(cx, |editor, cx| { if editor.show_local_selections { let mut layouts = Vec::new(); - let newest = editor.selections.newest(cx); + let newest = editor.selections.newest(&editor.display_snapshot(cx)); for selection in local_selections.iter().cloned() { let is_empty = selection.start == selection.end; let is_newest = selection == newest; let layout = SelectionLayout::new( selection, - editor.selections.line_mode, + editor.selections.line_mode(), editor.cursor_shape, &snapshot.display_snapshot, is_newest, @@ -1372,7 +1425,11 @@ impl EditorElement { layouts.push(layout); } - let player = editor.current_user_player_color(cx); + let mut player = editor.current_user_player_color(cx); + if !editor.is_focused(window) { + const UNFOCUS_EDITOR_SELECTION_OPACITY: f32 = 0.5; + player.selection = player.selection.opacity(UNFOCUS_EDITOR_SELECTION_OPACITY); + } selections.push((player, layouts)); if let SelectionDragState::Dragging { @@ -1380,29 +1437,27 @@ impl EditorElement { ref drop_cursor, ref hide_drop_cursor, } = editor.selection_drag_state + && !hide_drop_cursor + && (drop_cursor + .start + .cmp(&selection.start, &snapshot.buffer_snapshot()) + .eq(&Ordering::Less) + || drop_cursor + .end + .cmp(&selection.end, &snapshot.buffer_snapshot()) + .eq(&Ordering::Greater)) { - if !hide_drop_cursor - && (drop_cursor - .start - .cmp(&selection.start, &snapshot.buffer_snapshot) - .eq(&Ordering::Less) - || drop_cursor - .end - .cmp(&selection.end, &snapshot.buffer_snapshot) - .eq(&Ordering::Greater)) - { - let drag_cursor_layout = SelectionLayout::new( - drop_cursor.clone(), - false, - CursorShape::Bar, - &snapshot.display_snapshot, - false, - false, - None, - ); - let absent_color = cx.theme().players().absent(); - selections.push((absent_color, vec![drag_cursor_layout])); - } + let drag_cursor_layout = SelectionLayout::new( + drop_cursor.clone(), + false, + CursorShape::Bar, + &snapshot.display_snapshot, + false, + false, + None, + ); + let absent_color = cx.theme().players().absent(); + selections.push((absent_color, vec![drag_cursor_layout])); } } @@ -1413,19 +1468,15 @@ impl EditorElement { CollaboratorId::PeerId(peer_id) => { if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&peer_id) - { - if let Some(participant_index) = collaboration_hub + && let Some(participant_index) = collaboration_hub .user_participant_indices(cx) .get(&collaborator.user_id) - { - if let Some((local_selection_style, _)) = selections.first_mut() - { - *local_selection_style = cx - .theme() - .players() - .color_for_participant(participant_index.0); - } - } + && let Some((local_selection_style, _)) = selections.first_mut() + { + *local_selection_style = cx + .theme() + .players() + .color_for_participant(participant_index.0); } } CollaboratorId::Agent => { @@ -1473,7 +1524,7 @@ impl EditorElement { selections.extend(remote_selections.into_values()); } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused { let layouts = snapshot - .buffer_snapshot + .buffer_snapshot() .selections_in_range(&(start_anchor..end_anchor), true) .map(move |(_, line_mode, cursor_shape, selection)| { SelectionLayout::new( @@ -1491,6 +1542,15 @@ impl EditorElement { selections.push((player, layouts)); } }); + + #[cfg(debug_assertions)] + Self::layout_debug_ranges( + &mut selections, + start_anchor..end_anchor, + &snapshot.display_snapshot, + cx, + ); + (selections, active_rows, newest_selection_head) } @@ -1524,9 +1584,13 @@ impl EditorElement { // Local cursors if !skip_local { let color = cx.theme().players().local().cursor; - editor.selections.disjoint.iter().for_each(|selection| { - add_cursor(selection.head(), color); - }); + editor + .selections + .disjoint_anchors() + .iter() + .for_each(|selection| { + add_cursor(selection.head(), color); + }); if let Some(ref selection) = editor.selections.pending_anchor() { add_cursor(selection.head(), color); } @@ -1543,8 +1607,8 @@ impl EditorElement { line_layouts: &[LineWithInvisibles], text_hitbox: &Hitbox, content_origin: gpui::Point, - scroll_position: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_height: Pixels, em_width: Pixels, em_advance: Pixels, @@ -1595,12 +1659,13 @@ impl EditorElement { .map(|text| { let len = text.len(); - let font = cursor_row_layout + let mut font = cursor_row_layout .font_id_for_index(cursor_column) .and_then(|cursor_font_id| { window.text_system().get_font_for_id(cursor_font_id) }) .unwrap_or(self.style.text.font()); + font.features = self.style.text.font_features.clone(); // Invert the text color for the block cursor. Ensure that the text // color is opaque enough to be visible against the background color. @@ -1636,10 +1701,10 @@ impl EditorElement { None }; - let x = cursor_character_x - scroll_pixel_position.x; - let y = (cursor_position.row().as_f32() - - scroll_pixel_position.y / line_height) - * line_height; + let x = cursor_character_x - scroll_pixel_position.x.into(); + let y = ((cursor_position.row().as_f64() - scroll_position.y) + * ScrollPixelOffset::from(line_height)) + .into(); if selection.is_newest { editor.pixel_position_of_newest_cursor = Some(point( text_hitbox.origin.x + x + block_width / 2., @@ -1648,19 +1713,27 @@ impl EditorElement { if autoscroll_containing_element { let top = text_hitbox.origin.y - + (cursor_position.row().as_f32() - scroll_position.y - 3.).max(0.) - * line_height; + + ((cursor_position.row().as_f64() - scroll_position.y - 3.) + .max(0.) + * ScrollPixelOffset::from(line_height)) + .into(); let left = text_hitbox.origin.x - + (cursor_position.column() as f32 - scroll_position.x - 3.) + + ((cursor_position.column() as ScrollOffset + - scroll_position.x + - 3.) .max(0.) - * em_width; + * ScrollPixelOffset::from(em_width)) + .into(); let bottom = text_hitbox.origin.y - + (cursor_position.row().as_f32() - scroll_position.y + 4.) - * line_height; + + ((cursor_position.row().as_f64() - scroll_position.y + 4.) + * ScrollPixelOffset::from(line_height)) + .into(); let right = text_hitbox.origin.x - + (cursor_position.column() as f32 - scroll_position.x + 4.) - * em_width; + + ((cursor_position.column() as ScrollOffset - scroll_position.x + + 4.) + * ScrollPixelOffset::from(em_width)) + .into(); autoscroll_bounds = Some(Bounds::from_corners(point(left, top), point(right, bottom))) @@ -1701,7 +1774,7 @@ impl EditorElement { snapshot: &EditorSnapshot, scrollbar_layout_information: &ScrollbarLayoutInformation, content_offset: gpui::Point, - scroll_position: gpui::Point, + scroll_position: gpui::Point, non_visible_cursors: bool, right_margin: Pixels, editor_width: Pixels, @@ -1728,9 +1801,9 @@ impl EditorElement { let show_scrollbars = match scrollbar_settings.show { ShowScrollbar::Auto => { let editor = self.editor.read(cx); - let is_singleton = editor.is_singleton(cx); + let is_singleton = editor.buffer_kind(cx) == ItemBufferKind::Singleton; // Git - (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_diff_hunks()) + (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot().has_diff_hunks()) || // Buffer Search Results (is_singleton && scrollbar_settings.search_results && editor.has_background_highlights::()) @@ -1742,7 +1815,7 @@ impl EditorElement { (is_singleton && scrollbar_settings.selected_symbol && (editor.has_background_highlights::() || editor.has_background_highlights::())) || // Diagnostics - (is_singleton && scrollbar_settings.diagnostics != ScrollbarDiagnostics::None && snapshot.buffer_snapshot.has_diagnostics()) + (is_singleton && scrollbar_settings.diagnostics != ScrollbarDiagnostics::None && snapshot.buffer_snapshot().has_diagnostics()) || // Cursors out of sight non_visible_cursors @@ -1789,7 +1862,7 @@ impl EditorElement { &self, snapshot: &EditorSnapshot, minimap_width: Pixels, - scroll_position: gpui::Point, + scroll_position: gpui::Point, scrollbar_layout_information: &ScrollbarLayoutInformation, scrollbar_layout: Option<&EditorScrollbars>, window: &mut Window, @@ -1864,9 +1937,9 @@ impl EditorElement { ); let minimap_height = minimap_bounds.size.height; - let visible_editor_lines = editor_bounds.size.height / line_height; - let total_editor_lines = scroll_range.height / line_height; - let minimap_lines = minimap_height / minimap_line_height; + let visible_editor_lines = (editor_bounds.size.height / line_height) as f64; + let total_editor_lines = (scroll_range.height / line_height) as f64; + let minimap_lines = (minimap_height / minimap_line_height) as f64; let minimap_scroll_top = MinimapLayout::calculate_minimap_top_offset( total_editor_lines, @@ -1972,7 +2045,7 @@ impl EditorElement { line_height: Pixels, gutter_dimensions: &GutterDimensions, gutter_settings: crate::editor_settings::Gutter, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, gutter_hitbox: &Hitbox, window: &mut Window, cx: &mut App, @@ -1988,7 +2061,8 @@ impl EditorElement { let position = point( gutter_dimensions.width - gutter_dimensions.right_padding, - ix as f32 * line_height - (scroll_pixel_position.y % line_height), + ix as f32 * line_height + - (scroll_pixel_position.y % ScrollPixelOffset::from(line_height)).into(), ); let centering_offset = point( (gutter_dimensions.fold_area_width() - crease_toggle_size.width) / 2., @@ -2019,7 +2093,7 @@ impl EditorElement { lines: &[LineWithInvisibles], line_height: Pixels, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, em_width: Pixels, window: &mut Window, cx: &mut App, @@ -2042,8 +2116,9 @@ impl EditorElement { 4. * em_width }; let position = point( - scroll_pixel_position.x + line.width + padding, - ix as f32 * line_height - (scroll_pixel_position.y % line_height), + Pixels::from(scroll_pixel_position.x) + line.width + padding, + ix as f32 * line_height + - (scroll_pixel_position.y % ScrollPixelOffset::from(line_height)).into(), ); let centering_offset = point(px(0.), (line_height - size.height) / 2.); let origin = content_origin + position + centering_offset; @@ -2072,10 +2147,7 @@ impl EditorElement { .display_diff_hunks_for_rows(display_rows, folded_buffers) .map(|hunk| (hunk, None)) .collect::>(); - let git_gutter_setting = ProjectSettings::get_global(cx) - .git - .git_gutter - .unwrap_or_default(); + let git_gutter_setting = ProjectSettings::get_global(cx).git.git_gutter; if let GitGutterSetting::TrackedFiles = git_gutter_setting { for (hunk, hitbox) in &mut display_hunks { if matches!(hunk, DisplayDiffHunk::Unfolded { .. }) { @@ -2095,7 +2167,8 @@ impl EditorElement { crease_trailers: &[Option], row_block_types: &HashMap, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, edit_prediction_popover_origin: Option>, start_row: DisplayRow, end_row: DisplayRow, @@ -2168,11 +2241,13 @@ impl EditorElement { }; let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width; - let min_x = ProjectSettings::get_global(cx) - .diagnostics - .inline - .min_column as f32 - * em_width; + let min_x = self.column_pixels( + ProjectSettings::get_global(cx) + .diagnostics + .inline + .min_column as usize, + window, + ); let mut elements = HashMap::default(); for (row, mut diagnostics) in diagnostics_by_rows { @@ -2193,8 +2268,7 @@ impl EditorElement { continue; }; - let pos_y = content_origin.y - + line_height * (row.0 as f32 - scroll_pixel_position.y / line_height); + let pos_y = content_origin.y + line_height * (row.0 as f64 - scroll_position.y) as f32; let window_ix = row.0.saturating_sub(start_row.0) as usize; let pos_x = { @@ -2204,21 +2278,25 @@ impl EditorElement { let line_end = if let Some(crease_trailer) = crease_trailer_layout { crease_trailer.bounds.right() } else { - content_origin.x - scroll_pixel_position.x + line_layout.width + Pixels::from( + ScrollPixelOffset::from(content_origin.x + line_layout.width) + - scroll_pixel_position.x, + ) }; let padded_line = line_end + padding; - let min_start = content_origin.x - scroll_pixel_position.x + min_x; + let min_start = Pixels::from( + ScrollPixelOffset::from(content_origin.x + min_x) - scroll_pixel_position.x, + ); cmp::max(padded_line, min_start) }; - let behind_edit_prediction_popover = edit_prediction_popover_origin.as_ref().map_or( - false, - |edit_prediction_popover_origin| { + let behind_edit_prediction_popover = edit_prediction_popover_origin + .as_ref() + .is_some_and(|edit_prediction_popover_origin| { (pos_y..pos_y + line_height).contains(&edit_prediction_popover_origin.y) - }, - ); + }); let opacity = if behind_edit_prediction_popover { 0.5 } else { @@ -2253,7 +2331,8 @@ impl EditorElement { &self, display_point: DisplayPoint, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_height: Pixels, snapshot: &EditorSnapshot, window: &mut Window, @@ -2284,9 +2363,7 @@ impl EditorElement { None } }) - .map_or(false, |source| { - matches!(source, CodeActionSource::Indicator(..)) - }); + .is_some_and(|source| matches!(source, CodeActionSource::Indicator(..))); Some(editor.render_inline_code_actions(icon_size, display_point.row(), active, cx)) })?; @@ -2300,7 +2377,7 @@ impl EditorElement { // do not show code action for blank line with cursor let line_indent = snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .line_indent_for_row(MultiBufferRow(buffer_point.row)); if line_indent.is_line_blank() { return None; @@ -2311,7 +2388,7 @@ impl EditorElement { let excerpt_id = snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .excerpt_containing(buffer_point..buffer_point) .map(|excerpt| excerpt.id()); @@ -2332,7 +2409,7 @@ impl EditorElement { }; let candidate_excerpt_id = snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .excerpt_containing(candidate_point..candidate_point) .map(|excerpt| excerpt.id()); // move to other row if different excerpt @@ -2342,7 +2419,7 @@ impl EditorElement { } let line_indent = snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .line_indent_for_row(MultiBufferRow(row_candidate)); // use this row if it's blank if line_indent.is_line_blank() { @@ -2351,7 +2428,7 @@ impl EditorElement { // use this row if code starts after slot let indent_size = snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .indent_size_for_line(MultiBufferRow(row_candidate)); indent_size.len >= INLINE_SLOT_CHAR_LIMIT } @@ -2360,7 +2437,7 @@ impl EditorElement { let new_buffer_row = if is_valid_row(buffer_point.row) { Some(buffer_point.row) } else { - let max_row = snapshot.display_snapshot.buffer_snapshot.max_point().row; + let max_row = snapshot.display_snapshot.buffer_snapshot().max_point().row; (1..=MAX_ALTERNATE_DISTANCE).find_map(|offset| { let row_above = buffer_point.row.saturating_sub(offset); let row_below = buffer_point.row + offset; @@ -2386,10 +2463,12 @@ impl EditorElement { .row(); let start_y = content_origin.y - + ((new_display_row.as_f32() - (scroll_pixel_position.y / line_height)) * line_height) + + (((new_display_row.as_f64() - scroll_position.y) as f32) * line_height) + (line_height / 2.0) - (icon_size.square(window, cx) / 2.); - let start_x = content_origin.x - scroll_pixel_position.x + (window.rem_size() * 0.1); + let start_x = (ScrollPixelOffset::from(content_origin.x) - scroll_pixel_position.x + + ScrollPixelOffset::from(window.rem_size() * 0.1)) + .into(); let absolute_offset = gpui::point(start_x, start_y); button.layout_as_root(gpui::AvailableSpace::min_size(), window, cx); @@ -2410,7 +2489,8 @@ impl EditorElement { crease_trailer: Option<&CreaseTrailerLayout>, em_width: Pixels, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_height: Pixels, text_hitbox: &Hitbox, window: &mut Window, @@ -2428,26 +2508,21 @@ impl EditorElement { let padding = { const INLINE_ACCEPT_SUGGESTION_EM_WIDTHS: f32 = 14.; - let mut padding = ProjectSettings::get_global(cx) - .git - .inline_blame - .unwrap_or_default() - .padding as f32; + let mut padding = ProjectSettings::get_global(cx).git.inline_blame.padding as f32; - if let Some(edit_prediction) = editor.active_edit_prediction.as_ref() { - match &edit_prediction.completion { - EditPrediction::Edit { - display_mode: EditDisplayMode::TabAccept, - .. - } => padding += INLINE_ACCEPT_SUGGESTION_EM_WIDTHS, - _ => {} - } + if let Some(edit_prediction) = editor.active_edit_prediction.as_ref() + && let EditPrediction::Edit { + display_mode: EditDisplayMode::TabAccept, + .. + } = &edit_prediction.completion + { + padding += INLINE_ACCEPT_SUGGESTION_EM_WIDTHS } padding * em_width }; - let entry = blame + let (buffer_id, entry) = blame .update(cx, |blame, cx| { blame.blame_for_rows(&[*row_info], cx).next() }) @@ -2455,25 +2530,29 @@ impl EditorElement { let mut element = render_inline_blame_entry(entry.clone(), &self.style, cx)?; - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + let start_y = + content_origin.y + line_height * ((display_row.as_f64() - scroll_position.y) as f32); let start_x = { let line_end = if let Some(crease_trailer) = crease_trailer { crease_trailer.bounds.right() } else { - content_origin.x - scroll_pixel_position.x + line_layout.width + Pixels::from( + ScrollPixelOffset::from(content_origin.x + line_layout.width) + - scroll_pixel_position.x, + ) }; let padded_line_end = line_end + padding; - let min_column_in_pixels = ProjectSettings::get_global(cx) - .git - .inline_blame - .map(|settings| settings.min_column) - .map(|col| self.column_pixels(col as usize, window)) - .unwrap_or(px(0.)); - let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; + let min_column_in_pixels = self.column_pixels( + ProjectSettings::get_global(cx).git.inline_blame.min_column as usize, + window, + ); + let min_start = Pixels::from( + ScrollPixelOffset::from(content_origin.x + min_column_in_pixels) + - scroll_pixel_position.x, + ); cmp::max(padded_line_end, min_start) }; @@ -2482,13 +2561,22 @@ impl EditorElement { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let bounds = Bounds::new(absolute_offset, size); - self.layout_blame_entry_popover(entry.clone(), blame, line_height, text_hitbox, window, cx); + self.layout_blame_entry_popover( + entry.clone(), + blame, + line_height, + text_hitbox, + row_info.buffer_id?, + window, + cx, + ); element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx); Some(InlineBlameLayout { element, bounds, + buffer_id, entry, }) } @@ -2499,6 +2587,7 @@ impl EditorElement { blame: Entity, line_height: Pixels, text_hitbox: &Hitbox, + buffer: BufferId, window: &mut Window, cx: &mut App, ) { @@ -2523,6 +2612,7 @@ impl EditorElement { popover_state.markdown, workspace, &blame, + buffer, window, cx, ) @@ -2562,7 +2652,7 @@ impl EditorElement { &self, buffer_rows: &[RowInfo], em_width: Pixels, - scroll_position: gpui::Point, + scroll_position: gpui::Point, line_height: Pixels, gutter_hitbox: &Hitbox, max_width: Option, @@ -2587,29 +2677,33 @@ impl EditorElement { } else { AvailableSpace::MaxContent }; - let scroll_top = scroll_position.y * line_height; + let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); let start_x = em_width; - let mut last_used_color: Option<(PlayerColor, Oid)> = None; + let mut last_used_color: Option<(Hsla, Oid)> = None; let blame_renderer = cx.global::().0.clone(); let shaped_lines = blamed_rows .into_iter() .enumerate() .flat_map(|(ix, blame_entry)| { + let (buffer_id, blame_entry) = blame_entry?; let mut element = render_blame_entry( ix, &blame, - blame_entry?, + blame_entry, &self.style, &mut last_used_color, self.editor.clone(), workspace.clone(), - blame_renderer.clone(), + buffer_id, + &*blame_renderer, + window, cx, )?; - let start_y = ix as f32 * line_height - (scroll_top % line_height); + let start_y = ix as f32 * line_height + - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)); let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); element.prepaint_as_root( @@ -2631,7 +2725,7 @@ impl EditorElement { content_origin: gpui::Point, text_origin: gpui::Point, visible_buffer_range: Range, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_height: Pixels, snapshot: &DisplaySnapshot, window: &mut Window, @@ -2655,7 +2749,10 @@ impl EditorElement { let single_indent_width = self.column_pixels(indent_guide.tab_size as usize, window); let total_width = single_indent_width * indent_guide.depth as f32; - let start_x = content_origin.x + total_width - scroll_pixel_position.x; + let start_x = Pixels::from( + ScrollOffset::from(content_origin.x + total_width) + - scroll_pixel_position.x, + ); if start_x >= text_origin.x { let (offset_y, length) = Self::calculate_indent_guide_bounds( indent_guide.start_row..indent_guide.end_row, @@ -2663,7 +2760,10 @@ impl EditorElement { snapshot, ); - let start_y = content_origin.y + offset_y - scroll_pixel_position.y; + let start_y = Pixels::from( + ScrollOffset::from(content_origin.y) + offset_y + - scroll_pixel_position.y, + ); Some(IndentGuideLayout { origin: point(start_x, start_y), @@ -2684,7 +2784,7 @@ impl EditorElement { fn layout_wrap_guides( &self, em_advance: Pixels, - scroll_position: gpui::Point, + scroll_position: gpui::Point, content_origin: gpui::Point, scrollbar_layout: Option<&EditorScrollbars>, vertical_scrollbar_width: Pixels, @@ -2692,7 +2792,7 @@ impl EditorElement { window: &Window, cx: &App, ) -> SmallVec<[(Pixels, bool); 2]> { - let scroll_left = scroll_position.x * em_advance; + let scroll_left = scroll_position.x as f32 * em_advance; let content_origin = content_origin.x; let horizontal_offset = content_origin - scroll_left; let vertical_scrollbar_width = scrollbar_layout @@ -2718,7 +2818,7 @@ impl EditorElement { row_range: Range, line_height: Pixels, snapshot: &DisplaySnapshot, - ) -> (gpui::Pixels, gpui::Pixels) { + ) -> (f64, gpui::Pixels) { let start_point = Point::new(row_range.start.0, 0); let end_point = Point::new(row_range.end.0, 0); @@ -2733,7 +2833,7 @@ impl EditorElement { cons_line.row += 1; let cons_line = cons_line.to_display_point(snapshot).row(); - let mut offset_y = row_range.start.0 as f32 * line_height; + let mut offset_y = row_range.start.as_f64() * f64::from(line_height); let mut length = (cons_line.0.saturating_sub(row_range.start.0)) as f32 * line_height; // If we are at the end of the buffer, ensure that the indent guide extends to the end of the line. @@ -2747,7 +2847,10 @@ impl EditorElement { let mut block_offset = 0; let mut found_excerpt_header = false; for (_, block) in snapshot.blocks_in_range(prev_line..row_range.start) { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { found_excerpt_header = true; break; } @@ -2755,7 +2858,7 @@ impl EditorElement { block_height += block.height(); } if !found_excerpt_header { - offset_y -= block_offset as f32 * line_height; + offset_y -= block_offset as f64 * f64::from(line_height); length += block_height as f32 * line_height; } @@ -2764,7 +2867,10 @@ impl EditorElement { let mut block_height = 0; let mut found_excerpt_header = false; for (_, block) in snapshot.blocks_in_range(row_range.end..cons_line) { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { found_excerpt_header = true; } block_height += block.height(); @@ -2780,7 +2886,7 @@ impl EditorElement { &self, line_height: Pixels, range: Range, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, display_hunks: &[(DisplayDiffHunk, Option)], @@ -2811,7 +2917,7 @@ impl EditorElement { } let row = - MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(&snapshot).row); + MultiBufferRow(DisplayPoint::new(display_row, 0).to_point(snapshot).row); if snapshot.is_line_folded(row) { return None; } @@ -2823,7 +2929,7 @@ impl EditorElement { display_row, line_height, gutter_dimensions, - scroll_pixel_position, + scroll_position, gutter_hitbox, display_hunks, window, @@ -2841,7 +2947,7 @@ impl EditorElement { line_height: Pixels, range: Range, row_infos: &[RowInfo], - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, display_hunks: &[(DisplayDiffHunk, Option)], @@ -2880,7 +2986,7 @@ impl EditorElement { .tasks .iter() .filter_map(|(_, tasks)| { - let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot); + let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); if multibuffer_point < offset_range_start || multibuffer_point > offset_range_end { @@ -2888,7 +2994,7 @@ impl EditorElement { } let multibuffer_row = MultiBufferRow(multibuffer_point.row); let buffer_folded = snapshot - .buffer_snapshot + .buffer_snapshot() .buffer_line_for_row(multibuffer_row) .map(|(buffer_snapshot, _)| buffer_snapshot.remote_id()) .map(|buffer_id| editor.is_buffer_folded(buffer_id, cx)) @@ -2902,7 +3008,7 @@ impl EditorElement { if multibuffer_row .0 .checked_sub(1) - .map_or(false, |previous_row| { + .is_some_and(|previous_row| { snapshot.is_line_folded(MultiBufferRow(previous_row)) }) { @@ -2934,7 +3040,7 @@ impl EditorElement { display_row, line_height, gutter_dimensions, - scroll_pixel_position, + scroll_position, gutter_hitbox, display_hunks, window, @@ -2952,7 +3058,7 @@ impl EditorElement { gutter_dimensions: GutterDimensions, em_width: Pixels, line_height: Pixels, - scroll_position: gpui::Point, + scroll_position: gpui::Point, buffer_rows: &[RowInfo], window: &mut Window, cx: &mut App, @@ -2963,7 +3069,7 @@ impl EditorElement { let editor_font_size = self.style.text.font_size.to_pixels(window.rem_size()) * 1.2; - let scroll_top = scroll_position.y * line_height; + let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); let max_line_number_length = self .editor @@ -2975,8 +3081,14 @@ impl EditorElement { .ilog10() + 1; - let elements = buffer_rows - .into_iter() + let git_gutter_width = Self::gutter_strip_width(line_height) + + gutter_dimensions + .git_blame_entries_width + .unwrap_or_default(); + let available_width = gutter_dimensions.left_padding - git_gutter_width; + + buffer_rows + .iter() .enumerate() .map(|(ix, row_info)| { let ExpandInfo { @@ -2990,9 +3102,6 @@ impl EditorElement { ExpandExcerptDirection::UpAndDown => IconName::ExpandVertical, }; - let git_gutter_width = Self::gutter_strip_width(line_height); - let available_width = gutter_dimensions.left_padding - git_gutter_width; - let editor = self.editor.clone(); let is_wide = max_line_number_length >= EditorSettings::get_global(cx).gutter.min_line_number_digits as u32 @@ -3025,15 +3134,15 @@ impl EditorElement { let position = point( git_gutter_width + px(1.), - ix as f32 * line_height - (scroll_top % line_height) + px(1.), + ix as f32 * line_height + - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)) + + px(1.), ); let origin = gutter_hitbox.origin + position; Some((toggle, origin)) }) - .collect(); - - elements + .collect() } fn calculate_relative_line_numbers( @@ -3068,7 +3177,7 @@ impl EditorElement { i += 1; } delta = 1; - i = head_idx.min(buffer_rows.len() as u32 - 1); + i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32); while i > 0 && buffer_rows[i as usize].buffer_row.is_none() { i -= 1; } @@ -3091,7 +3200,7 @@ impl EditorElement { gutter_hitbox: Option<&Hitbox>, gutter_dimensions: GutterDimensions, line_height: Pixels, - scroll_position: gpui::Point, + scroll_position: gpui::Point, rows: Range, buffer_rows: &[RowInfo], active_rows: &BTreeMap, @@ -3109,10 +3218,12 @@ impl EditorElement { let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| { let newest_selection_head = newest_selection_head.unwrap_or_else(|| { - let newest = editor.selections.newest::(cx); + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); SelectionLayout::new( newest, - editor.selections.line_mode, + editor.selections.line_mode(), editor.cursor_shape, &snapshot.display_snapshot, true, @@ -3133,7 +3244,7 @@ impl EditorElement { let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to); let mut line_number = String::new(); let line_numbers = buffer_rows - .into_iter() + .iter() .enumerate() .flat_map(|(ix, row_info)| { let display_row = DisplayRow(rows.start.0 + ix as u32); @@ -3162,12 +3273,13 @@ impl EditorElement { .unwrap_or_else(|| cx.theme().colors().editor_line_number); let shaped_line = self.shape_line_number(SharedString::from(&line_number), color, window); - let scroll_top = scroll_position.y * line_height; + let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); let line_origin = gutter_hitbox.map(|hitbox| { hitbox.origin + point( hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding, - ix as f32 * line_height - (scroll_top % line_height), + ix as f32 * line_height + - Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)), ) }); @@ -3207,10 +3319,10 @@ impl EditorElement { ) -> Vec> { let include_fold_statuses = EditorSettings::get_global(cx).gutter.folds && snapshot.mode.is_full() - && self.editor.read(cx).is_singleton(cx); + && self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton; if include_fold_statuses { row_infos - .into_iter() + .iter() .enumerate() .map(|(ix, info)| { if info.expand_info.is_some() { @@ -3250,12 +3362,165 @@ impl EditorElement { .collect() } + fn bg_segments_per_row( + rows: Range, + selections: &[(PlayerColor, Vec)], + highlight_ranges: &[(Range, Hsla)], + base_background: Hsla, + ) -> Vec, Hsla)>> { + if rows.start >= rows.end { + return Vec::new(); + } + if !base_background.is_opaque() { + // We don't actually know what color is behind this editor. + return Vec::new(); + } + let highlight_iter = highlight_ranges.iter().cloned(); + let selection_iter = selections.iter().flat_map(|(player_color, layouts)| { + let color = player_color.selection; + layouts.iter().filter_map(move |selection_layout| { + if selection_layout.range.start != selection_layout.range.end { + Some((selection_layout.range.clone(), color)) + } else { + None + } + }) + }); + let mut per_row_map = vec![Vec::new(); rows.len()]; + for (range, color) in highlight_iter.chain(selection_iter) { + let covered_rows = if range.end.column() == 0 { + cmp::max(range.start.row(), rows.start)..cmp::min(range.end.row(), rows.end) + } else { + cmp::max(range.start.row(), rows.start) + ..cmp::min(range.end.row().next_row(), rows.end) + }; + for row in covered_rows.iter_rows() { + let seg_start = if row == range.start.row() { + range.start + } else { + DisplayPoint::new(row, 0) + }; + let seg_end = if row == range.end.row() && range.end.column() != 0 { + range.end + } else { + DisplayPoint::new(row, u32::MAX) + }; + let ix = row.minus(rows.start) as usize; + debug_assert!(row >= rows.start && row < rows.end); + debug_assert!(ix < per_row_map.len()); + per_row_map[ix].push((seg_start..seg_end, color)); + } + } + for row_segments in per_row_map.iter_mut() { + if row_segments.is_empty() { + continue; + } + let segments = mem::take(row_segments); + let merged = Self::merge_overlapping_ranges(segments, base_background); + *row_segments = merged; + } + per_row_map + } + + /// Merge overlapping ranges by splitting at all range boundaries and blending colors where + /// multiple ranges overlap. The result contains non-overlapping ranges ordered from left to right. + /// + /// Expects `start.row() == end.row()` for each range. + fn merge_overlapping_ranges( + ranges: Vec<(Range, Hsla)>, + base_background: Hsla, + ) -> Vec<(Range, Hsla)> { + struct Boundary { + pos: DisplayPoint, + is_start: bool, + index: usize, + color: Hsla, + } + + let mut boundaries: SmallVec<[Boundary; 16]> = SmallVec::with_capacity(ranges.len() * 2); + for (index, (range, color)) in ranges.iter().enumerate() { + debug_assert!( + range.start.row() == range.end.row(), + "expects single-row ranges" + ); + if range.start < range.end { + boundaries.push(Boundary { + pos: range.start, + is_start: true, + index, + color: *color, + }); + boundaries.push(Boundary { + pos: range.end, + is_start: false, + index, + color: *color, + }); + } + } + + if boundaries.is_empty() { + return Vec::new(); + } + + boundaries + .sort_unstable_by(|a, b| a.pos.cmp(&b.pos).then_with(|| a.is_start.cmp(&b.is_start))); + + let mut processed_ranges: Vec<(Range, Hsla)> = Vec::new(); + let mut active_ranges: SmallVec<[(usize, Hsla); 8]> = SmallVec::new(); + + let mut i = 0; + let mut start_pos = boundaries[0].pos; + + let boundaries_len = boundaries.len(); + while i < boundaries_len { + let current_boundary_pos = boundaries[i].pos; + if start_pos < current_boundary_pos { + if !active_ranges.is_empty() { + let mut color = base_background; + for &(_, c) in &active_ranges { + color = Hsla::blend(color, c); + } + if let Some((last_range, last_color)) = processed_ranges.last_mut() { + if *last_color == color && last_range.end == start_pos { + last_range.end = current_boundary_pos; + } else { + processed_ranges.push((start_pos..current_boundary_pos, color)); + } + } else { + processed_ranges.push((start_pos..current_boundary_pos, color)); + } + } + } + while i < boundaries_len && boundaries[i].pos == current_boundary_pos { + let active_range = &boundaries[i]; + if active_range.is_start { + let idx = active_range.index; + let pos = active_ranges + .binary_search_by_key(&idx, |(i, _)| *i) + .unwrap_or_else(|p| p); + active_ranges.insert(pos, (idx, active_range.color)); + } else { + let idx = active_range.index; + if let Ok(pos) = active_ranges.binary_search_by_key(&idx, |(i, _)| *i) { + active_ranges.remove(pos); + } + } + i += 1; + } + start_pos = current_boundary_pos; + } + + processed_ranges + } + fn layout_lines( rows: Range, snapshot: &EditorSnapshot, style: &EditorStyle, editor_width: Pixels, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + bg_segments_per_row: &[Vec<(Range, Hsla)>], window: &mut Window, cx: &mut App, ) -> Vec { @@ -3271,12 +3536,15 @@ impl EditorElement { let placeholder_lines = placeholder_text .as_ref() - .map_or("", AsRef::as_ref) - .split('\n') + .map_or(Vec::new(), |text| text.split('\n').collect::>()); + + let placeholder_line_count = placeholder_lines.len(); + + placeholder_lines + .into_iter() .skip(rows.start.0 as usize) .chain(iter::repeat("")) - .take(rows.len()); - placeholder_lines + .take(cmp::max(rows.len(), placeholder_line_count)) .map(move |line| { let run = TextRun { len: line.len(), @@ -3305,12 +3573,13 @@ impl EditorElement { let chunks = snapshot.highlighted_chunks(rows.clone(), true, style); LineWithInvisibles::from_chunks( chunks, - &style, + style, MAX_LINE_LEN, rows.len(), &snapshot.mode, editor_width, is_row_soft_wrapped, + bg_segments_per_row, window, cx, ) @@ -3322,7 +3591,8 @@ impl EditorElement { start_row: DisplayRow, line_layouts: &mut [LineWithInvisibles], line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, content_origin: gpui::Point, window: &mut Window, cx: &mut App, @@ -3332,6 +3602,7 @@ impl EditorElement { let row = start_row + DisplayRow(ix as u32); line.prepaint( line_height, + scroll_position, scroll_pixel_position, row, content_origin, @@ -3371,8 +3642,8 @@ impl EditorElement { let mut x_position = None; let mut element = match block { Block::Custom(custom) => { - let block_start = custom.start().to_point(&snapshot.buffer_snapshot); - let block_end = custom.end().to_point(&snapshot.buffer_snapshot); + let block_start = custom.start().to_point(&snapshot.buffer_snapshot()); + let block_end = custom.end().to_point(&snapshot.buffer_snapshot()); if block.place_near() && snapshot.is_line_folded(MultiBufferRow(block_start.row)) { return None; } @@ -3386,7 +3657,7 @@ impl EditorElement { let line_ix = align_to.row().0.checked_sub(rows.start.0); x_position = if let Some(layout) = line_ix.and_then(|ix| line_layouts.get(ix as usize)) { - x_and_width(&layout) + x_and_width(layout) } else { x_and_width(&layout_line( align_to.row(), @@ -3452,42 +3723,41 @@ impl EditorElement { .into_any_element() } - Block::ExcerptBoundary { - excerpt, - height, - starts_new_buffer, - .. - } => { + Block::ExcerptBoundary { .. } => { let color = cx.theme().colors().clone(); let mut result = v_flex().id(block_id).w_full(); + result = result.child( + h_flex().relative().child( + div() + .top(line_height / 2.) + .absolute() + .w_full() + .h_px() + .bg(color.border_variant), + ), + ); + + result.into_any() + } + + Block::BufferHeader { excerpt, height } => { + let mut result = v_flex().id(block_id).w_full(); + let jump_data = header_jump_data(snapshot, block_row_start, *height, excerpt); - if *starts_new_buffer { - if sticky_header_excerpt_id != Some(excerpt.id) { - let selected = selected_buffer_ids.contains(&excerpt.buffer_id); + if sticky_header_excerpt_id != Some(excerpt.id) { + let selected = selected_buffer_ids.contains(&excerpt.buffer_id); - result = result.child(div().pr(editor_margins.right).child( - self.render_buffer_header( - excerpt, false, selected, false, jump_data, window, cx, - ), - )); - } else { - result = - result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height())); - } - } else { - result = result.child( - h_flex().relative().child( - div() - .top(line_height / 2.) - .absolute() - .w_full() - .h_px() - .bg(color.border_variant), + result = result.child(div().pr(editor_margins.right).child( + self.render_buffer_header( + excerpt, false, selected, false, jump_data, window, cx, ), - ); - }; + )); + } else { + result = + result.child(div().h(FILE_HEADER_HEIGHT as f32 * window.line_height())); + } result.into_any() } @@ -3511,33 +3781,33 @@ impl EditorElement { let mut x_offset = px(0.); let mut is_block = true; - if let BlockId::Custom(custom_block_id) = block_id { - if block.has_height() { - if block.place_near() { - if let Some((x_target, line_width)) = x_position { - let margin = em_width * 2; - if line_width + final_size.width + margin - < editor_width + editor_margins.gutter.full_width() - && !row_block_types.contains_key(&(row - 1)) - && element_height_in_lines == 1 - { - x_offset = line_width + margin; - row = row - 1; - is_block = false; - element_height_in_lines = 0; - row_block_types.insert(row, is_block); - } else { - let max_offset = editor_width + editor_margins.gutter.full_width() - - final_size.width; - let min_offset = (x_target + em_width - final_size.width) - .max(editor_margins.gutter.full_width()); - x_offset = x_target.min(max_offset).max(min_offset); - } - } - }; - if element_height_in_lines != block.height() { - resized_blocks.insert(custom_block_id, element_height_in_lines); + if let BlockId::Custom(custom_block_id) = block_id + && block.has_height() + { + if block.place_near() + && let Some((x_target, line_width)) = x_position + { + let margin = em_width * 2; + if line_width + final_size.width + margin + < editor_width + editor_margins.gutter.full_width() + && !row_block_types.contains_key(&(row - 1)) + && element_height_in_lines == 1 + { + x_offset = line_width + margin; + row = row - 1; + is_block = false; + element_height_in_lines = 0; + row_block_types.insert(row, is_block); + } else { + let max_offset = + editor_width + editor_margins.gutter.full_width() - final_size.width; + let min_offset = (x_target + em_width - final_size.width) + .max(editor_margins.gutter.full_width()); + x_offset = x_target.min(max_offset).max(min_offset); } + }; + if element_height_in_lines != block.height() { + resized_blocks.insert(custom_block_id, element_height_in_lines); } } for i in 0..element_height_in_lines { @@ -3556,38 +3826,48 @@ impl EditorElement { jump_data: JumpData, window: &mut Window, cx: &mut App, - ) -> Div { + ) -> impl IntoElement { let editor = self.editor.read(cx); - let file_status = editor - .buffer - .read(cx) + let multi_buffer = editor.buffer.read(cx); + let file_status = multi_buffer .all_diff_hunks_expanded() - .then(|| { - editor - .project - .as_ref()? - .read(cx) - .status_for_buffer_id(for_excerpt.buffer_id, cx) - }) + .then(|| editor.status_for_buffer_id(for_excerpt.buffer_id, cx)) .flatten(); + let indicator = multi_buffer + .buffer(for_excerpt.buffer_id) + .and_then(|buffer| { + let buffer = buffer.read(cx); + let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { + (true, _) => Some(Color::Warning), + (_, true) => Some(Color::Accent), + (false, false) => None, + }; + indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color)) + }); let include_root = editor .project .as_ref() .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) .unwrap_or_default(); - let can_open_excerpts = Editor::can_open_excerpts_in_file(for_excerpt.buffer.file()); - let path = for_excerpt.buffer.resolve_file_path(cx, include_root); - let filename = path - .as_ref() - .and_then(|path| Some(path.file_name()?.to_string_lossy().to_string())); - let parent_path = path.as_ref().and_then(|path| { - Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR) - }); + let file = for_excerpt.buffer.file(); + let can_open_excerpts = Editor::can_open_excerpts_in_file(file); + let path_style = file.map(|file| file.path_style(cx)); + let relative_path = for_excerpt.buffer.resolve_file_path(include_root, cx); + let (parent_path, filename) = if let Some(path) = &relative_path { + if let Some(path_style) = path_style { + let (dir, file_name) = path_style.split(path); + (dir.map(|dir| dir.to_owned()), Some(file_name.to_owned())) + } else { + (None, Some(path.clone())) + } + } else { + (None, None) + }; let focus_handle = editor.focus_handle(cx); let colors = cx.theme().colors(); - div() + let header = div() .p_1() .w_full() .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) @@ -3631,13 +3911,19 @@ impl EditorElement { .children(toggle_chevron_icon) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::with_meta_in( "Toggle Excerpt Fold", Some(&ToggleFold), - "Alt+click to toggle all", + format!( + "{} to toggle all", + text_for_keystroke( + &Modifiers::alt(), + "click", + cx + ) + ), &focus_handle, - window, cx, ) } @@ -3677,6 +3963,7 @@ impl EditorElement { }) .take(1), ) + .child(h_flex().size(px(12.0)).justify_center().children(indicator)) .child( h_flex() .cursor_pointer() @@ -3687,29 +3974,38 @@ impl EditorElement { .child( h_flex() .gap_2() - .child( - Label::new( - filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()), - ) - .single_line() - .when_some( - file_status, - |el, status| { - el.color(if status.is_conflicted() { - Color::Conflict - } else if status.is_modified() { - Color::Modified - } else if status.is_deleted() { - Color::Disabled - } else { - Color::Created - }) - .when(status.is_deleted(), |el| el.strikethrough()) - }, - ), - ) + .map(|path_header| { + let filename = filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()); + + path_header + .when(ItemSettings::get_global(cx).file_icons, |el| { + let path = path::Path::new(filename.as_str()); + let icon = FileIcons::get_icon(path, cx) + .unwrap_or_default(); + let icon = + Icon::from_path(icon).color(Color::Muted); + el.child(icon) + }) + .child(Label::new(filename).single_line().when_some( + file_status, + |el, status| { + el.color(if status.is_conflicted() { + Color::Conflict + } else if status.is_modified() { + Color::Modified + } else if status.is_deleted() { + Color::Disabled + } else { + Color::Created + }) + .when(status.is_deleted(), |el| { + el.strikethrough() + }) + }, + )) + }) .when_some(parent_path, |then, path| { then.child(div().child(path).text_color( if file_status.is_some_and(FileStatus::is_deleted) { @@ -3720,23 +4016,22 @@ impl EditorElement { )) }), ) - .when(can_open_excerpts && is_selected && path.is_some(), |el| { - el.child( - h_flex() - .id("jump-to-file-button") - .gap_2p5() - .child(Label::new("Jump To File")) - .children( - KeyBinding::for_action_in( + .when( + can_open_excerpts && is_selected && relative_path.is_some(), + |el| { + el.child( + h_flex() + .id("jump-to-file-button") + .gap_2p5() + .child(Label::new("Jump To File")) + .child(KeyBinding::for_action_in( &OpenExcerpts, &focus_handle, - window, cx, - ) - .map(|binding| binding.into_any_element()), - ), - ) - }) + )), + ) + }, + ) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(window.listener_for(&self.editor, { move |editor, e: &ClickEvent, window, cx| { @@ -3749,7 +4044,108 @@ impl EditorElement { } })), ), - ) + ); + + let file = for_excerpt.buffer.file().cloned(); + let editor = self.editor.clone(); + right_click_menu("buffer-header-context-menu") + .trigger(move |_, _, _| header) + .menu(move |window, cx| { + let menu_context = focus_handle.clone(); + let editor = editor.clone(); + let file = file.clone(); + ContextMenu::build(window, cx, move |mut menu, window, cx| { + if let Some(file) = file + && let Some(project) = editor.read(cx).project() + && let Some(worktree) = + project.read(cx).worktree_for_id(file.worktree_id(cx), cx) + { + let path_style = file.path_style(cx); + let worktree = worktree.read(cx); + let relative_path = file.path(); + let entry_for_path = worktree.entry_for_path(relative_path); + let abs_path = entry_for_path.map(|e| { + e.canonical_path.as_deref().map_or_else( + || worktree.absolutize(relative_path), + Path::to_path_buf, + ) + }); + let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir); + + let parent_abs_path = abs_path + .as_ref() + .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let relative_path = has_relative_path + .then_some(relative_path) + .map(ToOwned::to_owned); + + let visible_in_project_panel = + relative_path.is_some() && worktree.is_visible(); + let reveal_in_project_panel = entry_for_path + .filter(|_| visible_in_project_panel) + .map(|entry| entry.id); + menu = menu + .when_some(abs_path, |menu, abs_path| { + menu.entry( + "Copy Path", + Some(Box::new(zed_actions::workspace::CopyPath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + abs_path.to_string_lossy().into_owned(), + )); + }), + ) + }) + .when_some(relative_path, |menu, relative_path| { + menu.entry( + "Copy Relative Path", + Some(Box::new(zed_actions::workspace::CopyRelativePath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + relative_path.display(path_style).to_string(), + )); + }), + ) + }) + .when( + reveal_in_project_panel.is_some() || parent_abs_path.is_some(), + |menu| menu.separator(), + ) + .when_some(reveal_in_project_panel, |menu, entry_id| { + menu.entry( + "Reveal In Project Panel", + Some(Box::new(RevealInProjectPanel::default())), + window.handler_for(&editor, move |editor, _, cx| { + if let Some(project) = &mut editor.project { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel( + entry_id, + )) + }); + } + }), + ) + }) + .when_some(parent_abs_path, |menu, parent_abs_path| { + menu.entry( + "Open in Terminal", + Some(Box::new(OpenInTerminal)), + window.handler_for(&editor, move |_, window, cx| { + window.dispatch_action( + OpenTerminal { + working_directory: parent_abs_path.clone(), + } + .boxed_clone(), + cx, + ); + }), + ) + }); + } + + menu.context(menu_context) + }) + }) } fn render_blocks( @@ -3787,7 +4183,7 @@ impl EditorElement { for (row, block) in fixed_blocks { let block_id = block.id(); - if focused_block.as_ref().map_or(false, |b| b.id == block_id) { + if focused_block.as_ref().is_some_and(|b| b.id == block_id) { focused_block = None; } @@ -3844,7 +4240,7 @@ impl EditorElement { }; let block_id = block.id(); - if focused_block.as_ref().map_or(false, |b| b.id == block_id) { + if focused_block.as_ref().is_some_and(|b| b.id == block_id) { focused_block = None; } @@ -3885,60 +4281,58 @@ impl EditorElement { } } - if let Some(focused_block) = focused_block { - if let Some(focus_handle) = focused_block.focus_handle.upgrade() { - if focus_handle.is_focused(window) { - if let Some(block) = snapshot.block_for_id(focused_block.id) { - let style = block.style(); - let width = match style { - BlockStyle::Fixed => AvailableSpace::MinContent, - BlockStyle::Flex => AvailableSpace::Definite( - hitbox - .size - .width - .max(fixed_block_max_width) - .max(editor_margins.gutter.width + *scroll_width), - ), - BlockStyle::Sticky => AvailableSpace::Definite(hitbox.size.width), - }; + if let Some(focused_block) = focused_block + && let Some(focus_handle) = focused_block.focus_handle.upgrade() + && focus_handle.is_focused(window) + && let Some(block) = snapshot.block_for_id(focused_block.id) + { + let style = block.style(); + let width = match style { + BlockStyle::Fixed => AvailableSpace::MinContent, + BlockStyle::Flex => AvailableSpace::Definite( + hitbox + .size + .width + .max(fixed_block_max_width) + .max(editor_margins.gutter.width + *scroll_width), + ), + BlockStyle::Sticky => AvailableSpace::Definite(hitbox.size.width), + }; - if let Some((element, element_size, _, x_offset)) = self.render_block( - &block, - width, - focused_block.id, - rows.end, - snapshot, - text_x, - &rows, - line_layouts, - editor_margins, - line_height, - em_width, - text_hitbox, - editor_width, - scroll_width, - &mut resized_blocks, - &mut row_block_types, - selections, - selected_buffer_ids, - is_row_soft_wrapped, - sticky_header_excerpt_id, - window, - cx, - ) { - blocks.push(BlockLayout { - id: block.id(), - x_offset, - row: None, - element, - available_space: size(width, element_size.height.into()), - style, - overlaps_gutter: true, - is_buffer_header: block.is_buffer_header(), - }); - } - } - } + if let Some((element, element_size, _, x_offset)) = self.render_block( + &block, + width, + focused_block.id, + rows.end, + snapshot, + text_x, + &rows, + line_layouts, + editor_margins, + line_height, + em_width, + text_hitbox, + editor_width, + scroll_width, + &mut resized_blocks, + &mut row_block_types, + selections, + selected_buffer_ids, + is_row_soft_wrapped, + sticky_header_excerpt_id, + window, + cx, + ) { + blocks.push(BlockLayout { + id: block.id(), + x_offset, + row: None, + element, + available_space: size(width, element_size.height.into()), + style, + overlaps_gutter: true, + is_buffer_header: block.is_buffer_header(), + }); } } @@ -3956,7 +4350,8 @@ impl EditorElement { blocks: &mut Vec, hitbox: &Hitbox, line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, window: &mut Window, cx: &mut App, ) { @@ -3965,7 +4360,10 @@ impl EditorElement { hitbox.origin + point( block.x_offset, - row.as_f32() * line_height - scroll_pixel_position.y, + Pixels::from( + (row.as_f64() - scroll_position.y) + * ScrollPixelOffset::from(line_height), + ), ) } else { // Position the block outside the visible area @@ -3973,7 +4371,7 @@ impl EditorElement { }; if !matches!(block.style, BlockStyle::Sticky) { - origin += point(-scroll_pixel_position.x, Pixels::ZERO); + origin += point(Pixels::from(-scroll_pixel_position.x), Pixels::ZERO); } let focus_handle = @@ -3995,7 +4393,7 @@ impl EditorElement { fn layout_sticky_buffer_header( &self, StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>, - scroll_position: f32, + scroll_position: gpui::Point, line_height: Pixels, right_margin: Pixels, snapshot: &EditorSnapshot, @@ -4007,7 +4405,7 @@ impl EditorElement { ) -> AnyElement { let jump_data = header_jump_data( snapshot, - DisplayRow(scroll_position as u32), + DisplayRow(scroll_position.y as u32), FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, excerpt, ); @@ -4046,15 +4444,15 @@ impl EditorElement { continue; } - let Some(display_row) = block.row.filter(|row| row.0 > scroll_position as u32) else { + let Some(display_row) = block.row.filter(|row| row.0 > scroll_position.y as u32) else { continue; }; let max_row = display_row.0.saturating_sub(FILE_HEADER_HEIGHT); - let offset = scroll_position - max_row as f32; + let offset = scroll_position.y - max_row as f64; if offset > 0.0 { - origin.y -= offset * line_height; + origin.y -= Pixels::from(offset * ScrollPixelOffset::from(line_height)); } break; } @@ -4076,7 +4474,7 @@ impl EditorElement { content_origin: gpui::Point, right_margin: Pixels, start_row: DisplayRow, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_layouts: &[LineWithInvisibles], cursor: DisplayPoint, cursor_point: Point, @@ -4101,19 +4499,19 @@ impl EditorElement { edit_prediction_popover_visible = true; } - if editor.context_menu_visible() { - if let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() { - let (min_height_in_lines, max_height_in_lines) = editor - .context_menu_options - .as_ref() - .map_or((3, 12), |options| { - (options.min_entries_visible, options.max_entries_visible) - }); + if editor.context_menu_visible() + && let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() + { + let (min_height_in_lines, max_height_in_lines) = editor + .context_menu_options + .as_ref() + .map_or((3, 12), |options| { + (options.min_entries_visible, options.max_entries_visible) + }); - min_menu_height += line_height * min_height_in_lines as f32 + POPOVER_Y_PADDING; - max_menu_height += line_height * max_height_in_lines as f32 + POPOVER_Y_PADDING; - context_menu_visible = true; - } + min_menu_height += line_height * min_height_in_lines as f32 + POPOVER_Y_PADDING; + max_menu_height += line_height * max_height_in_lines as f32 + POPOVER_Y_PADDING; + context_menu_visible = true; } context_menu_placement = editor .context_menu_options @@ -4131,12 +4529,18 @@ impl EditorElement { + gpui::Point { x: cmp::max( px(0.), - cursor_row_layout.x_for_index(cursor.column() as usize) - - scroll_pixel_position.x, + Pixels::from( + ScrollPixelOffset::from( + cursor_row_layout.x_for_index(cursor.column() as usize), + ) - scroll_pixel_position.x, + ), ), y: cmp::max( px(0.), - cursor.row().next_row().as_f32() * line_height - scroll_pixel_position.y, + Pixels::from( + cursor.row().next_row().as_f64() * ScrollPixelOffset::from(line_height) + - scroll_pixel_position.y, + ), ), }; @@ -4289,7 +4693,7 @@ impl EditorElement { text_hitbox: &Hitbox, content_origin: gpui::Point, right_margin: Pixels, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, gutter_overshoot: Pixels, window: &mut Window, cx: &mut App, @@ -4308,7 +4712,10 @@ impl EditorElement { let target_position = content_origin + gpui::Point { x: -gutter_overshoot, - y: gutter_row.next_row().as_f32() * line_height - scroll_pixel_position.y, + y: Pixels::from( + gutter_row.next_row().as_f64() * ScrollPixelOffset::from(line_height) + - scroll_pixel_position.y, + ), }; let (min_height_in_lines, max_height_in_lines) = editor @@ -4625,7 +5032,7 @@ impl EditorElement { } }; - let source_included = source_display_point.map_or(true, |source_display_point| { + let source_included = source_display_point.is_none_or(|source_display_point| { visible_range .to_inclusive() .contains(&source_display_point.row()) @@ -4674,7 +5081,7 @@ impl EditorElement { hitbox: &Hitbox, visible_display_row_range: Range, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, line_layouts: &[LineWithInvisibles], line_height: Pixels, em_width: Pixels, @@ -4702,6 +5109,7 @@ impl EditorElement { snapshot, visible_display_row_range.clone(), max_size, + &editor.text_layout_details(window), window, cx, ) @@ -4715,9 +5123,12 @@ impl EditorElement { &line_layouts[position.row().minus(visible_display_row_range.start) as usize]; // Compute Hovered Point - let x = - hovered_row_layout.x_for_index(position.column() as usize) - scroll_pixel_position.x; - let y = position.row().as_f32() * line_height - scroll_pixel_position.y; + let x = hovered_row_layout.x_for_index(position.column() as usize) + - Pixels::from(scroll_pixel_position.x); + let y = Pixels::from( + position.row().as_f64() * ScrollPixelOffset::from(line_height) + - scroll_pixel_position.y, + ); let hovered_point = content_origin + point(x, y); let mut overall_height = Pixels::ZERO; @@ -4805,7 +5216,7 @@ impl EditorElement { let intersects_menu = |bounds: Bounds| -> bool { context_menu_layout .as_ref() - .map_or(false, |menu| bounds.intersects(&menu.bounds)) + .is_some_and(|menu| bounds.intersects(&menu.bounds)) }; let can_place_above = { @@ -4928,7 +5339,7 @@ impl EditorElement { newest_cursor_position: Option, line_height: Pixels, right_margin: Pixels, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, display_hunks: &[(DisplayDiffHunk, Option)], highlighted_rows: &BTreeMap, editor: Entity, @@ -4990,11 +5401,13 @@ impl EditorElement { if active_positions .iter() - .any(|p| p.map_or(false, |p| display_row_range.contains(&p.row()))) + .any(|p| p.is_some_and(|p| display_row_range.contains(&p.row()))) { - let y = display_row_range.start.as_f32() * line_height - + text_hitbox.bounds.top() - - scroll_pixel_position.y; + let y = (display_row_range.start.as_f64() + * ScrollPixelOffset::from(line_height) + + ScrollPixelOffset::from(text_hitbox.bounds.top()) + - scroll_pixel_position.y) + .into(); let mut element = render_diff_hunk_controls( display_row_range.start.0, @@ -5029,7 +5442,7 @@ impl EditorElement { &self, hitbox: &Hitbox, content_origin: gpui::Point, - scroll_pixel_position: gpui::Point, + scroll_pixel_position: gpui::Point, newest_selection_head: Option, start_row: DisplayRow, line_layouts: &[LineWithInvisibles], @@ -5076,8 +5489,10 @@ impl EditorElement { }; let target_x = cursor_row_layout.x_for_index(newest_selection_head.column() as usize) - - scroll_pixel_position.x; - let target_y = selection_row.as_f32() * line_height - scroll_pixel_position.y; + - Pixels::from(scroll_pixel_position.x); + let target_y = Pixels::from( + selection_row.as_f64() * ScrollPixelOffset::from(line_height) - scroll_pixel_position.y, + ); let target_point = content_origin + point(target_x, target_y); let actual_size = element.layout_as_root(Size::::default(), window, cx); @@ -5103,7 +5518,7 @@ impl EditorElement { let intersects_menu = |bounds: Bounds| -> bool { context_menu_layout .as_ref() - .map_or(false, |menu| bounds.intersects(&menu.bounds)) + .is_some_and(|menu| bounds.intersects(&menu.bounds)) }; let final_origin = if popover_bounds_above.is_contained_within(hitbox) @@ -5188,7 +5603,7 @@ impl EditorElement { let mut end_row = start_row.0; while active_rows .peek() - .map_or(false, |(active_row, has_selection)| { + .is_some_and(|(active_row, has_selection)| { active_row.0 == end_row + 1 && has_selection.selection == contains_non_empty_selection.selection }) @@ -5220,8 +5635,12 @@ impl EditorElement { origin: point( range.start, layout.hitbox.origin.y - + (start_row.as_f32() - scroll_top) - * layout.position_map.line_height, + + Pixels::from( + (start_row.as_f64() - scroll_top) + * ScrollPixelOffset::from( + layout.position_map.line_height, + ), + ), ), size: size( range.end - range.start, @@ -5248,8 +5667,10 @@ impl EditorElement { let origin = point( origin_x, layout.hitbox.origin.y - + (highlight_row_start.as_f32() - scroll_top) - * layout.position_map.line_height, + + Pixels::from( + (highlight_row_start.as_f64() - scroll_top) + * ScrollPixelOffset::from(layout.position_map.line_height), + ), ); let size = size( width, @@ -5346,7 +5767,7 @@ impl EditorElement { for indent_guide in indent_guides { let indent_accent_colors = cx.theme().accents().color_for_index(indent_guide.depth); - let settings = indent_guide.settings; + let settings = &indent_guide.settings; // TODO fixed for now, expose them through themes later const INDENT_AWARE_ALPHA: f32 = 0.2; @@ -5417,7 +5838,7 @@ impl EditorElement { } fn paint_line_numbers(&mut self, layout: &mut EditorLayout, window: &mut Window, cx: &mut App) { - let is_singleton = self.editor.read(cx).is_singleton(cx); + let is_singleton = self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton; let line_height = layout.position_map.line_height; window.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox); @@ -5447,9 +5868,9 @@ impl EditorElement { // In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor. // In multi buffers, we open file at the line number clicked, so use a pointing hand cursor. if is_singleton { - window.set_cursor_style(CursorStyle::IBeam, &hitbox); + window.set_cursor_style(CursorStyle::IBeam, hitbox); } else { - window.set_cursor_style(CursorStyle::PointingHand, &hitbox); + window.set_cursor_style(CursorStyle::PointingHand, hitbox); } } } @@ -5468,7 +5889,7 @@ impl EditorElement { &layout.position_map.snapshot, line_height, layout.gutter_hitbox.bounds, - &hunk, + hunk, ); Some(( hunk_bounds, @@ -5544,7 +5965,7 @@ impl EditorElement { hunk_bounds, corner_radii, flattened_unstaged_background_color, - Edges::all(Pixels(1.0)), + Edges::all(px(1.0)), flattened_background_color, BorderStyle::Solid, )); @@ -5565,12 +5986,14 @@ impl EditorElement { hunk: &DisplayDiffHunk, ) -> Bounds { let scroll_position = snapshot.scroll_position(); - let scroll_top = scroll_position.y * line_height; + let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height); let gutter_strip_width = Self::gutter_strip_width(line_height); match hunk { DisplayDiffHunk::Folded { display_row, .. } => { - let start_y = display_row.as_f32() * line_height - scroll_top; + let start_y = (display_row.as_f64() * ScrollPixelOffset::from(line_height) + - scroll_top) + .into(); let end_y = start_y + line_height; let highlight_origin = gutter_bounds.origin + point(px(0.), start_y); let highlight_size = size(gutter_strip_width, end_y - start_y); @@ -5584,8 +6007,10 @@ impl EditorElement { if status.is_deleted() && display_row_range.is_empty() { let row = display_row_range.start; - let offset = line_height / 2.; - let start_y = row.as_f32() * line_height - offset - scroll_top; + let offset = ScrollPixelOffset::from(line_height / 2.); + let start_y = + (row.as_f64() * ScrollPixelOffset::from(line_height) - offset - scroll_top) + .into(); let end_y = start_y + line_height; let width = (0.35 * line_height).floor(); @@ -5604,7 +6029,10 @@ impl EditorElement { let end_row_in_current_excerpt = snapshot .blocks_in_range(start_row..end_row) .find_map(|(start_row, block)| { - if matches!(block, Block::ExcerptBoundary { .. }) { + if matches!( + block, + Block::ExcerptBoundary { .. } | Block::BufferHeader { .. } + ) { Some(start_row) } else { None @@ -5612,8 +6040,13 @@ impl EditorElement { }) .unwrap_or(end_row); - let start_y = start_row.as_f32() * line_height - scroll_top; - let end_y = end_row_in_current_excerpt.as_f32() * line_height - scroll_top; + let start_y = (start_row.as_f64() * ScrollPixelOffset::from(line_height) + - scroll_top) + .into(); + let end_y = Pixels::from( + end_row_in_current_excerpt.as_f64() * ScrollPixelOffset::from(line_height) + - scroll_top, + ); let highlight_origin = gutter_bounds.origin + point(px(0.), start_y); let highlight_size = size(gutter_strip_width, end_y - start_y); @@ -5659,16 +6092,15 @@ impl EditorElement { cx: &mut App, ) { for (_, hunk_hitbox) in &layout.display_hunks { - if let Some(hunk_hitbox) = hunk_hitbox { - if !self + if let Some(hunk_hitbox) = hunk_hitbox + && !self .editor .read(cx) .buffer() .read(cx) .all_diff_hunks_expanded() - { - window.set_cursor_style(CursorStyle::PointingHand, hunk_hitbox); - } + { + window.set_cursor_style(CursorStyle::PointingHand, hunk_hitbox); } } @@ -5679,7 +6111,7 @@ impl EditorElement { .unwrap_or_else(|| { matches!( ProjectSettings::get_global(cx).git.git_gutter, - Some(GitGutterSetting::TrackedFiles) + GitGutterSetting::TrackedFiles ) }); if show_git_gutter { @@ -5702,11 +6134,17 @@ impl EditorElement { }; let start_y = layout.gutter_hitbox.top() - + start_row.0 as f32 * layout.position_map.line_height - - layout.position_map.scroll_pixel_position.y; + + Pixels::from( + start_row.0 as f64 + * ScrollPixelOffset::from(layout.position_map.line_height) + - layout.position_map.scroll_pixel_position.y, + ); let end_y = layout.gutter_hitbox.top() - + (end_row.0 + 1) as f32 * layout.position_map.line_height - - layout.position_map.scroll_pixel_position.y; + + Pixels::from( + (end_row.0 + 1) as f64 + * ScrollPixelOffset::from(layout.position_map.line_height) + - layout.position_map.scroll_pixel_position.y, + ); let bounds = Bounds::from_corners( point(layout.gutter_hitbox.left(), start_y), point(layout.gutter_hitbox.left() + highlight_width, end_y), @@ -5747,7 +6185,10 @@ impl EditorElement { } = &editor.selection_drag_state { let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx).drag_and_drop_selection.delay, + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, ); if mouse_down_time.elapsed() >= drag_and_drop_delay { window.set_cursor_style( @@ -5775,7 +6216,7 @@ impl EditorElement { }; self.paint_lines_background(layout, window, cx); - let invisible_display_ranges = self.paint_highlights(layout, window); + let invisible_display_ranges = self.paint_highlights(layout, window, cx); self.paint_document_colors(layout, window); self.paint_lines(&invisible_display_ranges, layout, window, cx); self.paint_redactions(layout, window); @@ -5797,6 +6238,7 @@ impl EditorElement { &mut self, layout: &mut EditorLayout, window: &mut Window, + cx: &mut App, ) -> SmallVec<[Range; 32]> { window.paint_layer(layout.position_map.text_hitbox.bounds, |window| { let mut invisible_display_ranges = SmallVec::<[Range; 32]>::new(); @@ -5813,7 +6255,11 @@ impl EditorElement { ); } - let corner_radius = 0.15 * layout.position_map.line_height; + let corner_radius = if EditorSettings::get_global(cx).rounded_selection { + 0.15 * layout.position_map.line_height + } else { + Pixels::ZERO + }; for (player_color, selections) in &layout.selections { for selection in selections.iter() { @@ -5990,10 +6436,10 @@ impl EditorElement { if axis == ScrollbarAxis::Vertical { let fast_markers = - self.collect_fast_scrollbar_markers(layout, &scrollbar_layout, cx); + self.collect_fast_scrollbar_markers(layout, scrollbar_layout, cx); // Refresh slow scrollbar markers in the background. Below, we // paint whatever markers have already been computed. - self.refresh_slow_scrollbar_markers(layout, &scrollbar_layout, window, cx); + self.refresh_slow_scrollbar_markers(layout, scrollbar_layout, window, cx); let markers = self.editor.read(cx).scrollbar_marker_state.markers.clone(); for marker in markers.iter().chain(&fast_markers) { @@ -6027,7 +6473,7 @@ impl EditorElement { if any_scrollbar_dragged { window.set_window_cursor_style(CursorStyle::Arrow); } else { - window.set_cursor_style(CursorStyle::Arrow, &hitbox); + window.set_cursor_style(CursorStyle::Arrow, hitbox); } } }) @@ -6067,7 +6513,10 @@ impl EditorElement { .contains(&old_position) { let position = editor.scroll_position(cx).apply_along(axis, |p| { - (p + (new_position - old_position) / *text_unit_size).max(0.) + (p + ScrollOffset::from( + (new_position - old_position) / *text_unit_size, + )) + .max(0.) }); editor.set_scroll_position(position, window, cx); } @@ -6160,7 +6609,7 @@ impl EditorElement { let position = editor .scroll_position(cx) - .apply_along(axis, |_| start_position as f32); + .apply_along(axis, |_| start_position as ScrollOffset); editor.set_scroll_position(position, window, cx); } else { @@ -6204,7 +6653,7 @@ impl EditorElement { cx: &mut App, ) { self.editor.update(cx, |editor, cx| { - if !editor.is_singleton(cx) + if editor.buffer_kind(cx) != ItemBufferKind::Singleton || !editor .scrollbar_marker_state .should_refresh(scrollbar_layout.hitbox.size) @@ -6224,11 +6673,11 @@ impl EditorElement { let scrollbar_size = scrollbar_layout.hitbox.size; let scrollbar_markers = cx .background_spawn(async move { - let max_point = snapshot.display_snapshot.buffer_snapshot.max_point(); + let max_point = snapshot.display_snapshot.buffer_snapshot().max_point(); let mut marker_quads = Vec::new(); if scrollbar_settings.git_diff { let marker_row_ranges = - snapshot.buffer_snapshot.diff_hunks().map(|hunk| { + snapshot.buffer_snapshot().diff_hunks().map(|hunk| { let start_display_row = MultiBufferPoint::new(hunk.row_range.start.0, 0) .to_display_point(&snapshot.display_snapshot) @@ -6306,7 +6755,7 @@ impl EditorElement { if scrollbar_settings.diagnostics != ScrollbarDiagnostics::None { let diagnostics = snapshot - .buffer_snapshot + .buffer_snapshot() .diagnostics_in_range::(Point::zero()..max_point) // Don't show diagnostics the user doesn't care about .filter(|diagnostic| { @@ -6406,8 +6855,10 @@ impl EditorElement { line_height: layout.position_map.line_height, corner_radius, start_y: layout.content_origin.y - + row_range.start.as_f32() * layout.position_map.line_height - - layout.position_map.scroll_pixel_position.y, + + Pixels::from( + (row_range.start.as_f64() - layout.position_map.scroll_position.y) + * ScrollOffset::from(layout.position_map.line_height), + ), lines: row_range .iter_rows() .map(|row| { @@ -6416,19 +6867,30 @@ impl EditorElement { HighlightedRangeLine { start_x: if row == range.start.row() { layout.content_origin.x - + line_layout.x_for_index(range.start.column() as usize) - - layout.position_map.scroll_pixel_position.x + + Pixels::from( + ScrollPixelOffset::from( + line_layout.x_for_index(range.start.column() as usize), + ) - layout.position_map.scroll_pixel_position.x, + ) } else { layout.content_origin.x - - layout.position_map.scroll_pixel_position.x + - Pixels::from(layout.position_map.scroll_pixel_position.x) }, end_x: if row == range.end.row() { layout.content_origin.x - + line_layout.x_for_index(range.end.column() as usize) - - layout.position_map.scroll_pixel_position.x + + Pixels::from( + ScrollPixelOffset::from( + line_layout.x_for_index(range.end.column() as usize), + ) - layout.position_map.scroll_pixel_position.x, + ) } else { - layout.content_origin.x + line_layout.width + line_end_overshoot - - layout.position_map.scroll_pixel_position.x + Pixels::from( + ScrollPixelOffset::from( + layout.content_origin.x + + line_layout.width + + line_end_overshoot, + ) - layout.position_map.scroll_pixel_position.x, + ) }, } }) @@ -6544,8 +7006,10 @@ impl EditorElement { } let minimap_axis = ScrollbarAxis::Vertical; - let pixels_per_line = (minimap_hitbox.size.height / layout.max_scroll_top) - .min(layout.minimap_line_height); + let pixels_per_line = Pixels::from( + ScrollPixelOffset::from(minimap_hitbox.size.height) / layout.max_scroll_top, + ) + .min(layout.minimap_line_height); let mut mouse_position = window.mouse_position(); @@ -6571,31 +7035,32 @@ impl EditorElement { { let position = editor.scroll_position(cx).apply_along(minimap_axis, |p| { - (p + (new_position - old_position) / pixels_per_line) - .max(0.) + (p + ScrollPixelOffset::from( + (new_position - old_position) / pixels_per_line, + )) + .max(0.) }); + editor.set_scroll_position(position, window, cx); } cx.stop_propagation(); - } else { - if minimap_hitbox.is_hovered(window) { - editor.scroll_manager.set_is_hovering_minimap_thumb( - !event.dragging() - && layout - .thumb_layout - .thumb_bounds - .is_some_and(|bounds| bounds.contains(&event.position)), - cx, - ); + } else if minimap_hitbox.is_hovered(window) { + editor.scroll_manager.set_is_hovering_minimap_thumb( + !event.dragging() + && layout + .thumb_layout + .thumb_bounds + .is_some_and(|bounds| bounds.contains(&event.position)), + cx, + ); - // Stop hover events from propagating to the - // underlying editor if the minimap hitbox is hovered - if !event.dragging() { - cx.stop_propagation(); - } - } else { - editor.scroll_manager.hide_minimap_thumb(cx); + // Stop hover events from propagating to the + // underlying editor if the minimap hitbox is hovered + if !event.dragging() { + cx.stop_propagation(); } + } else { + editor.scroll_manager.hide_minimap_thumb(cx); } mouse_position = event.position; }); @@ -6651,8 +7116,10 @@ impl EditorElement { .max(Pixels::ZERO); let scroll_offset = (layout.minimap_scroll_top - + top_position / layout.minimap_line_height) - .min(layout.max_scroll_top); + + ScrollPixelOffset::from( + top_position / layout.minimap_line_height, + )) + .min(layout.max_scroll_top); let scroll_position = editor .scroll_position(cx) @@ -6759,12 +7226,13 @@ impl EditorElement { }; let current_scroll_position = position_map.snapshot.scroll_position(); - let x = (current_scroll_position.x * max_glyph_advance - - (delta.x * scroll_sensitivity)) - / max_glyph_advance; - let y = (current_scroll_position.y * line_height - - (delta.y * scroll_sensitivity)) - / line_height; + let x = (current_scroll_position.x + * ScrollPixelOffset::from(max_glyph_advance) + - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) + / ScrollPixelOffset::from(max_glyph_advance); + let y = (current_scroll_position.y * ScrollPixelOffset::from(line_height) + - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) + / ScrollPixelOffset::from(line_height); let mut scroll_position = point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); let forbid_vertical_scroll = editor.scroll_manager.forbid_vertical_scroll(); @@ -6797,26 +7265,6 @@ impl EditorElement { window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); - let diff_hunk_range = - layout - .display_hunks - .iter() - .find_map(|(hunk, hunk_hitbox)| match hunk { - DisplayDiffHunk::Folded { .. } => None, - DisplayDiffHunk::Unfolded { - multi_buffer_range, .. - } => { - if hunk_hitbox - .as_ref() - .map(|hitbox| hitbox.is_hovered(window)) - .unwrap_or(false) - { - Some(multi_buffer_range.clone()) - } else { - None - } - } - }); let line_numbers = layout.line_numbers.clone(); move |event: &MouseDownEvent, phase, window, cx| { @@ -6833,7 +7281,6 @@ impl EditorElement { Self::mouse_left_down( editor, event, - diff_hunk_range.clone(), &position_map, line_numbers.as_ref(), window, @@ -6971,15 +7418,61 @@ impl EditorElement { fn diff_hunk_hollow(status: DiffHunkStatus, cx: &mut App) -> bool { let unstaged = status.has_secondary_hunk(); - let unstaged_hollow = ProjectSettings::get_global(cx) - .git - .hunk_style - .map_or(false, |style| { - matches!(style, GitHunkStyleSetting::UnstagedHollow) - }); + let unstaged_hollow = matches!( + ProjectSettings::get_global(cx).git.hunk_style, + GitHunkStyleSetting::UnstagedHollow + ); unstaged == unstaged_hollow } + + #[cfg(debug_assertions)] + fn layout_debug_ranges( + selections: &mut Vec<(PlayerColor, Vec)>, + anchor_range: Range, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) { + let theme = cx.theme(); + text::debug::GlobalDebugRanges::with_locked(|debug_ranges| { + if debug_ranges.ranges.is_empty() { + return; + } + let buffer_snapshot = &display_snapshot.buffer_snapshot(); + for (buffer, buffer_range, excerpt_id) in + buffer_snapshot.range_to_buffer_ranges(anchor_range) + { + let buffer_range = + buffer.anchor_after(buffer_range.start)..buffer.anchor_before(buffer_range.end); + selections.extend(debug_ranges.ranges.iter().flat_map(|debug_range| { + let player_color = theme + .players() + .color_for_participant(debug_range.occurrence_index as u32 + 1); + debug_range.ranges.iter().filter_map(move |range| { + if range.start.buffer_id != Some(buffer.remote_id()) { + return None; + } + let clipped_start = range.start.max(&buffer_range.start, buffer); + let clipped_end = range.end.min(&buffer_range.end, buffer); + let range = buffer_snapshot + .anchor_range_in_excerpt(excerpt_id, *clipped_start..*clipped_end)?; + let start = range.start.to_display_point(display_snapshot); + let end = range.end.to_display_point(display_snapshot); + let selection_layout = SelectionLayout { + head: start, + range: start..end, + cursor_shape: CursorShape::Bar, + is_newest: false, + is_local: false, + active_rows: start.row()..end.row(), + user_name: Some(SharedString::new(debug_range.value.clone())), + }; + Some((player_color, vec![selection_layout])) + }) + })); + } + }); + } } fn header_jump_data( @@ -7020,7 +7513,7 @@ fn header_jump_data( pub struct AcceptEditPredictionBinding(pub(crate) Option); impl AcceptEditPredictionBinding { - pub fn keystroke(&self) -> Option<&Keystroke> { + pub fn keystroke(&self) -> Option<&KeybindingKeystroke> { if let Some(binding) = self.0.as_ref() { match &binding.keystrokes() { [keystroke, ..] => Some(keystroke), @@ -7037,7 +7530,7 @@ fn prepaint_gutter_button( row: DisplayRow, line_height: Pixels, gutter_dimensions: &GutterDimensions, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, gutter_hitbox: &Hitbox, display_hunks: &[(DisplayDiffHunk, Option)], window: &mut Window, @@ -7077,7 +7570,8 @@ fn prepaint_gutter_button( - left_offset; x += available_width / 2.; - let mut y = row.as_f32() * line_height - scroll_pixel_position.y; + let mut y = + Pixels::from((row.as_f64() - scroll_position.y) * ScrollPixelOffset::from(line_height)); y += (line_height - indicator_size.height) / 2.; button.prepaint_as_root( @@ -7105,12 +7599,13 @@ fn render_blame_entry_popover( markdown: Entity, workspace: WeakEntity, blame: &Entity, + buffer: BufferId, window: &mut Window, cx: &mut App, ) -> Option { let renderer = cx.global::().0.clone(); let blame = blame.read(cx); - let repository = blame.repository(cx)?.clone(); + let repository = blame.repository(cx, buffer)?; renderer.render_blame_entry_popover( blame_entry, scroll_handle, @@ -7128,31 +7623,30 @@ fn render_blame_entry( blame: &Entity, blame_entry: BlameEntry, style: &EditorStyle, - last_used_color: &mut Option<(PlayerColor, Oid)>, + last_used_color: &mut Option<(Hsla, Oid)>, editor: Entity, workspace: Entity, - renderer: Arc, + buffer: BufferId, + renderer: &dyn BlameRenderer, + window: &mut Window, cx: &mut App, ) -> Option { - let mut sha_color = cx - .theme() - .players() - .color_for_participant(blame_entry.sha.into()); + let index: u32 = blame_entry.sha.into(); + let mut sha_color = cx.theme().players().color_for_participant(index).cursor; // If the last color we used is the same as the one we get for this line, but // the commit SHAs are different, then we try again to get a different color. - match *last_used_color { - Some((color, sha)) if sha != blame_entry.sha && color.cursor == sha_color.cursor => { - let index: u32 = blame_entry.sha.into(); - sha_color = cx.theme().players().color_for_participant(index + 1); - } - _ => {} - }; + if let Some((color, sha)) = *last_used_color + && sha != blame_entry.sha + && color == sha_color + { + sha_color = cx.theme().players().color_for_participant(index + 1).cursor; + } last_used_color.replace((sha_color, blame_entry.sha)); let blame = blame.read(cx); - let details = blame.details_for_entry(&blame_entry); - let repository = blame.repository(cx)?; + let details = blame.details_for_entry(buffer, &blame_entry); + let repository = blame.repository(cx, buffer)?; renderer.render_blame_entry( &style.text, blame_entry, @@ -7161,7 +7655,8 @@ fn render_blame_entry( workspace.downgrade(), editor, ix, - sha_color.cursor, + sha_color, + window, cx, ) } @@ -7207,6 +7702,7 @@ impl LineWithInvisibles { editor_mode: &EditorMode, text_width: Pixels, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + bg_segments_per_row: &[Vec<(Range, Hsla)>], window: &mut Window, cx: &mut App, ) -> Vec { @@ -7222,6 +7718,7 @@ impl LineWithInvisibles { let mut row = 0; let mut line_exceeded_max_len = false; let font_size = text_style.font_size.to_pixels(window.rem_size()); + let min_contrast = EditorSettings::get_global(cx).minimum_contrast_for_highlights; let ellipsis = SharedString::from("⋯"); @@ -7234,10 +7731,16 @@ impl LineWithInvisibles { }]) { if let Some(replacement) = highlighted_chunk.replacement { if !line.is_empty() { + let segments = bg_segments_per_row.get(row).map(|v| &v[..]).unwrap_or(&[]); + let text_runs: &[TextRun] = if segments.is_empty() { + &styles + } else { + &Self::split_runs_by_bg_segments(&styles, segments, min_contrast, len) + }; let shaped_line = window.text_system().shape_line( line.clone().into(), font_size, - &styles, + text_runs, None, ); width += shaped_line.width; @@ -7315,10 +7818,16 @@ impl LineWithInvisibles { } else { for (ix, mut line_chunk) in highlighted_chunk.text.split('\n').enumerate() { if ix > 0 { + let segments = bg_segments_per_row.get(row).map(|v| &v[..]).unwrap_or(&[]); + let text_runs = if segments.is_empty() { + &styles + } else { + &Self::split_runs_by_bg_segments(&styles, segments, min_contrast, len) + }; let shaped_line = window.text_system().shape_line( line.clone().into(), font_size, - &styles, + text_runs, None, ); width += shaped_line.width; @@ -7406,18 +7915,96 @@ impl LineWithInvisibles { layouts } + /// Takes text runs and non-overlapping left-to-right background ranges with color. + /// Returns new text runs with adjusted contrast as per background ranges. + fn split_runs_by_bg_segments( + text_runs: &[TextRun], + bg_segments: &[(Range, Hsla)], + min_contrast: f32, + start_col_offset: usize, + ) -> Vec { + let mut output_runs: Vec = Vec::with_capacity(text_runs.len()); + let mut line_col = start_col_offset; + let mut segment_ix = 0usize; + + for text_run in text_runs.iter() { + let run_start_col = line_col; + let run_end_col = run_start_col + text_run.len; + while segment_ix < bg_segments.len() + && (bg_segments[segment_ix].0.end.column() as usize) <= run_start_col + { + segment_ix += 1; + } + let mut cursor_col = run_start_col; + let mut local_segment_ix = segment_ix; + while local_segment_ix < bg_segments.len() { + let (range, segment_color) = &bg_segments[local_segment_ix]; + let segment_start_col = range.start.column() as usize; + let segment_end_col = range.end.column() as usize; + if segment_start_col >= run_end_col { + break; + } + if segment_start_col > cursor_col { + let span_len = segment_start_col - cursor_col; + output_runs.push(TextRun { + len: span_len, + font: text_run.font.clone(), + color: text_run.color, + background_color: text_run.background_color, + underline: text_run.underline, + strikethrough: text_run.strikethrough, + }); + cursor_col = segment_start_col; + } + let segment_slice_end_col = segment_end_col.min(run_end_col); + if segment_slice_end_col > cursor_col { + let new_text_color = + ensure_minimum_contrast(text_run.color, *segment_color, min_contrast); + output_runs.push(TextRun { + len: segment_slice_end_col - cursor_col, + font: text_run.font.clone(), + color: new_text_color, + background_color: text_run.background_color, + underline: text_run.underline, + strikethrough: text_run.strikethrough, + }); + cursor_col = segment_slice_end_col; + } + if segment_end_col >= run_end_col { + break; + } + local_segment_ix += 1; + } + if cursor_col < run_end_col { + output_runs.push(TextRun { + len: run_end_col - cursor_col, + font: text_run.font.clone(), + color: text_run.color, + background_color: text_run.background_color, + underline: text_run.underline, + strikethrough: text_run.strikethrough, + }); + } + line_col = run_end_col; + segment_ix = local_segment_ix; + } + output_runs + } + fn prepaint( &mut self, line_height: Pixels, - scroll_pixel_position: gpui::Point, + scroll_position: gpui::Point, + scroll_pixel_position: gpui::Point, row: DisplayRow, content_origin: gpui::Point, line_elements: &mut SmallVec<[AnyElement; 1]>, window: &mut Window, cx: &mut App, ) { - let line_y = line_height * (row.as_f32() - scroll_pixel_position.y / line_height); - let mut fragment_origin = content_origin + gpui::point(-scroll_pixel_position.x, line_y); + let line_y = f32::from(line_height) * Pixels::from(row.as_f64() - scroll_position.y); + let mut fragment_origin = + content_origin + gpui::point(Pixels::from(-scroll_pixel_position.x), line_y); for fragment in &mut self.fragments { match fragment { LineFragment::Text(line) => { @@ -7451,11 +8038,13 @@ impl LineWithInvisibles { cx: &mut App, ) { let line_height = layout.position_map.line_height; - let line_y = line_height - * (row.as_f32() - layout.position_map.scroll_pixel_position.y / line_height); + let line_y = line_height * (row.as_f64() - layout.position_map.scroll_position.y) as f32; - let mut fragment_origin = - content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y); + let mut fragment_origin = content_origin + + gpui::point( + Pixels::from(-layout.position_map.scroll_pixel_position.x), + line_y, + ); for fragment in &self.fragments { match fragment { @@ -7492,11 +8081,13 @@ impl LineWithInvisibles { cx: &mut App, ) { let line_height = layout.position_map.line_height; - let line_y = line_height - * (row.as_f32() - layout.position_map.scroll_pixel_position.y / line_height); + let line_y = line_height * (row.as_f64() - layout.position_map.scroll_position.y) as f32; - let mut fragment_origin = - content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y); + let mut fragment_origin = content_origin + + gpui::point( + Pixels::from(-layout.position_map.scroll_pixel_position.x), + line_y, + ); for fragment in &self.fragments { match fragment { @@ -7535,12 +8126,15 @@ impl LineWithInvisibles { } }; - let x_offset = self.x_for_index(token_offset); - let invisible_offset = - (layout.position_map.em_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0; + let x_offset: ScrollPixelOffset = self.x_for_index(token_offset).into(); + let invisible_offset: ScrollPixelOffset = + ((layout.position_map.em_width - invisible_symbol.width).max(Pixels::ZERO) / 2.0) + .into(); let origin = content_origin + gpui::point( - x_offset + invisible_offset - layout.position_map.scroll_pixel_position.x, + Pixels::from( + x_offset + invisible_offset - layout.position_map.scroll_pixel_position.x, + ), line_y, ); @@ -7898,7 +8492,7 @@ impl Element for EditorElement { let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| { (editor.snapshot(window, cx), editor.read_only(cx)) }); - let style = self.style.clone(); + let style = &self.style; let rem_size = window.rem_size(); let font_id = window.text_system().resolve_font(&style.text.font()); @@ -7954,8 +8548,12 @@ impl Element for EditorElement { snapshot = self.editor.update(cx, |editor, cx| { editor.last_bounds = Some(bounds); editor.gutter_dimensions = gutter_dimensions; - editor.set_visible_line_count(bounds.size.height / line_height, window, cx); - editor.set_visible_column_count(editor_width / em_advance); + editor.set_visible_line_count( + (bounds.size.height / line_height) as f64, + window, + cx, + ); + editor.set_visible_column_count(f64::from(editor_width / em_advance)); if matches!( editor.mode, @@ -8000,13 +8598,13 @@ impl Element for EditorElement { let content_offset = point(editor_margins.gutter.margin, Pixels::ZERO); let content_origin = text_hitbox.origin + content_offset; - let height_in_lines = bounds.size.height / line_height; - let max_row = snapshot.max_point().row().as_f32(); + let height_in_lines = f64::from(bounds.size.height / line_height); + let max_row = snapshot.max_point().row().as_f64(); // The max scroll position for the top of the window let max_scroll_top = if matches!( snapshot.mode, - EditorMode::SingleLine { .. } + EditorMode::SingleLine | EditorMode::AutoHeight { .. } | EditorMode::Full { sized_by_content: true, @@ -8073,20 +8671,20 @@ impl Element for EditorElement { let is_row_soft_wrapped = |row: usize| { row_infos .get(row) - .map_or(true, |info| info.buffer_row.is_none()) + .is_none_or(|info| info.buffer_row.is_none()) }; let start_anchor = if start_row == Default::default() { Anchor::min() } else { - snapshot.buffer_snapshot.anchor_before( + snapshot.buffer_snapshot().anchor_before( DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left), ) }; let end_anchor = if end_row > max_row { Anchor::max() } else { - snapshot.buffer_snapshot.anchor_before( + snapshot.buffer_snapshot().anchor_before( DisplayPoint::new(end_row, 0).to_offset(&snapshot, Bias::Right), ) }; @@ -8185,31 +8783,35 @@ impl Element for EditorElement { .editor_with_selections(cx) .map(|editor| { editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all::(cx); - let selected_buffer_ids = if editor.is_singleton(cx) { - Vec::new() - } else { - let mut selected_buffer_ids = - Vec::with_capacity(all_selections.len()); - - for selection in all_selections { - for buffer_id in snapshot - .buffer_snapshot - .buffer_ids_for_range(selection.range()) - { - if selected_buffer_ids.last() != Some(&buffer_id) { - selected_buffer_ids.push(buffer_id); + let all_selections = + editor.selections.all::(&snapshot.display_snapshot); + let selected_buffer_ids = + if editor.buffer_kind(cx) == ItemBufferKind::Singleton { + Vec::new() + } else { + let mut selected_buffer_ids = + Vec::with_capacity(all_selections.len()); + + for selection in all_selections { + for buffer_id in snapshot + .buffer_snapshot() + .buffer_ids_for_range(selection.range()) + { + if selected_buffer_ids.last() != Some(&buffer_id) { + selected_buffer_ids.push(buffer_id); + } } } - } - selected_buffer_ids - }; + selected_buffer_ids + }; - let mut selections = editor - .selections - .disjoint_in_range(start_anchor..end_anchor, cx); - selections.extend(editor.selections.pending(cx)); + let mut selections = editor.selections.disjoint_in_range( + start_anchor..end_anchor, + &snapshot.display_snapshot, + ); + selections + .extend(editor.selections.pending(&snapshot.display_snapshot)); (selections, selected_buffer_ids) }) @@ -8319,12 +8921,30 @@ impl Element for EditorElement { cx, ); + let merged_highlighted_ranges = + if let Some((_, colors)) = document_colors.as_ref() { + &highlighted_ranges + .clone() + .into_iter() + .chain(colors.clone()) + .collect() + } else { + &highlighted_ranges + }; + let bg_segments_per_row = Self::bg_segments_per_row( + start_row..end_row, + &selections, + &merged_highlighted_ranges, + self.style.background, + ); + let mut line_layouts = Self::layout_lines( start_row..end_row, &snapshot, &self.style, editor_width, is_row_soft_wrapped, + &bg_segments_per_row, window, cx, ); @@ -8358,21 +8978,17 @@ impl Element for EditorElement { return None; } let blame = editor.blame.as_ref()?; - let blame_entry = blame + let (_, blame_entry) = blame .update(cx, |blame, cx| { let row_infos = snapshot.row_infos(snapshot.longest_row()).next()?; blame.blame_for_rows(&[row_infos], cx).next() }) .flatten()?; - let mut element = render_inline_blame_entry(blame_entry, &style, cx)?; - let inline_blame_padding = ProjectSettings::get_global(cx) - .git - .inline_blame - .unwrap_or_default() - .padding - as f32 - * em_advance; + let mut element = render_inline_blame_entry(blame_entry, style, cx)?; + let inline_blame_padding = + ProjectSettings::get_global(cx).git.inline_blame.padding as f32 + * em_advance; Some( element .layout_as_root(AvailableSpace::min_size(), window, cx) @@ -8385,7 +9001,7 @@ impl Element for EditorElement { let longest_line_width = layout_line( snapshot.longest_row(), &snapshot, - &style, + style, editor_width, is_row_soft_wrapped, window, @@ -8396,14 +9012,17 @@ impl Element for EditorElement { let scrollbar_layout_information = ScrollbarLayoutInformation::new( text_hitbox.bounds, glyph_grid_cell, - size(longest_line_width, max_row.as_f32() * line_height), + size( + longest_line_width, + Pixels::from(max_row.as_f64() * f64::from(line_height)), + ), longest_line_blame_width, EditorSettings::get_global(cx), ); let mut scroll_width = scrollbar_layout_information.scroll_range.width; - let sticky_header_excerpt = if snapshot.buffer_snapshot.show_headers() { + let sticky_header_excerpt = if snapshot.buffer_snapshot().show_headers() { snapshot.sticky_header_excerpt(scroll_position.y) } else { None @@ -8454,7 +9073,7 @@ impl Element for EditorElement { window.with_element_namespace("blocks", |window| { self.layout_sticky_buffer_header( sticky_header_excerpt, - scroll_position.y, + scroll_position, line_height, right_margin, &snapshot, @@ -8468,12 +9087,14 @@ impl Element for EditorElement { }); let start_buffer_row = - MultiBufferRow(start_anchor.to_point(&snapshot.buffer_snapshot).row); + MultiBufferRow(start_anchor.to_point(&snapshot.buffer_snapshot()).row); let end_buffer_row = - MultiBufferRow(end_anchor.to_point(&snapshot.buffer_snapshot).row); + MultiBufferRow(end_anchor.to_point(&snapshot.buffer_snapshot()).row); - let scroll_max = point( - ((scroll_width - editor_width) / em_advance).max(0.0), + let scroll_max: gpui::Point = point( + ScrollPixelOffset::from( + ((scroll_width - editor_width) / em_advance).max(0.0), + ), max_scroll_top, ); @@ -8499,8 +9120,8 @@ impl Element for EditorElement { }); let scroll_pixel_position = point( - scroll_position.x * em_advance, - scroll_position.y * line_height, + scroll_position.x * f64::from(em_advance), + scroll_position.y * f64::from(line_height), ); let indent_guides = self.layout_indent_guides( content_origin, @@ -8540,10 +9161,11 @@ impl Element for EditorElement { scroll_position.y + height_in_lines, &line_layouts, line_height, + scroll_position, scroll_pixel_position, newest_selection_head, editor_width, - &style, + style, window, cx, ) @@ -8555,13 +9177,14 @@ impl Element for EditorElement { &crease_trailers, &row_block_types, content_origin, + scroll_position, scroll_pixel_position, edit_prediction_popover_origin, start_row, end_row, line_height, em_width, - &style, + style, window, cx, ); @@ -8576,6 +9199,7 @@ impl Element for EditorElement { inline_code_actions = self.layout_inline_code_actions( newest_selection_head, content_origin, + scroll_position, scroll_pixel_position, line_height, &snapshot, @@ -8597,6 +9221,7 @@ impl Element for EditorElement { crease_trailer_layout, em_width, content_origin, + scroll_position, scroll_pixel_position, line_height, &text_hitbox, @@ -8636,6 +9261,7 @@ impl Element for EditorElement { start_row, &mut line_layouts, line_height, + scroll_position, scroll_pixel_position, content_origin, window, @@ -8647,6 +9273,7 @@ impl Element for EditorElement { &mut blocks, &hitbox, line_height, + scroll_position, scroll_pixel_position, window, cx, @@ -8706,7 +9333,7 @@ impl Element for EditorElement { &line_layouts, newest_selection_head, newest_selection_point, - &style, + style, window, cx, ) @@ -8733,7 +9360,7 @@ impl Element for EditorElement { line_height, start_row..end_row, &row_infos, - scroll_pixel_position, + scroll_position, &gutter_dimensions, &gutter_hitbox, &display_hunks, @@ -8753,7 +9380,7 @@ impl Element for EditorElement { self.layout_breakpoints( line_height, start_row..end_row, - scroll_pixel_position, + scroll_position, &gutter_dimensions, &gutter_hitbox, &display_hunks, @@ -8846,11 +9473,21 @@ impl Element for EditorElement { }); let invisible_symbol_font_size = font_size / 2.; + let whitespace_map = &self + .editor + .read(cx) + .buffer + .read(cx) + .language_settings(cx) + .whitespace_map; + + let tab_char = whitespace_map.tab.clone(); + let tab_len = tab_char.len(); let tab_invisible = window.text_system().shape_line( - "→".into(), + tab_char, invisible_symbol_font_size, &[TextRun { - len: "→".len(), + len: tab_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, background_color: None, @@ -8859,11 +9496,14 @@ impl Element for EditorElement { }], None, ); + + let space_char = whitespace_map.space.clone(); + let space_len = space_char.len(); let space_invisible = window.text_system().shape_line( - "•".into(), + space_char, invisible_symbol_font_size, &[TextRun { - len: "•".len(), + len: space_len, font: self.style.text.font(), color: cx.theme().colors().editor_invisible, background_color: None, @@ -8897,6 +9537,7 @@ impl Element for EditorElement { let position_map = Rc::new(PositionMap { size: bounds.size, visible_row_range, + scroll_position, scroll_pixel_position, scroll_max, line_layouts, @@ -8908,9 +9549,9 @@ impl Element for EditorElement { text_hitbox: text_hitbox.clone(), inline_blame_bounds: inline_blame_layout .as_ref() - .map(|layout| (layout.bounds, layout.entry.clone())), + .map(|layout| (layout.bounds, layout.buffer_id, layout.entry.clone())), display_hunks: display_hunks.clone(), - diff_hunk_control_bounds: diff_hunk_control_bounds.clone(), + diff_hunk_control_bounds, }); self.editor.update(cx, |editor, _| { @@ -9073,7 +9714,7 @@ impl ScrollbarLayoutInformation { ScrollBeyondLastLine::OnePage => editor_bounds.size.height, ScrollBeyondLastLine::Off => glyph_grid_cell.height, ScrollBeyondLastLine::VerticalScrollMargin => { - (1.0 + settings.vertical_scroll_margin) * glyph_grid_cell.height + (1.0 + settings.vertical_scroll_margin) as f32 * glyph_grid_cell.height } }; @@ -9189,7 +9830,7 @@ impl EditorScrollbars { show_scrollbar: ScrollbarAxes, layout_information: &ScrollbarLayoutInformation, content_offset: gpui::Point, - scroll_position: gpui::Point, + scroll_position: gpui::Point, scrollbar_width: Pixels, right_margin: Pixels, editor_width: Pixels, @@ -9211,7 +9852,7 @@ impl EditorScrollbars { editor_bounds.bottom_left(), size( // The horizontal viewport size differs from the space available for the - // horizontal scrollbar, so we have to manually stich it together here. + // horizontal scrollbar, so we have to manually stitch it together here. editor_bounds.size.width - right_margin, scrollbar_width, ), @@ -9273,7 +9914,7 @@ impl EditorScrollbars { #[derive(Clone)] struct ScrollbarLayout { hitbox: Hitbox, - visible_range: Range, + visible_range: Range, text_unit_size: Pixels, thumb_bounds: Option>, thumb_state: ScrollbarThumbState, @@ -9291,7 +9932,7 @@ impl ScrollbarLayout { scroll_range: Pixels, glyph_space: Pixels, content_offset: Pixels, - scroll_position: f32, + scroll_position: ScrollOffset, show_thumb: bool, axis: ScrollbarAxis, ) -> Self { @@ -9304,9 +9945,9 @@ impl ScrollbarLayout { scrollbar_track_hitbox, track_length, viewport_size, - scroll_range, + scroll_range.into(), glyph_space, - content_offset, + content_offset.into(), scroll_position, show_thumb, axis, @@ -9315,11 +9956,11 @@ impl ScrollbarLayout { fn for_minimap( minimap_track_hitbox: Hitbox, - visible_lines: f32, - total_editor_lines: f32, + visible_lines: f64, + total_editor_lines: f64, minimap_line_height: Pixels, - scroll_position: f32, - minimap_scroll_top: f32, + scroll_position: ScrollOffset, + minimap_scroll_top: ScrollOffset, show_thumb: bool, ) -> Self { // The scrollbar thumb size is calculated as @@ -9336,15 +9977,15 @@ impl ScrollbarLayout { // This approach ensures that the minimap thumb accurately reflects the // editor's current scroll position whilst nicely synchronizing the minimap // thumb and scrollbar thumb. - let scroll_range = total_editor_lines * minimap_line_height; - let viewport_size = visible_lines * minimap_line_height; + let scroll_range = total_editor_lines * f64::from(minimap_line_height); + let viewport_size = visible_lines * f64::from(minimap_line_height); - let track_top_offset = -minimap_scroll_top * minimap_line_height; + let track_top_offset = -minimap_scroll_top * f64::from(minimap_line_height); Self::new_with_hitbox_and_track_length( minimap_track_hitbox, - scroll_range, - viewport_size, + Pixels::from(scroll_range), + Pixels::from(viewport_size), scroll_range, minimap_line_height, track_top_offset, @@ -9358,19 +9999,19 @@ impl ScrollbarLayout { scrollbar_track_hitbox: Hitbox, track_length: Pixels, viewport_size: Pixels, - scroll_range: Pixels, + scroll_range: f64, glyph_space: Pixels, - content_offset: Pixels, - scroll_position: f32, + content_offset: ScrollOffset, + scroll_position: ScrollOffset, show_thumb: bool, axis: ScrollbarAxis, ) -> Self { - let text_units_per_page = viewport_size / glyph_space; + let text_units_per_page = f64::from(viewport_size / glyph_space); let visible_range = scroll_position..scroll_position + text_units_per_page; - let total_text_units = scroll_range / glyph_space; + let total_text_units = scroll_range / f64::from(glyph_space); let thumb_percentage = text_units_per_page / total_text_units; - let thumb_size = (track_length * thumb_percentage) + let thumb_size = Pixels::from(ScrollOffset::from(track_length) * thumb_percentage) .max(ScrollbarLayout::MIN_THUMB_SIZE) .min(track_length); @@ -9379,7 +10020,7 @@ impl ScrollbarLayout { let content_larger_than_viewport = text_unit_divisor > 0.; let text_unit_size = if content_larger_than_viewport { - (track_length - thumb_size) / text_unit_divisor + Pixels::from(ScrollOffset::from(track_length - thumb_size) / text_unit_divisor) } else { glyph_space }; @@ -9417,14 +10058,17 @@ impl ScrollbarLayout { fn thumb_bounds( scrollbar_track: &Hitbox, - content_offset: Pixels, - visible_range_start: f32, + content_offset: f64, + visible_range_start: f64, text_unit_size: Pixels, thumb_size: Pixels, axis: ScrollbarAxis, ) -> Bounds { let thumb_origin = scrollbar_track.origin.apply_along(axis, |origin| { - origin + content_offset + visible_range_start * text_unit_size + origin + + Pixels::from( + content_offset + visible_range_start * ScrollOffset::from(text_unit_size), + ) }); Bounds::new( thumb_origin, @@ -9447,7 +10091,7 @@ impl ScrollbarLayout { max: Pixels, } let (x_range, height_limit) = if let Some(column) = column { - let column_width = px(((self.hitbox.size.width - Self::BORDER_WIDTH).0 / 3.0).floor()); + let column_width = ((self.hitbox.size.width - Self::BORDER_WIDTH) / 3.0).floor(); let start = Self::BORDER_WIDTH + (column as f32 * column_width); let end = start + column_width; ( @@ -9470,7 +10114,7 @@ impl ScrollbarLayout { ) }; - let row_to_y = |row: DisplayRow| row.as_f32() * self.text_unit_size; + let row_to_y = |row: DisplayRow| row.as_f64() as f32 * self.text_unit_size; let mut pixel_ranges = row_ranges .into_iter() .map(|range| { @@ -9522,10 +10166,10 @@ impl ScrollbarLayout { struct MinimapLayout { pub minimap: AnyElement, pub thumb_layout: ScrollbarLayout, - pub minimap_scroll_top: f32, + pub minimap_scroll_top: ScrollOffset, pub minimap_line_height: Pixels, pub thumb_border_style: MinimapThumbBorder, - pub max_scroll_top: f32, + pub max_scroll_top: ScrollOffset, } impl MinimapLayout { @@ -9536,11 +10180,11 @@ impl MinimapLayout { /// Calculates the scroll top offset the minimap editor has to have based on the /// current scroll progress. fn calculate_minimap_top_offset( - document_lines: f32, - visible_editor_lines: f32, - visible_minimap_lines: f32, - scroll_position: f32, - ) -> f32 { + document_lines: f64, + visible_editor_lines: f64, + visible_minimap_lines: f64, + scroll_position: f64, + ) -> ScrollOffset { let non_visible_document_lines = (document_lines - visible_editor_lines).max(0.); if non_visible_document_lines == 0. { 0. @@ -9559,8 +10203,9 @@ struct CreaseTrailerLayout { pub(crate) struct PositionMap { pub size: Size, pub line_height: Pixels, - pub scroll_pixel_position: gpui::Point, - pub scroll_max: gpui::Point, + pub scroll_position: gpui::Point, + pub scroll_pixel_position: gpui::Point, + pub scroll_max: gpui::Point, pub em_width: Pixels, pub em_advance: Pixels, pub visible_row_range: Range, @@ -9568,7 +10213,7 @@ pub(crate) struct PositionMap { pub snapshot: EditorSnapshot, pub text_hitbox: Hitbox, pub gutter_hitbox: Hitbox, - pub inline_blame_bounds: Option<(Bounds, BlameEntry)>, + pub inline_blame_bounds: Option<(Bounds, BufferId, BlameEntry)>, pub display_hunks: Vec<(DisplayDiffHunk, Option)>, pub diff_hunk_control_bounds: Vec<(DisplayRow, Bounds)>, } @@ -9608,14 +10253,12 @@ impl PointForPosition { false } else if start_row == end_row { candidate_col >= start_col && candidate_col < end_col + } else if candidate_row == start_row { + candidate_col >= start_col + } else if candidate_row == end_row { + candidate_col < end_col } else { - if candidate_row == start_row { - candidate_col >= start_col - } else if candidate_row == end_row { - candidate_col < end_col - } else { - true - } + true } } } @@ -9626,8 +10269,8 @@ impl PositionMap { let scroll_position = self.snapshot.scroll_position(); let position = position - text_bounds.origin; let y = position.y.max(px(0.)).min(self.size.height); - let x = position.x + (scroll_position.x * self.em_advance); - let row = ((y / self.line_height) + scroll_position.y) as u32; + let x = position.x + (scroll_position.x as f32 * self.em_advance); + let row = ((y / self.line_height) as f64 + scroll_position.y) as u32; let (column, x_overshoot_after_line_end) = if let Some(line) = self .line_layouts @@ -9680,12 +10323,13 @@ pub fn layout_line( let chunks = snapshot.highlighted_chunks(row..row + DisplayRow(1), true, style); LineWithInvisibles::from_chunks( chunks, - &style, + style, MAX_LINE_LEN, 1, &snapshot.mode, text_width, is_row_soft_wrapped, + &[], window, cx, ) @@ -9797,7 +10441,7 @@ impl CursorLayout { .px_0p5() .line_height(text_size + px(2.)) .text_color(cursor_name.color) - .child(cursor_name.string.clone()) + .child(cursor_name.string) .into_any_element(); name_element.prepaint_as_root(name_origin, AvailableSpace::min_size(), window, cx); @@ -10050,10 +10694,10 @@ fn compute_auto_height_layout( let overscroll = size(em_width, px(0.)); let editor_width = text_width - gutter_dimensions.margin - overscroll.width - em_width; - if !matches!(editor.soft_wrap_mode(cx), SoftWrap::None) { - if editor.set_wrap_width(Some(editor_width), cx) { - snapshot = editor.snapshot(window, cx); - } + if !matches!(editor.soft_wrap_mode(cx), SoftWrap::None) + && editor.set_wrap_width(Some(editor_width), cx) + { + snapshot = editor.snapshot(window, cx); } let scroll_height = (snapshot.max_point().row().next_row().0 as f32) * line_height; @@ -10085,6 +10729,71 @@ mod tests { use std::num::NonZeroU32; use util::test::sample_text; + #[gpui::test] + async fn test_soft_wrap_editor_width_auto_height_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&"a ".to_string().repeat(100), cx); + let mut editor = Editor::new( + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = window.root(cx).unwrap(); + let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + + for x in 1..=100 { + let (_, state) = cx.draw( + Default::default(), + size(px(200. + 0.13 * x as f32), px(500.)), + |_, _| EditorElement::new(&editor, style.clone()), + ); + + assert!( + state.position_map.scroll_max.x == 0., + "Soft wrapped editor should have no horizontal scrolling!" + ); + } + } + + #[gpui::test] + async fn test_soft_wrap_editor_width_full_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&"a ".to_string().repeat(100), cx); + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = window.root(cx).unwrap(); + let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + + for x in 1..=100 { + let (_, state) = cx.draw( + Default::default(), + size(px(200. + 0.13 * x as f32), px(500.)), + |_, _| EditorElement::new(&editor, style.clone()), + ); + + assert!( + state.position_map.scroll_max.x == 0., + "Soft wrapped editor should have no horizontal scrolling!" + ); + } + } + #[gpui::test] fn test_shape_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -10269,7 +10978,7 @@ mod tests { let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); window .update(cx, |editor, window, cx| { - editor.set_placeholder_text("hello", cx); + editor.set_placeholder_text("hello", window, cx); editor.insert_blocks( [BlockProperties { style: BlockStyle::Fixed, @@ -10491,7 +11200,7 @@ mod tests { ) -> Vec { info!( "Creating editor with mode {editor_mode:?}, width {}px and text '{input_text}'", - editor_width.0 + f32::from(editor_width) ); let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(input_text, cx); @@ -10521,4 +11230,330 @@ mod tests { .cloned() .collect() } + + #[gpui::test] + fn test_merge_overlapping_ranges() { + let base_bg = Hsla::white(); + let color1 = Hsla { + h: 0.0, + s: 0.5, + l: 0.5, + a: 0.5, + }; + let color2 = Hsla { + h: 120.0, + s: 0.5, + l: 0.5, + a: 0.5, + }; + + let display_point = |col| DisplayPoint::new(DisplayRow(0), col); + let cols = |v: &Vec<(Range, Hsla)>| -> Vec<(u32, u32)> { + v.iter() + .map(|(r, _)| (r.start.column(), r.end.column())) + .collect() + }; + + // Test overlapping ranges blend colors + let overlapping = vec![ + (display_point(5)..display_point(15), color1), + (display_point(10)..display_point(20), color2), + ]; + let result = EditorElement::merge_overlapping_ranges(overlapping, base_bg); + assert_eq!(cols(&result), vec![(5, 10), (10, 15), (15, 20)]); + + // Test middle segment should have blended color + let blended = Hsla::blend(Hsla::blend(base_bg, color1), color2); + assert_eq!(result[1].1, blended); + + // Test adjacent same-color ranges merge + let adjacent_same = vec![ + (display_point(5)..display_point(10), color1), + (display_point(10)..display_point(15), color1), + ]; + let result = EditorElement::merge_overlapping_ranges(adjacent_same, base_bg); + assert_eq!(cols(&result), vec![(5, 15)]); + + // Test contained range splits + let contained = vec![ + (display_point(5)..display_point(20), color1), + (display_point(10)..display_point(15), color2), + ]; + let result = EditorElement::merge_overlapping_ranges(contained, base_bg); + assert_eq!(cols(&result), vec![(5, 10), (10, 15), (15, 20)]); + + // Test multiple overlaps split at every boundary + let color3 = Hsla { + h: 240.0, + s: 0.5, + l: 0.5, + a: 0.5, + }; + let complex = vec![ + (display_point(5)..display_point(12), color1), + (display_point(8)..display_point(16), color2), + (display_point(10)..display_point(14), color3), + ]; + let result = EditorElement::merge_overlapping_ranges(complex, base_bg); + assert_eq!( + cols(&result), + vec![(5, 8), (8, 10), (10, 12), (12, 14), (14, 16)] + ); + } + + #[gpui::test] + fn test_bg_segments_per_row() { + let base_bg = Hsla::white(); + + // Case A: selection spans three display rows: row 1 [5, end), full row 2, row 3 [0, 7) + { + let selection_color = Hsla { + h: 200.0, + s: 0.5, + l: 0.5, + a: 0.5, + }; + let player_color = PlayerColor { + cursor: selection_color, + background: selection_color, + selection: selection_color, + }; + + let spanning_selection = SelectionLayout { + head: DisplayPoint::new(DisplayRow(3), 7), + cursor_shape: CursorShape::Bar, + is_newest: true, + is_local: true, + range: DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(3), 7), + active_rows: DisplayRow(1)..DisplayRow(4), + user_name: None, + }; + + let selections = vec![(player_color, vec![spanning_selection])]; + let result = EditorElement::bg_segments_per_row( + DisplayRow(0)..DisplayRow(5), + &selections, + &[], + base_bg, + ); + + assert_eq!(result.len(), 5); + assert!(result[0].is_empty()); + assert_eq!(result[1].len(), 1); + assert_eq!(result[2].len(), 1); + assert_eq!(result[3].len(), 1); + assert!(result[4].is_empty()); + + assert_eq!(result[1][0].0.start, DisplayPoint::new(DisplayRow(1), 5)); + assert_eq!(result[1][0].0.end.row(), DisplayRow(1)); + assert_eq!(result[1][0].0.end.column(), u32::MAX); + assert_eq!(result[2][0].0.start, DisplayPoint::new(DisplayRow(2), 0)); + assert_eq!(result[2][0].0.end.row(), DisplayRow(2)); + assert_eq!(result[2][0].0.end.column(), u32::MAX); + assert_eq!(result[3][0].0.start, DisplayPoint::new(DisplayRow(3), 0)); + assert_eq!(result[3][0].0.end, DisplayPoint::new(DisplayRow(3), 7)); + } + + // Case B: selection ends exactly at the start of row 3, excluding row 3 + { + let selection_color = Hsla { + h: 120.0, + s: 0.5, + l: 0.5, + a: 0.5, + }; + let player_color = PlayerColor { + cursor: selection_color, + background: selection_color, + selection: selection_color, + }; + + let selection = SelectionLayout { + head: DisplayPoint::new(DisplayRow(2), 0), + cursor_shape: CursorShape::Bar, + is_newest: true, + is_local: true, + range: DisplayPoint::new(DisplayRow(1), 5)..DisplayPoint::new(DisplayRow(3), 0), + active_rows: DisplayRow(1)..DisplayRow(3), + user_name: None, + }; + + let selections = vec![(player_color, vec![selection])]; + let result = EditorElement::bg_segments_per_row( + DisplayRow(0)..DisplayRow(4), + &selections, + &[], + base_bg, + ); + + assert_eq!(result.len(), 4); + assert!(result[0].is_empty()); + assert_eq!(result[1].len(), 1); + assert_eq!(result[2].len(), 1); + assert!(result[3].is_empty()); + + assert_eq!(result[1][0].0.start, DisplayPoint::new(DisplayRow(1), 5)); + assert_eq!(result[1][0].0.end.row(), DisplayRow(1)); + assert_eq!(result[1][0].0.end.column(), u32::MAX); + assert_eq!(result[2][0].0.start, DisplayPoint::new(DisplayRow(2), 0)); + assert_eq!(result[2][0].0.end.row(), DisplayRow(2)); + assert_eq!(result[2][0].0.end.column(), u32::MAX); + } + } + + #[cfg(test)] + fn generate_test_run(len: usize, color: Hsla) -> TextRun { + TextRun { + len, + font: gpui::font(".SystemUIFont"), + color, + background_color: None, + underline: None, + strikethrough: None, + } + } + + #[gpui::test] + fn test_split_runs_by_bg_segments(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let dx = |start: u32, end: u32| { + DisplayPoint::new(DisplayRow(0), start)..DisplayPoint::new(DisplayRow(0), end) + }; + + let text_color = Hsla { + h: 210.0, + s: 0.1, + l: 0.4, + a: 1.0, + }; + let bg_1 = Hsla { + h: 30.0, + s: 0.6, + l: 0.8, + a: 1.0, + }; + let bg_2 = Hsla { + h: 200.0, + s: 0.6, + l: 0.2, + a: 1.0, + }; + let min_contrast = 45.0; + let adjusted_bg1 = ensure_minimum_contrast(text_color, bg_1, min_contrast); + let adjusted_bg2 = ensure_minimum_contrast(text_color, bg_2, min_contrast); + + // Case A: single run; disjoint segments inside the run + { + let runs = vec![generate_test_run(20, text_color)]; + let segs = vec![(dx(5, 10), bg_1), (dx(12, 16), bg_2)]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0); + // Expected slices: [0,5) [5,10) [10,12) [12,16) [16,20) + assert_eq!( + out.iter().map(|r| r.len).collect::>(), + vec![5, 5, 2, 4, 4] + ); + assert_eq!(out[0].color, text_color); + assert_eq!(out[1].color, adjusted_bg1); + assert_eq!(out[2].color, text_color); + assert_eq!(out[3].color, adjusted_bg2); + assert_eq!(out[4].color, text_color); + } + + // Case B: multiple runs; segment extends to end of line (u32::MAX) + { + let runs = vec![ + generate_test_run(8, text_color), + generate_test_run(7, text_color), + ]; + let segs = vec![(dx(6, u32::MAX), bg_1)]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0); + // Expected slices across runs: [0,6) [6,8) | [0,7) + assert_eq!(out.iter().map(|r| r.len).collect::>(), vec![6, 2, 7]); + assert_eq!(out[0].color, text_color); + assert_eq!(out[1].color, adjusted_bg1); + assert_eq!(out[2].color, adjusted_bg1); + } + + // Case C: multi-byte characters + { + // for text: "Hello 🌍 世界!" + let runs = vec![ + generate_test_run(5, text_color), // "Hello" + generate_test_run(6, text_color), // " 🌍 " + generate_test_run(6, text_color), // "世界" + generate_test_run(1, text_color), // "!" + ]; + // selecting "🌍 世" + let segs = vec![(dx(6, 14), bg_1)]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0); + // "Hello" | " " | "🌍 " | "世" | "界" | "!" + assert_eq!( + out.iter().map(|r| r.len).collect::>(), + vec![5, 1, 5, 3, 3, 1] + ); + assert_eq!(out[0].color, text_color); // "Hello" + assert_eq!(out[2].color, adjusted_bg1); // "🌍 " + assert_eq!(out[3].color, adjusted_bg1); // "世" + assert_eq!(out[4].color, text_color); // "界" + assert_eq!(out[5].color, text_color); // "!" + } + + // Case D: split multiple consecutive text runs with segments + { + let segs = vec![ + (dx(2, 4), bg_1), // selecting "cd" + (dx(4, 8), bg_2), // selecting "efgh" + (dx(9, 11), bg_1), // selecting "jk" + (dx(12, 16), bg_2), // selecting "mnop" + (dx(18, 19), bg_1), // selecting "s" + ]; + + // for text: "abcdef" + let runs = vec![ + generate_test_run(2, text_color), // ab + generate_test_run(4, text_color), // cdef + ]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 0); + // new splits "ab", "cd", "ef" + assert_eq!(out.iter().map(|r| r.len).collect::>(), vec![2, 2, 2]); + assert_eq!(out[0].color, text_color); + assert_eq!(out[1].color, adjusted_bg1); + assert_eq!(out[2].color, adjusted_bg2); + + // for text: "ghijklmn" + let runs = vec![ + generate_test_run(3, text_color), // ghi + generate_test_run(2, text_color), // jk + generate_test_run(3, text_color), // lmn + ]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 6); // 2 + 4 from first run + // new splits "gh", "i", "jk", "l", "mn" + assert_eq!( + out.iter().map(|r| r.len).collect::>(), + vec![2, 1, 2, 1, 2] + ); + assert_eq!(out[0].color, adjusted_bg2); + assert_eq!(out[1].color, text_color); + assert_eq!(out[2].color, adjusted_bg1); + assert_eq!(out[3].color, text_color); + assert_eq!(out[4].color, adjusted_bg2); + + // for text: "opqrs" + let runs = vec![ + generate_test_run(1, text_color), // o + generate_test_run(4, text_color), // pqrs + ]; + let out = LineWithInvisibles::split_runs_by_bg_segments(&runs, &segs, min_contrast, 14); // 6 + 3 + 2 + 3 from first two runs + // new splits "o", "p", "qr", "s" + assert_eq!( + out.iter().map(|r| r.len).collect::>(), + vec![1, 1, 2, 1] + ); + assert_eq!(out[0].color, adjusted_bg2); + assert_eq!(out[1].color, adjusted_bg2); + assert_eq!(out[2].color, text_color); + assert_eq!(out[3].color, adjusted_bg1); + } + } } diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index fc350a5a15b4f7b105872e61e5a2401d183c1a6d..b36a57a7e47bf148fff4201ec87ac7c868658a04 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,6 +1,7 @@ use crate::Editor; use anyhow::Result; use collections::HashMap; +use futures::StreamExt; use git::{ GitHostingProviderRegistry, GitRemote, Oid, blame::{Blame, BlameEntry, ParsedCommitMessage}, @@ -10,16 +11,18 @@ use gpui::{ AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task, TextStyle, WeakEntity, Window, }; -use language::{Bias, Buffer, BufferSnapshot, Edit}; +use itertools::Itertools; +use language::{Bias, BufferSnapshot, Edit}; use markdown::Markdown; -use multi_buffer::RowInfo; +use multi_buffer::{MultiBuffer, RowInfo}; use project::{ - Project, ProjectItem, - git_store::{GitStoreEvent, Repository, RepositoryEvent}, + Project, ProjectItem as _, + git_store::{GitStoreEvent, Repository}, }; use smallvec::SmallVec; use std::{sync::Arc, time::Duration}; use sum_tree::SumTree; +use text::BufferId; use workspace::Workspace; #[derive(Clone, Debug, Default)] @@ -36,43 +39,44 @@ pub struct GitBlameEntrySummary { impl sum_tree::Item for GitBlameEntry { type Summary = GitBlameEntrySummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { GitBlameEntrySummary { rows: self.rows } } } -impl sum_tree::Summary for GitBlameEntrySummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for GitBlameEntrySummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, summary: &Self, _cx: &()) { + fn add_summary(&mut self, summary: &Self) { self.rows += summary.rows; } } impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a GitBlameEntrySummary, _cx: &()) { + fn add_summary(&mut self, summary: &'a GitBlameEntrySummary, _cx: ()) { *self += summary.rows; } } -pub struct GitBlame { - project: Entity, - buffer: Entity, +struct GitBlameBuffer { entries: SumTree, - commit_details: HashMap, buffer_snapshot: BufferSnapshot, buffer_edits: text::Subscription, + commit_details: HashMap, +} + +pub struct GitBlame { + project: Entity, + multi_buffer: WeakEntity, + buffers: HashMap, task: Task>, focused: bool, - generated: bool, changed_while_blurred: bool, user_triggered: bool, regenerate_on_edit_task: Task>, @@ -92,6 +96,7 @@ pub trait BlameRenderer { _: Entity, _: usize, _: Hsla, + window: &mut Window, _: &mut App, ) -> Option; @@ -139,6 +144,7 @@ impl BlameRenderer for () { _: Entity, _: usize, _: Hsla, + _: &mut Window, _: &mut App, ) -> Option { None @@ -184,55 +190,54 @@ impl gpui::Global for GlobalBlameRenderer {} impl GitBlame { pub fn new( - buffer: Entity, + multi_buffer: Entity, project: Entity, user_triggered: bool, focused: bool, cx: &mut Context, ) -> Self { - let entries = SumTree::from_item( - GitBlameEntry { - rows: buffer.read(cx).max_point().row + 1, - blame: None, + let multi_buffer_subscription = cx.subscribe( + &multi_buffer, + |git_blame, multi_buffer, event, cx| match event { + multi_buffer::Event::DirtyChanged => { + if !multi_buffer.read(cx).is_dirty(cx) { + git_blame.generate(cx); + } + } + multi_buffer::Event::ExcerptsAdded { .. } + | multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx), + _ => {} }, - &(), ); - let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event { - language::BufferEvent::DirtyChanged => { - if !buffer.read(cx).is_dirty() { - this.generate(cx); - } - } - language::BufferEvent::Edited => { - this.regenerate_on_edit(cx); - } - _ => {} - }); - let project_subscription = cx.subscribe(&project, { - let buffer = buffer.clone(); - - move |this, _, event, cx| match event { - project::Event::WorktreeUpdatedEntries(_, updated) => { - let project_entry_id = buffer.read(cx).entry_id(cx); + let multi_buffer = multi_buffer.downgrade(); + + move |git_blame, _, event, cx| { + if let project::Event::WorktreeUpdatedEntries(_, updated) = event { + let Some(multi_buffer) = multi_buffer.upgrade() else { + return; + }; + let project_entry_id = multi_buffer + .read(cx) + .as_singleton() + .and_then(|it| it.read(cx).entry_id(cx)); if updated .iter() .any(|(_, entry_id, _)| project_entry_id == Some(*entry_id)) { log::debug!("Updated buffers. Regenerating blame data...",); - this.generate(cx); + git_blame.generate(cx); } } - _ => {} } }); let git_store = project.read(cx).git_store().clone(); let git_store_subscription = cx.subscribe(&git_store, move |this, _, event, cx| match event { - GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, _) - | GitStoreEvent::RepositoryAdded(_) + GitStoreEvent::RepositoryUpdated(_, _, _) + | GitStoreEvent::RepositoryAdded | GitStoreEvent::RepositoryRemoved(_) => { log::debug!("Status of git repositories updated. Regenerating blame data...",); this.generate(cx); @@ -240,24 +245,17 @@ impl GitBlame { _ => {} }); - let buffer_snapshot = buffer.read(cx).snapshot(); - let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - let mut this = Self { project, - buffer, - buffer_snapshot, - entries, - buffer_edits, + multi_buffer: multi_buffer.downgrade(), + buffers: HashMap::default(), user_triggered, focused, changed_while_blurred: false, - commit_details: HashMap::default(), task: Task::ready(Ok(())), - generated: false, regenerate_on_edit_task: Task::ready(Ok(())), _regenerate_subscriptions: vec![ - buffer_subscriptions, + multi_buffer_subscription, project_subscription, git_store_subscription, ], @@ -266,54 +264,61 @@ impl GitBlame { this } - pub fn repository(&self, cx: &App) -> Option> { + pub fn repository(&self, cx: &App, id: BufferId) -> Option> { self.project .read(cx) .git_store() .read(cx) - .repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx) + .repository_and_path_for_buffer_id(id, cx) .map(|(repo, _)| repo) } pub fn has_generated_entries(&self) -> bool { - self.generated + !self.buffers.is_empty() } - pub fn details_for_entry(&self, entry: &BlameEntry) -> Option { - self.commit_details.get(&entry.sha).cloned() + pub fn details_for_entry( + &self, + buffer: BufferId, + entry: &BlameEntry, + ) -> Option { + self.buffers + .get(&buffer)? + .commit_details + .get(&entry.sha) + .cloned() } pub fn blame_for_rows<'a>( &'a mut self, rows: &'a [RowInfo], - cx: &App, - ) -> impl 'a + Iterator> { - self.sync(cx); - - let buffer_id = self.buffer_snapshot.remote_id(); - let mut cursor = self.entries.cursor::(&()); - rows.into_iter().map(move |info| { - let row = info - .buffer_row - .filter(|_| info.buffer_id == Some(buffer_id))?; - cursor.seek_forward(&row, Bias::Right); - cursor.item()?.blame.clone() + cx: &'a mut App, + ) -> impl Iterator> + use<'a> { + rows.iter().map(move |info| { + let buffer_id = info.buffer_id?; + self.sync(cx, buffer_id); + + let buffer_row = info.buffer_row?; + let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::(()); + cursor.seek_forward(&buffer_row, Bias::Right); + Some((buffer_id, cursor.item()?.blame.clone()?)) }) } - pub fn max_author_length(&mut self, cx: &App) -> usize { - self.sync(cx); - + pub fn max_author_length(&mut self, cx: &mut App) -> usize { let mut max_author_length = 0; - - for entry in self.entries.iter() { - let author_len = entry - .blame - .as_ref() - .and_then(|entry| entry.author.as_ref()) - .map(|author| author.len()); - if let Some(author_len) = author_len { - if author_len > max_author_length { + self.sync_all(cx); + + for buffer in self.buffers.values() { + for entry in buffer.entries.iter() { + let author_len = entry + .blame + .as_ref() + .and_then(|entry| entry.author.as_ref()) + .map(|author| author.len()); + if let Some(author_len) = author_len + && author_len > max_author_length + { max_author_length = author_len; } } @@ -337,22 +342,48 @@ impl GitBlame { } } - fn sync(&mut self, cx: &App) { - let edits = self.buffer_edits.consume(); - let new_snapshot = self.buffer.read(cx).snapshot(); + fn sync_all(&mut self, cx: &mut App) { + let Some(multi_buffer) = self.multi_buffer.upgrade() else { + return; + }; + multi_buffer + .read(cx) + .excerpt_buffer_ids() + .into_iter() + .for_each(|id| self.sync(cx, id)); + } + + fn sync(&mut self, cx: &mut App, buffer_id: BufferId) { + let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else { + return; + }; + let Some(buffer) = self + .multi_buffer + .upgrade() + .and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id)) + else { + return; + }; + let edits = blame_buffer.buffer_edits.consume(); + let new_snapshot = buffer.read(cx).snapshot(); let mut row_edits = edits .into_iter() .map(|edit| { - let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start) - ..self.buffer_snapshot.offset_to_point(edit.old.end); + let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start) + ..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end); let new_point_range = new_snapshot.offset_to_point(edit.new.start) ..new_snapshot.offset_to_point(edit.new.end); if old_point_range.start.column - == self.buffer_snapshot.line_len(old_point_range.start.row) + == blame_buffer + .buffer_snapshot + .line_len(old_point_range.start.row) && (new_snapshot.chars_at(edit.new.start).next() == Some('\n') - || self.buffer_snapshot.line_len(old_point_range.end.row) == 0) + || blame_buffer + .buffer_snapshot + .line_len(old_point_range.end.row) + == 0) { Edit { old: old_point_range.start.row + 1..old_point_range.end.row + 1, @@ -376,7 +407,7 @@ impl GitBlame { .peekable(); let mut new_entries = SumTree::default(); - let mut cursor = self.entries.cursor::(&()); + let mut cursor = blame_buffer.entries.cursor::(()); while let Some(mut edit) = row_edits.next() { while let Some(next_edit) = row_edits.peek() { @@ -389,7 +420,7 @@ impl GitBlame { } } - new_entries.append(cursor.slice(&edit.old.start, Bias::Right), &()); + new_entries.append(cursor.slice(&edit.old.start, Bias::Right), ()); if edit.new.start > new_entries.summary().rows { new_entries.push( @@ -397,7 +428,7 @@ impl GitBlame { rows: edit.new.start - new_entries.summary().rows, blame: cursor.item().and_then(|entry| entry.blame.clone()), }, - &(), + (), ); } @@ -408,44 +439,54 @@ impl GitBlame { rows: edit.new.len() as u32, blame: None, }, - &(), + (), ); } let old_end = cursor.end(); if row_edits .peek() - .map_or(true, |next_edit| next_edit.old.start >= old_end) + .is_none_or(|next_edit| next_edit.old.start >= old_end) + && let Some(entry) = cursor.item() { - if let Some(entry) = cursor.item() { - if old_end > edit.old.end { - new_entries.push( - GitBlameEntry { - rows: cursor.end() - edit.old.end, - blame: entry.blame.clone(), - }, - &(), - ); - } - - cursor.next(); + if old_end > edit.old.end { + new_entries.push( + GitBlameEntry { + rows: cursor.end() - edit.old.end, + blame: entry.blame.clone(), + }, + (), + ); } + + cursor.next(); } } - new_entries.append(cursor.suffix(), &()); + new_entries.append(cursor.suffix(), ()); drop(cursor); - self.buffer_snapshot = new_snapshot; - self.entries = new_entries; + blame_buffer.buffer_snapshot = new_snapshot; + blame_buffer.entries = new_entries; } #[cfg(test)] fn check_invariants(&mut self, cx: &mut Context) { - self.sync(cx); - assert_eq!( - self.entries.summary().rows, - self.buffer.read(cx).max_point().row + 1 - ); + self.sync_all(cx); + for (&id, buffer) in &self.buffers { + assert_eq!( + buffer.entries.summary().rows, + self.multi_buffer + .upgrade() + .unwrap() + .read(cx) + .buffer(id) + .unwrap() + .read(cx) + .max_point() + .row + + 1 + ); + } } fn generate(&mut self, cx: &mut Context) { @@ -453,67 +494,115 @@ impl GitBlame { self.changed_while_blurred = true; return; } - let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe()); - let snapshot = self.buffer.read(cx).snapshot(); let blame = self.project.update(cx, |project, cx| { - project.blame_buffer(&self.buffer, None, cx) + let Some(multi_buffer) = self.multi_buffer.upgrade() else { + return Vec::new(); + }; + multi_buffer + .read(cx) + .all_buffer_ids() + .into_iter() + .filter_map(|id| { + let buffer = multi_buffer.read(cx).buffer(id)?; + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + + let blame_buffer = project.blame_buffer(&buffer, None, cx); + Some(async move { (id, snapshot, buffer_edits, blame_buffer.await) }) + }) + .collect::>() }); let provider_registry = GitHostingProviderRegistry::default_global(cx); self.task = cx.spawn(async move |this, cx| { - let result = cx + let (result, errors) = cx .background_spawn({ - let snapshot = snapshot.clone(); async move { - let Some(Blame { - entries, - messages, - remote_url, - }) = blame.await? - else { - return Ok(None); - }; - - let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row); - let commit_details = - parse_commit_messages(messages, remote_url, provider_registry).await; - - anyhow::Ok(Some((entries, commit_details))) + let blame = futures::stream::iter(blame) + .buffered(4) + .collect::>() + .await; + let mut res = vec![]; + let mut errors = vec![]; + for (id, snapshot, buffer_edits, blame) in blame { + match blame { + Ok(Some(Blame { + entries, + messages, + remote_url, + })) => { + let entries = build_blame_entry_sum_tree( + entries, + snapshot.max_point().row, + ); + let commit_details = parse_commit_messages( + messages, + remote_url, + provider_registry.clone(), + ) + .await; + + res.push(( + id, + snapshot, + buffer_edits, + Some(entries), + commit_details, + )); + } + Ok(None) => { + res.push((id, snapshot, buffer_edits, None, Default::default())) + } + Err(e) => errors.push(e), + } + } + (res, errors) } }) .await; - this.update(cx, |this, cx| match result { - Ok(None) => { - // Nothing to do, e.g. no repository found + this.update(cx, |this, cx| { + this.buffers.clear(); + for (id, snapshot, buffer_edits, entries, commit_details) in result { + let Some(entries) = entries else { + continue; + }; + this.buffers.insert( + id, + GitBlameBuffer { + buffer_edits, + buffer_snapshot: snapshot, + entries, + commit_details, + }, + ); } - Ok(Some((entries, commit_details))) => { - this.buffer_edits = buffer_edits; - this.buffer_snapshot = snapshot; - this.entries = entries; - this.commit_details = commit_details; - this.generated = true; - cx.notify(); + cx.notify(); + if !errors.is_empty() { + this.project.update(cx, |_, cx| { + if this.user_triggered { + log::error!("failed to get git blame data: {errors:?}"); + let notification = errors + .into_iter() + .format_with(",", |e, f| f(&format_args!("{:#}", e))) + .to_string(); + cx.emit(project::Event::Toast { + notification_id: "git-blame".into(), + message: notification, + }); + } else { + // If we weren't triggered by a user, we just log errors in the background, instead of sending + // notifications. + log::debug!("failed to get git blame data: {errors:?}"); + } + }) } - Err(error) => this.project.update(cx, |_, cx| { - if this.user_triggered { - log::error!("failed to get git blame data: {error:?}"); - let notification = format!("{:#}", error).trim().to_string(); - cx.emit(project::Event::Toast { - notification_id: "git-blame".into(), - message: notification, - }); - } else { - // If we weren't triggered by a user, we just log errors in the background, instead of sending - // notifications. - log::debug!("failed to get git blame data: {error:?}"); - } - }), }) }); } fn regenerate_on_edit(&mut self, cx: &mut Context) { + // todo(lw): hot foreground spawn self.regenerate_on_edit_task = cx.spawn(async move |this, cx| { cx.background_executor() .timer(REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL) @@ -522,7 +611,7 @@ impl GitBlame { this.update(cx, |this, cx| { this.generate(cx); }) - }) + }); } } @@ -549,7 +638,7 @@ fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree current_row = entry.range.end; entries }), - &(), + (), ); if max_row >= current_row { @@ -558,7 +647,7 @@ fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree rows: (max_row + 1) - current_row, blame: None, }, - &(), + (), ); } @@ -592,8 +681,8 @@ async fn parse_commit_messages( .as_ref() .map(|(provider, remote)| GitRemote { host: provider.clone(), - owner: remote.owner.to_string(), - repo: remote.repo.to_string(), + owner: remote.owner.clone().into(), + repo: remote.repo.clone().into(), }); let pull_request = parsed_remote_url @@ -617,6 +706,7 @@ async fn parse_commit_messages( #[cfg(test)] mod tests { use super::*; + use git::repository::repo_path; use gpui::Context; use language::{Point, Rope}; use project::FakeFs; @@ -661,6 +751,9 @@ mod tests { ) .collect::>(), expected + .into_iter() + .map(|it| Some((buffer_id, it?))) + .collect::>() ); } @@ -707,6 +800,7 @@ mod tests { }) .await .unwrap(); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx)); @@ -765,7 +859,7 @@ mod tests { fs.set_blame_for_repo( Path::new("/my-repo/.git"), vec![( - "file.txt".into(), + repo_path("file.txt"), Blame { entries: vec![ blame_entry("1b1b1b", 0..1), @@ -787,6 +881,7 @@ mod tests { .await .unwrap(); let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx)); @@ -808,14 +903,14 @@ mod tests { ) .collect::>(), vec![ - Some(blame_entry("1b1b1b", 0..1)), - Some(blame_entry("0d0d0d", 1..2)), - Some(blame_entry("3a3a3a", 2..3)), + Some((buffer_id, blame_entry("1b1b1b", 0..1))), + Some((buffer_id, blame_entry("0d0d0d", 1..2))), + Some((buffer_id, blame_entry("3a3a3a", 2..3))), None, None, - Some(blame_entry("3a3a3a", 5..6)), - Some(blame_entry("0d0d0d", 6..7)), - Some(blame_entry("3a3a3a", 7..8)), + Some((buffer_id, blame_entry("3a3a3a", 5..6))), + Some((buffer_id, blame_entry("0d0d0d", 6..7))), + Some((buffer_id, blame_entry("3a3a3a", 7..8))), ] ); // Subset of lines @@ -833,8 +928,8 @@ mod tests { ) .collect::>(), vec![ - Some(blame_entry("0d0d0d", 1..2)), - Some(blame_entry("3a3a3a", 2..3)), + Some((buffer_id, blame_entry("0d0d0d", 1..2))), + Some((buffer_id, blame_entry("3a3a3a", 2..3))), None ] ); @@ -854,7 +949,7 @@ mod tests { cx ) .collect::>(), - vec![Some(blame_entry("0d0d0d", 1..2)), None, None] + vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None] ); }); } @@ -881,7 +976,7 @@ mod tests { fs.set_blame_for_repo( Path::new(path!("/my-repo/.git")), vec![( - "file.txt".into(), + repo_path("file.txt"), Blame { entries: vec![blame_entry("1b1b1b", 0..4)], ..Default::default() @@ -897,6 +992,7 @@ mod tests { .await .unwrap(); let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx)); @@ -1018,7 +1114,7 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.executor()); - let buffer_initial_text_len = rng.gen_range(5..15); + let buffer_initial_text_len = rng.random_range(5..15); let mut buffer_initial_text = Rope::from( RandomCharIter::new(&mut rng) .take(buffer_initial_text_len) @@ -1048,7 +1144,7 @@ mod tests { fs.set_blame_for_repo( Path::new(path!("/my-repo/.git")), vec![( - "file.txt".into(), + repo_path("file.txt"), Blame { entries: blame_entries, ..Default::default() @@ -1063,13 +1159,14 @@ mod tests { }) .await .unwrap(); + let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); - let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx)); + let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx)); cx.executor().run_until_parked(); git_blame.update(cx, |blame, cx| blame.check_invariants(cx)); for _ in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..=19 => { log::info!("quiescing"); cx.executor().run_until_parked(); @@ -1090,7 +1187,7 @@ mod tests { fs.set_blame_for_repo( Path::new(path!("/my-repo/.git")), vec![( - "file.txt".into(), + repo_path("file.txt"), Blame { entries: blame_entries, ..Default::default() @@ -1112,8 +1209,8 @@ mod tests { let mut blame_entries = Vec::new(); for ix in 0..5 { if last_row < max_row { - let row_start = rng.gen_range(last_row..max_row); - let row_end = rng.gen_range(row_start + 1..cmp::min(row_start + 3, max_row) + 1); + let row_start = rng.random_range(last_row..max_row); + let row_end = rng.random_range(row_start + 1..cmp::min(row_start + 3, max_row) + 1); blame_entries.push(blame_entry(&ix.to_string(), row_start..row_end)); last_row = row_end; } else { diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index e38197283d4a4e2623ecadb30d90d0363053fdc5..286260e3b0f42da0c3416a07357128ac5e3d0c57 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -1,48 +1,60 @@ use crate::{Editor, RangeToAnchorExt}; -use gpui::{Context, Window}; +use gpui::{Context, HighlightStyle, Window}; use language::CursorShape; +use theme::ActiveTheme; enum MatchingBracketHighlight {} -pub fn refresh_matching_bracket_highlights( - editor: &mut Editor, - window: &mut Window, - cx: &mut Context, -) { - editor.clear_background_highlights::(cx); +impl Editor { + pub fn refresh_matching_bracket_highlights( + &mut self, + window: &Window, + cx: &mut Context, + ) { + self.clear_highlights::(cx); - let newest_selection = editor.selections.newest::(cx); - // Don't highlight brackets if the selection isn't empty - if !newest_selection.is_empty() { - return; - } + let snapshot = self.snapshot(window, cx); + let buffer_snapshot = snapshot.buffer_snapshot(); + let newest_selection = self.selections.newest::(&snapshot); + // Don't highlight brackets if the selection isn't empty + if !newest_selection.is_empty() { + return; + } - let snapshot = editor.snapshot(window, cx); - let head = newest_selection.head(); - if head > snapshot.buffer_snapshot.len() { - log::error!("bug: cursor offset is out of range while refreshing bracket highlights"); - return; - } + let head = newest_selection.head(); + if head > buffer_snapshot.len() { + log::error!("bug: cursor offset is out of range while refreshing bracket highlights"); + return; + } - let mut tail = head; - if (editor.cursor_shape == CursorShape::Block || editor.cursor_shape == CursorShape::Hollow) - && head < snapshot.buffer_snapshot.len() - { - tail += 1; - } + let mut tail = head; + if (self.cursor_shape == CursorShape::Block || self.cursor_shape == CursorShape::Hollow) + && head < buffer_snapshot.len() + { + if let Some(tail_ch) = buffer_snapshot.chars_at(tail).next() { + tail += tail_ch.len_utf8(); + } + } - if let Some((opening_range, closing_range)) = snapshot - .buffer_snapshot - .innermost_enclosing_bracket_ranges(head..tail, None) - { - editor.highlight_background::( - &[ - opening_range.to_anchors(&snapshot.buffer_snapshot), - closing_range.to_anchors(&snapshot.buffer_snapshot), - ], - |theme| theme.colors().editor_document_highlight_bracket_background, - cx, - ) + if let Some((opening_range, closing_range)) = + buffer_snapshot.innermost_enclosing_bracket_ranges(head..tail, None) + { + self.highlight_text::( + vec![ + opening_range.to_anchors(&buffer_snapshot), + closing_range.to_anchors(&buffer_snapshot), + ], + HighlightStyle { + background_color: Some( + cx.theme() + .colors() + .editor_document_highlight_bracket_background, + ), + ..Default::default() + }, + cx, + ) + } } } @@ -104,7 +116,7 @@ mod tests { another_test(1, 2, 3); } "#}); - cx.assert_editor_background_highlights::(indoc! {r#" + cx.assert_editor_text_highlights::(indoc! {r#" pub fn test«(»"Test argument"«)» { another_test(1, 2, 3); } @@ -115,7 +127,7 @@ mod tests { another_test(1, ˇ2, 3); } "#}); - cx.assert_editor_background_highlights::(indoc! {r#" + cx.assert_editor_text_highlights::(indoc! {r#" pub fn test("Test argument") { another_test«(»1, 2, 3«)»; } @@ -126,7 +138,7 @@ mod tests { anotherˇ_test(1, 2, 3); } "#}); - cx.assert_editor_background_highlights::(indoc! {r#" + cx.assert_editor_text_highlights::(indoc! {r#" pub fn test("Test argument") «{» another_test(1, 2, 3); «}» @@ -138,7 +150,7 @@ mod tests { another_test(1, 2, 3); } "#}); - cx.assert_editor_background_highlights::(indoc! {r#" + cx.assert_editor_text_highlights::(indoc! {r#" pub fn test("Test argument") { another_test(1, 2, 3); } @@ -150,8 +162,8 @@ mod tests { another_test(1, 2, 3); } "#}); - cx.assert_editor_background_highlights::(indoc! {r#" - pub fn test("Test argument") { + cx.assert_editor_text_highlights::(indoc! {r#" + pub fn test«("Test argument") { another_test(1, 2, 3); } "#}); diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 02f93e6829a3f7ac08ec7dfa390cd846560bb7d5..f36c82b20277fc748620928e6d7fc49a2b20cd3e 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1,18 +1,14 @@ use crate::{ Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition, - GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase, - editor_settings::GoToDefinitionFallback, - hover_popover::{self, InlayHover}, + GoToDefinitionSplit, GoToTypeDefinition, GoToTypeDefinitionSplit, GotoDefinitionKind, + Navigated, PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; -use project::{ - HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project, - ResolveState, ResolvedPath, -}; +use project::{InlayId, LocationLink, Project, ResolvedPath}; use settings::Settings; use std::ops::Range; use theme::ActiveTheme as _; @@ -48,8 +44,8 @@ impl RangeInEditor { ) -> bool { match (self, trigger_point) { (Self::Text(range), TriggerPoint::Text(point)) => { - let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot).is_le(); - point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot).is_ge() + let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot()).is_le(); + point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot()).is_ge() } (Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => { highlight.inlay == point.inlay @@ -130,17 +126,16 @@ impl Editor { Some(point) => { let trigger_point = TriggerPoint::Text( snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)), ); show_link_definition(modifiers.shift, self, trigger_point, snapshot, window, cx); } None => { - update_inlay_link_and_hover_points( + self.update_inlay_link_and_hover_points( snapshot, point_for_position, - self, hovered_link_modifier, modifiers.shift, window, @@ -188,22 +183,26 @@ impl Editor { pub fn scroll_hover( &mut self, - amount: &ScrollAmount, + amount: ScrollAmount, window: &mut Window, cx: &mut Context, ) -> bool { let selection = self.selections.newest_anchor().head(); let snapshot = self.snapshot(window, cx); - let Some(popover) = self.hover_state.info_popovers.iter().find(|popover| { + if let Some(popover) = self.hover_state.info_popovers.iter().find(|popover| { popover .symbol_range .point_within_range(&TriggerPoint::Text(selection), &snapshot) - }) else { - return false; - }; - popover.scroll(amount, window, cx); - true + }) { + popover.scroll(amount, window, cx); + true + } else if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() { + context_menu.scroll_aside(amount, window, cx); + true + } else { + false + } } fn cmd_click_reveal_task( @@ -262,196 +261,19 @@ impl Editor { ); let navigate_task = if point.as_valid().is_some() { - if modifiers.shift { - self.go_to_type_definition(&GoToTypeDefinition, window, cx) - } else { - self.go_to_definition(&GoToDefinition, window, cx) + match (modifiers.shift, modifiers.alt) { + (true, true) => { + self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx) + } + (true, false) => self.go_to_type_definition(&GoToTypeDefinition, window, cx), + (false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx), + (false, false) => self.go_to_definition(&GoToDefinition, window, cx), } } else { Task::ready(Ok(Navigated::No)) }; self.select(SelectPhase::End, window, cx); - return navigate_task; - } -} - -pub fn update_inlay_link_and_hover_points( - snapshot: &EditorSnapshot, - point_for_position: PointForPosition, - editor: &mut Editor, - secondary_held: bool, - shift_held: bool, - window: &mut Window, - cx: &mut Context, -) { - let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 { - Some(snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left)) - } else { - None - }; - let mut go_to_definition_updated = false; - let mut hover_updated = false; - if let Some(hovered_offset) = hovered_offset { - let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let previous_valid_anchor = buffer_snapshot.anchor_at( - point_for_position.previous_valid.to_point(snapshot), - Bias::Left, - ); - let next_valid_anchor = buffer_snapshot.anchor_at( - point_for_position.next_valid.to_point(snapshot), - Bias::Right, - ); - if let Some(hovered_hint) = editor - .visible_inlay_hints(cx) - .into_iter() - .skip_while(|hint| { - hint.position - .cmp(&previous_valid_anchor, &buffer_snapshot) - .is_lt() - }) - .take_while(|hint| { - hint.position - .cmp(&next_valid_anchor, &buffer_snapshot) - .is_le() - }) - .max_by_key(|hint| hint.id) - { - let inlay_hint_cache = editor.inlay_hint_cache(); - let excerpt_id = previous_valid_anchor.excerpt_id; - if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) { - match cached_hint.resolve_state { - ResolveState::CanResolve(_, _) => { - if let Some(buffer_id) = previous_valid_anchor.buffer_id { - inlay_hint_cache.spawn_hint_resolve( - buffer_id, - excerpt_id, - hovered_hint.id, - window, - cx, - ); - } - } - ResolveState::Resolved => { - let mut extra_shift_left = 0; - let mut extra_shift_right = 0; - if cached_hint.padding_left { - extra_shift_left += 1; - extra_shift_right += 1; - } - if cached_hint.padding_right { - extra_shift_right += 1; - } - match cached_hint.label { - project::InlayHintLabel::String(_) => { - if let Some(tooltip) = cached_hint.tooltip { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: match tooltip { - InlayHintTooltip::String(text) => HoverBlock { - text, - kind: HoverBlockKind::PlainText, - }, - InlayHintTooltip::MarkupContent(content) => { - HoverBlock { - text: content.value, - kind: content.kind, - } - } - }, - range: InlayHighlight { - inlay: hovered_hint.id, - inlay_position: hovered_hint.position, - range: extra_shift_left - ..hovered_hint.text.len() + extra_shift_right, - }, - }, - window, - cx, - ); - hover_updated = true; - } - } - project::InlayHintLabel::LabelParts(label_parts) => { - let hint_start = - snapshot.anchor_to_inlay_offset(hovered_hint.position); - if let Some((hovered_hint_part, part_range)) = - hover_popover::find_hovered_hint_part( - label_parts, - hint_start, - hovered_offset, - ) - { - let highlight_start = - (part_range.start - hint_start).0 + extra_shift_left; - let highlight_end = - (part_range.end - hint_start).0 + extra_shift_right; - let highlight = InlayHighlight { - inlay: hovered_hint.id, - inlay_position: hovered_hint.position, - range: highlight_start..highlight_end, - }; - if let Some(tooltip) = hovered_hint_part.tooltip { - hover_popover::hover_at_inlay( - editor, - InlayHover { - tooltip: match tooltip { - InlayHintLabelPartTooltip::String(text) => { - HoverBlock { - text, - kind: HoverBlockKind::PlainText, - } - } - InlayHintLabelPartTooltip::MarkupContent( - content, - ) => HoverBlock { - text: content.value, - kind: content.kind, - }, - }, - range: highlight.clone(), - }, - window, - cx, - ); - hover_updated = true; - } - if let Some((language_server_id, location)) = - hovered_hint_part.location - { - if secondary_held - && !editor.has_pending_nonempty_selection() - { - go_to_definition_updated = true; - show_link_definition( - shift_held, - editor, - TriggerPoint::InlayHint( - highlight, - location, - language_server_id, - ), - snapshot, - window, - cx, - ); - } - } - } - } - }; - } - ResolveState::Resolving => {} - } - } - } - } - - if !go_to_definition_updated { - editor.hide_hovered_link(cx) - } - if !hover_updated { - hover_popover::hover_at(editor, None, window, cx); + navigate_task } } @@ -489,22 +311,15 @@ pub fn show_link_definition( } let trigger_anchor = trigger_point.anchor(); - let Some((buffer, buffer_position)) = editor - .buffer - .read(cx) - .text_anchor_for_position(*trigger_anchor, cx) - else { - return; - }; - - let Some((excerpt_id, _, _)) = editor - .buffer() - .read(cx) - .excerpt_containing(*trigger_anchor, cx) - else { + let anchor = snapshot.buffer_snapshot().anchor_before(*trigger_anchor); + let Some(buffer) = editor.buffer().read(cx).buffer_for_anchor(anchor, cx) else { return; }; - + let Anchor { + excerpt_id, + text_anchor, + .. + } = anchor; let same_kind = hovered_link_state.preferred_kind == preferred_kind || hovered_link_state .links @@ -529,49 +344,45 @@ pub fn show_link_definition( let project = editor.project.clone(); let provider = editor.semantics_provider.clone(); - let snapshot = snapshot.buffer_snapshot.clone(); + let snapshot = snapshot.buffer_snapshot().clone(); hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| { async move { let result = match &trigger_point { TriggerPoint::Text(_) => { - if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) { + if let Some((url_range, url)) = find_url(&buffer, text_anchor, cx.clone()) { this.read_with(cx, |_, _| { let range = maybe!({ - let start = - snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?; - let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?; - Some(RangeInEditor::Text(start..end)) + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, url_range)?; + Some(RangeInEditor::Text(range)) }); (range, vec![HoverLink::Url(url)]) }) .ok() } else if let Some((filename_range, filename)) = - find_file(&buffer, project.clone(), buffer_position, cx).await + find_file(&buffer, project.clone(), text_anchor, cx).await { let range = maybe!({ - let start = - snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?; - let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?; - Some(RangeInEditor::Text(start..end)) + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, filename_range)?; + Some(RangeInEditor::Text(range)) }); Some((range, vec![HoverLink::File(filename)])) } else if let Some(provider) = provider { let task = cx.update(|_, cx| { - provider.definitions(&buffer, buffer_position, preferred_kind, cx) + provider.definitions(&buffer, text_anchor, preferred_kind, cx) })?; if let Some(task) = task { - task.await.ok().map(|definition_result| { + task.await.ok().flatten().map(|definition_result| { ( definition_result.iter().find_map(|link| { link.origin.as_ref().and_then(|origin| { - let start = snapshot.anchor_in_excerpt( + let range = snapshot.anchor_range_in_excerpt( excerpt_id, - origin.range.start, + origin.range.clone(), )?; - let end = snapshot - .anchor_in_excerpt(excerpt_id, origin.range.end)?; - Some(RangeInEditor::Text(start..end)) + Some(RangeInEditor::Text(range)) }) }), definition_result.into_iter().map(HoverLink::Text).collect(), @@ -622,7 +433,7 @@ pub fn show_link_definition( TriggerPoint::Text(trigger_anchor) => { // If no symbol range returned from language server, use the surrounding word. let (offset_range, _) = - snapshot.surrounding_word(*trigger_anchor, false); + snapshot.surrounding_word(*trigger_anchor, None); RangeInEditor::Text( snapshot.anchor_before(offset_range.start) ..snapshot.anchor_after(offset_range.end), @@ -657,13 +468,11 @@ pub fn show_link_definition( pub(crate) fn find_url( buffer: &Entity, position: text::Anchor, - mut cx: AsyncWindowContext, + cx: AsyncWindowContext, ) -> Option<(Range, String)> { const LIMIT: usize = 2048; - let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else { - return None; - }; + let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot()).ok()?; let offset = position.to_offset(&snapshot); let mut token_start = offset; @@ -719,11 +528,11 @@ pub(crate) fn find_url( pub(crate) fn find_url_from_range( buffer: &Entity, range: Range, - mut cx: AsyncWindowContext, + cx: AsyncWindowContext, ) -> Option { const LIMIT: usize = 2048; - let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else { + let Ok(snapshot) = buffer.read_with(&cx, |buffer, _| buffer.snapshot()) else { return None; }; @@ -766,10 +575,11 @@ pub(crate) fn find_url_from_range( let mut finder = LinkFinder::new(); finder.kinds(&[LinkKind::Url]); - if let Some(link) = finder.links(&text).next() { - if link.start() == 0 && link.end() == text.len() { - return Some(link.as_str().to_string()); - } + if let Some(link) = finder.links(&text).next() + && link.start() == 0 + && link.end() == text.len() + { + return Some(link.as_str().to_string()); } None @@ -794,7 +604,7 @@ pub(crate) async fn find_file( ) -> Option { project .update(cx, |project, cx| { - project.resolve_path_in_buffer(&candidate_file_path, buffer, cx) + project.resolve_path_in_buffer(candidate_file_path, buffer, cx) }) .ok()? .await @@ -872,7 +682,7 @@ fn surrounding_filename( .peekable(); while let Some(ch) = forwards.next() { // Skip escaped whitespace - if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) { + if ch == '\\' && forwards.peek().is_some_and(|ch| ch.is_whitespace()) { token_end += ch.len_utf8(); let whitespace = forwards.next().unwrap(); token_end += whitespace.len_utf8(); @@ -892,6 +702,7 @@ fn surrounding_filename( } else { // Otherwise, we skip the quote inside_quotes = true; + token_end += ch.len_utf8(); continue; } } @@ -919,14 +730,14 @@ mod tests { DisplayPoint, display_map::ToDisplayPoint, editor_tests::init_test, - inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, + inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; use gpui::Modifiers; use indoc::indoc; - use language::language_settings::InlayHintSettings; use lsp::request::{GotoDefinition, GotoTypeDefinition}; + use settings::InlayHintSettingsContent; use util::{assert_set_eq, path}; use workspace::item::Item; @@ -1274,15 +1085,15 @@ mod tests { #[gpui::test] async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - enabled: true, - show_value_hints: false, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + show_value_hints: Some(false), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), toggle_on_modifiers_press: None, }) }); @@ -1350,7 +1161,7 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, _window, cx| { let expected_layers = vec![hint_label.to_string()]; - assert_eq!(expected_layers, cached_hint_labels(editor)); + assert_eq!(expected_layers, cached_hint_labels(editor, cx)); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); }); @@ -1391,7 +1202,7 @@ mod tests { let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); let expected_highlight = InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right), + inlay_position: buffer_snapshot.anchor_after(inlay_range.start), range: 0..hint_label.len(), }; assert_set_eq!(actual_highlights, vec![&expected_highlight]); @@ -1539,6 +1350,10 @@ mod tests { ("'fˇile.txt'", Some("file.txt")), ("ˇ'file.txt'", Some("file.txt")), ("ˇ'fi\\ le.txt'", Some("fi le.txt")), + // Quoted multibyte characters + (" ˇ\"常\"", Some("常")), + (" \"ˇ常\"", Some("常")), + ("ˇ\"常\"", Some("常")), ]; for (input, expected) in test_cases { @@ -1851,4 +1666,42 @@ mod tests { cx.simulate_click(screen_coord, Modifiers::secondary_key()); cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); } + + #[gpui::test] + async fn test_hover_unicode(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + You can't open ˇ\"🤩\" because it's an emoji. + "}); + + // File does not exist + let screen_coord = cx.pixel_position(indoc! {" + You can't open ˇ\"🤩\" because it's an emoji. + "}); + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + + // No highlight, does not panic... + cx.update_editor(|editor, window, cx| { + assert!( + editor + .snapshot(window, cx) + .text_highlight_ranges::() + .unwrap_or_default() + .1 + .is_empty() + ); + }); + + // Does not open the directory + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 3fc673bad9197a9142b4027ffe77a1e123a0522a..6227d90e9be7a5fbbe98b9dd8900860c219d07d2 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -3,31 +3,31 @@ use crate::{ EditorSnapshot, GlobalDiagnosticRenderer, Hover, display_map::{InlayOffset, ToDisplayPoint, invisibles::is_invisible}, hover_links::{InlayHighlight, RangeInEditor}, + movement::TextLayoutDetails, scroll::ScrollAmount, }; use anyhow::Context as _; use gpui::{ AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, - Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, - TextStyleRefinement, Window, div, px, + StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, + Window, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use multi_buffer::{MultiOrSingleBufferOffsetRange, ToOffset, ToPoint}; +use multi_buffer::{ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; use std::{borrow::Cow, cell::RefCell}; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; use theme::ThemeSettings; -use ui::{Scrollbar, ScrollbarState, prelude::*, theme_is_transparent}; +use ui::{Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; use workspace::{OpenOptions, OpenVisible, Workspace}; -pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200; pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.; pub const MIN_POPOVER_LINE_HEIGHT: f32 = 4.; @@ -142,11 +142,11 @@ pub fn hover_at_inlay( .info_popovers .iter() .any(|InfoPopover { symbol_range, .. }| { - if let RangeInEditor::Inlay(range) = symbol_range { - if range == &inlay_hover.range { - // Hover triggered from same location as last time. Don't show again. - return true; - } + if let RangeInEditor::Inlay(range) = symbol_range + && range == &inlay_hover.range + { + // Hover triggered from same location as last time. Don't show again. + return true; } false }) @@ -154,7 +154,7 @@ pub fn hover_at_inlay( hide_hover(editor, cx); } - let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; + let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let task = cx.spawn_in(window, async move |this, cx| { async move { @@ -167,17 +167,16 @@ pub fn hover_at_inlay( let language_registry = project.read_with(cx, |p, _| p.languages().clone())?; let blocks = vec![inlay_hover.tooltip]; - let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await; + let parsed_content = + parse_blocks(&blocks, Some(&language_registry), None, cx).await; let scroll_handle = ScrollHandle::new(); let subscription = this .update(cx, |_, cx| { - if let Some(parsed_content) = &parsed_content { - Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) - } else { - None - } + parsed_content.as_ref().map(|parsed_content| { + cx.observe(parsed_content, |_, _, cx| cx.notify()) + }) }) .ok() .flatten(); @@ -185,7 +184,6 @@ pub fn hover_at_inlay( let hover_popover = InfoPopover { symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()), parsed_content, - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, @@ -251,7 +249,9 @@ fn show_hover( let (excerpt_id, _, _) = editor.buffer().read(cx).excerpt_containing(anchor, cx)?; - let language_registry = editor.project()?.read(cx).languages().clone(); + let language_registry = editor + .project() + .map(|project| project.read(cx).languages().clone()); let provider = editor.semantics_provider.clone()?; if !ignore_timeout { @@ -267,16 +267,15 @@ fn show_hover( } // Don't request again if the location is the same as the previous request - if let Some(triggered_from) = &editor.hover_state.triggered_from { - if triggered_from - .cmp(&anchor, &snapshot.buffer_snapshot) + if let Some(triggered_from) = &editor.hover_state.triggered_from + && triggered_from + .cmp(&anchor, &snapshot.buffer_snapshot()) .is_eq() - { - return None; - } + { + return None; } - let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; + let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let all_diagnostics_active = editor.active_diagnostics == ActiveDiagnostic::All; let active_group_id = if let ActiveDiagnostic::Group(group) = &editor.active_diagnostics { Some(group.group_id) @@ -291,15 +290,18 @@ fn show_hover( let delay = if ignore_timeout { None } else { + let lsp_request_early = hover_popover_delay / 2; + cx.background_executor() + .timer(Duration::from_millis( + hover_popover_delay - lsp_request_early, + )) + .await; + // Construct delay task to wait for later let total_delay = Some( cx.background_executor() - .timer(Duration::from_millis(hover_popover_delay)), + .timer(Duration::from_millis(lsp_request_early)), ); - - cx.background_executor() - .timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS)) - .await; total_delay }; @@ -308,13 +310,12 @@ fn show_hover( if let Some(delay) = delay { delay.await; } - - let offset = anchor.to_offset(&snapshot.buffer_snapshot); + let offset = anchor.to_offset(&snapshot.buffer_snapshot()); let local_diagnostic = if all_diagnostics_active { None } else { snapshot - .buffer_snapshot + .buffer_snapshot() .diagnostics_with_buffer_ids_in_range::(offset..offset) .filter(|(_, diagnostic)| { Some(diagnostic.diagnostic.group_id) != active_group_id @@ -325,17 +326,17 @@ fn show_hover( let diagnostic_popover = if let Some((buffer_id, local_diagnostic)) = local_diagnostic { let group = snapshot - .buffer_snapshot + .buffer_snapshot() .diagnostic_group(buffer_id, local_diagnostic.diagnostic.group_id) .collect::>(); let point_range = local_diagnostic .range .start - .to_point(&snapshot.buffer_snapshot) + .to_point(&snapshot.buffer_snapshot()) ..local_diagnostic .range .end - .to_point(&snapshot.buffer_snapshot); + .to_point(&snapshot.buffer_snapshot()); let markdown = cx.update(|_, cx| { renderer .as_ref() @@ -372,12 +373,12 @@ fn show_hover( this.update(cx, |_, cx| cx.observe(&markdown, |_, _, cx| cx.notify()))?; let local_diagnostic = DiagnosticEntry { - diagnostic: local_diagnostic.diagnostic, + diagnostic: local_diagnostic.diagnostic.to_owned(), range: snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(local_diagnostic.range.start) ..snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(local_diagnostic.range.end), }; @@ -387,7 +388,6 @@ fn show_hover( local_diagnostic, markdown, border_color, - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), scroll_handle, background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), @@ -403,23 +403,23 @@ fn show_hover( })?; let invisible_char = if let Some(invisible) = snapshot - .buffer_snapshot + .buffer_snapshot() .chars_at(anchor) .next() .filter(|&c| is_invisible(c)) { - let after = snapshot.buffer_snapshot.anchor_after( - anchor.to_offset(&snapshot.buffer_snapshot) + invisible.len_utf8(), + let after = snapshot.buffer_snapshot().anchor_after( + anchor.to_offset(&snapshot.buffer_snapshot()) + invisible.len_utf8(), ); Some((invisible, anchor..after)) } else if let Some(invisible) = snapshot - .buffer_snapshot + .buffer_snapshot() .reversed_chars_at(anchor) .next() .filter(|&c| is_invisible(c)) { - let before = snapshot.buffer_snapshot.anchor_before( - anchor.to_offset(&snapshot.buffer_snapshot) - invisible.len_utf8(), + let before = snapshot.buffer_snapshot().anchor_before( + anchor.to_offset(&snapshot.buffer_snapshot()) - invisible.len_utf8(), ); Some((invisible, before..anchor)) @@ -428,7 +428,7 @@ fn show_hover( }; let hovers_response = if let Some(hover_request) = hover_request { - hover_request.await + hover_request.await.unwrap_or_default() } else { Vec::new() }; @@ -443,22 +443,20 @@ fn show_hover( text: format!("Unicode character U+{:02X}", invisible as u32), kind: HoverBlockKind::PlainText, }]; - let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await; + let parsed_content = + parse_blocks(&blocks, language_registry.as_ref(), None, cx).await; let scroll_handle = ScrollHandle::new(); let subscription = this .update(cx, |_, cx| { - if let Some(parsed_content) = &parsed_content { - Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) - } else { - None - } + parsed_content.as_ref().map(|parsed_content| { + cx.observe(parsed_content, |_, _, cx| cx.notify()) + }) }) .ok() .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), @@ -471,45 +469,35 @@ fn show_hover( let range = hover_result .range .and_then(|range| { - let start = snapshot - .buffer_snapshot - .anchor_in_excerpt(excerpt_id, range.start)?; - let end = snapshot - .buffer_snapshot - .anchor_in_excerpt(excerpt_id, range.end)?; - Some(start..end) + let range = snapshot + .buffer_snapshot() + .anchor_range_in_excerpt(excerpt_id, range)?; + Some(range) }) .or_else(|| { - let snapshot = &snapshot.buffer_snapshot; - match snapshot.syntax_ancestor(anchor..anchor)?.1 { - MultiOrSingleBufferOffsetRange::Multi(range) => Some( - snapshot.anchor_before(range.start) - ..snapshot.anchor_after(range.end), - ), - MultiOrSingleBufferOffsetRange::Single(_) => None, - } + let snapshot = &snapshot.buffer_snapshot(); + let range = snapshot.syntax_ancestor(anchor..anchor)?.1; + Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end)) }) .unwrap_or_else(|| anchor..anchor); let blocks = hover_result.contents; let language = hover_result.language; - let parsed_content = parse_blocks(&blocks, &language_registry, language, cx).await; + let parsed_content = + parse_blocks(&blocks, language_registry.as_ref(), language, cx).await; let scroll_handle = ScrollHandle::new(); hover_highlights.push(range.clone()); let subscription = this .update(cx, |_, cx| { - if let Some(parsed_content) = &parsed_content { - Some(cx.observe(parsed_content, |_, _, cx| cx.notify())) - } else { - None - } + parsed_content.as_ref().map(|parsed_content| { + cx.observe(parsed_content, |_, _, cx| cx.notify()) + }) }) .ok() .flatten(); info_popovers.push(InfoPopover { symbol_range: RangeInEditor::Text(range), parsed_content, - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), @@ -553,8 +541,8 @@ fn same_info_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anchor) - symbol_range .as_text_range() .map(|range| { - let hover_range = range.to_offset(&snapshot.buffer_snapshot); - let offset = anchor.to_offset(&snapshot.buffer_snapshot); + let hover_range = range.to_offset(&snapshot.buffer_snapshot()); + let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // LSP returns a hover result for the end index of ranges that should be hovered, so we need to // use an inclusive range here to check if we should dismiss the popover (hover_range.start..=hover_range.end).contains(&offset) @@ -572,8 +560,8 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc let hover_range = diagnostic .local_diagnostic .range - .to_offset(&snapshot.buffer_snapshot); - let offset = anchor.to_offset(&snapshot.buffer_snapshot); + .to_offset(&snapshot.buffer_snapshot()); + let offset = anchor.to_offset(&snapshot.buffer_snapshot()); // Here we do basically the same as in `same_info_hover`, see comment there for an explanation (hover_range.start..=hover_range.end).contains(&offset) @@ -583,7 +571,7 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc async fn parse_blocks( blocks: &[HoverBlock], - language_registry: &Arc, + language_registry: Option<&Arc>, language: Option>, cx: &mut AsyncWindowContext, ) -> Option> { @@ -599,18 +587,15 @@ async fn parse_blocks( }) .join("\n\n"); - let rendered_block = cx - .new_window_entity(|_window, cx| { - Markdown::new( - combined_text.into(), - Some(language_registry.clone()), - language.map(|language| language.name()), - cx, - ) - }) - .ok(); - - rendered_block + cx.new_window_entity(|_window, cx| { + Markdown::new( + combined_text.into(), + language_registry.cloned(), + language.map(|language| language.name()), + cx, + ) + }) + .ok() } pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { @@ -622,7 +607,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { - font_family: Some(ui_font_family.clone()), + font_family: Some(ui_font_family), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() @@ -671,7 +656,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { - font_family: Some(ui_font_family.clone()), + font_family: Some(ui_font_family), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() @@ -712,59 +697,54 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) { - if let Ok(uri) = Url::parse(&link) { - if uri.scheme() == "file" { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - let task = workspace.open_abs_path( - PathBuf::from(uri.path()), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ); + if let Ok(uri) = Url::parse(&link) + && uri.scheme() == "file" + && let Some(workspace) = window.root::().flatten() + { + workspace.update(cx, |workspace, cx| { + let task = workspace.open_abs_path( + PathBuf::from(uri.path()), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ); - cx.spawn_in(window, async move |_, cx| { - let item = task.await?; - // Ruby LSP uses URLs with #L1,1-4,4 - // we'll just take the first number and assume it's a line number - let Some(fragment) = uri.fragment() else { - return anyhow::Ok(()); - }; - let mut accum = 0u32; - for c in fragment.chars() { - if c >= '0' && c <= '9' && accum < u32::MAX / 2 { - accum *= 10; - accum += c as u32 - '0' as u32; - } else if accum > 0 { - break; - } - } - if accum == 0 { - return Ok(()); - } - let Some(editor) = cx.update(|_, cx| item.act_as::(cx))? else { - return Ok(()); - }; - editor.update_in(cx, |editor, window, cx| { - editor.change_selections( - Default::default(), - window, - cx, - |selections| { - selections.select_ranges([text::Point::new(accum - 1, 0) - ..text::Point::new(accum - 1, 0)]); - }, - ); - }) - }) - .detach_and_log_err(cx); - }); - return; - } - } + cx.spawn_in(window, async move |_, cx| { + let item = task.await?; + // Ruby LSP uses URLs with #L1,1-4,4 + // we'll just take the first number and assume it's a line number + let Some(fragment) = uri.fragment() else { + return anyhow::Ok(()); + }; + let mut accum = 0u32; + for c in fragment.chars() { + if c >= '0' && c <= '9' && accum < u32::MAX / 2 { + accum *= 10; + accum += c as u32 - '0' as u32; + } else if accum > 0 { + break; + } + } + if accum == 0 { + return Ok(()); + } + let Some(editor) = cx.update(|_, cx| item.act_as::(cx))? else { + return Ok(()); + }; + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + text::Point::new(accum - 1, 0)..text::Point::new(accum - 1, 0) + ]); + }); + }) + }) + .detach_and_log_err(cx); + }); + return; } cx.open_url(&link); } @@ -787,9 +767,13 @@ impl HoverState { snapshot: &EditorSnapshot, visible_rows: Range, max_size: Size, + text_layout_details: &TextLayoutDetails, window: &mut Window, cx: &mut Context, ) -> Option<(DisplayPoint, Vec)> { + if !self.visible() { + return None; + } // If there is a diagnostic, position the popovers based on that. // Otherwise use the start of the hover range let anchor = self @@ -812,11 +796,29 @@ impl HoverState { } }) })?; - let point = anchor.to_display_point(&snapshot.display_snapshot); - - // Don't render if the relevant point isn't on screen - if !self.visible() || !visible_rows.contains(&point.row()) { - return None; + let mut point = anchor.to_display_point(&snapshot.display_snapshot); + + // Clamp the point within the visible rows in case the popup source spans multiple lines + if point.row() < visible_rows.start { + point = crate::movement::down_by_rows( + &snapshot.display_snapshot, + point, + (visible_rows.start - point.row()).0, + text::SelectionGoal::None, + true, + text_layout_details, + ) + .0; + } else if visible_rows.end <= point.row() { + point = crate::movement::up_by_rows( + &snapshot.display_snapshot, + point, + (visible_rows.end - point.row()).0, + text::SelectionGoal::None, + true, + text_layout_details, + ) + .0; } let mut elements = Vec::new(); @@ -834,20 +836,19 @@ impl HoverState { pub fn focused(&self, window: &mut Window, cx: &mut Context) -> bool { let mut hover_popover_is_focused = false; for info_popover in &self.info_popovers { - if let Some(markdown_view) = &info_popover.parsed_content { - if markdown_view.focus_handle(cx).is_focused(window) { - hover_popover_is_focused = true; - } + if let Some(markdown_view) = &info_popover.parsed_content + && markdown_view.focus_handle(cx).is_focused(window) + { + hover_popover_is_focused = true; } } - if let Some(diagnostic_popover) = &self.diagnostic_popover { - if diagnostic_popover + if let Some(diagnostic_popover) = &self.diagnostic_popover + && diagnostic_popover .markdown .focus_handle(cx) .is_focused(window) - { - hover_popover_is_focused = true; - } + { + hover_popover_is_focused = true; } hover_popover_is_focused } @@ -857,7 +858,6 @@ pub struct InfoPopover { pub symbol_range: RangeInEditor, pub parsed_content: Option>, pub scroll_handle: ScrollHandle, - pub scrollbar_state: ScrollbarState, pub keyboard_grace: Rc>, pub anchor: Option, _subscription: Option, @@ -902,12 +902,17 @@ impl InfoPopover { .on_url_click(open_markdown_url), ), ) - .child(self.render_vertical_scrollbar(cx)) + .custom_scrollbars( + Scrollbars::for_settings::() + .tracked_scroll_handle(self.scroll_handle.clone()), + window, + cx, + ) }) .into_any_element() } - pub fn scroll(&self, amount: &ScrollAmount, window: &mut Window, cx: &mut Context) { + pub fn scroll(&self, amount: ScrollAmount, window: &mut Window, cx: &mut Context) { let mut current = self.scroll_handle.offset(); current.y -= amount.pixels( window.line_height(), @@ -916,39 +921,6 @@ impl InfoPopover { cx.notify(); self.scroll_handle.set_offset(current); } - - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("info-popover-vertical-scroll") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } } pub struct DiagnosticPopover { @@ -960,7 +932,6 @@ pub struct DiagnosticPopover { pub anchor: Anchor, _subscription: Subscription, pub scroll_handle: ScrollHandle, - pub scrollbar_state: ScrollbarState, } impl DiagnosticPopover { @@ -1024,68 +995,40 @@ impl DiagnosticPopover { ), ), ) - .child(self.render_vertical_scrollbar(cx)), + .custom_scrollbars( + Scrollbars::for_settings::() + .tracked_scroll_handle(self.scroll_handle.clone()), + window, + cx, + ), ) .into_any_element() } - - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("diagnostic-popover-vertical-scroll") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } } #[cfg(test)] mod tests { use super::*; use crate::{ - InlayId, PointForPosition, + PointForPosition, actions::ConfirmCompletion, editor_tests::{handle_completion_request, init_test}, - hover_links::update_inlay_link_and_hover_points, - inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, + inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels}, test::editor_lsp_test_context::EditorLspTestContext, }; use collections::BTreeSet; use gpui::App; use indoc::indoc; - use language::language_settings::InlayHintSettings; use markdown::parser::MarkdownEvent; + use project::InlayId; + use settings::InlayHintSettingsContent; use smol::stream::StreamExt; use std::sync::atomic; use std::sync::atomic::AtomicUsize; use text::Bias; fn get_hover_popover_delay(cx: &gpui::TestAppContext) -> u64 { - cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay }) + cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 }) } impl InfoPopover { @@ -1168,7 +1111,7 @@ mod tests { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), window, cx) }); @@ -1268,7 +1211,7 @@ mod tests { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), window, cx) }); @@ -1306,7 +1249,7 @@ mod tests { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), window, cx) }); @@ -1360,7 +1303,7 @@ mod tests { cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); let anchor = snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); hover_at(editor, Some(anchor), window, cx) }); @@ -1624,15 +1567,15 @@ mod tests { #[gpui::test] async fn test_hover_inlay_label_parts(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), toggle_on_modifiers_press: None, }) }); @@ -1729,7 +1672,7 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, _, cx| { let expected_layers = vec![entire_hint_label.to_string()]; - assert_eq!(expected_layers, cached_hint_labels(editor)); + assert_eq!(expected_layers, cached_hint_labels(editor, cx)); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); }); @@ -1768,10 +1711,9 @@ mod tests { } }); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, - editor, true, false, window, @@ -1839,10 +1781,9 @@ mod tests { cx.background_executor.run_until_parked(); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, - editor, true, false, window, @@ -1863,7 +1804,7 @@ mod tests { popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right), + inlay_position: buffer_snapshot.anchor_after(inlay_range.start), range: ": ".len()..": ".len() + new_type_label.len(), }), "Popover range should match the new type label part" @@ -1894,10 +1835,9 @@ mod tests { } }); cx.update_editor(|editor, window, cx| { - update_inlay_link_and_hover_points( + editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, - editor, true, false, window, @@ -1918,7 +1858,7 @@ mod tests { popover.symbol_range, RangeInEditor::Inlay(InlayHighlight { inlay: InlayId::Hint(0), - inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right), + inlay_position: buffer_snapshot.anchor_after(inlay_range.start), range: ": ".len() + new_type_label.len() + "<".len() ..": ".len() + new_type_label.len() + "<".len() + struct_label.len(), }), diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index f6d51c929a95ac3d256095b627c303a6c49a64a5..7c392d27531472a413ce4d32d09cce4eb722e462 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -69,7 +69,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option> { - let selection = self.selections.newest::(cx); + let selection = self.selections.newest::(&self.display_snapshot(cx)); let cursor_row = MultiBufferRow(selection.head().row); let state = &mut self.active_indent_guides_state; @@ -155,30 +155,30 @@ pub fn indent_guides_in_range( cx: &App, ) -> Vec { let start_offset = snapshot - .buffer_snapshot + .buffer_snapshot() .point_to_offset(Point::new(visible_buffer_range.start.0, 0)); let end_offset = snapshot - .buffer_snapshot + .buffer_snapshot() .point_to_offset(Point::new(visible_buffer_range.end.0, 0)); - let start_anchor = snapshot.buffer_snapshot.anchor_before(start_offset); - let end_anchor = snapshot.buffer_snapshot.anchor_after(end_offset); + let start_anchor = snapshot.buffer_snapshot().anchor_before(start_offset); + let end_anchor = snapshot.buffer_snapshot().anchor_after(end_offset); let mut fold_ranges = Vec::>::new(); - let mut folds = snapshot.folds_in_range(start_offset..end_offset).peekable(); - while let Some(fold) = folds.next() { - let start = fold.range.start.to_point(&snapshot.buffer_snapshot); - let end = fold.range.end.to_point(&snapshot.buffer_snapshot); - if let Some(last_range) = fold_ranges.last_mut() { - if last_range.end >= start { - last_range.end = last_range.end.max(end); - continue; - } + let folds = snapshot.folds_in_range(start_offset..end_offset).peekable(); + for fold in folds { + let start = fold.range.start.to_point(&snapshot.buffer_snapshot()); + let end = fold.range.end.to_point(&snapshot.buffer_snapshot()); + if let Some(last_range) = fold_ranges.last_mut() + && last_range.end >= start + { + last_range.end = last_range.end.max(end); + continue; } fold_ranges.push(start..end); } snapshot - .buffer_snapshot + .buffer_snapshot() .indent_guides_in_range(start_anchor..end_anchor, ignore_disabled_for_language, cx) .filter(|indent_guide| { if editor.is_buffer_folded(indent_guide.buffer_id, cx) { @@ -207,7 +207,7 @@ async fn resolve_indented_range( buffer_row: MultiBufferRow, ) -> Option { snapshot - .buffer_snapshot + .buffer_snapshot() .enclosing_indent(buffer_row) .await .map(|(row_range, indent)| ActiveIndentedRange { row_range, indent }) @@ -222,23 +222,23 @@ fn should_recalculate_indented_range( if prev_row.0 == new_row.0 { return false; } - if snapshot.buffer_snapshot.is_singleton() { + if snapshot.buffer_snapshot().is_singleton() { if !current_indent_range.row_range.contains(&new_row) { return true; } - let old_line_indent = snapshot.buffer_snapshot.line_indent_for_row(prev_row); - let new_line_indent = snapshot.buffer_snapshot.line_indent_for_row(new_row); + let old_line_indent = snapshot.buffer_snapshot().line_indent_for_row(prev_row); + let new_line_indent = snapshot.buffer_snapshot().line_indent_for_row(new_row); if old_line_indent.is_line_empty() || new_line_indent.is_line_empty() || old_line_indent != new_line_indent - || snapshot.buffer_snapshot.max_point().row == new_row.0 + || snapshot.buffer_snapshot().max_point().row == new_row.0 { return true; } - let next_line_indent = snapshot.buffer_snapshot.line_indent_for_row(new_row + 1); + let next_line_indent = snapshot.buffer_snapshot().line_indent_for_row(new_row + 1); next_line_indent.is_line_empty() || next_line_indent != old_line_indent } else { true diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs deleted file mode 100644 index 60ad0e5bf6c5672a3ce651793b8f76a82ab4c0ff..0000000000000000000000000000000000000000 --- a/crates/editor/src/inlay_hint_cache.rs +++ /dev/null @@ -1,3570 +0,0 @@ -/// Stores and updates all data received from LSP textDocument/inlayHint requests. -/// Has nothing to do with other inlays, e.g. copilot suggestions — those are stored elsewhere. -/// On every update, cache may query for more inlay hints and update inlays on the screen. -/// -/// Inlays stored on screen are in [`crate::display_map::inlay_map`] and this cache is the only way to update any inlay hint data in the visible hints in the inlay map. -/// For determining the update to the `inlay_map`, the cache requires a list of visible inlay hints — all other hints are not relevant and their separate updates are not influencing the cache work. -/// -/// Due to the way the data is stored for both visible inlays and the cache, every inlay (and inlay hint) collection is editor-specific, so a single buffer may have multiple sets of inlays of open on different panes. -use std::{ - cmp, - ops::{ControlFlow, Range}, - sync::Arc, - time::Duration, -}; - -use crate::{ - Anchor, Editor, ExcerptId, InlayId, MultiBuffer, MultiBufferSnapshot, display_map::Inlay, -}; -use anyhow::Context as _; -use clock::Global; -use futures::future; -use gpui::{AppContext as _, AsyncApp, Context, Entity, Task, Window}; -use language::{Buffer, BufferSnapshot, language_settings::InlayHintKind}; -use parking_lot::RwLock; -use project::{InlayHint, ResolveState}; - -use collections::{HashMap, HashSet, hash_map}; -use language::language_settings::InlayHintSettings; -use smol::lock::Semaphore; -use sum_tree::Bias; -use text::{BufferId, ToOffset, ToPoint}; -use util::{ResultExt, post_inc}; - -pub struct InlayHintCache { - hints: HashMap>>, - allowed_hint_kinds: HashSet>, - version: usize, - pub(super) enabled: bool, - modifiers_override: bool, - enabled_in_settings: bool, - update_tasks: HashMap, - refresh_task: Task<()>, - invalidate_debounce: Option, - append_debounce: Option, - lsp_request_limiter: Arc, -} - -#[derive(Debug)] -struct TasksForRanges { - tasks: Vec>, - sorted_ranges: Vec>, -} - -#[derive(Debug)] -struct CachedExcerptHints { - version: usize, - buffer_version: Global, - buffer_id: BufferId, - ordered_hints: Vec, - hints_by_id: HashMap, -} - -/// A logic to apply when querying for new inlay hints and deciding what to do with the old entries in the cache in case of conflicts. -#[derive(Debug, Clone, Copy)] -pub(super) enum InvalidationStrategy { - /// Hints reset is requested by the LSP server. - /// Demands to re-query all inlay hints needed and invalidate all cached entries, but does not require instant update with invalidation. - /// - /// Despite nothing forbids language server from sending this request on every edit, it is expected to be sent only when certain internal server state update, invisible for the editor otherwise. - RefreshRequested, - /// Multibuffer excerpt(s) and/or singleton buffer(s) were edited at least on one place. - /// Neither editor nor LSP is able to tell which open file hints' are not affected, so all of them have to be invalidated, re-queried and do that fast enough to avoid being slow, but also debounce to avoid loading hints on every fast keystroke sequence. - BufferEdited, - /// A new file got opened/new excerpt was added to a multibuffer/a [multi]buffer was scrolled to a new position. - /// No invalidation should be done at all, all new hints are added to the cache. - /// - /// A special case is the settings change: in addition to LSP capabilities, Zed allows omitting certain hint kinds (defined by the corresponding LSP part: type/parameter/other). - /// This does not lead to cache invalidation, but would require cache usage for determining which hints are not displayed and issuing an update to inlays on the screen. - None, -} - -/// A splice to send into the `inlay_map` for updating the visible inlays on the screen. -/// "Visible" inlays may not be displayed in the buffer right away, but those are ready to be displayed on further buffer scroll, pane item activations, etc. right away without additional LSP queries or settings changes. -/// The data in the cache is never used directly for displaying inlays on the screen, to avoid races with updates from LSP queries and sync overhead. -/// Splice is picked to help avoid extra hint flickering and "jumps" on the screen. -#[derive(Debug, Default)] -pub(super) struct InlaySplice { - pub to_remove: Vec, - pub to_insert: Vec, -} - -#[derive(Debug)] -struct ExcerptHintsUpdate { - excerpt_id: ExcerptId, - remove_from_visible: HashSet, - remove_from_cache: HashSet, - add_to_cache: Vec, -} - -#[derive(Debug, Clone, Copy)] -struct ExcerptQuery { - buffer_id: BufferId, - excerpt_id: ExcerptId, - cache_version: usize, - invalidate: InvalidationStrategy, - reason: &'static str, -} - -impl InvalidationStrategy { - fn should_invalidate(&self) -> bool { - matches!( - self, - InvalidationStrategy::RefreshRequested | InvalidationStrategy::BufferEdited - ) - } -} - -impl TasksForRanges { - fn new(query_ranges: QueryRanges, task: Task<()>) -> Self { - Self { - tasks: vec![task], - sorted_ranges: query_ranges.into_sorted_query_ranges(), - } - } - - fn update_cached_tasks( - &mut self, - buffer_snapshot: &BufferSnapshot, - query_ranges: QueryRanges, - invalidate: InvalidationStrategy, - spawn_task: impl FnOnce(QueryRanges) -> Task<()>, - ) { - let query_ranges = if invalidate.should_invalidate() { - self.tasks.clear(); - self.sorted_ranges = query_ranges.clone().into_sorted_query_ranges(); - query_ranges - } else { - let mut non_cached_query_ranges = query_ranges; - non_cached_query_ranges.before_visible = non_cached_query_ranges - .before_visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges.visible = non_cached_query_ranges - .visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges.after_visible = non_cached_query_ranges - .after_visible - .into_iter() - .flat_map(|query_range| { - self.remove_cached_ranges_from_query(buffer_snapshot, query_range) - }) - .collect(); - non_cached_query_ranges - }; - - if !query_ranges.is_empty() { - self.tasks.push(spawn_task(query_ranges)); - } - } - - fn remove_cached_ranges_from_query( - &mut self, - buffer_snapshot: &BufferSnapshot, - query_range: Range, - ) -> Vec> { - let mut ranges_to_query = Vec::new(); - let mut latest_cached_range = None::<&mut Range>; - for cached_range in self - .sorted_ranges - .iter_mut() - .skip_while(|cached_range| { - cached_range - .end - .cmp(&query_range.start, buffer_snapshot) - .is_lt() - }) - .take_while(|cached_range| { - cached_range - .start - .cmp(&query_range.end, buffer_snapshot) - .is_le() - }) - { - match latest_cached_range { - Some(latest_cached_range) => { - if latest_cached_range.end.offset.saturating_add(1) < cached_range.start.offset - { - ranges_to_query.push(latest_cached_range.end..cached_range.start); - cached_range.start = latest_cached_range.end; - } - } - None => { - if query_range - .start - .cmp(&cached_range.start, buffer_snapshot) - .is_lt() - { - ranges_to_query.push(query_range.start..cached_range.start); - cached_range.start = query_range.start; - } - } - } - latest_cached_range = Some(cached_range); - } - - match latest_cached_range { - Some(latest_cached_range) => { - if latest_cached_range.end.offset.saturating_add(1) < query_range.end.offset { - ranges_to_query.push(latest_cached_range.end..query_range.end); - latest_cached_range.end = query_range.end; - } - } - None => { - ranges_to_query.push(query_range.clone()); - self.sorted_ranges.push(query_range); - self.sorted_ranges - .sort_by(|range_a, range_b| range_a.start.cmp(&range_b.start, buffer_snapshot)); - } - } - - ranges_to_query - } - - fn invalidate_range(&mut self, buffer: &BufferSnapshot, range: &Range) { - self.sorted_ranges = self - .sorted_ranges - .drain(..) - .filter_map(|mut cached_range| { - if cached_range.start.cmp(&range.end, buffer).is_gt() - || cached_range.end.cmp(&range.start, buffer).is_lt() - { - Some(vec![cached_range]) - } else if cached_range.start.cmp(&range.start, buffer).is_ge() - && cached_range.end.cmp(&range.end, buffer).is_le() - { - None - } else if range.start.cmp(&cached_range.start, buffer).is_ge() - && range.end.cmp(&cached_range.end, buffer).is_le() - { - Some(vec![ - cached_range.start..range.start, - range.end..cached_range.end, - ]) - } else if cached_range.start.cmp(&range.start, buffer).is_ge() { - cached_range.start = range.end; - Some(vec![cached_range]) - } else { - cached_range.end = range.start; - Some(vec![cached_range]) - } - }) - .flatten() - .collect(); - } -} - -impl InlayHintCache { - pub(super) fn new(inlay_hint_settings: InlayHintSettings) -> Self { - Self { - allowed_hint_kinds: inlay_hint_settings.enabled_inlay_hint_kinds(), - enabled: inlay_hint_settings.enabled, - modifiers_override: false, - enabled_in_settings: inlay_hint_settings.enabled, - hints: HashMap::default(), - update_tasks: HashMap::default(), - refresh_task: Task::ready(()), - invalidate_debounce: debounce_value(inlay_hint_settings.edit_debounce_ms), - append_debounce: debounce_value(inlay_hint_settings.scroll_debounce_ms), - version: 0, - lsp_request_limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_LSP_REQUESTS)), - } - } - - /// Checks inlay hint settings for enabled hint kinds and general enabled state. - /// Generates corresponding inlay_map splice updates on settings changes. - /// Does not update inlay hint cache state on disabling or inlay hint kinds change: only reenabling forces new LSP queries. - pub(super) fn update_settings( - &mut self, - multi_buffer: &Entity, - new_hint_settings: InlayHintSettings, - visible_hints: Vec, - cx: &mut Context, - ) -> ControlFlow> { - let old_enabled = self.enabled; - // If the setting for inlay hints has changed, update `enabled`. This condition avoids inlay - // hint visibility changes when other settings change (such as theme). - // - // Another option might be to store whether the user has manually toggled inlay hint - // visibility, and prefer this. This could lead to confusion as it means inlay hint - // visibility would not change when updating the setting if they were ever toggled. - if new_hint_settings.enabled != self.enabled_in_settings { - self.enabled = new_hint_settings.enabled; - self.enabled_in_settings = new_hint_settings.enabled; - self.modifiers_override = false; - }; - self.invalidate_debounce = debounce_value(new_hint_settings.edit_debounce_ms); - self.append_debounce = debounce_value(new_hint_settings.scroll_debounce_ms); - let new_allowed_hint_kinds = new_hint_settings.enabled_inlay_hint_kinds(); - match (old_enabled, self.enabled) { - (false, false) => { - self.allowed_hint_kinds = new_allowed_hint_kinds; - ControlFlow::Break(None) - } - (true, true) => { - if new_allowed_hint_kinds == self.allowed_hint_kinds { - ControlFlow::Break(None) - } else { - let new_splice = self.new_allowed_hint_kinds_splice( - multi_buffer, - &visible_hints, - &new_allowed_hint_kinds, - cx, - ); - if new_splice.is_some() { - self.version += 1; - self.allowed_hint_kinds = new_allowed_hint_kinds; - } - ControlFlow::Break(new_splice) - } - } - (true, false) => { - self.modifiers_override = false; - self.allowed_hint_kinds = new_allowed_hint_kinds; - if self.hints.is_empty() { - ControlFlow::Break(None) - } else { - self.clear(); - ControlFlow::Break(Some(InlaySplice { - to_remove: visible_hints.iter().map(|inlay| inlay.id).collect(), - to_insert: Vec::new(), - })) - } - } - (false, true) => { - self.modifiers_override = false; - self.allowed_hint_kinds = new_allowed_hint_kinds; - ControlFlow::Continue(()) - } - } - } - - pub(super) fn modifiers_override(&mut self, new_override: bool) -> Option { - if self.modifiers_override == new_override { - return None; - } - self.modifiers_override = new_override; - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { - self.clear(); - Some(false) - } else { - Some(true) - } - } - - pub(super) fn toggle(&mut self, enabled: bool) -> bool { - if self.enabled == enabled { - return false; - } - self.enabled = enabled; - self.modifiers_override = false; - if !enabled { - self.clear(); - } - true - } - - /// If needed, queries LSP for new inlay hints, using the invalidation strategy given. - /// To reduce inlay hint jumping, attempts to query a visible range of the editor(s) first, - /// followed by the delayed queries of the same range above and below the visible one. - /// This way, subsequent refresh invocations are less likely to trigger LSP queries for the invisible ranges. - pub(super) fn spawn_hint_refresh( - &mut self, - reason_description: &'static str, - excerpts_to_query: HashMap, Global, Range)>, - invalidate: InvalidationStrategy, - ignore_debounce: bool, - cx: &mut Context, - ) -> Option { - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { - return None; - } - let mut invalidated_hints = Vec::new(); - if invalidate.should_invalidate() { - self.update_tasks - .retain(|task_excerpt_id, _| excerpts_to_query.contains_key(task_excerpt_id)); - self.hints.retain(|cached_excerpt, cached_hints| { - let retain = excerpts_to_query.contains_key(cached_excerpt); - if !retain { - invalidated_hints.extend(cached_hints.read().ordered_hints.iter().copied()); - } - retain - }); - } - if excerpts_to_query.is_empty() && invalidated_hints.is_empty() { - return None; - } - - let cache_version = self.version + 1; - let debounce_duration = if ignore_debounce { - None - } else if invalidate.should_invalidate() { - self.invalidate_debounce - } else { - self.append_debounce - }; - self.refresh_task = cx.spawn(async move |editor, cx| { - if let Some(debounce_duration) = debounce_duration { - cx.background_executor().timer(debounce_duration).await; - } - - editor - .update(cx, |editor, cx| { - spawn_new_update_tasks( - editor, - reason_description, - excerpts_to_query, - invalidate, - cache_version, - cx, - ) - }) - .ok(); - }); - - if invalidated_hints.is_empty() { - None - } else { - Some(InlaySplice { - to_remove: invalidated_hints, - to_insert: Vec::new(), - }) - } - } - - fn new_allowed_hint_kinds_splice( - &self, - multi_buffer: &Entity, - visible_hints: &[Inlay], - new_kinds: &HashSet>, - cx: &mut Context, - ) -> Option { - let old_kinds = &self.allowed_hint_kinds; - if new_kinds == old_kinds { - return None; - } - - let mut to_remove = Vec::new(); - let mut to_insert = Vec::new(); - let mut shown_hints_to_remove = visible_hints.iter().fold( - HashMap::>::default(), - |mut current_hints, inlay| { - current_hints - .entry(inlay.position.excerpt_id) - .or_default() - .push((inlay.position, inlay.id)); - current_hints - }, - ); - - let multi_buffer = multi_buffer.read(cx); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); - - for (excerpt_id, excerpt_cached_hints) in &self.hints { - let shown_excerpt_hints_to_remove = - shown_hints_to_remove.entry(*excerpt_id).or_default(); - let excerpt_cached_hints = excerpt_cached_hints.read(); - let mut excerpt_cache = excerpt_cached_hints.ordered_hints.iter().fuse().peekable(); - shown_excerpt_hints_to_remove.retain(|(shown_anchor, shown_hint_id)| { - let Some(buffer) = shown_anchor - .buffer_id - .and_then(|buffer_id| multi_buffer.buffer(buffer_id)) - else { - return false; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - loop { - match excerpt_cache.peek() { - Some(&cached_hint_id) => { - let cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id]; - if cached_hint_id == shown_hint_id { - excerpt_cache.next(); - return !new_kinds.contains(&cached_hint.kind); - } - - match cached_hint - .position - .cmp(&shown_anchor.text_anchor, &buffer_snapshot) - { - cmp::Ordering::Less | cmp::Ordering::Equal => { - if !old_kinds.contains(&cached_hint.kind) - && new_kinds.contains(&cached_hint.kind) - { - if let Some(anchor) = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, cached_hint.position) - { - to_insert.push(Inlay::hint( - cached_hint_id.id(), - anchor, - cached_hint, - )); - } - } - excerpt_cache.next(); - } - cmp::Ordering::Greater => return true, - } - } - None => return true, - } - } - }); - - for cached_hint_id in excerpt_cache { - let maybe_missed_cached_hint = &excerpt_cached_hints.hints_by_id[cached_hint_id]; - let cached_hint_kind = maybe_missed_cached_hint.kind; - if !old_kinds.contains(&cached_hint_kind) && new_kinds.contains(&cached_hint_kind) { - if let Some(anchor) = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, maybe_missed_cached_hint.position) - { - to_insert.push(Inlay::hint( - cached_hint_id.id(), - anchor, - maybe_missed_cached_hint, - )); - } - } - } - } - - to_remove.extend( - shown_hints_to_remove - .into_values() - .flatten() - .map(|(_, hint_id)| hint_id), - ); - if to_remove.is_empty() && to_insert.is_empty() { - None - } else { - Some(InlaySplice { - to_remove, - to_insert, - }) - } - } - - /// Completely forget of certain excerpts that were removed from the multibuffer. - pub(super) fn remove_excerpts( - &mut self, - excerpts_removed: &[ExcerptId], - ) -> Option { - let mut to_remove = Vec::new(); - for excerpt_to_remove in excerpts_removed { - self.update_tasks.remove(excerpt_to_remove); - if let Some(cached_hints) = self.hints.remove(excerpt_to_remove) { - let cached_hints = cached_hints.read(); - to_remove.extend(cached_hints.ordered_hints.iter().copied()); - } - } - if to_remove.is_empty() { - None - } else { - self.version += 1; - Some(InlaySplice { - to_remove, - to_insert: Vec::new(), - }) - } - } - - pub(super) fn clear(&mut self) { - if !self.update_tasks.is_empty() || !self.hints.is_empty() { - self.version += 1; - } - self.update_tasks.clear(); - self.refresh_task = Task::ready(()); - self.hints.clear(); - } - - pub(super) fn hint_by_id(&self, excerpt_id: ExcerptId, hint_id: InlayId) -> Option { - self.hints - .get(&excerpt_id)? - .read() - .hints_by_id - .get(&hint_id) - .cloned() - } - - pub fn hints(&self) -> Vec { - let mut hints = Vec::new(); - for excerpt_hints in self.hints.values() { - let excerpt_hints = excerpt_hints.read(); - hints.extend( - excerpt_hints - .ordered_hints - .iter() - .map(|id| &excerpt_hints.hints_by_id[id]) - .cloned(), - ); - } - hints - } - - /// Queries a certain hint from the cache for extra data via the LSP resolve request. - pub(super) fn spawn_hint_resolve( - &self, - buffer_id: BufferId, - excerpt_id: ExcerptId, - id: InlayId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(excerpt_hints) = self.hints.get(&excerpt_id) { - let mut guard = excerpt_hints.write(); - if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) { - if let ResolveState::CanResolve(server_id, _) = &cached_hint.resolve_state { - let hint_to_resolve = cached_hint.clone(); - let server_id = *server_id; - cached_hint.resolve_state = ResolveState::Resolving; - drop(guard); - cx.spawn_in(window, async move |editor, cx| { - let resolved_hint_task = editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).buffer(buffer_id)?; - editor.semantics_provider.as_ref()?.resolve_inlay_hint( - hint_to_resolve, - buffer, - server_id, - cx, - ) - })?; - if let Some(resolved_hint_task) = resolved_hint_task { - let mut resolved_hint = - resolved_hint_task.await.context("hint resolve task")?; - editor.read_with(cx, |editor, _| { - if let Some(excerpt_hints) = - editor.inlay_hint_cache.hints.get(&excerpt_id) - { - let mut guard = excerpt_hints.write(); - if let Some(cached_hint) = guard.hints_by_id.get_mut(&id) { - if cached_hint.resolve_state == ResolveState::Resolving { - resolved_hint.resolve_state = ResolveState::Resolved; - *cached_hint = resolved_hint; - } - } - } - })?; - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - } - } -} - -fn debounce_value(debounce_ms: u64) -> Option { - if debounce_ms > 0 { - Some(Duration::from_millis(debounce_ms)) - } else { - None - } -} - -fn spawn_new_update_tasks( - editor: &mut Editor, - reason: &'static str, - excerpts_to_query: HashMap, Global, Range)>, - invalidate: InvalidationStrategy, - update_cache_version: usize, - cx: &mut Context, -) { - for (excerpt_id, (excerpt_buffer, new_task_buffer_version, excerpt_visible_range)) in - excerpts_to_query - { - if excerpt_visible_range.is_empty() { - continue; - } - let buffer = excerpt_buffer.read(cx); - let buffer_id = buffer.remote_id(); - let buffer_snapshot = buffer.snapshot(); - if buffer_snapshot - .version() - .changed_since(&new_task_buffer_version) - { - continue; - } - - if let Some(cached_excerpt_hints) = editor.inlay_hint_cache.hints.get(&excerpt_id) { - let cached_excerpt_hints = cached_excerpt_hints.read(); - let cached_buffer_version = &cached_excerpt_hints.buffer_version; - if cached_excerpt_hints.version > update_cache_version - || cached_buffer_version.changed_since(&new_task_buffer_version) - { - continue; - } - }; - - let Some(query_ranges) = editor.buffer.update(cx, |multi_buffer, cx| { - determine_query_ranges( - multi_buffer, - excerpt_id, - &excerpt_buffer, - excerpt_visible_range, - cx, - ) - }) else { - return; - }; - let query = ExcerptQuery { - buffer_id, - excerpt_id, - cache_version: update_cache_version, - invalidate, - reason, - }; - - let mut new_update_task = - |query_ranges| new_update_task(query, query_ranges, excerpt_buffer.clone(), cx); - - match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) { - hash_map::Entry::Occupied(mut o) => { - o.get_mut().update_cached_tasks( - &buffer_snapshot, - query_ranges, - invalidate, - new_update_task, - ); - } - hash_map::Entry::Vacant(v) => { - v.insert(TasksForRanges::new( - query_ranges.clone(), - new_update_task(query_ranges), - )); - } - } - } -} - -#[derive(Debug, Clone)] -struct QueryRanges { - before_visible: Vec>, - visible: Vec>, - after_visible: Vec>, -} - -impl QueryRanges { - fn is_empty(&self) -> bool { - self.before_visible.is_empty() && self.visible.is_empty() && self.after_visible.is_empty() - } - - fn into_sorted_query_ranges(self) -> Vec> { - let mut sorted_ranges = Vec::with_capacity( - self.before_visible.len() + self.visible.len() + self.after_visible.len(), - ); - sorted_ranges.extend(self.before_visible); - sorted_ranges.extend(self.visible); - sorted_ranges.extend(self.after_visible); - sorted_ranges - } -} - -fn determine_query_ranges( - multi_buffer: &mut MultiBuffer, - excerpt_id: ExcerptId, - excerpt_buffer: &Entity, - excerpt_visible_range: Range, - cx: &mut Context, -) -> Option { - let buffer = excerpt_buffer.read(cx); - let full_excerpt_range = multi_buffer - .excerpts_for_buffer(buffer.remote_id(), cx) - .into_iter() - .find(|(id, _)| id == &excerpt_id) - .map(|(_, range)| range.context)?; - let snapshot = buffer.snapshot(); - let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start; - - let visible_range = if excerpt_visible_range.start == excerpt_visible_range.end { - return None; - } else { - vec![ - buffer.anchor_before(snapshot.clip_offset(excerpt_visible_range.start, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(excerpt_visible_range.end, Bias::Right)), - ] - }; - - let full_excerpt_range_end_offset = full_excerpt_range.end.to_offset(&snapshot); - let after_visible_range_start = excerpt_visible_range - .end - .saturating_add(1) - .min(full_excerpt_range_end_offset) - .min(buffer.len()); - let after_visible_range = if after_visible_range_start == full_excerpt_range_end_offset { - Vec::new() - } else { - let after_range_end_offset = after_visible_range_start - .saturating_add(excerpt_visible_len) - .min(full_excerpt_range_end_offset) - .min(buffer.len()); - vec![ - buffer.anchor_before(snapshot.clip_offset(after_visible_range_start, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(after_range_end_offset, Bias::Right)), - ] - }; - - let full_excerpt_range_start_offset = full_excerpt_range.start.to_offset(&snapshot); - let before_visible_range_end = excerpt_visible_range - .start - .saturating_sub(1) - .max(full_excerpt_range_start_offset); - let before_visible_range = if before_visible_range_end == full_excerpt_range_start_offset { - Vec::new() - } else { - let before_range_start_offset = before_visible_range_end - .saturating_sub(excerpt_visible_len) - .max(full_excerpt_range_start_offset); - vec![ - buffer.anchor_before(snapshot.clip_offset(before_range_start_offset, Bias::Left)) - ..buffer.anchor_after(snapshot.clip_offset(before_visible_range_end, Bias::Right)), - ] - }; - - Some(QueryRanges { - before_visible: before_visible_range, - visible: visible_range, - after_visible: after_visible_range, - }) -} - -const MAX_CONCURRENT_LSP_REQUESTS: usize = 5; -const INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS: u64 = 400; - -fn new_update_task( - query: ExcerptQuery, - query_ranges: QueryRanges, - excerpt_buffer: Entity, - cx: &mut Context, -) -> Task<()> { - cx.spawn(async move |editor, cx| { - let visible_range_update_results = future::join_all( - query_ranges - .visible - .into_iter() - .filter_map(|visible_range| { - let fetch_task = editor - .update(cx, |_, cx| { - fetch_and_update_hints( - excerpt_buffer.clone(), - query, - visible_range.clone(), - query.invalidate.should_invalidate(), - cx, - ) - }) - .log_err()?; - Some(async move { (visible_range, fetch_task.await) }) - }), - ) - .await; - - let hint_delay = cx.background_executor().timer(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS, - )); - - let query_range_failed = - |range: &Range, e: anyhow::Error, cx: &mut AsyncApp| { - log::error!("inlay hint update task for range failed: {e:#?}"); - editor - .update(cx, |editor, cx| { - if let Some(task_ranges) = editor - .inlay_hint_cache - .update_tasks - .get_mut(&query.excerpt_id) - { - let buffer_snapshot = excerpt_buffer.read(cx).snapshot(); - task_ranges.invalidate_range(&buffer_snapshot, range); - } - }) - .ok() - }; - - for (range, result) in visible_range_update_results { - if let Err(e) = result { - query_range_failed(&range, e, cx); - } - } - - hint_delay.await; - let invisible_range_update_results = future::join_all( - query_ranges - .before_visible - .into_iter() - .chain(query_ranges.after_visible.into_iter()) - .filter_map(|invisible_range| { - let fetch_task = editor - .update(cx, |_, cx| { - fetch_and_update_hints( - excerpt_buffer.clone(), - query, - invisible_range.clone(), - false, // visible screen request already invalidated the entries - cx, - ) - }) - .log_err()?; - Some(async move { (invisible_range, fetch_task.await) }) - }), - ) - .await; - for (range, result) in invisible_range_update_results { - if let Err(e) = result { - query_range_failed(&range, e, cx); - } - } - }) -} - -fn fetch_and_update_hints( - excerpt_buffer: Entity, - query: ExcerptQuery, - fetch_range: Range, - invalidate: bool, - cx: &mut Context, -) -> Task> { - cx.spawn(async move |editor, cx|{ - let buffer_snapshot = excerpt_buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let (lsp_request_limiter, multi_buffer_snapshot) = - editor.update(cx, |editor, cx| { - let multi_buffer_snapshot = - editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); - let lsp_request_limiter = Arc::clone(&editor.inlay_hint_cache.lsp_request_limiter); - (lsp_request_limiter, multi_buffer_snapshot) - })?; - - let (lsp_request_guard, got_throttled) = if query.invalidate.should_invalidate() { - (None, false) - } else { - match lsp_request_limiter.try_acquire() { - Some(guard) => (Some(guard), false), - None => (Some(lsp_request_limiter.acquire().await), true), - } - }; - let fetch_range_to_log = fetch_range.start.to_point(&buffer_snapshot) - ..fetch_range.end.to_point(&buffer_snapshot); - let inlay_hints_fetch_task = editor - .update(cx, |editor, cx| { - if got_throttled { - let query_not_around_visible_range = match editor - .visible_excerpts(None, cx) - .remove(&query.excerpt_id) - { - Some((_, _, current_visible_range)) => { - let visible_offset_length = current_visible_range.len(); - let double_visible_range = current_visible_range - .start - .saturating_sub(visible_offset_length) - ..current_visible_range - .end - .saturating_add(visible_offset_length) - .min(buffer_snapshot.len()); - !double_visible_range - .contains(&fetch_range.start.to_offset(&buffer_snapshot)) - && !double_visible_range - .contains(&fetch_range.end.to_offset(&buffer_snapshot)) - } - None => true, - }; - if query_not_around_visible_range { - log::trace!("Fetching inlay hints for range {fetch_range_to_log:?} got throttled and fell off the current visible range, skipping."); - if let Some(task_ranges) = editor - .inlay_hint_cache - .update_tasks - .get_mut(&query.excerpt_id) - { - task_ranges.invalidate_range(&buffer_snapshot, &fetch_range); - } - return None; - } - } - - let buffer = editor.buffer().read(cx).buffer(query.buffer_id)?; - - if !editor.registered_buffers.contains_key(&query.buffer_id) { - if let Some(project) = editor.project.as_ref() { - project.update(cx, |project, cx| { - editor.registered_buffers.insert( - query.buffer_id, - project.register_buffer_with_language_servers(&buffer, cx), - ); - }) - } - } - - editor - .semantics_provider - .as_ref()? - .inlay_hints(buffer, fetch_range.clone(), cx) - }) - .ok() - .flatten(); - - let cached_excerpt_hints = editor.read_with(cx, |editor, _| { - editor - .inlay_hint_cache - .hints - .get(&query.excerpt_id) - .cloned() - })?; - - let visible_hints = editor.update(cx, |editor, cx| editor.visible_inlay_hints(cx))?; - let new_hints = match inlay_hints_fetch_task { - Some(fetch_task) => { - log::debug!( - "Fetching inlay hints for range {fetch_range_to_log:?}, reason: {query_reason}, invalidate: {invalidate}", - query_reason = query.reason, - ); - log::trace!( - "Currently visible hints: {visible_hints:?}, cached hints present: {}", - cached_excerpt_hints.is_some(), - ); - fetch_task.await.context("inlay hint fetch task")? - } - None => return Ok(()), - }; - drop(lsp_request_guard); - log::debug!( - "Fetched {} hints for range {fetch_range_to_log:?}", - new_hints.len() - ); - log::trace!("Fetched hints: {new_hints:?}"); - - let background_task_buffer_snapshot = buffer_snapshot.clone(); - let background_fetch_range = fetch_range.clone(); - let new_update = cx.background_spawn(async move { - calculate_hint_updates( - query.excerpt_id, - invalidate, - background_fetch_range, - new_hints, - &background_task_buffer_snapshot, - cached_excerpt_hints, - &visible_hints, - ) - }) - .await; - if let Some(new_update) = new_update { - log::debug!( - "Applying update for range {fetch_range_to_log:?}: remove from editor: {}, remove from cache: {}, add to cache: {}", - new_update.remove_from_visible.len(), - new_update.remove_from_cache.len(), - new_update.add_to_cache.len() - ); - log::trace!("New update: {new_update:?}"); - editor - .update(cx, |editor, cx| { - apply_hint_update( - editor, - new_update, - query, - invalidate, - buffer_snapshot, - multi_buffer_snapshot, - cx, - ); - }) - .ok(); - } - anyhow::Ok(()) - }) -} - -fn calculate_hint_updates( - excerpt_id: ExcerptId, - invalidate: bool, - fetch_range: Range, - new_excerpt_hints: Vec, - buffer_snapshot: &BufferSnapshot, - cached_excerpt_hints: Option>>, - visible_hints: &[Inlay], -) -> Option { - let mut add_to_cache = Vec::::new(); - let mut excerpt_hints_to_persist = HashMap::default(); - for new_hint in new_excerpt_hints { - if !contains_position(&fetch_range, new_hint.position, buffer_snapshot) { - continue; - } - let missing_from_cache = match &cached_excerpt_hints { - Some(cached_excerpt_hints) => { - let cached_excerpt_hints = cached_excerpt_hints.read(); - match cached_excerpt_hints - .ordered_hints - .binary_search_by(|probe| { - cached_excerpt_hints.hints_by_id[probe] - .position - .cmp(&new_hint.position, buffer_snapshot) - }) { - Ok(ix) => { - let mut missing_from_cache = true; - for id in &cached_excerpt_hints.ordered_hints[ix..] { - let cached_hint = &cached_excerpt_hints.hints_by_id[id]; - if new_hint - .position - .cmp(&cached_hint.position, buffer_snapshot) - .is_gt() - { - break; - } - if cached_hint == &new_hint { - excerpt_hints_to_persist.insert(*id, cached_hint.kind); - missing_from_cache = false; - } - } - missing_from_cache - } - Err(_) => true, - } - } - None => true, - }; - if missing_from_cache { - add_to_cache.push(new_hint); - } - } - - let mut remove_from_visible = HashSet::default(); - let mut remove_from_cache = HashSet::default(); - if invalidate { - remove_from_visible.extend( - visible_hints - .iter() - .filter(|hint| hint.position.excerpt_id == excerpt_id) - .map(|inlay_hint| inlay_hint.id) - .filter(|hint_id| !excerpt_hints_to_persist.contains_key(hint_id)), - ); - - if let Some(cached_excerpt_hints) = &cached_excerpt_hints { - let cached_excerpt_hints = cached_excerpt_hints.read(); - remove_from_cache.extend( - cached_excerpt_hints - .ordered_hints - .iter() - .filter(|cached_inlay_id| { - !excerpt_hints_to_persist.contains_key(cached_inlay_id) - }) - .copied(), - ); - remove_from_visible.extend(remove_from_cache.iter().cloned()); - } - } - - if remove_from_visible.is_empty() && remove_from_cache.is_empty() && add_to_cache.is_empty() { - None - } else { - Some(ExcerptHintsUpdate { - excerpt_id, - remove_from_visible, - remove_from_cache, - add_to_cache, - }) - } -} - -fn contains_position( - range: &Range, - position: language::Anchor, - buffer_snapshot: &BufferSnapshot, -) -> bool { - range.start.cmp(&position, buffer_snapshot).is_le() - && range.end.cmp(&position, buffer_snapshot).is_ge() -} - -fn apply_hint_update( - editor: &mut Editor, - new_update: ExcerptHintsUpdate, - query: ExcerptQuery, - invalidate: bool, - buffer_snapshot: BufferSnapshot, - multi_buffer_snapshot: MultiBufferSnapshot, - cx: &mut Context, -) { - let cached_excerpt_hints = editor - .inlay_hint_cache - .hints - .entry(new_update.excerpt_id) - .or_insert_with(|| { - Arc::new(RwLock::new(CachedExcerptHints { - version: query.cache_version, - buffer_version: buffer_snapshot.version().clone(), - buffer_id: query.buffer_id, - ordered_hints: Vec::new(), - hints_by_id: HashMap::default(), - })) - }); - let mut cached_excerpt_hints = cached_excerpt_hints.write(); - match query.cache_version.cmp(&cached_excerpt_hints.version) { - cmp::Ordering::Less => return, - cmp::Ordering::Greater | cmp::Ordering::Equal => { - cached_excerpt_hints.version = query.cache_version; - } - } - - let mut cached_inlays_changed = !new_update.remove_from_cache.is_empty(); - cached_excerpt_hints - .ordered_hints - .retain(|hint_id| !new_update.remove_from_cache.contains(hint_id)); - cached_excerpt_hints - .hints_by_id - .retain(|hint_id, _| !new_update.remove_from_cache.contains(hint_id)); - let mut splice = InlaySplice::default(); - splice.to_remove.extend(new_update.remove_from_visible); - for new_hint in new_update.add_to_cache { - let insert_position = match cached_excerpt_hints - .ordered_hints - .binary_search_by(|probe| { - cached_excerpt_hints.hints_by_id[probe] - .position - .cmp(&new_hint.position, &buffer_snapshot) - }) { - Ok(i) => { - // When a hint is added to the same position where existing ones are present, - // do not deduplicate it: we split hint queries into non-overlapping ranges - // and each hint batch returned by the server should already contain unique hints. - i + cached_excerpt_hints.ordered_hints[i..].len() + 1 - } - Err(i) => i, - }; - - let new_inlay_id = post_inc(&mut editor.next_inlay_id); - if editor - .inlay_hint_cache - .allowed_hint_kinds - .contains(&new_hint.kind) - { - if let Some(new_hint_position) = - multi_buffer_snapshot.anchor_in_excerpt(query.excerpt_id, new_hint.position) - { - splice - .to_insert - .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint)); - } - } - let new_id = InlayId::Hint(new_inlay_id); - cached_excerpt_hints.hints_by_id.insert(new_id, new_hint); - if cached_excerpt_hints.ordered_hints.len() <= insert_position { - cached_excerpt_hints.ordered_hints.push(new_id); - } else { - cached_excerpt_hints - .ordered_hints - .insert(insert_position, new_id); - } - - cached_inlays_changed = true; - } - cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone(); - drop(cached_excerpt_hints); - - if invalidate { - let mut outdated_excerpt_caches = HashSet::default(); - for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints { - let excerpt_hints = excerpt_hints.read(); - if excerpt_hints.buffer_id == query.buffer_id - && excerpt_id != &query.excerpt_id - && buffer_snapshot - .version() - .changed_since(&excerpt_hints.buffer_version) - { - outdated_excerpt_caches.insert(*excerpt_id); - splice - .to_remove - .extend(excerpt_hints.ordered_hints.iter().copied()); - } - } - cached_inlays_changed |= !outdated_excerpt_caches.is_empty(); - editor - .inlay_hint_cache - .hints - .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id)); - } - - let InlaySplice { - to_remove, - to_insert, - } = splice; - let displayed_inlays_changed = !to_remove.is_empty() || !to_insert.is_empty(); - if cached_inlays_changed || displayed_inlays_changed { - editor.inlay_hint_cache.version += 1; - } - if displayed_inlays_changed { - editor.splice_inlays(&to_remove, to_insert, cx) - } -} - -#[cfg(test)] -pub mod tests { - use crate::SelectionEffects; - use crate::editor_tests::update_test_language_settings; - use crate::scroll::ScrollAmount; - use crate::{ExcerptRange, scroll::Autoscroll, test::editor_lsp_test_context::rust_lang}; - use futures::StreamExt; - use gpui::{AppContext as _, Context, SemanticVersion, TestAppContext, WindowHandle}; - use itertools::Itertools as _; - use language::{Capability, FakeLspAdapter, language_settings::AllLanguageSettingsContent}; - use language::{Language, LanguageConfig, LanguageMatcher}; - use lsp::FakeLanguageServer; - use parking_lot::Mutex; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; - use text::Point; - use util::path; - - use super::*; - - #[gpui::test] - async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) { - let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), - show_parameter_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), - show_other_hints: allowed_hint_kinds.contains(&None), - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { - let lsp_request_count = Arc::new(AtomicU32::new(0)); - fake_server.set_request_handler::( - move |params, _| { - let task_lsp_request_count = Arc::clone(&lsp_request_count); - async move { - let i = task_lsp_request_count.fetch_add(1, Ordering::Release) + 1; - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(file_with_hints).unwrap(), - ); - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, i), - label: lsp::InlayHintLabel::String(i.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - }) - .await; - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get its first hints when opening the editor" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, - "Cache should use editor settings to get the allowed hint kinds" - ); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) - }); - editor.handle_input("some change", window, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["2".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get new hints after an edit" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, - "Cache should use editor settings to get the allowed hint kinds" - ); - }) - .unwrap(); - - fake_server - .request::(()) - .await - .into_response() - .expect("inlay refresh request failed"); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["3".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get new hints after hint refresh/ request" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, - "Cache should use editor settings to get the allowed hint kinds" - ); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_cache_update_on_lsp_completion_tasks(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { - let lsp_request_count = Arc::new(AtomicU32::new(0)); - fake_server.set_request_handler::( - move |params, _| { - let task_lsp_request_count = Arc::clone(&lsp_request_count); - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(file_with_hints).unwrap(), - ); - let current_call_id = - Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst); - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, current_call_id), - label: lsp::InlayHintLabel::String(current_call_id.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - }) - .await; - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["0".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get its first hints when opening the editor" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - let progress_token = "test_progress_token"; - fake_server - .request::(lsp::WorkDoneProgressCreateParams { - token: lsp::ProgressToken::String(progress_token.to_string()), - }) - .await - .into_response() - .expect("work done progress create request failed"); - cx.executor().run_until_parked(); - fake_server.notify::(&lsp::ProgressParams { - token: lsp::ProgressToken::String(progress_token.to_string()), - value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( - lsp::WorkDoneProgressBegin::default(), - )), - }); - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["0".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should not update hints while the work task is running" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - fake_server.notify::(&lsp::ProgressParams { - token: lsp::ProgressToken::String(progress_token.to_string()), - value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( - lsp::WorkDoneProgressEnd::default(), - )), - }); - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "New hints should be queried after the work task is done" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_no_hint_updates_for_unrelated_language_files(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", - "other.md": "Test md file with some text", - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let mut rs_fake_servers = None; - let mut md_fake_servers = None; - for (name, path_suffix) in [("Rust", "rs"), ("Markdown", "md")] { - language_registry.add(Arc::new(Language::new( - LanguageConfig { - name: name.into(), - matcher: LanguageMatcher { - path_suffixes: vec![path_suffix.to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ))); - let fake_servers = language_registry.register_fake_lsp( - name, - FakeLspAdapter { - name, - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - initializer: Some(Box::new({ - move |fake_server| { - let rs_lsp_request_count = Arc::new(AtomicU32::new(0)); - let md_lsp_request_count = Arc::new(AtomicU32::new(0)); - fake_server - .set_request_handler::( - move |params, _| { - let i = match name { - "Rust" => { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/main.rs")) - .unwrap(), - ); - rs_lsp_request_count.fetch_add(1, Ordering::Release) - + 1 - } - "Markdown" => { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/other.md")) - .unwrap(), - ); - md_lsp_request_count.fetch_add(1, Ordering::Release) - + 1 - } - unexpected => { - panic!("Unexpected language: {unexpected}") - } - }; - - async move { - let query_start = params.range.start; - Ok(Some(vec![lsp::InlayHint { - position: query_start, - label: lsp::InlayHintLabel::String(i.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - } - })), - ..Default::default() - }, - ); - match name { - "Rust" => rs_fake_servers = Some(fake_servers), - "Markdown" => md_fake_servers = Some(fake_servers), - _ => unreachable!(), - } - } - - let rs_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let rs_editor = cx.add_window(|window, cx| { - Editor::for_buffer(rs_buffer, Some(project.clone()), window, cx) - }); - cx.executor().run_until_parked(); - - let _rs_fake_server = rs_fake_servers.unwrap().next().await.unwrap(); - cx.executor().run_until_parked(); - rs_editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get its first hints when opening the editor" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - cx.executor().run_until_parked(); - let md_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/other.md"), cx) - }) - .await - .unwrap(); - let md_editor = - cx.add_window(|window, cx| Editor::for_buffer(md_buffer, Some(project), window, cx)); - cx.executor().run_until_parked(); - - let _md_fake_server = md_fake_servers.unwrap().next().await.unwrap(); - cx.executor().run_until_parked(); - md_editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Markdown editor should have a separate version, repeating Rust editor rules" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - rs_editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) - }); - editor.handle_input("some rs change", window, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - rs_editor - .update(cx, |editor, _window, cx| { - // TODO: Here, we do not get "2", because inserting another language server will trigger `RefreshInlayHints` event from the `LspStore` - // A project is listened in every editor, so each of them will react to this event. - // - // We do not have language server IDs for remote projects, so cannot easily say on the editor level, - // whether we should ignore a particular `RefreshInlayHints` event. - let expected_hints = vec!["3".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Rust inlay cache should change after the edit" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - md_editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Markdown editor should not be affected by Rust editor changes" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - md_editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) - }); - editor.handle_input("some md change", window, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - md_editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["2".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Rust editor should not be affected by Markdown editor changes" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - rs_editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec!["3".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Markdown editor should also change independently" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_hint_setting_changes(cx: &mut gpui::TestAppContext) { - let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), - show_parameter_hints: allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), - show_other_hints: allowed_hint_kinds.contains(&None), - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let lsp_request_count = Arc::new(AtomicUsize::new(0)); - let (_, editor, fake_server) = prepare_test_objects(cx, { - let lsp_request_count = lsp_request_count.clone(); - move |fake_server, file_with_hints| { - let lsp_request_count = lsp_request_count.clone(); - fake_server.set_request_handler::( - move |params, _| { - lsp_request_count.fetch_add(1, Ordering::Release); - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(file_with_hints).unwrap(), - ); - Ok(Some(vec![ - lsp::InlayHint { - position: lsp::Position::new(0, 1), - label: lsp::InlayHintLabel::String("type hint".to_string()), - kind: Some(lsp::InlayHintKind::TYPE), - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }, - lsp::InlayHint { - position: lsp::Position::new(0, 2), - label: lsp::InlayHintLabel::String( - "parameter hint".to_string(), - ), - kind: Some(lsp::InlayHintKind::PARAMETER), - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }, - lsp::InlayHint { - position: lsp::Position::new(0, 3), - label: lsp::InlayHintLabel::String("other hint".to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }, - ])) - } - }, - ); - } - }) - .await; - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 1, - "Should query new hints once" - ); - assert_eq!( - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - cached_hint_labels(editor), - "Should get its first hints when opening the editor" - ); - assert_eq!( - vec!["type hint".to_string(), "other hint".to_string()], - visible_hint_labels(editor, cx) - ); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, allowed_hint_kinds, - "Cache should use editor settings to get the allowed hint kinds" - ); - }) - .unwrap(); - - fake_server - .request::(()) - .await - .into_response() - .expect("inlay refresh request failed"); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 2, - "Should load new hints twice" - ); - assert_eq!( - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - cached_hint_labels(editor), - "Cached hints should not change due to allowed hint kinds settings update" - ); - assert_eq!( - vec!["type hint".to_string(), "other hint".to_string()], - visible_hint_labels(editor, cx) - ); - }) - .unwrap(); - - for (new_allowed_hint_kinds, expected_visible_hints) in [ - (HashSet::from_iter([None]), vec!["other hint".to_string()]), - ( - HashSet::from_iter([Some(InlayHintKind::Type)]), - vec!["type hint".to_string()], - ), - ( - HashSet::from_iter([Some(InlayHintKind::Parameter)]), - vec!["parameter hint".to_string()], - ), - ( - HashSet::from_iter([None, Some(InlayHintKind::Type)]), - vec!["type hint".to_string(), "other hint".to_string()], - ), - ( - HashSet::from_iter([None, Some(InlayHintKind::Parameter)]), - vec!["parameter hint".to_string(), "other hint".to_string()], - ), - ( - HashSet::from_iter([Some(InlayHintKind::Type), Some(InlayHintKind::Parameter)]), - vec!["type hint".to_string(), "parameter hint".to_string()], - ), - ( - HashSet::from_iter([ - None, - Some(InlayHintKind::Type), - Some(InlayHintKind::Parameter), - ]), - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - ), - ] { - update_test_language_settings(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: new_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), - show_parameter_hints: new_allowed_hint_kinds - .contains(&Some(InlayHintKind::Parameter)), - show_other_hints: new_allowed_hint_kinds.contains(&None), - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - cx.executor().run_until_parked(); - editor.update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 2, - "Should not load new hints on allowed hint kinds change for hint kinds {new_allowed_hint_kinds:?}" - ); - assert_eq!( - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - cached_hint_labels(editor), - "Should get its cached hints unchanged after the settings change for hint kinds {new_allowed_hint_kinds:?}" - ); - assert_eq!( - expected_visible_hints, - visible_hint_labels(editor, cx), - "Should get its visible hints filtered after the settings change for hint kinds {new_allowed_hint_kinds:?}" - ); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, new_allowed_hint_kinds, - "Cache should use editor settings to get the allowed hint kinds for hint kinds {new_allowed_hint_kinds:?}" - ); - }).unwrap(); - } - - let another_allowed_hint_kinds = HashSet::from_iter([Some(InlayHintKind::Type)]); - update_test_language_settings(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: false, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: another_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), - show_parameter_hints: another_allowed_hint_kinds - .contains(&Some(InlayHintKind::Parameter)), - show_other_hints: another_allowed_hint_kinds.contains(&None), - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 2, - "Should not load new hints when hints got disabled" - ); - assert!( - cached_hint_labels(editor).is_empty(), - "Should clear the cache when hints got disabled" - ); - assert!( - visible_hint_labels(editor, cx).is_empty(), - "Should clear visible hints when hints got disabled" - ); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, another_allowed_hint_kinds, - "Should update its allowed hint kinds even when hints got disabled" - ); - }) - .unwrap(); - - fake_server - .request::(()) - .await - .into_response() - .expect("inlay refresh request failed"); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 2, - "Should not load new hints when they got disabled" - ); - assert!(cached_hint_labels(editor).is_empty()); - assert!(visible_hint_labels(editor, cx).is_empty()); - }) - .unwrap(); - - let final_allowed_hint_kinds = HashSet::from_iter([Some(InlayHintKind::Parameter)]); - update_test_language_settings(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: final_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), - show_parameter_hints: final_allowed_hint_kinds - .contains(&Some(InlayHintKind::Parameter)), - show_other_hints: final_allowed_hint_kinds.contains(&None), - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 3, - "Should query for new hints when they got re-enabled" - ); - assert_eq!( - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - cached_hint_labels(editor), - "Should get its cached hints fully repopulated after the hints got re-enabled" - ); - assert_eq!( - vec!["parameter hint".to_string()], - visible_hint_labels(editor, cx), - "Should get its visible hints repopulated and filtered after the h" - ); - let inlay_cache = editor.inlay_hint_cache(); - assert_eq!( - inlay_cache.allowed_hint_kinds, final_allowed_hint_kinds, - "Cache should update editor settings when hints got re-enabled" - ); - }) - .unwrap(); - - fake_server - .request::(()) - .await - .into_response() - .expect("inlay refresh request failed"); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 4, - "Should query for new hints again" - ); - assert_eq!( - vec![ - "type hint".to_string(), - "parameter hint".to_string(), - "other hint".to_string(), - ], - cached_hint_labels(editor), - ); - assert_eq!( - vec!["parameter hint".to_string()], - visible_hint_labels(editor, cx), - ); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_hint_request_cancellation(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let lsp_request_count = Arc::new(AtomicU32::new(0)); - let (_, editor, _) = prepare_test_objects(cx, { - let lsp_request_count = lsp_request_count.clone(); - move |fake_server, file_with_hints| { - let lsp_request_count = lsp_request_count.clone(); - fake_server.set_request_handler::( - move |params, _| { - let lsp_request_count = lsp_request_count.clone(); - async move { - let i = lsp_request_count.fetch_add(1, Ordering::SeqCst) + 1; - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(file_with_hints).unwrap(), - ); - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, i), - label: lsp::InlayHintLabel::String(i.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - } - }) - .await; - - let mut expected_changes = Vec::new(); - for change_after_opening in [ - "initial change #1", - "initial change #2", - "initial change #3", - ] { - editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) - }); - editor.handle_input(change_after_opening, window, cx); - }) - .unwrap(); - expected_changes.push(change_after_opening); - } - - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _window, cx| { - let current_text = editor.text(cx); - for change in &expected_changes { - assert!( - current_text.contains(change), - "Should apply all changes made" - ); - } - assert_eq!( - lsp_request_count.load(Ordering::Relaxed), - 2, - "Should query new hints twice: for editor init and for the last edit that interrupted all others" - ); - let expected_hints = vec!["2".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get hints from the last edit landed only" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - let mut edits = Vec::new(); - for async_later_change in [ - "another change #1", - "another change #2", - "another change #3", - ] { - expected_changes.push(async_later_change); - let task_editor = editor; - edits.push(cx.spawn(|mut cx| async move { - task_editor - .update(&mut cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([13..13]) - }); - editor.handle_input(async_later_change, window, cx); - }) - .unwrap(); - })); - } - let _ = future::join_all(edits).await; - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _, cx| { - let current_text = editor.text(cx); - for change in &expected_changes { - assert!( - current_text.contains(change), - "Should apply all changes made" - ); - } - assert_eq!( - lsp_request_count.load(Ordering::SeqCst), - 3, - "Should query new hints one more time, for the last edit only" - ); - let expected_hints = vec!["3".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should get hints from the last edit landed only" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - #[gpui::test(iterations = 10)] - async fn test_large_buffer_inlay_requests_split(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": format!("fn main() {{\n{}\n}}", "let i = 5;\n".repeat(500)), - "other.rs": "// Test file", - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let lsp_request_ranges = Arc::new(Mutex::new(Vec::new())); - let lsp_request_count = Arc::new(AtomicUsize::new(0)); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - initializer: Some(Box::new({ - let lsp_request_ranges = lsp_request_ranges.clone(); - let lsp_request_count = lsp_request_count.clone(); - move |fake_server| { - let closure_lsp_request_ranges = Arc::clone(&lsp_request_ranges); - let closure_lsp_request_count = Arc::clone(&lsp_request_count); - fake_server.set_request_handler::( - move |params, _| { - let task_lsp_request_ranges = - Arc::clone(&closure_lsp_request_ranges); - let task_lsp_request_count = Arc::clone(&closure_lsp_request_count); - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(), - ); - - task_lsp_request_ranges.lock().push(params.range); - task_lsp_request_count.fetch_add(1, Ordering::Release); - Ok(Some(vec![lsp::InlayHint { - position: params.range.end, - label: lsp::InlayHintLabel::String( - params.range.end.line.to_string(), - ), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - } - })), - ..Default::default() - }, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let editor = - cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); - - cx.executor().run_until_parked(); - - let _fake_server = fake_servers.next().await.unwrap(); - - // in large buffers, requests are made for more than visible range of a buffer. - // invisible parts are queried later, to avoid excessive requests on quick typing. - // wait the timeout needed to get all requests. - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - let initial_visible_range = editor_visible_range(&editor, cx); - let lsp_initial_visible_range = lsp::Range::new( - lsp::Position::new( - initial_visible_range.start.row, - initial_visible_range.start.column, - ), - lsp::Position::new( - initial_visible_range.end.row, - initial_visible_range.end.column, - ), - ); - let expected_initial_query_range_end = - lsp::Position::new(initial_visible_range.end.row * 2, 2); - let mut expected_invisible_query_start = lsp_initial_visible_range.end; - expected_invisible_query_start.character += 1; - editor.update(cx, |editor, _window, cx| { - let ranges = lsp_request_ranges.lock().drain(..).collect::>(); - assert_eq!(ranges.len(), 2, - "When scroll is at the edge of a big document, its visible part and the same range further should be queried in order, but got: {ranges:?}"); - let visible_query_range = &ranges[0]; - assert_eq!(visible_query_range.start, lsp_initial_visible_range.start); - assert_eq!(visible_query_range.end, lsp_initial_visible_range.end); - let invisible_query_range = &ranges[1]; - - assert_eq!(invisible_query_range.start, expected_invisible_query_start, "Should initially query visible edge of the document"); - assert_eq!(invisible_query_range.end, expected_initial_query_range_end, "Should initially query visible edge of the document"); - - let requests_count = lsp_request_count.load(Ordering::Acquire); - assert_eq!(requests_count, 2, "Visible + invisible request"); - let expected_hints = vec!["47".to_string(), "94".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should have hints from both LSP requests made for a big file" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx), "Should display only hints from the visible range"); - }).unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, window, cx| { - editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - let visible_range_after_scrolls = editor_visible_range(&editor, cx); - let visible_line_count = editor - .update(cx, |editor, _window, _| { - editor.visible_line_count().unwrap() - }) - .unwrap(); - let selection_in_cached_range = editor - .update(cx, |editor, _window, cx| { - let ranges = lsp_request_ranges - .lock() - .drain(..) - .sorted_by_key(|r| r.start) - .collect::>(); - assert_eq!( - ranges.len(), - 2, - "Should query 2 ranges after both scrolls, but got: {ranges:?}" - ); - let first_scroll = &ranges[0]; - let second_scroll = &ranges[1]; - assert_eq!( - first_scroll.end, second_scroll.start, - "Should query 2 adjacent ranges after the scrolls, but got: {ranges:?}" - ); - assert_eq!( - first_scroll.start, expected_initial_query_range_end, - "First scroll should start the query right after the end of the original scroll", - ); - assert_eq!( - second_scroll.end, - lsp::Position::new( - visible_range_after_scrolls.end.row - + visible_line_count.ceil() as u32, - 1, - ), - "Second scroll should query one more screen down after the end of the visible range" - ); - - let lsp_requests = lsp_request_count.load(Ordering::Acquire); - assert_eq!(lsp_requests, 4, "Should query for hints after every scroll"); - let expected_hints = vec![ - "47".to_string(), - "94".to_string(), - "139".to_string(), - "184".to_string(), - ]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should have hints from the new LSP response after the edit" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - - let mut selection_in_cached_range = visible_range_after_scrolls.end; - selection_in_cached_range.row -= visible_line_count.ceil() as u32; - selection_in_cached_range - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_ranges([selection_in_cached_range..selection_in_cached_range]), - ); - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - editor.update(cx, |_, _, _| { - let ranges = lsp_request_ranges - .lock() - .drain(..) - .sorted_by_key(|r| r.start) - .collect::>(); - assert!(ranges.is_empty(), "No new ranges or LSP queries should be made after returning to the selection with cached hints"); - assert_eq!(lsp_request_count.load(Ordering::Acquire), 4); - }).unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.handle_input("++++more text++++", window, cx); - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - editor.update(cx, |editor, _window, cx| { - let mut ranges = lsp_request_ranges.lock().drain(..).collect::>(); - ranges.sort_by_key(|r| r.start); - - assert_eq!(ranges.len(), 3, - "On edit, should scroll to selection and query a range around it: visible + same range above and below. Instead, got query ranges {ranges:?}"); - let above_query_range = &ranges[0]; - let visible_query_range = &ranges[1]; - let below_query_range = &ranges[2]; - assert!(above_query_range.end.character < visible_query_range.start.character || above_query_range.end.line + 1 == visible_query_range.start.line, - "Above range {above_query_range:?} should be before visible range {visible_query_range:?}"); - assert!(visible_query_range.end.character < below_query_range.start.character || visible_query_range.end.line + 1 == below_query_range.start.line, - "Visible range {visible_query_range:?} should be before below range {below_query_range:?}"); - assert!(above_query_range.start.line < selection_in_cached_range.row, - "Hints should be queried with the selected range after the query range start"); - assert!(below_query_range.end.line > selection_in_cached_range.row, - "Hints should be queried with the selected range before the query range end"); - assert!(above_query_range.start.line <= selection_in_cached_range.row - (visible_line_count * 3.0 / 2.0) as u32, - "Hints query range should contain one more screen before"); - assert!(below_query_range.end.line >= selection_in_cached_range.row + (visible_line_count * 3.0 / 2.0) as u32, - "Hints query range should contain one more screen after"); - - let lsp_requests = lsp_request_count.load(Ordering::Acquire); - assert_eq!(lsp_requests, 7, "There should be a visible range and two ranges above and below it queried"); - let expected_hints = vec!["67".to_string(), "115".to_string(), "163".to_string()]; - assert_eq!(expected_hints, cached_hint_labels(editor), - "Should have hints from the new LSP response after the edit"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }).unwrap(); - } - - fn editor_visible_range( - editor: &WindowHandle, - cx: &mut gpui::TestAppContext, - ) -> Range { - let ranges = editor - .update(cx, |editor, _window, cx| editor.visible_excerpts(None, cx)) - .unwrap(); - assert_eq!( - ranges.len(), - 1, - "Single buffer should produce a single excerpt with visible range" - ); - let (_, (excerpt_buffer, _, excerpt_visible_range)) = ranges.into_iter().next().unwrap(); - excerpt_buffer.read_with(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let start = buffer - .anchor_before(excerpt_visible_range.start) - .to_point(&snapshot); - let end = buffer - .anchor_after(excerpt_visible_range.end) - .to_point(&snapshot); - start..end - }) - } - - #[gpui::test] - async fn test_multiple_excerpts_large_multibuffer(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), - "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let language = rust_lang(); - language_registry.add(language); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - let (buffer_1, _handle1) = project - .update(cx, |project, cx| { - project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let (buffer_2, _handle2) = project - .update(cx, |project, cx| { - project.open_local_buffer_with_lsp(path!("/a/other.rs"), cx) - }) - .await - .unwrap(); - let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); - multibuffer.push_excerpts( - buffer_1.clone(), - [ - ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0)), - ExcerptRange::new(Point::new(4, 0)..Point::new(11, 0)), - ExcerptRange::new(Point::new(22, 0)..Point::new(33, 0)), - ExcerptRange::new(Point::new(44, 0)..Point::new(55, 0)), - ExcerptRange::new(Point::new(56, 0)..Point::new(66, 0)), - ExcerptRange::new(Point::new(67, 0)..Point::new(77, 0)), - ], - cx, - ); - multibuffer.push_excerpts( - buffer_2.clone(), - [ - ExcerptRange::new(Point::new(0, 1)..Point::new(2, 1)), - ExcerptRange::new(Point::new(4, 1)..Point::new(11, 1)), - ExcerptRange::new(Point::new(22, 1)..Point::new(33, 1)), - ExcerptRange::new(Point::new(44, 1)..Point::new(55, 1)), - ExcerptRange::new(Point::new(56, 1)..Point::new(66, 1)), - ExcerptRange::new(Point::new(67, 1)..Point::new(77, 1)), - ], - cx, - ); - multibuffer - }); - - cx.executor().run_until_parked(); - let editor = cx.add_window(|window, cx| { - Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx) - }); - - let editor_edited = Arc::new(AtomicBool::new(false)); - let fake_server = fake_servers.next().await.unwrap(); - let closure_editor_edited = Arc::clone(&editor_edited); - fake_server - .set_request_handler::(move |params, _| { - let task_editor_edited = Arc::clone(&closure_editor_edited); - async move { - let hint_text = if params.text_document.uri - == lsp::Url::from_file_path(path!("/a/main.rs")).unwrap() - { - "main hint" - } else if params.text_document.uri - == lsp::Url::from_file_path(path!("/a/other.rs")).unwrap() - { - "other hint" - } else { - panic!("unexpected uri: {:?}", params.text_document.uri); - }; - - // one hint per excerpt - let positions = [ - lsp::Position::new(0, 2), - lsp::Position::new(4, 2), - lsp::Position::new(22, 2), - lsp::Position::new(44, 2), - lsp::Position::new(56, 2), - lsp::Position::new(67, 2), - ]; - let out_of_range_hint = lsp::InlayHint { - position: lsp::Position::new( - params.range.start.line + 99, - params.range.start.character + 99, - ), - label: lsp::InlayHintLabel::String( - "out of excerpt range, should be ignored".to_string(), - ), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }; - - let edited = task_editor_edited.load(Ordering::Acquire); - Ok(Some( - std::iter::once(out_of_range_hint) - .chain(positions.into_iter().enumerate().map(|(i, position)| { - lsp::InlayHint { - position, - label: lsp::InlayHintLabel::String(format!( - "{hint_text}{E} #{i}", - E = if edited { "(edited)" } else { "" }, - )), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - } - })) - .collect(), - )) - } - }) - .next() - .await; - cx.executor().run_until_parked(); - - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - ]; - assert_eq!( - expected_hints, - sorted_cached_hint_labels(editor), - "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::Next), - window, - cx, - |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]), - ); - editor.change_selections( - SelectionEffects::scroll(Autoscroll::Next), - window, - cx, - |s| s.select_ranges([Point::new(22, 0)..Point::new(22, 0)]), - ); - editor.change_selections( - SelectionEffects::scroll(Autoscroll::Next), - window, - cx, - |s| s.select_ranges([Point::new(50, 0)..Point::new(50, 0)]), - ); - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "With more scrolls of the multibuffer, more hints should be added into the cache and nothing invalidated without edits"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::Next), - window, - cx, - |s| s.select_ranges([Point::new(100, 0)..Point::new(100, 0)]), - ); - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - "other hint #3".to_string(), - "other hint #4".to_string(), - "other hint #5".to_string(), - ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::Next), - window, - cx, - |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]), - ); - }) - .unwrap(); - cx.executor().advance_clock(Duration::from_millis( - INVISIBLE_RANGES_HINTS_REQUEST_DELAY_MILLIS + 100, - )); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - "other hint #3".to_string(), - "other hint #4".to_string(), - "other hint #5".to_string(), - ]; - assert_eq!(expected_hints, sorted_cached_hint_labels(editor), - "After multibuffer was scrolled to the end, further scrolls up should not bring more hints"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor_edited.store(true, Ordering::Release); - editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]) - }); - editor.handle_input("++++more text++++", window, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint(edited) #0".to_string(), - "other hint(edited) #1".to_string(), - ]; - assert_eq!( - expected_hints, - sorted_cached_hint_labels(editor), - "After multibuffer edit, editor gets scrolled back to the last selection; \ - all hints should be invalidated and required for all of its visible excerpts" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_excerpts_removed(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: false, - show_parameter_hints: false, - show_other_hints: false, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), - "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - let (buffer_1, _handle) = project - .update(cx, |project, cx| { - project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let (buffer_2, _handle2) = project - .update(cx, |project, cx| { - project.open_local_buffer_with_lsp(path!("/a/other.rs"), cx) - }) - .await - .unwrap(); - let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let (buffer_1_excerpts, buffer_2_excerpts) = multibuffer.update(cx, |multibuffer, cx| { - let buffer_1_excerpts = multibuffer.push_excerpts( - buffer_1.clone(), - [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], - cx, - ); - let buffer_2_excerpts = multibuffer.push_excerpts( - buffer_2.clone(), - [ExcerptRange::new(Point::new(0, 1)..Point::new(2, 1))], - cx, - ); - (buffer_1_excerpts, buffer_2_excerpts) - }); - - assert!(!buffer_1_excerpts.is_empty()); - assert!(!buffer_2_excerpts.is_empty()); - - cx.executor().run_until_parked(); - let editor = cx.add_window(|window, cx| { - Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx) - }); - let editor_edited = Arc::new(AtomicBool::new(false)); - let fake_server = fake_servers.next().await.unwrap(); - let closure_editor_edited = Arc::clone(&editor_edited); - fake_server - .set_request_handler::(move |params, _| { - let task_editor_edited = Arc::clone(&closure_editor_edited); - async move { - let hint_text = if params.text_document.uri - == lsp::Url::from_file_path(path!("/a/main.rs")).unwrap() - { - "main hint" - } else if params.text_document.uri - == lsp::Url::from_file_path(path!("/a/other.rs")).unwrap() - { - "other hint" - } else { - panic!("unexpected uri: {:?}", params.text_document.uri); - }; - - let positions = [ - lsp::Position::new(0, 2), - lsp::Position::new(4, 2), - lsp::Position::new(22, 2), - lsp::Position::new(44, 2), - lsp::Position::new(56, 2), - lsp::Position::new(67, 2), - ]; - let out_of_range_hint = lsp::InlayHint { - position: lsp::Position::new( - params.range.start.line + 99, - params.range.start.character + 99, - ), - label: lsp::InlayHintLabel::String( - "out of excerpt range, should be ignored".to_string(), - ), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }; - - let edited = task_editor_edited.load(Ordering::Acquire); - Ok(Some( - std::iter::once(out_of_range_hint) - .chain(positions.into_iter().enumerate().map(|(i, position)| { - lsp::InlayHint { - position, - label: lsp::InlayHintLabel::String(format!( - "{hint_text}{} #{i}", - if edited { "(edited)" } else { "" }, - )), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - } - })) - .collect(), - )) - } - }) - .next() - .await; - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - vec!["main hint #0".to_string(), "other hint #0".to_string()], - sorted_cached_hint_labels(editor), - "Cache should update for both excerpts despite hints display was disabled" - ); - assert!( - visible_hint_labels(editor, cx).is_empty(), - "All hints are disabled and should not be shown despite being present in the cache" - ); - }) - .unwrap(); - - editor - .update(cx, |editor, _, cx| { - editor.buffer().update(cx, |multibuffer, cx| { - multibuffer.remove_excerpts(buffer_2_excerpts, cx) - }) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert_eq!( - vec!["main hint #0".to_string()], - cached_hint_labels(editor), - "For the removed excerpt, should clean corresponding cached hints" - ); - assert!( - visible_hint_labels(editor, cx).is_empty(), - "All hints are disabled and should not be shown despite being present in the cache" - ); - }) - .unwrap(); - - update_test_language_settings(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["main hint #0".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Hint display settings change should not change the cache" - ); - assert_eq!( - expected_hints, - visible_hint_labels(editor, cx), - "Settings change should make cached hints visible" - ); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_inside_char_boundary_range_hints(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": format!(r#"fn main() {{\n{}\n}}"#, format!("let i = {};\n", "√".repeat(10)).repeat(500)), - "other.rs": "// Test file", - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - initializer: Some(Box::new(move |fake_server| { - let lsp_request_count = Arc::new(AtomicU32::new(0)); - fake_server.set_request_handler::( - move |params, _| { - let i = lsp_request_count.fetch_add(1, Ordering::Release) + 1; - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(), - ); - let query_start = params.range.start; - Ok(Some(vec![lsp::InlayHint { - position: query_start, - label: lsp::InlayHintLabel::String(i.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - })), - ..Default::default() - }, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let editor = - cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); - - cx.executor().run_until_parked(); - editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(10, 0)..Point::new(10, 0)]) - }) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!(expected_hints, cached_hint_labels(editor)); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - #[gpui::test] - async fn test_toggle_inlay_hints(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: false, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { - let lsp_request_count = Arc::new(AtomicU32::new(0)); - fake_server.set_request_handler::( - move |params, _| { - let lsp_request_count = lsp_request_count.clone(); - async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(file_with_hints).unwrap(), - ); - - let i = lsp_request_count.fetch_add(1, Ordering::SeqCst) + 1; - Ok(Some(vec![lsp::InlayHint { - position: lsp::Position::new(0, i), - label: lsp::InlayHintLabel::String(i.to_string()), - kind: None, - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }])) - } - }, - ); - }) - .await; - - editor - .update(cx, |editor, window, cx| { - editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) - }) - .unwrap(); - - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["1".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should display inlays after toggle despite them disabled in settings" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert!( - cached_hint_labels(editor).is_empty(), - "Should clear hints after 2nd toggle" - ); - assert!(visible_hint_labels(editor, cx).is_empty()); - }) - .unwrap(); - - update_test_language_settings(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - let expected_hints = vec!["2".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should query LSP hints for the 2nd time after enabling hints in settings" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _, cx| { - assert!( - cached_hint_labels(editor).is_empty(), - "Should clear hints after enabling in settings and a 3rd toggle" - ); - assert!(visible_hint_labels(editor, cx).is_empty()); - }) - .unwrap(); - - editor - .update(cx, |editor, window, cx| { - editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor.update(cx, |editor, _, cx| { - let expected_hints = vec!["3".to_string()]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Should query LSP hints for the 3rd time after enabling hints in settings and toggling them back on" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }).unwrap(); - } - - #[gpui::test] - async fn test_inlays_at_the_same_place(cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings.defaults.inlay_hints = Some(InlayHintSettings { - show_value_hints: true, - enabled: true, - edit_debounce_ms: 0, - scroll_debounce_ms: 0, - show_type_hints: true, - show_parameter_hints: true, - show_other_hints: true, - show_background: false, - toggle_on_modifiers_press: None, - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": "fn main() { - let x = 42; - std::thread::scope(|s| { - s.spawn(|| { - let _x = x; - }); - }); - }", - "other.rs": "// Test file", - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - initializer: Some(Box::new(move |fake_server| { - fake_server.set_request_handler::( - move |params, _| async move { - assert_eq!( - params.text_document.uri, - lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(), - ); - Ok(Some( - serde_json::from_value(json!([ - { - "position": { - "line": 3, - "character": 16 - }, - "label": "move", - "paddingLeft": false, - "paddingRight": false - }, - { - "position": { - "line": 3, - "character": 16 - }, - "label": "(", - "paddingLeft": false, - "paddingRight": false - }, - { - "position": { - "line": 3, - "character": 16 - }, - "label": [ - { - "value": "&x" - } - ], - "paddingLeft": false, - "paddingRight": false, - "data": { - "file_id": 0 - } - }, - { - "position": { - "line": 3, - "character": 16 - }, - "label": ")", - "paddingLeft": false, - "paddingRight": true - }, - // not a correct syntax, but checks that same symbols at the same place - // are not deduplicated - { - "position": { - "line": 3, - "character": 16 - }, - "label": ")", - "paddingLeft": false, - "paddingRight": true - }, - ])) - .unwrap(), - )) - }, - ); - })), - ..FakeLspAdapter::default() - }, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let editor = - cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); - - cx.executor().run_until_parked(); - editor - .update(cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(10, 0)..Point::new(10, 0)]) - }) - }) - .unwrap(); - cx.executor().run_until_parked(); - editor - .update(cx, |editor, _window, cx| { - let expected_hints = vec![ - "move".to_string(), - "(".to_string(), - "&x".to_string(), - ") ".to_string(), - ") ".to_string(), - ]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "Editor inlay hints should repeat server's order when placed at the same spot" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - }) - .unwrap(); - } - - pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - crate::init(cx); - }); - - update_test_language_settings(cx, f); - } - - async fn prepare_test_objects( - cx: &mut TestAppContext, - initialize: impl 'static + Send + Fn(&mut FakeLanguageServer, &'static str) + Send + Sync, - ) -> (&'static str, WindowHandle, FakeLanguageServer) { - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/a"), - json!({ - "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", - "other.rs": "// Test file", - }), - ) - .await; - - let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let file_path = path!("/a/main.rs"); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - initializer: Some(Box::new(move |server| initialize(server, file_path))), - ..Default::default() - }, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/a/main.rs"), cx) - }) - .await - .unwrap(); - let editor = - cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); - - editor - .update(cx, |editor, _, cx| { - assert!(cached_hint_labels(editor).is_empty()); - assert!(visible_hint_labels(editor, cx).is_empty()); - }) - .unwrap(); - - cx.executor().run_until_parked(); - let fake_server = fake_servers.next().await.unwrap(); - (file_path, editor, fake_server) - } - - // Inlay hints in the cache are stored per excerpt as a key, and those keys are guaranteed to be ordered same as in the multi buffer. - // Ensure a stable order for testing. - fn sorted_cached_hint_labels(editor: &Editor) -> Vec { - let mut labels = cached_hint_labels(editor); - labels.sort(); - labels - } - - pub fn cached_hint_labels(editor: &Editor) -> Vec { - let mut labels = Vec::new(); - for excerpt_hints in editor.inlay_hint_cache().hints.values() { - let excerpt_hints = excerpt_hints.read(); - for id in &excerpt_hints.ordered_hints { - let hint = &excerpt_hints.hints_by_id[id]; - let mut label = hint.text().to_string(); - if hint.padding_left { - label.insert(0, ' '); - } - if hint.padding_right { - label.push_str(" "); - } - labels.push(label); - } - } - - labels - } - - pub fn visible_hint_labels(editor: &Editor, cx: &Context) -> Vec { - editor - .visible_inlay_hints(cx) - .into_iter() - .map(|hint| hint.text.to_string()) - .collect() - } -} diff --git a/crates/editor/src/inlays.rs b/crates/editor/src/inlays.rs new file mode 100644 index 0000000000000000000000000000000000000000..f07bf0b315161f0ce9cdf3ef7e2f6db6d60abfb5 --- /dev/null +++ b/crates/editor/src/inlays.rs @@ -0,0 +1,193 @@ +//! The logic, responsible for managing [`Inlay`]s in the editor. +//! +//! Inlays are "not real" text that gets mixed into the "real" buffer's text. +//! They are attached to a certain [`Anchor`], and display certain contents (usually, strings) +//! between real text around that anchor. +//! +//! Inlay examples in Zed: +//! * inlay hints, received from LSP +//! * inline values, shown in the debugger +//! * inline predictions, showing the Zeta/Copilot/etc. predictions +//! * document color values, if configured to be displayed as inlays +//! * ... anything else, potentially. +//! +//! Editor uses [`crate::DisplayMap`] and [`crate::display_map::InlayMap`] to manage what's rendered inside the editor, using +//! [`InlaySplice`] to update this state. + +/// Logic, related to managing LSP inlay hint inlays. +pub mod inlay_hints; + +use std::{any::TypeId, sync::OnceLock}; + +use gpui::{Context, HighlightStyle, Hsla, Rgba, Task}; +use multi_buffer::Anchor; +use project::{InlayHint, InlayId}; +use text::Rope; + +use crate::{Editor, hover_links::InlayHighlight}; + +/// A splice to send into the `inlay_map` for updating the visible inlays on the screen. +/// "Visible" inlays may not be displayed in the buffer right away, but those are ready to be displayed on further buffer scroll, pane item activations, etc. right away without additional LSP queries or settings changes. +/// The data in the cache is never used directly for displaying inlays on the screen, to avoid races with updates from LSP queries and sync overhead. +/// Splice is picked to help avoid extra hint flickering and "jumps" on the screen. +#[derive(Debug, Default)] +pub struct InlaySplice { + pub to_remove: Vec, + pub to_insert: Vec, +} + +impl InlaySplice { + pub fn is_empty(&self) -> bool { + self.to_remove.is_empty() && self.to_insert.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct Inlay { + pub id: InlayId, + pub position: Anchor, + pub content: InlayContent, +} + +#[derive(Debug, Clone)] +pub enum InlayContent { + Text(text::Rope), + Color(Hsla), +} + +impl Inlay { + pub fn hint(id: InlayId, position: Anchor, hint: &InlayHint) -> Self { + let mut text = hint.text(); + if hint.padding_right && text.reversed_chars_at(text.len()).next() != Some(' ') { + text.push(" "); + } + if hint.padding_left && text.chars_at(0).next() != Some(' ') { + text.push_front(" "); + } + Self { + id, + position, + content: InlayContent::Text(text), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn mock_hint(id: usize, position: Anchor, text: impl Into) -> Self { + Self { + id: InlayId::Hint(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn color(id: usize, position: Anchor, color: Rgba) -> Self { + Self { + id: InlayId::Color(id), + position, + content: InlayContent::Color(color.into()), + } + } + + pub fn edit_prediction>(id: usize, position: Anchor, text: T) -> Self { + Self { + id: InlayId::EditPrediction(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn debugger>(id: usize, position: Anchor, text: T) -> Self { + Self { + id: InlayId::DebuggerValue(id), + position, + content: InlayContent::Text(text.into()), + } + } + + pub fn text(&self) -> &Rope { + static COLOR_TEXT: OnceLock = OnceLock::new(); + match &self.content { + InlayContent::Text(text) => text, + InlayContent::Color(_) => COLOR_TEXT.get_or_init(|| Rope::from("◼")), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_color(&self) -> Option { + match self.content { + InlayContent::Color(color) => Some(color), + _ => None, + } + } +} + +pub struct InlineValueCache { + pub enabled: bool, + pub inlays: Vec, + pub refresh_task: Task>, +} + +impl InlineValueCache { + pub fn new(enabled: bool) -> Self { + Self { + enabled, + inlays: Vec::new(), + refresh_task: Task::ready(None), + } + } +} + +impl Editor { + /// Modify which hints are displayed in the editor. + pub fn splice_inlays( + &mut self, + to_remove: &[InlayId], + to_insert: Vec, + cx: &mut Context, + ) { + if let Some(inlay_hints) = &mut self.inlay_hints { + for id_to_remove in to_remove { + inlay_hints.added_hints.remove(id_to_remove); + } + } + self.display_map.update(cx, |display_map, cx| { + display_map.splice_inlays(to_remove, to_insert, cx) + }); + cx.notify(); + } + + pub(crate) fn highlight_inlays( + &mut self, + highlights: Vec, + style: HighlightStyle, + cx: &mut Context, + ) { + self.display_map.update(cx, |map, _| { + map.highlight_inlays(TypeId::of::(), highlights, style) + }); + cx.notify(); + } + + pub fn inline_values_enabled(&self) -> bool { + self.inline_value_cache.enabled + } + + #[cfg(any(test, feature = "test-support"))] + pub fn inline_value_inlays(&self, cx: &gpui::App) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .filter(|inlay| matches!(inlay.id, InlayId::DebuggerValue(_))) + .cloned() + .collect() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn all_inlays(&self, cx: &gpui::App) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .cloned() + .collect() + } +} diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs new file mode 100644 index 0000000000000000000000000000000000000000..74fe9988763b976f315624b8e1ab36110e2137ee --- /dev/null +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -0,0 +1,4059 @@ +use std::{ + collections::hash_map, + ops::{ControlFlow, Range}, + time::Duration, +}; + +use clock::Global; +use collections::{HashMap, HashSet}; +use futures::future::join_all; +use gpui::{App, Entity, Task}; +use language::{ + BufferRow, + language_settings::{InlayHintKind, InlayHintSettings, language_settings}, +}; +use lsp::LanguageServerId; +use multi_buffer::{Anchor, ExcerptId, MultiBufferSnapshot}; +use project::{ + HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip, + InvalidationStrategy, ResolveState, + lsp_store::{CacheInlayHints, ResolvedHint}, +}; +use text::{Bias, BufferId}; +use ui::{Context, Window}; +use util::debug_panic; + +use super::{Inlay, InlayId}; +use crate::{ + Editor, EditorSnapshot, PointForPosition, ToggleInlayHints, ToggleInlineValues, debounce_value, + hover_links::{InlayHighlight, TriggerPoint, show_link_definition}, + hover_popover::{self, InlayHover}, + inlays::InlaySplice, +}; + +pub fn inlay_hint_settings( + location: Anchor, + snapshot: &MultiBufferSnapshot, + cx: &mut Context, +) -> InlayHintSettings { + let file = snapshot.file_at(location); + let language = snapshot.language_at(location).map(|l| l.name()); + language_settings(language, file, cx).inlay_hints +} + +#[derive(Debug)] +pub struct LspInlayHintData { + enabled: bool, + modifiers_override: bool, + enabled_in_settings: bool, + allowed_hint_kinds: HashSet>, + invalidate_debounce: Option, + append_debounce: Option, + hint_refresh_tasks: HashMap>, Vec>>>, + hint_chunk_fetched: HashMap>)>, + invalidate_hints_for_buffers: HashSet, + pub added_hints: HashMap>, +} + +impl LspInlayHintData { + pub fn new(settings: InlayHintSettings) -> Self { + Self { + modifiers_override: false, + enabled: settings.enabled, + enabled_in_settings: settings.enabled, + hint_refresh_tasks: HashMap::default(), + added_hints: HashMap::default(), + hint_chunk_fetched: HashMap::default(), + invalidate_hints_for_buffers: HashSet::default(), + invalidate_debounce: debounce_value(settings.edit_debounce_ms), + append_debounce: debounce_value(settings.scroll_debounce_ms), + allowed_hint_kinds: settings.enabled_inlay_hint_kinds(), + } + } + + pub fn modifiers_override(&mut self, new_override: bool) -> Option { + if self.modifiers_override == new_override { + return None; + } + self.modifiers_override = new_override; + if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) + { + self.clear(); + Some(false) + } else { + Some(true) + } + } + + pub fn toggle(&mut self, enabled: bool) -> bool { + if self.enabled == enabled { + return false; + } + self.enabled = enabled; + self.modifiers_override = false; + if !enabled { + self.clear(); + } + true + } + + pub fn clear(&mut self) { + self.hint_refresh_tasks.clear(); + self.hint_chunk_fetched.clear(); + self.added_hints.clear(); + self.invalidate_hints_for_buffers.clear(); + } + + /// Checks inlay hint settings for enabled hint kinds and general enabled state. + /// Generates corresponding inlay_map splice updates on settings changes. + /// Does not update inlay hint cache state on disabling or inlay hint kinds change: only reenabling forces new LSP queries. + fn update_settings( + &mut self, + new_hint_settings: InlayHintSettings, + visible_hints: Vec, + ) -> ControlFlow, Option> { + let old_enabled = self.enabled; + // If the setting for inlay hints has changed, update `enabled`. This condition avoids inlay + // hint visibility changes when other settings change (such as theme). + // + // Another option might be to store whether the user has manually toggled inlay hint + // visibility, and prefer this. This could lead to confusion as it means inlay hint + // visibility would not change when updating the setting if they were ever toggled. + if new_hint_settings.enabled != self.enabled_in_settings { + self.enabled = new_hint_settings.enabled; + self.enabled_in_settings = new_hint_settings.enabled; + self.modifiers_override = false; + }; + self.invalidate_debounce = debounce_value(new_hint_settings.edit_debounce_ms); + self.append_debounce = debounce_value(new_hint_settings.scroll_debounce_ms); + let new_allowed_hint_kinds = new_hint_settings.enabled_inlay_hint_kinds(); + match (old_enabled, self.enabled) { + (false, false) => { + self.allowed_hint_kinds = new_allowed_hint_kinds; + ControlFlow::Break(None) + } + (true, true) => { + if new_allowed_hint_kinds == self.allowed_hint_kinds { + ControlFlow::Break(None) + } else { + self.allowed_hint_kinds = new_allowed_hint_kinds; + ControlFlow::Continue( + Some(InlaySplice { + to_remove: visible_hints + .iter() + .filter_map(|inlay| { + let inlay_kind = self.added_hints.get(&inlay.id).copied()?; + if !self.allowed_hint_kinds.contains(&inlay_kind) { + Some(inlay.id) + } else { + None + } + }) + .collect(), + to_insert: Vec::new(), + }) + .filter(|splice| !splice.is_empty()), + ) + } + } + (true, false) => { + self.modifiers_override = false; + self.allowed_hint_kinds = new_allowed_hint_kinds; + if visible_hints.is_empty() { + ControlFlow::Break(None) + } else { + self.clear(); + ControlFlow::Break(Some(InlaySplice { + to_remove: visible_hints.iter().map(|inlay| inlay.id).collect(), + to_insert: Vec::new(), + })) + } + } + (false, true) => { + self.modifiers_override = false; + self.allowed_hint_kinds = new_allowed_hint_kinds; + ControlFlow::Continue( + Some(InlaySplice { + to_remove: visible_hints + .iter() + .filter_map(|inlay| { + let inlay_kind = self.added_hints.get(&inlay.id).copied()?; + if !self.allowed_hint_kinds.contains(&inlay_kind) { + Some(inlay.id) + } else { + None + } + }) + .collect(), + to_insert: Vec::new(), + }) + .filter(|splice| !splice.is_empty()), + ) + } + } + } + + pub(crate) fn remove_inlay_chunk_data<'a>( + &'a mut self, + removed_buffer_ids: impl IntoIterator + 'a, + ) { + for buffer_id in removed_buffer_ids { + self.hint_refresh_tasks.remove(buffer_id); + self.hint_chunk_fetched.remove(buffer_id); + } + } +} + +#[derive(Debug, Clone)] +pub enum InlayHintRefreshReason { + ModifiersChanged(bool), + Toggle(bool), + SettingsChange(InlayHintSettings), + NewLinesShown, + BufferEdited(BufferId), + RefreshRequested(LanguageServerId), + ExcerptsRemoved(Vec), +} + +impl Editor { + pub fn supports_inlay_hints(&self, cx: &mut App) -> bool { + let Some(provider) = self.semantics_provider.as_ref() else { + return false; + }; + + let mut supports = false; + self.buffer().update(cx, |this, cx| { + this.for_each_buffer(|buffer| { + supports |= provider.supports_inlay_hints(buffer, cx); + }); + }); + + supports + } + + pub fn toggle_inline_values( + &mut self, + _: &ToggleInlineValues, + _: &mut Window, + cx: &mut Context, + ) { + self.inline_value_cache.enabled = !self.inline_value_cache.enabled; + + self.refresh_inline_values(cx); + } + + pub fn toggle_inlay_hints( + &mut self, + _: &ToggleInlayHints, + _: &mut Window, + cx: &mut Context, + ) { + self.refresh_inlay_hints( + InlayHintRefreshReason::Toggle(!self.inlay_hints_enabled()), + cx, + ); + } + + pub fn inlay_hints_enabled(&self) -> bool { + self.inlay_hints.as_ref().is_some_and(|cache| cache.enabled) + } + + /// Updates inlay hints for the visible ranges of the singleton buffer(s). + /// Based on its parameters, either invalidates the previous data, or appends to it. + pub(crate) fn refresh_inlay_hints( + &mut self, + reason: InlayHintRefreshReason, + cx: &mut Context, + ) { + if !self.mode.is_full() || self.inlay_hints.is_none() { + return; + } + let Some(semantics_provider) = self.semantics_provider() else { + return; + }; + let Some(invalidate_cache) = self.refresh_editor_data(&reason, cx) else { + return; + }; + + let debounce = match &reason { + InlayHintRefreshReason::SettingsChange(_) + | InlayHintRefreshReason::Toggle(_) + | InlayHintRefreshReason::ExcerptsRemoved(_) + | InlayHintRefreshReason::ModifiersChanged(_) => None, + _may_need_lsp_call => self.inlay_hints.as_ref().and_then(|inlay_hints| { + if invalidate_cache.should_invalidate() { + inlay_hints.invalidate_debounce + } else { + inlay_hints.append_debounce + } + }), + }; + + let mut visible_excerpts = self.visible_excerpts(cx); + let mut invalidate_hints_for_buffers = HashSet::default(); + let ignore_previous_fetches = match reason { + InlayHintRefreshReason::ModifiersChanged(_) + | InlayHintRefreshReason::Toggle(_) + | InlayHintRefreshReason::SettingsChange(_) => true, + InlayHintRefreshReason::NewLinesShown + | InlayHintRefreshReason::RefreshRequested(_) + | InlayHintRefreshReason::ExcerptsRemoved(_) => false, + InlayHintRefreshReason::BufferEdited(buffer_id) => { + let Some(affected_language) = self + .buffer() + .read(cx) + .buffer(buffer_id) + .and_then(|buffer| buffer.read(cx).language().cloned()) + else { + return; + }; + + invalidate_hints_for_buffers.extend( + self.buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter_map(|buffer| { + let buffer = buffer.read(cx); + if buffer.language() == Some(&affected_language) { + Some(buffer.remote_id()) + } else { + None + } + }), + ); + + semantics_provider.invalidate_inlay_hints(&invalidate_hints_for_buffers, cx); + visible_excerpts.retain(|_, (visible_buffer, _, _)| { + visible_buffer.read(cx).language() == Some(&affected_language) + }); + false + } + }; + + let multi_buffer = self.buffer().clone(); + let Some(inlay_hints) = self.inlay_hints.as_mut() else { + return; + }; + + if invalidate_cache.should_invalidate() { + inlay_hints.clear(); + } + inlay_hints + .invalidate_hints_for_buffers + .extend(invalidate_hints_for_buffers); + + let mut buffers_to_query = HashMap::default(); + for (_, (buffer, buffer_version, visible_range)) in visible_excerpts { + let buffer_id = buffer.read(cx).remote_id(); + if !self.registered_buffers.contains_key(&buffer_id) { + continue; + } + + let buffer_snapshot = buffer.read(cx).snapshot(); + let buffer_anchor_range = buffer_snapshot.anchor_before(visible_range.start) + ..buffer_snapshot.anchor_after(visible_range.end); + + let visible_excerpts = + buffers_to_query + .entry(buffer_id) + .or_insert_with(|| VisibleExcerpts { + ranges: Vec::new(), + buffer_version: buffer_version.clone(), + buffer: buffer.clone(), + }); + visible_excerpts.buffer_version = buffer_version; + visible_excerpts.ranges.push(buffer_anchor_range); + } + + for (buffer_id, visible_excerpts) in buffers_to_query { + let Some(buffer) = multi_buffer.read(cx).buffer(buffer_id) else { + continue; + }; + let fetched_tasks = inlay_hints.hint_chunk_fetched.entry(buffer_id).or_default(); + if visible_excerpts + .buffer_version + .changed_since(&fetched_tasks.0) + { + fetched_tasks.1.clear(); + fetched_tasks.0 = visible_excerpts.buffer_version.clone(); + inlay_hints.hint_refresh_tasks.remove(&buffer_id); + } + + let applicable_chunks = + semantics_provider.applicable_inlay_chunks(&buffer, &visible_excerpts.ranges, cx); + + match inlay_hints + .hint_refresh_tasks + .entry(buffer_id) + .or_default() + .entry(applicable_chunks) + { + hash_map::Entry::Occupied(mut o) => { + if invalidate_cache.should_invalidate() || ignore_previous_fetches { + o.get_mut().push(spawn_editor_hints_refresh( + buffer_id, + invalidate_cache, + ignore_previous_fetches, + debounce, + visible_excerpts, + cx, + )); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(Vec::new()).push(spawn_editor_hints_refresh( + buffer_id, + invalidate_cache, + ignore_previous_fetches, + debounce, + visible_excerpts, + cx, + )); + } + } + } + } + + pub fn clear_inlay_hints(&mut self, cx: &mut Context) { + let to_remove = self + .visible_inlay_hints(cx) + .into_iter() + .map(|inlay| { + let inlay_id = inlay.id; + if let Some(inlay_hints) = &mut self.inlay_hints { + inlay_hints.added_hints.remove(&inlay_id); + } + inlay_id + }) + .collect::>(); + self.splice_inlays(&to_remove, Vec::new(), cx); + } + + fn refresh_editor_data( + &mut self, + reason: &InlayHintRefreshReason, + cx: &mut Context<'_, Editor>, + ) -> Option { + let visible_inlay_hints = self.visible_inlay_hints(cx); + let Some(inlay_hints) = self.inlay_hints.as_mut() else { + return None; + }; + + let invalidate_cache = match reason { + InlayHintRefreshReason::ModifiersChanged(enabled) => { + match inlay_hints.modifiers_override(*enabled) { + Some(enabled) => { + if enabled { + InvalidationStrategy::None + } else { + self.clear_inlay_hints(cx); + return None; + } + } + None => return None, + } + } + InlayHintRefreshReason::Toggle(enabled) => { + if inlay_hints.toggle(*enabled) { + if *enabled { + InvalidationStrategy::None + } else { + self.clear_inlay_hints(cx); + return None; + } + } else { + return None; + } + } + InlayHintRefreshReason::SettingsChange(new_settings) => { + match inlay_hints.update_settings(*new_settings, visible_inlay_hints) { + ControlFlow::Break(Some(InlaySplice { + to_remove, + to_insert, + })) => { + self.splice_inlays(&to_remove, to_insert, cx); + return None; + } + ControlFlow::Break(None) => return None, + ControlFlow::Continue(splice) => { + if let Some(InlaySplice { + to_remove, + to_insert, + }) = splice + { + self.splice_inlays(&to_remove, to_insert, cx); + } + InvalidationStrategy::None + } + } + } + InlayHintRefreshReason::ExcerptsRemoved(excerpts_removed) => { + let to_remove = self + .display_map + .read(cx) + .current_inlays() + .filter_map(|inlay| { + if excerpts_removed.contains(&inlay.position.excerpt_id) { + Some(inlay.id) + } else { + None + } + }) + .collect::>(); + self.splice_inlays(&to_remove, Vec::new(), cx); + return None; + } + InlayHintRefreshReason::NewLinesShown => InvalidationStrategy::None, + InlayHintRefreshReason::BufferEdited(_) => InvalidationStrategy::BufferEdited, + InlayHintRefreshReason::RefreshRequested(server_id) => { + InvalidationStrategy::RefreshRequested(*server_id) + } + }; + + match &mut self.inlay_hints { + Some(inlay_hints) => { + if !inlay_hints.enabled + && !matches!(reason, InlayHintRefreshReason::ModifiersChanged(_)) + { + return None; + } + } + None => return None, + } + + Some(invalidate_cache) + } + + pub(crate) fn visible_inlay_hints(&self, cx: &Context) -> Vec { + self.display_map + .read(cx) + .current_inlays() + .filter(move |inlay| matches!(inlay.id, InlayId::Hint(_))) + .cloned() + .collect() + } + + pub fn update_inlay_link_and_hover_points( + &mut self, + snapshot: &EditorSnapshot, + point_for_position: PointForPosition, + secondary_held: bool, + shift_held: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(lsp_store) = self.project().map(|project| project.read(cx).lsp_store()) else { + return; + }; + let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 { + Some( + snapshot + .display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left), + ) + } else { + None + }; + let mut go_to_definition_updated = false; + let mut hover_updated = false; + if let Some(hovered_offset) = hovered_offset { + let buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let previous_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.previous_valid.to_point(snapshot), + Bias::Left, + ); + let next_valid_anchor = buffer_snapshot.anchor_at( + point_for_position.next_valid.to_point(snapshot), + Bias::Right, + ); + if let Some(hovered_hint) = self + .visible_inlay_hints(cx) + .into_iter() + .skip_while(|hint| { + hint.position + .cmp(&previous_valid_anchor, &buffer_snapshot) + .is_lt() + }) + .take_while(|hint| { + hint.position + .cmp(&next_valid_anchor, &buffer_snapshot) + .is_le() + }) + .max_by_key(|hint| hint.id) + { + if let Some(ResolvedHint::Resolved(cached_hint)) = + hovered_hint.position.buffer_id.and_then(|buffer_id| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.resolved_hint(buffer_id, hovered_hint.id, cx) + }) + }) + { + match cached_hint.resolve_state { + ResolveState::Resolved => { + let mut extra_shift_left = 0; + let mut extra_shift_right = 0; + if cached_hint.padding_left { + extra_shift_left += 1; + extra_shift_right += 1; + } + if cached_hint.padding_right { + extra_shift_right += 1; + } + match cached_hint.label { + InlayHintLabel::String(_) => { + if let Some(tooltip) = cached_hint.tooltip { + hover_popover::hover_at_inlay( + self, + InlayHover { + tooltip: match tooltip { + InlayHintTooltip::String(text) => HoverBlock { + text, + kind: HoverBlockKind::PlainText, + }, + InlayHintTooltip::MarkupContent(content) => { + HoverBlock { + text: content.value, + kind: content.kind, + } + } + }, + range: InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: extra_shift_left + ..hovered_hint.text().len() + + extra_shift_right, + }, + }, + window, + cx, + ); + hover_updated = true; + } + } + InlayHintLabel::LabelParts(label_parts) => { + let hint_start = + snapshot.anchor_to_inlay_offset(hovered_hint.position); + if let Some((hovered_hint_part, part_range)) = + hover_popover::find_hovered_hint_part( + label_parts, + hint_start, + hovered_offset, + ) + { + let highlight_start = + (part_range.start - hint_start).0 + extra_shift_left; + let highlight_end = + (part_range.end - hint_start).0 + extra_shift_right; + let highlight = InlayHighlight { + inlay: hovered_hint.id, + inlay_position: hovered_hint.position, + range: highlight_start..highlight_end, + }; + if let Some(tooltip) = hovered_hint_part.tooltip { + hover_popover::hover_at_inlay( + self, + InlayHover { + tooltip: match tooltip { + InlayHintLabelPartTooltip::String(text) => { + HoverBlock { + text, + kind: HoverBlockKind::PlainText, + } + } + InlayHintLabelPartTooltip::MarkupContent( + content, + ) => HoverBlock { + text: content.value, + kind: content.kind, + }, + }, + range: highlight.clone(), + }, + window, + cx, + ); + hover_updated = true; + } + if let Some((language_server_id, location)) = + hovered_hint_part.location + && secondary_held + && !self.has_pending_nonempty_selection() + { + go_to_definition_updated = true; + show_link_definition( + shift_held, + self, + TriggerPoint::InlayHint( + highlight, + location, + language_server_id, + ), + snapshot, + window, + cx, + ); + } + } + } + }; + } + ResolveState::CanResolve(_, _) => debug_panic!( + "Expected resolved_hint retrieval to return a resolved hint" + ), + ResolveState::Resolving => {} + } + } + } + } + + if !go_to_definition_updated { + self.hide_hovered_link(cx) + } + if !hover_updated { + hover_popover::hover_at(self, None, window, cx); + } + } + + fn inlay_hints_for_buffer( + &mut self, + invalidate_cache: InvalidationStrategy, + ignore_previous_fetches: bool, + buffer_excerpts: VisibleExcerpts, + cx: &mut Context, + ) -> Option, anyhow::Result)>>> { + let semantics_provider = self.semantics_provider()?; + let inlay_hints = self.inlay_hints.as_mut()?; + let buffer_id = buffer_excerpts.buffer.read(cx).remote_id(); + + let new_hint_tasks = semantics_provider + .inlay_hints( + invalidate_cache, + buffer_excerpts.buffer, + buffer_excerpts.ranges, + inlay_hints + .hint_chunk_fetched + .get(&buffer_id) + .filter(|_| !ignore_previous_fetches && !invalidate_cache.should_invalidate()) + .cloned(), + cx, + ) + .unwrap_or_default(); + + let (known_version, known_chunks) = + inlay_hints.hint_chunk_fetched.entry(buffer_id).or_default(); + if buffer_excerpts.buffer_version.changed_since(known_version) { + known_chunks.clear(); + *known_version = buffer_excerpts.buffer_version; + } + + let mut hint_tasks = Vec::new(); + for (row_range, new_hints_task) in new_hint_tasks { + let inserted = known_chunks.insert(row_range.clone()); + if inserted || ignore_previous_fetches || invalidate_cache.should_invalidate() { + hint_tasks.push(cx.spawn(async move |_, _| (row_range, new_hints_task.await))); + } + } + + Some(hint_tasks) + } + + fn apply_fetched_hints( + &mut self, + buffer_id: BufferId, + query_version: Global, + invalidate_cache: InvalidationStrategy, + new_hints: Vec<(Range, anyhow::Result)>, + cx: &mut Context, + ) { + let visible_inlay_hint_ids = self + .visible_inlay_hints(cx) + .iter() + .filter(|inlay| inlay.position.buffer_id == Some(buffer_id)) + .map(|inlay| inlay.id) + .collect::>(); + let Some(inlay_hints) = &mut self.inlay_hints else { + return; + }; + + let mut hints_to_remove = Vec::new(); + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + + // If we've received hints from the cache, it means `invalidate_cache` had invalidated whatever possible there, + // and most probably there are no more hints with IDs from `visible_inlay_hint_ids` in the cache. + // So, if we hover such hints, no resolve will happen. + // + // Another issue is in the fact that changing one buffer may lead to other buffers' hints changing, so more cache entries may be removed. + // Hence, clear all excerpts' hints in the multi buffer: later, the invalidated ones will re-trigger the LSP query, the rest will be restored + // from the cache. + if invalidate_cache.should_invalidate() { + hints_to_remove.extend(visible_inlay_hint_ids); + } + + let excerpts = self.buffer.read(cx).excerpt_ids(); + let hints_to_insert = new_hints + .into_iter() + .filter_map(|(chunk_range, hints_result)| match hints_result { + Ok(new_hints) => Some(new_hints), + Err(e) => { + log::error!( + "Failed to query inlays for buffer row range {chunk_range:?}, {e:#}" + ); + if let Some((for_version, chunks_fetched)) = + inlay_hints.hint_chunk_fetched.get_mut(&buffer_id) + { + if for_version == &query_version { + chunks_fetched.remove(&chunk_range); + } + } + None + } + }) + .flat_map(|hints| hints.into_values()) + .flatten() + .filter_map(|(hint_id, lsp_hint)| { + if inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) + && inlay_hints + .added_hints + .insert(hint_id, lsp_hint.kind) + .is_none() + { + let position = excerpts.iter().find_map(|excerpt_id| { + multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, lsp_hint.position) + })?; + return Some(Inlay::hint(hint_id, position, &lsp_hint)); + } + None + }) + .collect::>(); + + let invalidate_hints_for_buffers = + std::mem::take(&mut inlay_hints.invalidate_hints_for_buffers); + if !invalidate_hints_for_buffers.is_empty() { + hints_to_remove.extend( + self.visible_inlay_hints(cx) + .iter() + .filter(|inlay| { + inlay.position.buffer_id.is_none_or(|buffer_id| { + invalidate_hints_for_buffers.contains(&buffer_id) + }) + }) + .map(|inlay| inlay.id), + ); + } + + self.splice_inlays(&hints_to_remove, hints_to_insert, cx); + } +} + +#[derive(Debug)] +struct VisibleExcerpts { + ranges: Vec>, + buffer_version: Global, + buffer: Entity, +} + +fn spawn_editor_hints_refresh( + buffer_id: BufferId, + invalidate_cache: InvalidationStrategy, + ignore_previous_fetches: bool, + debounce: Option, + buffer_excerpts: VisibleExcerpts, + cx: &mut Context<'_, Editor>, +) -> Task<()> { + cx.spawn(async move |editor, cx| { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + + let query_version = buffer_excerpts.buffer_version.clone(); + let Some(hint_tasks) = editor + .update(cx, |editor, cx| { + editor.inlay_hints_for_buffer( + invalidate_cache, + ignore_previous_fetches, + buffer_excerpts, + cx, + ) + }) + .ok() + else { + return; + }; + let hint_tasks = hint_tasks.unwrap_or_default(); + if hint_tasks.is_empty() { + return; + } + let new_hints = join_all(hint_tasks).await; + editor + .update(cx, |editor, cx| { + editor.apply_fetched_hints( + buffer_id, + query_version, + invalidate_cache, + new_hints, + cx, + ); + }) + .ok(); + }) +} + +#[cfg(test)] +pub mod tests { + use crate::editor_tests::update_test_language_settings; + use crate::inlays::inlay_hints::InlayHintRefreshReason; + use crate::scroll::ScrollAmount; + use crate::{Editor, SelectionEffects}; + use crate::{ExcerptRange, scroll::Autoscroll}; + use collections::HashSet; + use futures::{StreamExt, future}; + use gpui::{AppContext as _, Context, SemanticVersion, TestAppContext, WindowHandle}; + use itertools::Itertools as _; + use language::language_settings::InlayHintKind; + use language::{Capability, FakeLspAdapter}; + use language::{Language, LanguageConfig, LanguageMatcher}; + use languages::rust_lang; + use lsp::FakeLanguageServer; + use multi_buffer::MultiBuffer; + use parking_lot::Mutex; + use pretty_assertions::assert_eq; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::{AllLanguageSettingsContent, InlayHintSettingsContent, SettingsStore}; + use std::ops::Range; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; + use std::time::Duration; + use text::{OffsetRangeExt, Point}; + use ui::App; + use util::path; + use util::paths::natural_sort; + + #[gpui::test] + async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) { + let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(allowed_hint_kinds.contains(&Some(InlayHintKind::Type))), + show_parameter_hints: Some( + allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), + ), + show_other_hints: Some(allowed_hint_kinds.contains(&None)), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let task_lsp_request_count = Arc::clone(&lsp_request_count); + async move { + let i = task_lsp_request_count.fetch_add(1, Ordering::Release) + 1; + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, + "Cache should use editor settings to get the allowed hint kinds" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([13..13]) + }); + editor.handle_input("some change", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get new hints after an edit" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, + "Cache should use editor settings to get the allowed hint kinds" + ); + }) + .unwrap(); + + fake_server + .request::(()) + .await + .into_response() + .expect("inlay refresh request failed"); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["3".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get new hints after hint refresh/ request" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, + "Cache should use editor settings to get the allowed hint kinds" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_racy_cache_updates(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let task_lsp_request_count = Arc::clone(&lsp_request_count); + async move { + let i = task_lsp_request_count.fetch_add(1, Ordering::Release) + 1; + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + // Emulate simultaneous events: both editing, refresh and, slightly after, scroll updates are triggered. + editor + .update(cx, |editor, window, cx| { + editor.handle_input("foo", window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(5)); + editor + .update(cx, |editor, _window, cx| { + editor.refresh_inlay_hints( + InlayHintRefreshReason::RefreshRequested(fake_server.server.server_id()), + cx, + ); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(5)); + editor + .update(cx, |editor, _window, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!(expected_hints, cached_hint_labels(editor, cx), "Despite multiple simultaneous refreshes, only one inlay hint query should be issued"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_cache_update_on_lsp_completion_tasks(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let task_lsp_request_count = Arc::clone(&lsp_request_count); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + let current_call_id = + Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, current_call_id), + label: lsp::InlayHintLabel::String(current_call_id.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["0".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + let progress_token = 42; + fake_server + .request::(lsp::WorkDoneProgressCreateParams { + token: lsp::ProgressToken::Number(progress_token), + }) + .await + .into_response() + .expect("work done progress create request failed"); + cx.executor().run_until_parked(); + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::Number(progress_token), + value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( + lsp::WorkDoneProgressBegin::default(), + )), + }); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["0".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should not update hints while the work task is running" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::Number(progress_token), + value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( + lsp::WorkDoneProgressEnd::default(), + )), + }); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "New hints should be queried after the work task is done" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_no_hint_updates_for_unrelated_language_files(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", + "other.md": "Test md file with some text", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let mut rs_fake_servers = None; + let mut md_fake_servers = None; + for (name, path_suffix) in [("Rust", "rs"), ("Markdown", "md")] { + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ))); + let fake_servers = language_registry.register_fake_lsp( + name, + FakeLspAdapter { + name, + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + initializer: Some(Box::new({ + move |fake_server| { + let rs_lsp_request_count = Arc::new(AtomicU32::new(0)); + let md_lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server + .set_request_handler::( + move |params, _| { + let i = match name { + "Rust" => { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")) + .unwrap(), + ); + rs_lsp_request_count.fetch_add(1, Ordering::Release) + + 1 + } + "Markdown" => { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/other.md")) + .unwrap(), + ); + md_lsp_request_count.fetch_add(1, Ordering::Release) + + 1 + } + unexpected => { + panic!("Unexpected language: {unexpected}") + } + }; + + async move { + let query_start = params.range.start; + Ok(Some(vec![lsp::InlayHint { + position: query_start, + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..Default::default() + }, + ); + match name { + "Rust" => rs_fake_servers = Some(fake_servers), + "Markdown" => md_fake_servers = Some(fake_servers), + _ => unreachable!(), + } + } + + let rs_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let rs_editor = cx.add_window(|window, cx| { + Editor::for_buffer(rs_buffer, Some(project.clone()), window, cx) + }); + cx.executor().run_until_parked(); + + let _rs_fake_server = rs_fake_servers.unwrap().next().await.unwrap(); + cx.executor().run_until_parked(); + rs_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + cx.executor().run_until_parked(); + let md_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/other.md"), cx) + }) + .await + .unwrap(); + let md_editor = + cx.add_window(|window, cx| Editor::for_buffer(md_buffer, Some(project), window, cx)); + cx.executor().run_until_parked(); + + let _md_fake_server = md_fake_servers.unwrap().next().await.unwrap(); + cx.executor().run_until_parked(); + md_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Markdown editor should have a separate version, repeating Rust editor rules" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + rs_editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([13..13]) + }); + editor.handle_input("some rs change", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + rs_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Rust inlay cache should change after the edit" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + md_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Markdown editor should not be affected by Rust editor changes" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + md_editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([13..13]) + }); + editor.handle_input("some md change", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + md_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Rust editor should not be affected by Markdown editor changes" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + rs_editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Markdown editor should also change independently" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_hint_setting_changes(cx: &mut gpui::TestAppContext) { + let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(allowed_hint_kinds.contains(&Some(InlayHintKind::Type))), + show_parameter_hints: Some( + allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), + ), + show_other_hints: Some(allowed_hint_kinds.contains(&None)), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let lsp_request_count = Arc::new(AtomicUsize::new(0)); + let (_, editor, fake_server) = prepare_test_objects(cx, { + let lsp_request_count = lsp_request_count.clone(); + move |fake_server, file_with_hints| { + let lsp_request_count = lsp_request_count.clone(); + fake_server.set_request_handler::( + move |params, _| { + lsp_request_count.fetch_add(1, Ordering::Release); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(0, 1), + label: lsp::InlayHintLabel::String("type hint".to_string()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(0, 2), + label: lsp::InlayHintLabel::String( + "parameter hint".to_string(), + ), + kind: Some(lsp::InlayHintKind::PARAMETER), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(0, 3), + label: lsp::InlayHintLabel::String("other hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } + }, + ); + } + }) + .await; + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 1, + "Should query new hints once" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Should get its first hints when opening the editor" + ); + assert_eq!( + vec!["type hint".to_string(), "other hint".to_string()], + visible_hint_labels(editor, cx) + ); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + allowed_hint_kinds, + "Cache should use editor settings to get the allowed hint kinds" + ); + }) + .unwrap(); + + fake_server + .request::(()) + .await + .into_response() + .expect("inlay refresh request failed"); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should load new hints twice" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Cached hints should not change due to allowed hint kinds settings update" + ); + assert_eq!( + vec!["type hint".to_string(), "other hint".to_string()], + visible_hint_labels(editor, cx) + ); + }) + .unwrap(); + + for (new_allowed_hint_kinds, expected_visible_hints) in [ + (HashSet::from_iter([None]), vec!["other hint".to_string()]), + ( + HashSet::from_iter([Some(InlayHintKind::Type)]), + vec!["type hint".to_string()], + ), + ( + HashSet::from_iter([Some(InlayHintKind::Parameter)]), + vec!["parameter hint".to_string()], + ), + ( + HashSet::from_iter([None, Some(InlayHintKind::Type)]), + vec!["type hint".to_string(), "other hint".to_string()], + ), + ( + HashSet::from_iter([None, Some(InlayHintKind::Parameter)]), + vec!["parameter hint".to_string(), "other hint".to_string()], + ), + ( + HashSet::from_iter([Some(InlayHintKind::Type), Some(InlayHintKind::Parameter)]), + vec!["type hint".to_string(), "parameter hint".to_string()], + ), + ( + HashSet::from_iter([ + None, + Some(InlayHintKind::Type), + Some(InlayHintKind::Parameter), + ]), + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + ), + ] { + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some( + new_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), + ), + show_parameter_hints: Some( + new_allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), + ), + show_other_hints: Some(new_allowed_hint_kinds.contains(&None)), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + cx.executor().run_until_parked(); + editor.update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should not load new hints on allowed hint kinds change for hint kinds {new_allowed_hint_kinds:?}" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Should get its cached hints unchanged after the settings change for hint kinds {new_allowed_hint_kinds:?}" + ); + assert_eq!( + expected_visible_hints, + visible_hint_labels(editor, cx), + "Should get its visible hints filtered after the settings change for hint kinds {new_allowed_hint_kinds:?}" + ); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + new_allowed_hint_kinds, + "Cache should use editor settings to get the allowed hint kinds for hint kinds {new_allowed_hint_kinds:?}" + ); + }).unwrap(); + } + + let another_allowed_hint_kinds = HashSet::from_iter([Some(InlayHintKind::Type)]); + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(false), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some( + another_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), + ), + show_parameter_hints: Some( + another_allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), + ), + show_other_hints: Some(another_allowed_hint_kinds.contains(&None)), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should not load new hints when hints got disabled" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Should not clear the cache when hints got disabled" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Should clear visible hints when hints got disabled" + ); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + another_allowed_hint_kinds, + "Should update its allowed hint kinds even when hints got disabled" + ); + }) + .unwrap(); + + fake_server + .request::(()) + .await + .into_response() + .expect("inlay refresh request failed"); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should not load new hints when they got disabled" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx) + ); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + + let final_allowed_hint_kinds = HashSet::from_iter([Some(InlayHintKind::Parameter)]); + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some( + final_allowed_hint_kinds.contains(&Some(InlayHintKind::Type)), + ), + show_parameter_hints: Some( + final_allowed_hint_kinds.contains(&Some(InlayHintKind::Parameter)), + ), + show_other_hints: Some(final_allowed_hint_kinds.contains(&None)), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should not query for new hints when they got re-enabled, as the file version did not change" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + "Should get its cached hints fully repopulated after the hints got re-enabled" + ); + assert_eq!( + vec!["parameter hint".to_string()], + visible_hint_labels(editor, cx), + "Should get its visible hints repopulated and filtered after the h" + ); + assert_eq!( + allowed_hint_kinds_for_editor(editor), + final_allowed_hint_kinds, + "Cache should update editor settings when hints got re-enabled" + ); + }) + .unwrap(); + + fake_server + .request::(()) + .await + .into_response() + .expect("inlay refresh request failed"); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 3, + "Should query for new hints again" + ); + assert_eq!( + vec![ + "type hint".to_string(), + "parameter hint".to_string(), + "other hint".to_string(), + ], + cached_hint_labels(editor, cx), + ); + assert_eq!( + vec!["parameter hint".to_string()], + visible_hint_labels(editor, cx), + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_hint_request_cancellation(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let lsp_request_count = Arc::new(AtomicU32::new(0)); + let (_, editor, _) = prepare_test_objects(cx, { + let lsp_request_count = lsp_request_count.clone(); + move |fake_server, file_with_hints| { + let lsp_request_count = lsp_request_count.clone(); + fake_server.set_request_handler::( + move |params, _| { + let lsp_request_count = lsp_request_count.clone(); + async move { + let i = lsp_request_count.fetch_add(1, Ordering::SeqCst) + 1; + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + }) + .await; + + let mut expected_changes = Vec::new(); + for change_after_opening in [ + "initial change #1", + "initial change #2", + "initial change #3", + ] { + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([13..13]) + }); + editor.handle_input(change_after_opening, window, cx); + }) + .unwrap(); + expected_changes.push(change_after_opening); + } + + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + let current_text = editor.text(cx); + for change in &expected_changes { + assert!( + current_text.contains(change), + "Should apply all changes made" + ); + } + assert_eq!( + lsp_request_count.load(Ordering::Relaxed), + 2, + "Should query new hints twice: for editor init and for the last edit that interrupted all others" + ); + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get hints from the last edit landed only" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + let mut edits = Vec::new(); + for async_later_change in [ + "another change #1", + "another change #2", + "another change #3", + ] { + expected_changes.push(async_later_change); + let task_editor = editor; + edits.push(cx.spawn(|mut cx| async move { + task_editor + .update(&mut cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([13..13]) + }); + editor.handle_input(async_later_change, window, cx); + }) + .unwrap(); + })); + } + let _ = future::join_all(edits).await; + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + let current_text = editor.text(cx); + for change in &expected_changes { + assert!( + current_text.contains(change), + "Should apply all changes made" + ); + } + assert_eq!( + lsp_request_count.load(Ordering::SeqCst), + 3, + "Should query new hints one more time, for the last edit only" + ); + let expected_hints = vec!["3".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should get hints from the last edit landed only" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test(iterations = 4)] + async fn test_large_buffer_inlay_requests_split(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", "let i = 5;\n".repeat(500)), + "other.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let lsp_request_ranges = Arc::new(Mutex::new(Vec::new())); + let lsp_request_count = Arc::new(AtomicUsize::new(0)); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new({ + let lsp_request_ranges = lsp_request_ranges.clone(); + let lsp_request_count = lsp_request_count.clone(); + move |fake_server| { + let closure_lsp_request_ranges = Arc::clone(&lsp_request_ranges); + let closure_lsp_request_count = Arc::clone(&lsp_request_count); + fake_server.set_request_handler::( + move |params, _| { + let task_lsp_request_ranges = + Arc::clone(&closure_lsp_request_ranges); + let task_lsp_request_count = Arc::clone(&closure_lsp_request_count); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + + task_lsp_request_ranges.lock().push(params.range); + task_lsp_request_count.fetch_add(1, Ordering::Release); + Ok(Some(vec![lsp::InlayHint { + position: params.range.start, + label: lsp::InlayHintLabel::String( + params.range.end.line.to_string(), + ), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor = + cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); + cx.executor().run_until_parked(); + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let ranges = lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|r| r.start) + .collect::>(); + assert_eq!( + ranges.len(), + 1, + "Should query 1 range initially, but got: {ranges:?}" + ); + + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); + }) + .unwrap(); + // Wait for the first hints request to fire off + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + let visible_range_after_scrolls = editor_visible_range(&editor, cx); + let visible_line_count = editor + .update(cx, |editor, _window, _| { + editor.visible_line_count().unwrap() + }) + .unwrap(); + let selection_in_cached_range = editor + .update(cx, |editor, _window, cx| { + let ranges = lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|r| r.start) + .collect::>(); + assert_eq!( + ranges.len(), + 2, + "Should query 2 ranges after both scrolls, but got: {ranges:?}" + ); + let first_scroll = &ranges[0]; + let second_scroll = &ranges[1]; + assert_eq!( + first_scroll.end.line, second_scroll.start.line, + "Should query 2 adjacent ranges after the scrolls, but got: {ranges:?}" + ); + + let lsp_requests = lsp_request_count.load(Ordering::Acquire); + assert_eq!( + lsp_requests, 3, + "Should query hints initially, and after each scroll (2 times)" + ); + assert_eq!( + vec!["50".to_string(), "100".to_string(), "150".to_string()], + cached_hint_labels(editor, cx), + "Chunks of 50 line width should have been queried each time" + ); + assert_eq!( + vec!["50".to_string(), "100".to_string(), "150".to_string()], + visible_hint_labels(editor, cx), + "Editor should show only hints that it's scrolled to" + ); + + let mut selection_in_cached_range = visible_range_after_scrolls.end; + selection_in_cached_range.row -= visible_line_count.ceil() as u32; + selection_in_cached_range + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |s| s.select_ranges([selection_in_cached_range..selection_in_cached_range]), + ); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor.update(cx, |_, _, _| { + let ranges = lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|r| r.start) + .collect::>(); + assert!(ranges.is_empty(), "No new ranges or LSP queries should be made after returning to the selection with cached hints"); + assert_eq!(lsp_request_count.load(Ordering::Acquire), 3, "No new requests should be made when selecting within cached chunks"); + }).unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.handle_input("++++more text++++", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor.update(cx, |editor, _window, cx| { + let mut ranges = lsp_request_ranges.lock().drain(..).collect::>(); + ranges.sort_by_key(|r| r.start); + + assert_eq!(ranges.len(), 2, + "On edit, should scroll to selection and query a range around it: that range should split into 2 50 rows wide chunks. Instead, got query ranges {ranges:?}"); + let first_chunk = &ranges[0]; + let second_chunk = &ranges[1]; + assert!(first_chunk.end.line == second_chunk.start.line, + "First chunk {first_chunk:?} should be before second chunk {second_chunk:?}"); + assert!(first_chunk.start.line < selection_in_cached_range.row, + "Hints should be queried with the selected range after the query range start"); + + let lsp_requests = lsp_request_count.load(Ordering::Acquire); + assert_eq!(lsp_requests, 5, "Two chunks should be re-queried"); + assert_eq!(vec!["100".to_string(), "150".to_string()], cached_hint_labels(editor, cx), + "Should have (less) hints from the new LSP response after the edit"); + assert_eq!(vec!["100".to_string(), "150".to_string()], visible_hint_labels(editor, cx), "Should show only visible hints (in the center) from the new cached set"); + }).unwrap(); + } + + fn editor_visible_range( + editor: &WindowHandle, + cx: &mut gpui::TestAppContext, + ) -> Range { + let ranges = editor + .update(cx, |editor, _window, cx| editor.visible_excerpts(cx)) + .unwrap(); + assert_eq!( + ranges.len(), + 1, + "Single buffer should produce a single excerpt with visible range" + ); + let (_, (excerpt_buffer, _, excerpt_visible_range)) = ranges.into_iter().next().unwrap(); + excerpt_buffer.read_with(cx, |buffer, _| { + excerpt_visible_range.to_point(&buffer.snapshot()) + }) + } + + #[gpui::test] + async fn test_multiple_excerpts_large_multibuffer(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let language = rust_lang(); + language_registry.add(language); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle1) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/other.rs"), cx) + }) + .await + .unwrap(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0)), + ExcerptRange::new(Point::new(4, 0)..Point::new(11, 0)), + ExcerptRange::new(Point::new(22, 0)..Point::new(33, 0)), + ExcerptRange::new(Point::new(44, 0)..Point::new(55, 0)), + ExcerptRange::new(Point::new(56, 0)..Point::new(66, 0)), + ExcerptRange::new(Point::new(67, 0)..Point::new(77, 0)), + ], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ + ExcerptRange::new(Point::new(0, 1)..Point::new(2, 1)), + ExcerptRange::new(Point::new(4, 1)..Point::new(11, 1)), + ExcerptRange::new(Point::new(22, 1)..Point::new(33, 1)), + ExcerptRange::new(Point::new(44, 1)..Point::new(55, 1)), + ExcerptRange::new(Point::new(56, 1)..Point::new(66, 1)), + ExcerptRange::new(Point::new(67, 1)..Point::new(77, 1)), + ], + cx, + ); + multibuffer + }); + + cx.executor().run_until_parked(); + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx) + }); + + let editor_edited = Arc::new(AtomicBool::new(false)); + let fake_server = fake_servers.next().await.unwrap(); + let closure_editor_edited = Arc::clone(&editor_edited); + fake_server + .set_request_handler::(move |params, _| { + let task_editor_edited = Arc::clone(&closure_editor_edited); + async move { + let hint_text = if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + "main hint" + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap() + { + "other hint" + } else { + panic!("unexpected uri: {:?}", params.text_document.uri); + }; + + // one hint per excerpt + let positions = [ + lsp::Position::new(0, 2), + lsp::Position::new(4, 2), + lsp::Position::new(22, 2), + lsp::Position::new(44, 2), + lsp::Position::new(56, 2), + lsp::Position::new(67, 2), + ]; + let out_of_range_hint = lsp::InlayHint { + position: lsp::Position::new( + params.range.start.line + 99, + params.range.start.character + 99, + ), + label: lsp::InlayHintLabel::String( + "out of excerpt range, should be ignored".to_string(), + ), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }; + + let edited = task_editor_edited.load(Ordering::Acquire); + Ok(Some( + std::iter::once(out_of_range_hint) + .chain(positions.into_iter().enumerate().map(|(i, position)| { + lsp::InlayHint { + position, + label: lsp::InlayHintLabel::String(format!( + "{hint_text}{E} #{i}", + E = if edited { "(edited)" } else { "" }, + )), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + } + })) + .collect(), + )) + } + }) + .next() + .await; + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + ]; + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]), + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges([Point::new(22, 0)..Point::new(22, 0)]), + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]), + ); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + ]; + assert_eq!(expected_hints, sorted_cached_hint_labels(editor, cx), + "New hints are not shown right after scrolling, we need to wait for the buffer to be registered"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + ]; + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After scrolling to the new buffer and waiting for it to be registered, new hints should appear"); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "Editor should show only visible hints", + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges([Point::new(100, 0)..Point::new(100, 0)]), + ); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + "other hint #4".to_string(), + "other hint #5".to_string(), + ]; + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "Editor shows only hints for excerpts that were visible when scrolling" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::Next), + window, + cx, + |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]), + ); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + "other hint #4".to_string(), + "other hint #5".to_string(), + ]; + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After multibuffer was scrolled to the end, further scrolls up should not bring more hints" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + ); + }) + .unwrap(); + + // We prepare to change the scrolling on edit, but do not scroll yet + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]) + }); + }) + .unwrap(); + cx.executor().run_until_parked(); + // Edit triggers the scrolling too + editor_edited.store(true, Ordering::Release); + editor + .update(cx, |editor, window, cx| { + editor.handle_input("++++more text++++", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + // Wait again to trigger the inlay hints fetch on scroll + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "main hint(edited) #0".to_string(), + "main hint(edited) #1".to_string(), + "main hint(edited) #2".to_string(), + "main hint(edited) #3".to_string(), + "main hint(edited) #4".to_string(), + "main hint(edited) #5".to_string(), + "other hint(edited) #0".to_string(), + "other hint(edited) #1".to_string(), + "other hint(edited) #2".to_string(), + "other hint(edited) #3".to_string(), + ]; + assert_eq!( + expected_hints, + sorted_cached_hint_labels(editor, cx), + "After multibuffer edit, editor gets scrolled back to the last selection; \ + all hints should be invalidated and required for all of its visible excerpts" + ); + assert_eq!( + expected_hints, + visible_hint_labels(editor, cx), + "All excerpts should get their hints" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_editing_in_multi_buffer(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..200).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "lib.rs": r#"let a = 1; +let b = 2; +let c = 3;"# + }), + ) + .await; + + let lsp_request_ranges = Arc::new(Mutex::new(Vec::new())); + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let language = rust_lang(); + language_registry.add(language); + + let closure_ranges_fetched = lsp_request_ranges.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let closure_ranges_fetched = closure_ranges_fetched.clone(); + fake_server.set_request_handler::( + move |params, _| { + let closure_ranges_fetched = closure_ranges_fetched.clone(); + async move { + let prefix = if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + closure_ranges_fetched + .lock() + .push(("main.rs", params.range)); + "main.rs" + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + { + closure_ranges_fetched.lock().push(("lib.rs", params.range)); + "lib.rs" + } else { + panic!("Unexpected file path {:?}", params.text_document.uri); + }; + Ok(Some( + (params.range.start.line..params.range.end.line) + .map(|row| lsp::InlayHint { + position: lsp::Position::new(row, 0), + label: lsp::InlayHintLabel::String(format!( + "{prefix} Inlay hint #{row}" + )), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }) + .collect(), + )) + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle_1) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle_2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/lib.rs"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + // Have first excerpt to spawn over 2 chunks (50 lines each). + ExcerptRange::new(Point::new(49, 0)..Point::new(53, 0)), + // Have 2nd excerpt to be in the 2nd chunk only. + ExcerptRange::new(Point::new(70, 0)..Point::new(73, 0)), + ], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(4, 0))], + cx, + ); + multibuffer + }); + + let editor = cx.add_window(|window, cx| { + let mut editor = + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([0..0]) + }); + editor + }); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + vec![ + ( + "lib.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(2, 10)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(50, 0)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(50, 0), lsp::Position::new(100, 0)) + ), + ], + lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|(prefix, r)| (prefix.to_owned(), r.start)) + .collect::>(), + "For large buffers, should query chunks that cover both visible excerpt" + ); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + (0..2) + .map(|i| format!("lib.rs Inlay hint #{i}")) + .chain((0..100).map(|i| format!("main.rs Inlay hint #{i}"))) + .collect::>(), + sorted_cached_hint_labels(editor, cx), + "Both chunks should provide their inlay hints" + ); + assert_eq!( + vec![ + "main.rs Inlay hint #49".to_owned(), + "main.rs Inlay hint #50".to_owned(), + "main.rs Inlay hint #51".to_owned(), + "main.rs Inlay hint #52".to_owned(), + "main.rs Inlay hint #53".to_owned(), + "main.rs Inlay hint #70".to_owned(), + "main.rs Inlay hint #71".to_owned(), + "main.rs Inlay hint #72".to_owned(), + "main.rs Inlay hint #73".to_owned(), + "lib.rs Inlay hint #0".to_owned(), + "lib.rs Inlay hint #1".to_owned(), + ], + visible_hint_labels(editor, cx), + "Only hints from visible excerpt should be added into the editor" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.handle_input("a", window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(1000)); + cx.executor().run_until_parked(); + assert_eq!( + vec![ + ( + "lib.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(2, 10)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(50, 0)) + ), + ( + "main.rs", + lsp::Range::new(lsp::Position::new(50, 0), lsp::Position::new(100, 0)) + ), + ], + lsp_request_ranges + .lock() + .drain(..) + .sorted_by_key(|(prefix, r)| (prefix.to_owned(), r.start)) + .collect::>(), + "Same chunks should be re-queried on edit" + ); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + (0..2) + .map(|i| format!("lib.rs Inlay hint #{i}")) + .chain((0..100).map(|i| format!("main.rs Inlay hint #{i}"))) + .collect::>(), + sorted_cached_hint_labels(editor, cx), + "Same hints should be re-inserted after the edit" + ); + assert_eq!( + vec![ + "main.rs Inlay hint #49".to_owned(), + "main.rs Inlay hint #50".to_owned(), + "main.rs Inlay hint #51".to_owned(), + "main.rs Inlay hint #52".to_owned(), + "main.rs Inlay hint #53".to_owned(), + "main.rs Inlay hint #70".to_owned(), + "main.rs Inlay hint #71".to_owned(), + "main.rs Inlay hint #72".to_owned(), + "main.rs Inlay hint #73".to_owned(), + "lib.rs Inlay hint #0".to_owned(), + "lib.rs Inlay hint #1".to_owned(), + ], + visible_hint_labels(editor, cx), + "Same hints should be re-inserted into the editor after the edit" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_excerpts_removed(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(false), + show_parameter_hints: Some(false), + show_other_hints: Some(false), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/other.rs"), cx) + }) + .await + .unwrap(); + let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let (buffer_1_excerpts, buffer_2_excerpts) = multibuffer.update(cx, |multibuffer, cx| { + let buffer_1_excerpts = multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + let buffer_2_excerpts = multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 1)..Point::new(2, 1))], + cx, + ); + (buffer_1_excerpts, buffer_2_excerpts) + }); + + assert!(!buffer_1_excerpts.is_empty()); + assert!(!buffer_2_excerpts.is_empty()); + + cx.executor().run_until_parked(); + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx) + }); + let editor_edited = Arc::new(AtomicBool::new(false)); + let fake_server = fake_servers.next().await.unwrap(); + let closure_editor_edited = Arc::clone(&editor_edited); + fake_server + .set_request_handler::(move |params, _| { + let task_editor_edited = Arc::clone(&closure_editor_edited); + async move { + let hint_text = if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + "main hint" + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap() + { + "other hint" + } else { + panic!("unexpected uri: {:?}", params.text_document.uri); + }; + + let positions = [ + lsp::Position::new(0, 2), + lsp::Position::new(4, 2), + lsp::Position::new(22, 2), + lsp::Position::new(44, 2), + lsp::Position::new(56, 2), + lsp::Position::new(67, 2), + ]; + let out_of_range_hint = lsp::InlayHint { + position: lsp::Position::new( + params.range.start.line + 99, + params.range.start.character + 99, + ), + label: lsp::InlayHintLabel::String( + "out of excerpt range, should be ignored".to_string(), + ), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }; + + let edited = task_editor_edited.load(Ordering::Acquire); + Ok(Some( + std::iter::once(out_of_range_hint) + .chain(positions.into_iter().enumerate().map(|(i, position)| { + lsp::InlayHint { + position, + label: lsp::InlayHintLabel::String(format!( + "{hint_text}{} #{i}", + if edited { "(edited)" } else { "" }, + )), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + } + })) + .collect(), + )) + } + }) + .next() + .await; + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + "Cache should update for both excerpts despite hints display was disabled; after selecting 2nd buffer, it's now registered with the langserever and should get its hints" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "All hints are disabled and should not be shown despite being present in the cache" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts(buffer_2_excerpts, cx) + }) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + ], + cached_hint_labels(editor, cx), + "For the removed excerpt, should clean corresponding cached hints as its buffer was dropped" + ); + assert!( + visible_hint_labels(editor, cx).is_empty(), + "All hints are disabled and should not be shown despite being present in the cache" + ); + }) + .unwrap(); + + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + ], + cached_hint_labels(editor, cx), + "Hint display settings change should not change the cache" + ); + assert_eq!( + vec![ + "main hint #0".to_string(), + ], + visible_hint_labels(editor, cx), + "Settings change should make cached hints visible, but only the visible ones, from the remaining excerpt" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_inside_char_boundary_range_hints(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": format!(r#"fn main() {{\n{}\n}}"#, format!("let i = {};\n", "√".repeat(10)).repeat(500)), + "other.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let i = lsp_request_count.fetch_add(1, Ordering::Release) + 1; + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + let query_start = params.range.start; + Ok(Some(vec![lsp::InlayHint { + position: query_start, + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor = + cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(10, 0)..Point::new(10, 0)]) + }) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!(expected_hints, cached_hint_labels(editor, cx)); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_toggle_inlay_hints(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(false), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let lsp_request_count = lsp_request_count.clone(); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + + let i = lsp_request_count.fetch_add(1, Ordering::AcqRel) + 1; + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should display inlays after toggle despite them disabled in settings" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Cache does not change because of toggles in the editor" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Should clear hints after 2nd toggle" + ); + }) + .unwrap(); + + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Should not query LSP hints after enabling hints in settings, as file version is the same" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Cache does not change because of toggles in the editor" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Should clear hints after enabling in settings and a 3rd toggle" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor.update(cx, |editor, _, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor,cx), + "Should not query LSP hints after enabling hints in settings and toggling them back on" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }).unwrap(); + } + + #[gpui::test] + async fn test_modifiers_change(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + let lsp_request_count = Arc::new(AtomicU32::new(0)); + fake_server.set_request_handler::( + move |params, _| { + let lsp_request_count = lsp_request_count.clone(); + async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + + let i = lsp_request_count.fetch_add(1, Ordering::AcqRel) + 1; + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + }) + .await; + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Should display inlays after toggle despite them disabled in settings" + ); + assert_eq!(vec!["1".to_string()], visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "On modifiers change and hints toggled on, should hide editor inlays" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "When modifiers change is off, no extra requests are sent" + ); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "When modifiers change is off, hints are back into the editor" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind (2)" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "When toggled off, should hide editor inlays" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "Nothing happens with the cache on modifiers change" + ); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "On modifiers change & hints toggled off, should show editor inlays" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + vec!["1".to_string()], + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind" + ); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!( + vec!["1".to_string()], + cached_hint_labels(editor, cx), + "When modifiers change is off, no extra requests are sent" + ); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "When modifiers change is off, editor hints are back into their toggled off state" + ); + }) + .unwrap(); + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["1".to_string()], cached_hint_labels(editor, cx)); + assert_eq!( + Vec::::new(), + visible_hint_labels(editor, cx), + "Nothing changes on consequent modifiers change of the same kind (3)" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_inlays_at_the_same_place(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { + let x = 42; + std::thread::scope(|s| { + s.spawn(|| { + let _x = x; + }); + }); + }", + "other.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + Ok(Some( + serde_json::from_value(json!([ + { + "position": { + "line": 3, + "character": 16 + }, + "label": "move", + "paddingLeft": false, + "paddingRight": false + }, + { + "position": { + "line": 3, + "character": 16 + }, + "label": "(", + "paddingLeft": false, + "paddingRight": false + }, + { + "position": { + "line": 3, + "character": 16 + }, + "label": [ + { + "value": "&x" + } + ], + "paddingLeft": false, + "paddingRight": false, + "data": { + "file_id": 0 + } + }, + { + "position": { + "line": 3, + "character": 16 + }, + "label": ")", + "paddingLeft": false, + "paddingRight": true + }, + // not a correct syntax, but checks that same symbols at the same place + // are not deduplicated + { + "position": { + "line": 3, + "character": 16 + }, + "label": ")", + "paddingLeft": false, + "paddingRight": true + }, + ])) + .unwrap(), + )) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor = + cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(10, 0)..Point::new(10, 0)]) + }) + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + let expected_hints = vec![ + "move".to_string(), + "(".to_string(), + "&x".to_string(), + ") ".to_string(), + ") ".to_string(), + ]; + assert_eq!( + expected_hints, + cached_hint_labels(editor, cx), + "Editor inlay hints should repeat server's order when placed at the same spot" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_invalidation_and_addition_race(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": r#"fn main() { + let x = 1; + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + //// + let x = "2"; + } +"#, + "lib.rs": r#"fn aaa() { + let aa = 22; + } + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + + fn bb() { + let bb = 33; + } +"# + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let language = rust_lang(); + language_registry.add(language); + + let requests_count = Arc::new(AtomicUsize::new(0)); + let closure_requests_count = requests_count.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + let requests_count = closure_requests_count.clone(); + fake_server.set_request_handler::( + move |params, _| { + let requests_count = requests_count.clone(); + async move { + requests_count.fetch_add(1, Ordering::Release); + if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 9), + label: lsp::InlayHintLabel::String(": i32".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(19, 9), + label: lsp::InlayHintLabel::String(": i33".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else if params.text_document.uri + == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap() + { + Ok(Some(vec![ + lsp::InlayHint { + position: lsp::Position::new(1, 10), + label: lsp::InlayHintLabel::String(": i34".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + lsp::InlayHint { + position: lsp::Position::new(29, 10), + label: lsp::InlayHintLabel::String(": i35".to_owned()), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }, + ])) + } else { + panic!("Unexpected file path {:?}", params.text_document.uri); + } + } + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (buffer_1, _handle_1) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let (buffer_2, _handle_2) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/lib.rs"), cx) + }) + .await + .unwrap(); + let multi_buffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_2.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)), + ExcerptRange::new(Point::new(23, 0)..Point::new(34, 0)), + ], + cx, + ); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)), + ExcerptRange::new(Point::new(13, 0)..Point::new(23, 0)), + ], + cx, + ); + multibuffer + }); + + let editor = cx.add_window(|window, cx| { + let mut editor = + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(3, 3)..Point::new(3, 3)]) + }); + editor + }); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + vec![ + ": i32".to_string(), + ": i33".to_string(), + ": i34".to_string(), + ": i35".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + ); + assert_eq!( + vec![ + ": i34".to_string(), + ": i35".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ], + visible_hint_labels(editor, cx), + "lib.rs is added before main.rs , so its excerpts should be visible first" + ); + }) + .unwrap(); + assert_eq!( + requests_count.load(Ordering::Acquire), + 2, + "Should have queried hints once per each file" + ); + + // Scroll all the way down so the 1st buffer is out of sight. + // The selection is on the 1st buffer still. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Line(88.0), window, cx); + }) + .unwrap(); + // Emulate a language server refresh request, coming in the background.. + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints( + InlayHintRefreshReason::RefreshRequested(fake_server.server.server_id()), + cx, + ); + }) + .unwrap(); + // Edit the 1st buffer while scrolled down and not seeing that. + // The edit will auto scroll to the edit (1st buffer). + editor + .update(cx, |editor, window, cx| { + editor.handle_input("a", window, cx); + }) + .unwrap(); + // Add more racy additive hint tasks. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Line(0.2), window, cx); + }) + .unwrap(); + + cx.executor().advance_clock(Duration::from_millis(1000)); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _window, cx| { + assert_eq!( + vec![ + ": i32".to_string(), + ": i33".to_string(), + ": i34".to_string(), + ": i35".to_string(), + ], + sorted_cached_hint_labels(editor, cx), + "No hint changes/duplicates should occur in the cache", + ); + assert_eq!( + vec![ + ": i34".to_string(), + ": i35".to_string(), + ": i32".to_string(), + ": i33".to_string(), + ], + visible_hint_labels(editor, cx), + "No hint changes/duplicates should occur in the editor excerpts", + ); + }) + .unwrap(); + assert_eq!( + requests_count.load(Ordering::Acquire), + 4, + "Should have queried hints once more per each file, after editing the file once" + ); + } + + pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + crate::init(cx); + }); + + update_test_language_settings(cx, f); + } + + async fn prepare_test_objects( + cx: &mut TestAppContext, + initialize: impl 'static + Send + Fn(&mut FakeLanguageServer, &'static str) + Send + Sync, + ) -> (&'static str, WindowHandle, FakeLanguageServer) { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", + "other.rs": "// Test file", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let file_path = path!("/a/main.rs"); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |server| initialize(server, file_path))), + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let editor = + cx.add_window(|window, cx| Editor::for_buffer(buffer, Some(project), window, cx)); + + editor + .update(cx, |editor, _, cx| { + assert!(cached_hint_labels(editor, cx).is_empty()); + assert!(visible_hint_labels(editor, cx).is_empty()); + }) + .unwrap(); + + cx.executor().run_until_parked(); + let fake_server = fake_servers.next().await.unwrap(); + (file_path, editor, fake_server) + } + + // Inlay hints in the cache are stored per excerpt as a key, and those keys are guaranteed to be ordered same as in the multi buffer. + // Ensure a stable order for testing. + fn sorted_cached_hint_labels(editor: &Editor, cx: &mut App) -> Vec { + let mut labels = cached_hint_labels(editor, cx); + labels.sort_by(|a, b| natural_sort(a, b)); + labels + } + + pub fn cached_hint_labels(editor: &Editor, cx: &mut App) -> Vec { + let lsp_store = editor.project().unwrap().read(cx).lsp_store(); + + let mut all_cached_labels = Vec::new(); + let mut all_fetched_hints = Vec::new(); + for buffer in editor.buffer.read(cx).all_buffers() { + lsp_store.update(cx, |lsp_store, cx| { + let hints = &lsp_store.latest_lsp_data(&buffer, cx).inlay_hints(); + all_cached_labels.extend(hints.all_cached_hints().into_iter().map(|hint| { + let mut label = hint.text().to_string(); + if hint.padding_left { + label.insert(0, ' '); + } + if hint.padding_right { + label.push_str(" "); + } + label + })); + all_fetched_hints.extend(hints.all_fetched_hints()); + }); + } + + all_cached_labels + } + + pub fn visible_hint_labels(editor: &Editor, cx: &Context) -> Vec { + editor + .visible_inlay_hints(cx) + .into_iter() + .map(|hint| hint.text().to_string()) + .collect() + } + + fn allowed_hint_kinds_for_editor(editor: &Editor) -> HashSet> { + editor + .inlay_hints + .as_ref() + .unwrap() + .allowed_hint_kinds + .clone() + } +} diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 34533002ff2cea587c4179d6f0f0770ff53b4b98..346574eba440622a40139a52be6977e55e909980 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -5,7 +5,7 @@ use crate::{ display_map::HighlightKey, editor_settings::SeedQuerySetting, persistence::{DB, SerializedEditor}, - scroll::ScrollAnchor, + scroll::{ScrollAnchor, ScrollOffset}, }; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -17,8 +17,8 @@ use gpui::{ ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, }; use language::{ - Bias, Buffer, BufferRow, CharKind, DiskState, LocalFile, Point, SelectionGoal, - proto::serialize_anchor as serialize_text_anchor, + Bias, Buffer, BufferRow, CharKind, CharScopeContext, DiskState, LocalFile, Point, + SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use project::{ @@ -42,8 +42,11 @@ use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt}; use workspace::{ CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, - item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions}, - searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, + invalid_item_view::InvalidItemView, + item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions}, + searchable::{ + Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle, + }, }; use workspace::{ OpenOptions, @@ -103,9 +106,9 @@ impl FollowableItem for Editor { multibuffer = MultiBuffer::new(project.read(cx).capability()); let mut sorted_excerpts = state.excerpts.clone(); sorted_excerpts.sort_by_key(|e| e.id); - let mut sorted_excerpts = sorted_excerpts.into_iter().peekable(); + let sorted_excerpts = sorted_excerpts.into_iter().peekable(); - while let Some(excerpt) = sorted_excerpts.next() { + for excerpt in sorted_excerpts { let Ok(buffer_id) = BufferId::new(excerpt.buffer_id) else { continue; }; @@ -186,8 +189,8 @@ impl FollowableItem for Editor { } else if self.focus_handle.is_focused(window) { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( - &self.selections.disjoint_anchors(), - self.selections.line_mode, + &self.selections.disjoint_anchors_arc(), + self.selections.line_mode(), self.cursor_shape, cx, ); @@ -201,7 +204,7 @@ impl FollowableItem for Editor { if buffer .as_singleton() .and_then(|buffer| buffer.read(cx).file()) - .map_or(false, |file| file.is_private()) + .is_some_and(|file| file.is_private()) { return None; } @@ -223,14 +226,14 @@ impl FollowableItem for Editor { Some(proto::view::Variant::Editor(proto::view::Editor { singleton: buffer.is_singleton(), - title: (!buffer.is_singleton()).then(|| buffer.title(cx).into()), + title: buffer.explicit_title().map(ToOwned::to_owned), excerpts, scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.anchor, &snapshot)), scroll_x: scroll_anchor.offset.x, scroll_y: scroll_anchor.offset.y, selections: self .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .map(|s| serialize_selection(s, &snapshot)) .collect(), @@ -293,7 +296,7 @@ impl FollowableItem for Editor { EditorEvent::ExcerptsRemoved { ids, .. } => { update .deleted_excerpts - .extend(ids.iter().map(ExcerptId::to_proto)); + .extend(ids.iter().copied().map(ExcerptId::to_proto)); true } EditorEvent::ScrollPositionChanged { autoscroll, .. } if !autoscroll => { @@ -309,7 +312,7 @@ impl FollowableItem for Editor { let snapshot = self.buffer.read(cx).snapshot(cx); update.selections = self .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .map(|s| serialize_selection(s, &snapshot)) .collect(); @@ -361,10 +364,9 @@ impl FollowableItem for Editor { ) { let buffer = self.buffer.read(cx); let buffer = buffer.read(cx); - let Some((excerpt_id, _, _)) = buffer.as_singleton() else { + let Some(position) = buffer.as_singleton_anchor(location) else { return; }; - let position = buffer.anchor_in_excerpt(*excerpt_id, location).unwrap(); let selection = Selection { id: 0, reversed: false, @@ -524,8 +526,8 @@ fn serialize_selection( ) -> proto::Selection { proto::Selection { id: selection.id as u64, - start: Some(serialize_anchor(&selection.start, &buffer)), - end: Some(serialize_anchor(&selection.end, &buffer)), + start: Some(serialize_anchor(&selection.start, buffer)), + end: Some(serialize_anchor(&selection.end, buffer)), reversed: selection.reversed, } } @@ -575,12 +577,11 @@ fn deserialize_selection( fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) -> Option { let excerpt_id = ExcerptId::from_proto(anchor.excerpt_id); - Some(Anchor { + Some(Anchor::in_buffer( excerpt_id, - text_anchor: language::proto::deserialize_anchor(anchor.anchor?)?, - buffer_id: buffer.buffer_id_for_excerpt(excerpt_id), - diff_base_anchor: None, - }) + buffer.buffer_id_for_excerpt(excerpt_id)?, + language::proto::deserialize_anchor(anchor.anchor?)?, + )) } impl Item for Editor { @@ -593,7 +594,7 @@ impl Item for Editor { cx: &mut Context, ) -> bool { if let Ok(data) = data.downcast::() { - let newest_selection = self.selections.newest::(cx); + let newest_selection = self.selections.newest::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let offset = if buffer.can_resolve(&data.cursor_anchor) { data.cursor_anchor.to_point(&buffer) @@ -637,7 +638,7 @@ impl Item for Editor { .and_then(|f| f.as_local())? .abs_path(cx); - let file_path = file_path.compact().to_string_lossy().to_string(); + let file_path = file_path.compact().to_string_lossy().into_owned(); Some(file_path.into()) } @@ -648,9 +649,10 @@ impl Item for Editor { fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString { if let Some(path) = path_for_buffer(&self.buffer, detail, true, cx) { - path.to_string_lossy().to_string().into() + path.to_string().into() } else { - "untitled".into() + // Use the same logic as the displayed title for consistency + self.buffer.read(cx).title(cx).to_string().into() } } @@ -663,7 +665,7 @@ impl Item for Editor { .file_icons .then(|| { path_for_buffer(&self.buffer, 0, true, cx) - .and_then(|path| FileIcons::get_icon(path.as_ref(), cx)) + .and_then(|path| FileIcons::get_icon(Path::new(&*path), cx)) }) .flatten() .map(Icon::from_path) @@ -699,8 +701,7 @@ impl Item for Editor { let description = params.detail.and_then(|detail| { let path = path_for_buffer(&self.buffer, detail, false, cx)?; - let description = path.to_string_lossy(); - let description = description.trim(); + let description = path.trim(); if description.is_empty() { return None; @@ -715,7 +716,7 @@ impl Item for Editor { .read(cx) .as_singleton() .and_then(|buffer| buffer.read(cx).file()) - .map_or(false, |file| file.disk_state() == DiskState::Deleted); + .is_some_and(|file| file.disk_state() == DiskState::Deleted); h_flex() .gap_2() @@ -745,24 +746,31 @@ impl Item for Editor { .for_each_buffer(|buffer| f(buffer.entity_id(), buffer.read(cx))); } - fn is_singleton(&self, cx: &App) -> bool { - self.buffer.read(cx).is_singleton() + fn buffer_kind(&self, cx: &App) -> ItemBufferKind { + match self.buffer.read(cx).is_singleton() { + true => ItemBufferKind::Singleton, + false => ItemBufferKind::Multibuffer, + } } fn can_save_as(&self, cx: &App) -> bool { self.buffer.read(cx).is_singleton() } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| self.clone(window, cx))) + Task::ready(Some(cx.new(|cx| self.clone(window, cx)))) } fn set_nav_history( @@ -774,12 +782,6 @@ impl Item for Editor { self.nav_history = Some(history); } - fn discarded(&self, _project: Entity, _: &mut Window, cx: &mut Context) { - for buffer in self.buffer().clone().read(cx).all_buffers() { - buffer.update(cx, |buffer, cx| buffer.discarded(cx)) - } - } - fn on_removed(&self, cx: &App) { self.report_editor_event(ReportEditorEvent::Closed, None, cx); } @@ -836,12 +838,11 @@ impl Item for Editor { // let mut buffers_to_save = let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave { - buffers.clone() + buffers } else { buffers - .iter() + .into_iter() .filter(|buffer| buffer.read(cx).is_dirty()) - .cloned() .collect() }; @@ -867,22 +868,6 @@ impl Item for Editor { .await?; } - // Notify about clean buffers for language server events - let buffers_that_were_not_saved: Vec<_> = buffers - .into_iter() - .filter(|b| !buffers_to_save.contains(b)) - .collect(); - - for buffer in buffers_that_were_not_saved { - buffer - .update(cx, |buffer, cx| { - let version = buffer.saved_version().clone(); - let mtime = buffer.saved_mtime(); - buffer.did_save(version, mtime, cx); - }) - .ok(); - } - Ok(()) }) } @@ -900,10 +885,7 @@ impl Item for Editor { .as_singleton() .expect("cannot call save_as on an excerpt list"); - let file_extension = path - .path - .extension() - .map(|a| a.to_string_lossy().to_string()); + let file_extension = path.path.extension().map(|a| a.to_string()); self.report_editor_event( ReportEditorEvent::Saved { auto_saved: false }, file_extension, @@ -930,10 +912,10 @@ impl Item for Editor { })?; buffer .update(cx, |buffer, cx| { - if let Some(transaction) = transaction { - if !buffer.is_singleton() { - buffer.push_transaction(&transaction.0, cx); - } + if let Some(transaction) = transaction + && !buffer.is_singleton() + { + buffer.push_transaction(&transaction.0, cx); } }) .ok(); @@ -960,8 +942,9 @@ impl Item for Editor { fn breadcrumbs(&self, variant: &Theme, cx: &App) -> Option> { let cursor = self.selections.newest_anchor().head(); let multibuffer = &self.buffer().read(cx); - let (buffer_id, symbols) = - multibuffer.symbols_containing(cursor, Some(variant.syntax()), cx)?; + let (buffer_id, symbols) = multibuffer + .read(cx) + .symbols_containing(cursor, Some(variant.syntax()))?; let buffer = multibuffer.buffer(buffer_id)?; let buffer = buffer.read(cx); @@ -969,13 +952,12 @@ impl Item for Editor { buffer .snapshot() .resolve_file_path( - cx, self.project .as_ref() .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) .unwrap_or_default(), + cx, ) - .map(|path| path.to_string_lossy().to_string()) .unwrap_or_else(|| { if multibuffer.is_singleton() { multibuffer.title(cx).to_string() @@ -1009,24 +991,18 @@ impl Item for Editor { ) { self.workspace = Some((workspace.weak_handle(), workspace.database_id())); if let Some(workspace) = &workspace.weak_handle().upgrade() { - cx.subscribe( - &workspace, - |editor, _, event: &workspace::Event, _cx| match event { - workspace::Event::ModalOpened => { - editor.mouse_context_menu.take(); - editor.inline_blame_popover.take(); - } - _ => {} - }, - ) + cx.subscribe(workspace, |editor, _, event: &workspace::Event, _cx| { + if let workspace::Event::ModalOpened = event { + editor.mouse_context_menu.take(); + editor.inline_blame_popover.take(); + } + }) .detach(); } } fn to_item_events(event: &EditorEvent, mut f: impl FnMut(ItemEvent)) { match event { - EditorEvent::Closed => f(ItemEvent::CloseItem), - EditorEvent::Saved | EditorEvent::TitleChanged => { f(ItemEvent::UpdateTab); f(ItemEvent::UpdateBreadcrumbs); @@ -1108,12 +1084,17 @@ impl SerializableItem for Editor { } } Ok(None) => { - return Task::ready(Err(anyhow!("No path or contents found for buffer"))); + return Task::ready(Err(anyhow!( + "Unable to deserialize editor: No entry in database for item_id: {item_id} and workspace_id {workspace_id:?}" + ))); } Err(error) => { return Task::ready(Err(error)); } }; + log::debug!( + "Deserialized editor {item_id:?} in workspace {workspace_id:?}, {serialized_editor:?}" + ); match serialized_editor { SerializedEditor { @@ -1140,8 +1121,9 @@ impl SerializableItem for Editor { // First create the empty buffer let buffer = project - .update(cx, |project, cx| project.create_buffer(cx))? - .await?; + .update(cx, |project, cx| project.create_buffer(true, cx))? + .await + .context("Failed to create buffer while deserializing editor")?; // Then set the text so that the dirty bit is set correctly buffer.update(cx, |buffer, cx| { @@ -1175,7 +1157,7 @@ impl SerializableItem for Editor { let (worktree, path) = project.find_worktree(&abs_path, cx)?; let project_path = ProjectPath { worktree_id: worktree.read(cx).id(), - path: path.into(), + path: path, }; Some(project.open_path(project_path, cx)) }); @@ -1183,7 +1165,9 @@ impl SerializableItem for Editor { match opened_buffer { Some(opened_buffer) => { window.spawn(cx, async move |cx| { - let (_, buffer) = opened_buffer.await?; + let (_, buffer) = opened_buffer + .await + .context("Failed to open path in project")?; // This is a bit wasteful: we're loading the whole buffer from // disk and then overwrite the content. @@ -1248,8 +1232,9 @@ impl SerializableItem for Editor { .. } => window.spawn(cx, async move |cx| { let buffer = project - .update(cx, |project, cx| project.create_buffer(cx))? - .await?; + .update(cx, |project, cx| project.create_buffer(true, cx))? + .await + .context("Failed to create buffer")?; cx.update(|window, cx| { cx.new(|cx| { @@ -1296,7 +1281,7 @@ impl SerializableItem for Editor { project .read(cx) .worktree_for_id(worktree_id, cx) - .and_then(|worktree| worktree.read(cx).absolutize(&file.path()).ok()) + .map(|worktree| worktree.read(cx).absolutize(file.path())) .or_else(|| { let full_path = file.full_path(cx); let project_path = project.read(cx).find_project_path(&full_path, cx)?; @@ -1352,7 +1337,7 @@ struct EditorRestorationData { #[derive(Default, Debug)] pub struct RestorationData { - pub scroll_position: (BufferRow, gpui::Point), + pub scroll_position: (BufferRow, gpui::Point), pub folds: Vec>, pub selections: Vec>, } @@ -1374,40 +1359,47 @@ impl ProjectItem for Editor { let mut editor = Self::for_buffer(buffer.clone(), Some(project), window, cx); if let Some((excerpt_id, buffer_id, snapshot)) = editor.buffer().read(cx).snapshot(cx).as_singleton() + && WorkspaceSettings::get(None, cx).restore_on_file_reopen + && let Some(restoration_data) = Self::project_item_kind() + .and_then(|kind| pane.as_ref()?.project_item_restoration_data.get(&kind)) + .and_then(|data| data.downcast_ref::()) + .and_then(|data| { + let file = project::File::from_dyn(buffer.read(cx).file())?; + data.entries.get(&file.abs_path(cx)) + }) { - if WorkspaceSettings::get(None, cx).restore_on_file_reopen { - if let Some(restoration_data) = Self::project_item_kind() - .and_then(|kind| pane.as_ref()?.project_item_restoration_data.get(&kind)) - .and_then(|data| data.downcast_ref::()) - .and_then(|data| { - let file = project::File::from_dyn(buffer.read(cx).file())?; - data.entries.get(&file.abs_path(cx)) - }) - { - editor.fold_ranges( - clip_ranges(&restoration_data.folds, &snapshot), - false, - window, - cx, - ); - if !restoration_data.selections.is_empty() { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(clip_ranges(&restoration_data.selections, &snapshot)); - }); - } - let (top_row, offset) = restoration_data.scroll_position; - let anchor = Anchor::in_buffer( - *excerpt_id, - buffer_id, - snapshot.anchor_before(Point::new(top_row, 0)), - ); - editor.set_scroll_anchor(ScrollAnchor { anchor, offset }, window, cx); - } + editor.fold_ranges( + clip_ranges(&restoration_data.folds, snapshot), + false, + window, + cx, + ); + if !restoration_data.selections.is_empty() { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(clip_ranges(&restoration_data.selections, snapshot)); + }); } + let (top_row, offset) = restoration_data.scroll_position; + let anchor = Anchor::in_buffer( + *excerpt_id, + buffer_id, + snapshot.anchor_before(Point::new(top_row, 0)), + ); + editor.set_scroll_anchor(ScrollAnchor { anchor, offset }, window, cx); } editor } + + fn for_broken_project_item( + abs_path: &Path, + is_local: bool, + e: &anyhow::Error, + window: &mut Window, + cx: &mut App, + ) -> Option { + Some(InvalidItemView::new(abs_path, is_local, e, window, cx)) + } } fn clip_ranges<'a>( @@ -1513,7 +1505,7 @@ impl SearchableItem for Editor { fn toggle_filtered_search_ranges( &mut self, - enabled: bool, + enabled: Option, _: &mut Window, cx: &mut Context, ) { @@ -1523,15 +1515,16 @@ impl SearchableItem for Editor { .map(|(_, ranges)| ranges) } - if !enabled { - return; - } + if let Some(range) = enabled { + let ranges = self.selections.disjoint_anchor_ranges().collect::>(); - let ranges = self.selections.disjoint_anchor_ranges().collect::>(); - if ranges.iter().any(|s| s.start != s.end) { - self.set_search_within_ranges(&ranges, cx); - } else if let Some(previous_search_ranges) = self.previous_search_ranges.take() { - self.set_search_within_ranges(&previous_search_ranges, cx) + if ranges.iter().any(|s| s.start != s.end) { + self.set_search_within_ranges(&ranges, cx); + } else if let Some(previous_search_ranges) = self.previous_search_ranges.take() + && range != FilteredSearchRange::Selection + { + self.set_search_within_ranges(&previous_search_ranges, cx); + } } } @@ -1559,13 +1552,14 @@ impl SearchableItem for Editor { fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor; - let snapshot = &self.snapshot(window, cx).buffer_snapshot; - let selection = self.selections.newest_adjusted(cx); + let snapshot = self.snapshot(window, cx); + let selection = self.selections.newest_adjusted(&snapshot.display_snapshot); + let buffer_snapshot = snapshot.buffer_snapshot(); match setting { SeedQuerySetting::Never => String::new(), SeedQuerySetting::Selection | SeedQuerySetting::Always if !selection.is_empty() => { - let text: String = snapshot + let text: String = buffer_snapshot .text_for_range(selection.start..selection.end) .collect(); if text.contains('\n') { @@ -1576,9 +1570,10 @@ impl SearchableItem for Editor { } SeedQuerySetting::Selection => String::new(), SeedQuerySetting::Always => { - let (range, kind) = snapshot.surrounding_word(selection.start, true); + let (range, kind) = buffer_snapshot + .surrounding_word(selection.start, Some(CharScopeContext::Completion)); if kind == Some(CharKind::Word) { - let text: String = snapshot.text_for_range(range).collect(); + let text: String = buffer_snapshot.text_for_range(range).collect(); if !text.trim().is_empty() { return text; } @@ -1678,7 +1673,7 @@ impl SearchableItem for Editor { cx: &mut Context, ) -> usize { let buffer = self.buffer().read(cx).snapshot(cx); - let current_index_position = if self.selections.disjoint_anchors().len() == 1 { + let current_index_position = if self.selections.disjoint_anchors_arc().len() == 1 { self.selections.newest_anchor().head() } else { matches[current_index].start @@ -1756,13 +1751,8 @@ impl SearchableItem for Editor { .anchor_after(search_range.start + match_range.start); let end = search_buffer .anchor_before(search_range.start + match_range.end); - Anchor { - diff_base_anchor: Some(start), - ..deleted_hunk_anchor - }..Anchor { - diff_base_anchor: Some(end), - ..deleted_hunk_anchor - } + deleted_hunk_anchor.with_diff_base_anchor(start) + ..deleted_hunk_anchor.with_diff_base_anchor(end) } else { let start = search_buffer .anchor_after(search_range.start + match_range.start); @@ -1881,7 +1871,7 @@ fn path_for_buffer<'a>( height: usize, include_filename: bool, cx: &'a App, -) -> Option> { +) -> Option> { let file = buffer.read(cx).as_singleton()?.read(cx).file()?; path_for_file(file.as_ref(), height, include_filename, cx) } @@ -1891,7 +1881,7 @@ fn path_for_file<'a>( mut height: usize, include_filename: bool, cx: &'a App, -) -> Option> { +) -> Option> { // Ensure we always render at least the filename. height += 1; @@ -1905,22 +1895,21 @@ fn path_for_file<'a>( } } - // Here we could have just always used `full_path`, but that is very - // allocation-heavy and so we try to use a `Cow` if we haven't - // traversed all the way up to the worktree's root. + // The full_path method allocates, so avoid calling it if height is zero. if height > 0 { - let full_path = file.full_path(cx); - if include_filename { - Some(full_path.into()) - } else { - Some(full_path.parent()?.to_path_buf().into()) + let mut full_path = file.full_path(cx); + if !include_filename { + if !full_path.pop() { + return None; + } } + Some(full_path.to_string_lossy().into_owned().into()) } else { let mut path = file.path().strip_prefix(prefix).ok()?; if !include_filename { path = path.parent()?; } - Some(path.into()) + Some(path.display(file.path_style(cx))) } } @@ -1935,12 +1924,12 @@ mod tests { use language::{LanguageMatcher, TestFile}; use project::FakeFs; use std::path::{Path, PathBuf}; - use util::path; + use util::{path, rel_path::RelPath}; #[gpui::test] fn test_path_for_file(cx: &mut App) { let file = TestFile { - path: Path::new("").into(), + path: RelPath::empty().into(), root_name: String::new(), local_root: None, }; diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index 95a792583953e02a77e592ea957b752f0f8042bb..0e32bc686ad98a45b83712841c13fffc07421acb 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -37,7 +37,7 @@ pub(crate) fn should_auto_close( let text = buffer .text_for_range(edited_range.clone()) .collect::(); - let edited_range = edited_range.to_offset(&buffer); + let edited_range = edited_range.to_offset(buffer); if !text.ends_with(">") { continue; } @@ -51,12 +51,11 @@ pub(crate) fn should_auto_close( continue; }; let mut jsx_open_tag_node = node; - if node.grammar_name() != config.open_tag_node_name { - if let Some(parent) = node.parent() { - if parent.grammar_name() == config.open_tag_node_name { - jsx_open_tag_node = parent; - } - } + if node.grammar_name() != config.open_tag_node_name + && let Some(parent) = node.parent() + && parent.grammar_name() == config.open_tag_node_name + { + jsx_open_tag_node = parent; } if jsx_open_tag_node.grammar_name() != config.open_tag_node_name { continue; @@ -87,9 +86,9 @@ pub(crate) fn should_auto_close( }); } if to_auto_edit.is_empty() { - return None; + None } else { - return Some(to_auto_edit); + Some(to_auto_edit) } } @@ -182,12 +181,12 @@ pub(crate) fn generate_auto_close_edits( */ { let tag_node_name_equals = |node: &Node, name: &str| { - let is_empty = name.len() == 0; + let is_empty = name.is_empty(); if let Some(node_name) = node.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) { let range = node_name.byte_range(); return buffer.text_for_range(range).equals_str(name); } - return is_empty; + is_empty }; let tree_root_node = { @@ -208,7 +207,7 @@ pub(crate) fn generate_auto_close_edits( cur = descendant; } - assert!(ancestors.len() > 0); + assert!(!ancestors.is_empty()); let mut tree_root_node = open_tag; @@ -228,7 +227,7 @@ pub(crate) fn generate_auto_close_edits( let has_open_tag_with_same_tag_name = ancestor .named_child(0) .filter(|n| n.kind() == config.open_tag_node_name) - .map_or(false, |element_open_tag_node| { + .is_some_and(|element_open_tag_node| { tag_node_name_equals(&element_open_tag_node, &tag_name) }); if has_open_tag_with_same_tag_name { @@ -264,8 +263,7 @@ pub(crate) fn generate_auto_close_edits( } let is_after_open_tag = |node: &Node| { - return node.start_byte() < open_tag.start_byte() - && node.end_byte() < open_tag.start_byte(); + node.start_byte() < open_tag.start_byte() && node.end_byte() < open_tag.start_byte() }; // perf: use cursor for more efficient traversal @@ -284,10 +282,8 @@ pub(crate) fn generate_auto_close_edits( unclosed_open_tag_count -= 1; } } else if has_erroneous_close_tag && kind == erroneous_close_tag_node_name { - if tag_node_name_equals(&node, &tag_name) { - if !is_after_open_tag(&node) { - unclosed_open_tag_count -= 1; - } + if tag_node_name_equals(&node, &tag_name) && !is_after_open_tag(&node) { + unclosed_open_tag_count -= 1; } } else if kind == config.jsx_element_node_name { // perf: filter only open,close,element,erroneous nodes @@ -304,7 +300,7 @@ pub(crate) fn generate_auto_close_edits( let edit_range = edit_anchor..edit_anchor; edits.push((edit_range, format!("", tag_name))); } - return Ok(edits); + Ok(edits) } pub(crate) fn refresh_enabled_in_any_buffer( @@ -332,7 +328,7 @@ pub(crate) fn refresh_enabled_in_any_buffer( snapshot.file(), cx, ); - if language_settings.jsx_tag_auto_close.enabled { + if language_settings.jsx_tag_auto_close { found_enabled = true; } } @@ -370,7 +366,7 @@ pub(crate) fn construct_initial_buffer_versions_map< initial_buffer_versions.insert(buffer_id, buffer_version); } } - return initial_buffer_versions; + initial_buffer_versions } pub(crate) fn handle_from( @@ -410,7 +406,7 @@ pub(crate) fn handle_from( }; let language_settings = snapshot.settings_at(edit.new.end, cx); - if !language_settings.jsx_tag_auto_close.enabled { + if !language_settings.jsx_tag_auto_close { continue; } @@ -458,12 +454,9 @@ pub(crate) fn handle_from( let ensure_no_edits_since_start = || -> Option<()> { let has_edits_since_start = this .read_with(cx, |this, cx| { - this.buffer - .read(cx) - .buffer(buffer_id) - .map_or(true, |buffer| { - buffer.read(cx).has_edits_since(&buffer_version_initial) - }) + this.buffer.read(cx).buffer(buffer_id).is_none_or(|buffer| { + buffer.read(cx).has_edits_since(&buffer_version_initial) + }) }) .ok()?; @@ -514,7 +507,7 @@ pub(crate) fn handle_from( { let selections = this - .read_with(cx, |this, _| this.selections.disjoint_anchors().clone()) + .read_with(cx, |this, _| this.selections.disjoint_anchors_arc()) .ok()?; for selection in selections.iter() { let Some(selection_buffer_offset_head) = @@ -627,14 +620,17 @@ mod jsx_tag_autoclose_tests { use super::*; use gpui::{AppContext as _, TestAppContext}; - use language::language_settings::JsxTagAutoCloseSettings; use languages::language; use multi_buffer::ExcerptRange; use text::Selection; async fn test_setup(cx: &mut TestAppContext) -> EditorTestContext { init_test(cx, |settings| { - settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true }); + settings + .defaults + .jsx_tag_auto_close + .get_or_insert_default() + .enabled = Some(true); }); let mut cx = EditorTestContext::new(cx).await; @@ -796,7 +792,11 @@ mod jsx_tag_autoclose_tests { #[gpui::test] async fn test_multibuffer(cx: &mut TestAppContext) { init_test(cx, |settings| { - settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true }); + settings + .defaults + .jsx_tag_auto_close + .get_or_insert_default() + .enabled = Some(true); }); let buffer_a = cx.new(|cx| { @@ -815,10 +815,7 @@ mod jsx_tag_autoclose_tests { ); buf }); - let buffer_c = cx.new(|cx| { - let buf = language::Buffer::local(", ) -> Option<()> { - if editor.pending_rename.is_some() { + if editor.ignore_lsp_data() || editor.pending_rename.is_some() { return None; } let project = editor.project()?.downgrade(); @@ -59,7 +59,7 @@ pub(super) fn refresh_linked_ranges( let mut applicable_selections = Vec::new(); editor .update(cx, |editor, cx| { - let selections = editor.selections.all::(cx); + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer.read(cx).snapshot(cx); let buffer = editor.buffer.read(cx); for selection in selections { @@ -72,7 +72,7 @@ pub(super) fn refresh_linked_ranges( // Throw away selections spanning multiple buffers. continue; } - if let Some(buffer) = end_position.buffer_id.and_then(|id| buffer.buffer(id)) { + if let Some(buffer) = buffer.buffer_for_anchor(end_position, cx) { applicable_selections.push(( buffer, start_position.text_anchor, diff --git a/crates/editor/src/lsp_colors.rs b/crates/editor/src/lsp_colors.rs index 08cf9078f2301e84ec96b49cbc1abb16eb611d68..050363f219ee5579a73cf168cce82778df8810ab 100644 --- a/crates/editor/src/lsp_colors.rs +++ b/crates/editor/src/lsp_colors.rs @@ -2,19 +2,19 @@ use std::{cmp, ops::Range}; use collections::HashMap; use futures::future::join_all; -use gpui::{Hsla, Rgba}; +use gpui::{Hsla, Rgba, Task}; use itertools::Itertools; use language::point_from_lsp; use multi_buffer::Anchor; -use project::{DocumentColor, lsp_store::LspFetchStrategy}; +use project::{DocumentColor, InlayId}; use settings::Settings as _; use text::{Bias, BufferId, OffsetRangeExt as _}; use ui::{App, Context, Window}; use util::post_inc; use crate::{ - DisplayPoint, Editor, EditorSettings, EditorSnapshot, InlayId, InlaySplice, RangeToAnchorExt, - display_map::Inlay, editor_settings::DocumentColorsRenderMode, + DisplayPoint, Editor, EditorSettings, EditorSnapshot, FETCH_COLORS_DEBOUNCE_TIMEOUT, + InlaySplice, RangeToAnchorExt, editor_settings::DocumentColorsRenderMode, inlays::Inlay, }; #[derive(Debug)] @@ -143,14 +143,13 @@ impl LspColorData { } impl Editor { - pub(super) fn refresh_colors( + pub(super) fn refresh_colors_for_visible_range( &mut self, - ignore_cache: bool, buffer_id: Option, _: &Window, cx: &mut Context, ) { - if !self.mode().is_full() { + if self.ignore_lsp_data() { return; } let Some(project) = self.project.clone() else { @@ -165,11 +164,13 @@ impl Editor { } let visible_buffers = self - .visible_excerpts(None, cx) + .visible_excerpts(cx) .into_values() .map(|(buffer, ..)| buffer) .filter(|editor_buffer| { - buffer_id.is_none_or(|buffer_id| buffer_id == editor_buffer.read(cx).remote_id()) + let editor_buffer_id = editor_buffer.read(cx).remote_id(); + buffer_id.is_none_or(|buffer_id| buffer_id == editor_buffer_id) + && self.registered_buffers.contains_key(&editor_buffer_id) }) .unique_by(|buffer| buffer.read(cx).remote_id()) .collect::>(); @@ -179,21 +180,25 @@ impl Editor { .into_iter() .filter_map(|buffer| { let buffer_id = buffer.read(cx).remote_id(); - let fetch_strategy = if ignore_cache { - LspFetchStrategy::IgnoreCache - } else { - LspFetchStrategy::UseCache { - known_cache_version: self.colors.as_ref().and_then(|colors| { - Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used) - }), - } - }; - let colors_task = lsp_store.document_colors(fetch_strategy, buffer, cx)?; + let known_cache_version = self.colors.as_ref().and_then(|colors| { + Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used) + }); + let colors_task = lsp_store.document_colors(known_cache_version, buffer, cx)?; Some(async move { (buffer_id, colors_task.await) }) }) .collect::>() }); - cx.spawn(async move |editor, cx| { + + if all_colors_task.is_empty() { + self.refresh_colors_task = Task::ready(()); + return; + } + + self.refresh_colors_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(FETCH_COLORS_DEBOUNCE_TIMEOUT) + .await; + let all_colors = join_all(all_colors_task).await; if all_colors.is_empty() { return; @@ -207,7 +212,7 @@ impl Editor { .entry(buffer_snapshot.remote_id()) .or_insert_with(Vec::new); let excerpt_point_range = - excerpt_range.context.to_point_utf16(&buffer_snapshot); + excerpt_range.context.to_point_utf16(buffer_snapshot); excerpt_data.push(( excerpt_id, buffer_snapshot.clone(), @@ -228,60 +233,57 @@ impl Editor { }; match colors { Ok(colors) => { - for color in colors.colors { - let color_start = point_from_lsp(color.lsp_range.start); - let color_end = point_from_lsp(color.lsp_range.end); + if colors.colors.is_empty() { + let new_entry = + new_editor_colors.entry(buffer_id).or_insert_with(|| { + (Vec::<(Range, DocumentColor)>::new(), None) + }); + new_entry.0.clear(); + new_entry.1 = colors.cache_version; + } else { + for color in colors.colors { + let color_start = point_from_lsp(color.lsp_range.start); + let color_end = point_from_lsp(color.lsp_range.end); - for (excerpt_id, buffer_snapshot, excerpt_range) in excerpts { - if !excerpt_range.contains(&color_start.0) - || !excerpt_range.contains(&color_end.0) - { - continue; - } - let Some(color_start_anchor) = multi_buffer_snapshot - .anchor_in_excerpt( - *excerpt_id, - buffer_snapshot.anchor_before( - buffer_snapshot - .clip_point_utf16(color_start, Bias::Left), - ), - ) - else { - continue; - }; - let Some(color_end_anchor) = multi_buffer_snapshot - .anchor_in_excerpt( - *excerpt_id, - buffer_snapshot.anchor_after( - buffer_snapshot - .clip_point_utf16(color_end, Bias::Right), - ), - ) - else { - continue; - }; + for (excerpt_id, buffer_snapshot, excerpt_range) in excerpts { + if !excerpt_range.contains(&color_start.0) + || !excerpt_range.contains(&color_end.0) + { + continue; + } + let start = buffer_snapshot.anchor_before( + buffer_snapshot.clip_point_utf16(color_start, Bias::Left), + ); + let end = buffer_snapshot.anchor_after( + buffer_snapshot.clip_point_utf16(color_end, Bias::Right), + ); + let Some(range) = multi_buffer_snapshot + .anchor_range_in_excerpt(*excerpt_id, start..end) + else { + continue; + }; - let new_entry = - new_editor_colors.entry(buffer_id).or_insert_with(|| { - (Vec::<(Range, DocumentColor)>::new(), None) - }); - new_entry.1 = colors.cache_version; - let new_buffer_colors = &mut new_entry.0; + let new_entry = + new_editor_colors.entry(buffer_id).or_insert_with(|| { + (Vec::<(Range, DocumentColor)>::new(), None) + }); + new_entry.1 = colors.cache_version; + let new_buffer_colors = &mut new_entry.0; - let (Ok(i) | Err(i)) = - new_buffer_colors.binary_search_by(|(probe, _)| { - probe - .start - .cmp(&color_start_anchor, &multi_buffer_snapshot) - .then_with(|| { - probe - .end - .cmp(&color_end_anchor, &multi_buffer_snapshot) - }) - }); - new_buffer_colors - .insert(i, (color_start_anchor..color_end_anchor, color)); - break; + let (Ok(i) | Err(i)) = + new_buffer_colors.binary_search_by(|(probe, _)| { + probe + .start + .cmp(&range.start, &multi_buffer_snapshot) + .then_with(|| { + probe + .end + .cmp(&range.end, &multi_buffer_snapshot) + }) + }); + new_buffer_colors.insert(i, (range, color)); + break; + } } } } @@ -398,8 +400,7 @@ impl Editor { } if colors.render_mode == DocumentColorsRenderMode::Inlay - && (!colors_splice.to_insert.is_empty() - || !colors_splice.to_remove.is_empty()) + && !colors_splice.is_empty() { editor.splice_inlays(&colors_splice.to_remove, colors_splice.to_insert, cx); updated = true; @@ -410,7 +411,6 @@ impl Editor { } }) .ok(); - }) - .detach(); + }); } } diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index 6161afbbc0377d377e352f357b5a0ea6b0606770..0c4760f5684acf450b793a1deac54be983dcafd0 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -35,7 +35,7 @@ where let project = editor.project.clone()?; editor .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .filter_map(|selection| Some((selection.head(), selection.head().buffer_id?))) .unique_by(|(_, buffer_id)| *buffer_id) @@ -76,7 +76,7 @@ async fn lsp_task_context( let project_env = project .update(cx, |project, cx| { - project.buffer_environment(&buffer, &worktree_store, cx) + project.buffer_environment(buffer, &worktree_store, cx) }) .ok()? .await; @@ -147,16 +147,15 @@ pub fn lsp_tasks( }, cx, ) - }) { - if let Some(new_runnables) = runnables_task.await.log_err() { - new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map( - |(location, runnable)| { - let resolved_task = - runnable.resolve_task(&id_base, &lsp_buffer_context)?; - Some((location, resolved_task)) - }, - )); - } + }) && let Some(new_runnables) = runnables_task.await.log_err() + { + new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map( + |(location, runnable)| { + let resolved_task = + runnable.resolve_task(&id_base, &lsp_buffer_context)?; + Some((location, resolved_task)) + }, + )); } lsp_tasks .entry(source_kind) diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 9d5145dec1f380013fbf76776efd077d7b466a37..7c83113f7837565efc59889e74bf397b392c516b 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -11,6 +11,7 @@ use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscri use std::ops::Range; use text::PointUtf16; use workspace::OpenInTerminal; +use zed_actions::agent::AddSelectionToThread; #[derive(Debug)] pub enum MenuPosition { @@ -54,20 +55,20 @@ impl MouseContextMenu { let content_origin = editor.last_bounds?.origin + Point { x: editor.gutter_dimensions.width, - y: Pixels(0.0), + y: Pixels::ZERO, }; let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?; let menu_position = MenuPosition::PinnedToEditor { source, offset: position - (source_position + content_origin), }; - return Some(MouseContextMenu::new( + Some(MouseContextMenu::new( editor, menu_position, context_menu, window, cx, - )); + )) } pub(crate) fn new( @@ -102,11 +103,11 @@ impl MouseContextMenu { let display_snapshot = &editor .display_map .update(cx, |display_map, cx| display_map.snapshot(cx)); - let selection_init_range = selection_init.display_range(&display_snapshot); + let selection_init_range = selection_init.display_range(display_snapshot); let selection_now_range = editor .selections .newest_anchor() - .display_range(&display_snapshot); + .display_range(display_snapshot); if selection_now_range == selection_init_range { return; } @@ -130,12 +131,9 @@ fn display_ranges<'a>( display_map: &'a DisplaySnapshot, selections: &'a SelectionsCollection, ) -> impl Iterator> + 'a { - let pending = selections - .pending - .as_ref() - .map(|pending| &pending.selection); + let pending = selections.pending_anchor(); selections - .disjoint + .disjoint_anchors() .iter() .chain(pending) .map(move |s| s.start.to_display_point(display_map)..s.end.to_display_point(display_map)) @@ -157,7 +155,7 @@ pub fn deploy_context_menu( return; } - let display_map = editor.selections.display_map(cx); + let display_map = editor.display_snapshot(cx); let source_anchor = display_map.display_point_to_anchor(point, text::Bias::Right); let context_menu = if let Some(custom) = editor.custom_context_menu.take() { let menu = custom(editor, point, window, cx); @@ -172,8 +170,9 @@ pub fn deploy_context_menu( return; }; - let display_map = editor.selections.display_map(cx); - let buffer = &editor.snapshot(window, cx).buffer_snapshot; + let snapshot = editor.snapshot(window, cx); + let display_map = editor.display_snapshot(cx); + let buffer = snapshot.buffer_snapshot(); let anchor = buffer.anchor_before(point.to_point(&display_map)); if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) { // Move the cursor to the clicked location so that dispatched actions make sense @@ -187,17 +186,19 @@ pub fn deploy_context_menu( let has_reveal_target = editor.target_file(cx).is_some(); let has_selections = editor .selections - .all::(cx) + .all::(&display_map) .into_iter() .any(|s| !s.is_empty()); - let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| { - project - .read(cx) - .git_store() - .read(cx) - .repository_and_path_for_buffer_id(buffer_id, cx) - .is_some() - }); + let has_git_repo = buffer + .buffer_id_for_anchor(anchor) + .is_some_and(|buffer_id| { + project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .is_some() + }); let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx); let run_to_cursor = window.is_action_available(&RunToCursor, cx); @@ -233,6 +234,7 @@ pub fn deploy_context_menu( quick_launch: false, }), ) + .action("Add to Agent Thread", Box::new(AddSelectionToThread)) .separator() .action("Cut", Box::new(Cut)) .action("Copy", Box::new(Copy)) diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index fdda0e82bca6a85b25042ad7e8a662ff2fdae49d..418fa4fcb442b1de133972457497c0e592e77d15 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -2,9 +2,12 @@ //! in editor given a given motion (e.g. it handles converting a "move left" command into coordinates in editor). It is exposed mostly for use by vim crate. use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{DisplayRow, EditorStyle, ToOffset, ToPoint, scroll::ScrollAnchor}; +use crate::{ + DisplayRow, EditorStyle, ToOffset, ToPoint, + scroll::{ScrollAnchor, ScrollOffset}, +}; use gpui::{Pixels, WindowTextSystem}; -use language::Point; +use language::{CharClassifier, Point}; use multi_buffer::{MultiBufferRow, MultiBufferSnapshot}; use serde::Deserialize; use workspace::searchable::Direction; @@ -27,8 +30,8 @@ pub struct TextLayoutDetails { pub(crate) editor_style: EditorStyle, pub(crate) rem_size: Pixels, pub scroll_anchor: ScrollAnchor, - pub visible_rows: Option, - pub vertical_scroll_margin: f32, + pub visible_rows: Option, + pub vertical_scroll_margin: ScrollOffset, } /// Returns a column to the left of the current point, wrapping @@ -220,7 +223,7 @@ pub fn indented_line_beginning( let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right); let indent_start = Point::new( point.row, - map.buffer_snapshot + map.buffer_snapshot() .indent_size_for_line(MultiBufferRow(point.row)) .len, ) @@ -262,7 +265,7 @@ pub fn line_end( /// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS). pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); let mut is_first_iteration = true; find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { @@ -286,37 +289,142 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa /// uppercase letter, lowercase letter, '_' character, language-specific word character (like '-' in CSS) or newline. pub fn previous_word_start_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { - (classifier.kind(left) != classifier.kind(right) && !right.is_whitespace()) + (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right)) || left == '\n' || right == '\n' }) } +/// Text movements are too greedy, making deletions too greedy too. +/// Makes deletions more ergonomic by potentially reducing the deletion range based on its text contents: +/// * whitespace sequences with length >= 2 stop the deletion after removal (despite movement jumping over the word behind the whitespaces) +/// * brackets stop the deletion after removal (despite movement currently not accounting for these and jumping over) +pub fn adjust_greedy_deletion( + map: &DisplaySnapshot, + delete_from: DisplayPoint, + delete_until: DisplayPoint, + ignore_brackets: bool, +) -> DisplayPoint { + if delete_from == delete_until { + return delete_until; + } + let is_backward = delete_from > delete_until; + let delete_range = if is_backward { + map.display_point_to_point(delete_until, Bias::Left) + .to_offset(map.buffer_snapshot()) + ..map + .display_point_to_point(delete_from, Bias::Right) + .to_offset(map.buffer_snapshot()) + } else { + map.display_point_to_point(delete_from, Bias::Left) + .to_offset(map.buffer_snapshot()) + ..map + .display_point_to_point(delete_until, Bias::Right) + .to_offset(map.buffer_snapshot()) + }; + + let trimmed_delete_range = if ignore_brackets { + delete_range + } else { + let brackets_in_delete_range = map + .buffer_snapshot() + .bracket_ranges(delete_range.clone()) + .into_iter() + .flatten() + .flat_map(|(left_bracket, right_bracket)| { + [ + left_bracket.start, + left_bracket.end, + right_bracket.start, + right_bracket.end, + ] + }) + .filter(|&bracket| delete_range.start < bracket && bracket < delete_range.end); + let closest_bracket = if is_backward { + brackets_in_delete_range.max() + } else { + brackets_in_delete_range.min() + }; + + if is_backward { + closest_bracket.unwrap_or(delete_range.start)..delete_range.end + } else { + delete_range.start..closest_bracket.unwrap_or(delete_range.end) + } + }; + + let mut whitespace_sequences = Vec::new(); + let mut current_offset = trimmed_delete_range.start; + let mut whitespace_sequence_length = 0; + let mut whitespace_sequence_start = 0; + for ch in map + .buffer_snapshot() + .text_for_range(trimmed_delete_range.clone()) + .flat_map(str::chars) + { + if ch.is_whitespace() { + if whitespace_sequence_length == 0 { + whitespace_sequence_start = current_offset; + } + whitespace_sequence_length += 1; + } else { + if whitespace_sequence_length >= 2 { + whitespace_sequences.push((whitespace_sequence_start, current_offset)); + } + whitespace_sequence_start = 0; + whitespace_sequence_length = 0; + } + current_offset += ch.len_utf8(); + } + if whitespace_sequence_length >= 2 { + whitespace_sequences.push((whitespace_sequence_start, current_offset)); + } + + let closest_whitespace_end = if is_backward { + whitespace_sequences.last().map(|&(start, _)| start) + } else { + whitespace_sequences.first().map(|&(_, end)| end) + }; + + closest_whitespace_end + .unwrap_or_else(|| { + if is_backward { + trimmed_delete_range.start + } else { + trimmed_delete_range.end + } + }) + .to_display_point(map) +} + /// Returns a position of the previous subword boundary, where a subword is defined as a run of /// word characters of the same "subkind" - where subcharacter kinds are '_' character, /// lowerspace characters and uppercase characters. pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { - let is_word_start = - classifier.kind(left) != classifier.kind(right) && !right.is_whitespace(); - let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' - || left == '_' && right != '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_start || is_subword_start || left == '\n' + is_subword_start(left, right, &classifier) || left == '\n' }) } +pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) -> bool { + let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace(); + let is_subword_start = classifier.is_word('-') && left == '-' && right != '-' + || left == '_' && right != '_' + || left.is_lowercase() && right.is_uppercase(); + is_word_start || is_subword_start +} + /// Returns a position of the next word boundary, where a word character is defined as either /// uppercase letter, lowercase letter, '_' character or language-specific word character (like '-' in CSS). pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); let mut is_first_iteration = true; find_boundary(map, point, FindRange::MultiLine, |left, right| { // Make alt-right skip punctuation to respect VSCode behaviour. For example: |.hello goes to .hello| @@ -339,7 +447,7 @@ pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint /// uppercase letter, lowercase letter, '_' character, language-specific word character (like '-' in CSS) or newline. pub fn next_word_end_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); let mut on_starting_row = true; find_boundary(map, point, FindRange::MultiLine, |left, right| { @@ -358,18 +466,22 @@ pub fn next_word_end_or_newline(map: &DisplaySnapshot, point: DisplayPoint) -> D /// lowerspace characters and uppercase characters. pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { let raw_point = point.to_point(map); - let classifier = map.buffer_snapshot.char_classifier_at(raw_point); + let classifier = map.buffer_snapshot().char_classifier_at(raw_point); find_boundary(map, point, FindRange::MultiLine, |left, right| { - let is_word_end = - (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left); - let is_subword_end = classifier.is_word('-') && left != '-' && right == '-' - || left != '_' && right == '_' - || left.is_lowercase() && right.is_uppercase(); - is_word_end || is_subword_end || right == '\n' + is_subword_end(left, right, &classifier) || right == '\n' }) } +pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> bool { + let is_word_end = + (classifier.kind(left) != classifier.kind(right)) && !classifier.is_whitespace(left); + let is_subword_end = classifier.is_word('-') && left != '-' && right == '-' + || left != '_' && right == '_' + || left.is_lowercase() && right.is_uppercase(); + is_word_end || is_subword_end +} + /// Returns a position of the start of the current paragraph, where a paragraph /// is defined as a run of non-blank lines. pub fn start_of_paragraph( @@ -384,7 +496,7 @@ pub fn start_of_paragraph( let mut found_non_blank_line = false; for row in (0..point.row + 1).rev() { - let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); + let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row)); if found_non_blank_line && blank { if count <= 1 { return Point::new(row, 0).to_display_point(map); @@ -407,13 +519,13 @@ pub fn end_of_paragraph( mut count: usize, ) -> DisplayPoint { let point = display_point.to_point(map); - if point.row == map.buffer_snapshot.max_row().0 { + if point.row == map.buffer_snapshot().max_row().0 { return map.max_point(); } let mut found_non_blank_line = false; - for row in point.row..=map.buffer_snapshot.max_row().0 { - let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); + for row in point.row..=map.buffer_snapshot().max_row().0 { + let blank = map.buffer_snapshot().is_line_blank(MultiBufferRow(row)); if found_non_blank_line && blank { if count <= 1 { return Point::new(row, 0).to_display_point(map); @@ -434,22 +546,22 @@ pub fn start_of_excerpt( direction: Direction, ) -> DisplayPoint { let point = map.display_point_to_point(display_point, Bias::Left); - let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else { + let Some(excerpt) = map.buffer_snapshot().excerpt_containing(point..point) else { return display_point; }; match direction { Direction::Prev => { - let mut start = excerpt.start_anchor().to_display_point(&map); + let mut start = excerpt.start_anchor().to_display_point(map); if start >= display_point && start.row() > DisplayRow(0) { - let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else { + let Some(excerpt) = map.buffer_snapshot().excerpt_before(excerpt.id()) else { return display_point; }; - start = excerpt.start_anchor().to_display_point(&map); + start = excerpt.start_anchor().to_display_point(map); } start } Direction::Next => { - let mut end = excerpt.end_anchor().to_display_point(&map); + let mut end = excerpt.end_anchor().to_display_point(map); *end.row_mut() += 1; map.clip_point(end, Bias::Right) } @@ -462,12 +574,12 @@ pub fn end_of_excerpt( direction: Direction, ) -> DisplayPoint { let point = map.display_point_to_point(display_point, Bias::Left); - let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else { + let Some(excerpt) = map.buffer_snapshot().excerpt_containing(point..point) else { return display_point; }; match direction { Direction::Prev => { - let mut start = excerpt.start_anchor().to_display_point(&map); + let mut start = excerpt.start_anchor().to_display_point(map); if start.row() > DisplayRow(0) { *start.row_mut() -= 1; } @@ -476,16 +588,18 @@ pub fn end_of_excerpt( start } Direction::Next => { - let mut end = excerpt.end_anchor().to_display_point(&map); + let mut end = excerpt.end_anchor().to_display_point(map); *end.column_mut() = 0; if end <= display_point { *end.row_mut() += 1; let point_end = map.display_point_to_point(end, Bias::Right); - let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point_end..point_end) + let Some(excerpt) = map + .buffer_snapshot() + .excerpt_containing(point_end..point_end) else { return display_point; }; - end = excerpt.end_anchor().to_display_point(&map); + end = excerpt.end_anchor().to_display_point(map); *end.column_mut() = 0; } end @@ -510,10 +624,10 @@ pub fn find_preceding_boundary_point( if find_range == FindRange::SingleLine && ch == '\n' { break; } - if let Some(prev_ch) = prev_ch { - if is_boundary(ch, prev_ch) { - break; - } + if let Some(prev_ch) = prev_ch + && is_boundary(ch, prev_ch) + { + break; } offset -= ch.len_utf8(); @@ -534,7 +648,7 @@ pub fn find_preceding_boundary_display_point( is_boundary: impl FnMut(char, char) -> bool, ) -> DisplayPoint { let result = find_preceding_boundary_point( - &map.buffer_snapshot, + map.buffer_snapshot(), from.to_point(map), find_range, is_boundary, @@ -558,17 +672,17 @@ pub fn find_boundary_point( let mut prev_offset = offset; let mut prev_ch = None; - for ch in map.buffer_snapshot.chars_at(offset) { + for ch in map.buffer_snapshot().chars_at(offset) { if find_range == FindRange::SingleLine && ch == '\n' { break; } - if let Some(prev_ch) = prev_ch { - if is_boundary(prev_ch, ch) { - if return_point_before_boundary { - return map.clip_point(prev_offset.to_display_point(map), Bias::Right); - } else { - break; - } + if let Some(prev_ch) = prev_ch + && is_boundary(prev_ch, ch) + { + if return_point_before_boundary { + return map.clip_point(prev_offset.to_display_point(map), Bias::Right); + } else { + break; } } prev_offset = offset; @@ -586,8 +700,8 @@ pub fn find_preceding_boundary_trail( let mut offset = head.to_offset(map, Bias::Left); let mut trail_offset = None; - let mut prev_ch = map.buffer_snapshot.chars_at(offset).next(); - let mut forward = map.buffer_snapshot.reversed_chars_at(offset).peekable(); + let mut prev_ch = map.buffer_snapshot().chars_at(offset).next(); + let mut forward = map.buffer_snapshot().reversed_chars_at(offset).peekable(); // Skip newlines while let Some(&ch) = forward.peek() { @@ -603,13 +717,13 @@ pub fn find_preceding_boundary_trail( // Find the boundary let start_offset = offset; for ch in forward { - if let Some(prev_ch) = prev_ch { - if is_boundary(prev_ch, ch) { - if start_offset == offset { - trail_offset = Some(offset); - } else { - break; - } + if let Some(prev_ch) = prev_ch + && is_boundary(prev_ch, ch) + { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; } } offset -= ch.len_utf8(); @@ -634,8 +748,8 @@ pub fn find_boundary_trail( let mut offset = head.to_offset(map, Bias::Right); let mut trail_offset = None; - let mut prev_ch = map.buffer_snapshot.reversed_chars_at(offset).next(); - let mut forward = map.buffer_snapshot.chars_at(offset).peekable(); + let mut prev_ch = map.buffer_snapshot().reversed_chars_at(offset).next(); + let mut forward = map.buffer_snapshot().chars_at(offset).peekable(); // Skip newlines while let Some(&ch) = forward.peek() { @@ -651,13 +765,13 @@ pub fn find_boundary_trail( // Find the boundary let start_offset = offset; for ch in forward { - if let Some(prev_ch) = prev_ch { - if is_boundary(prev_ch, ch) { - if start_offset == offset { - trail_offset = Some(offset); - } else { - break; - } + if let Some(prev_ch) = prev_ch + && is_boundary(prev_ch, ch) + { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; } } offset += ch.len_utf8(); @@ -698,7 +812,7 @@ pub fn chars_after( map: &DisplaySnapshot, mut offset: usize, ) -> impl Iterator)> + '_ { - map.buffer_snapshot.chars_at(offset).map(move |ch| { + map.buffer_snapshot().chars_at(offset).map(move |ch| { let before = offset; offset += ch.len_utf8(); (ch, before..offset) @@ -712,7 +826,7 @@ pub fn chars_before( map: &DisplaySnapshot, mut offset: usize, ) -> impl Iterator)> + '_ { - map.buffer_snapshot + map.buffer_snapshot() .reversed_chars_at(offset) .map(move |ch| { let after = offset; @@ -758,7 +872,7 @@ mod tests { use super::*; use crate::{ Buffer, DisplayMap, DisplayRow, ExcerptRange, FoldPlaceholder, MultiBuffer, - display_map::Inlay, + inlays::Inlay, test::{editor_test_context::EditorTestContext, marked_display_snapshot}, }; use gpui::{AppContext as _, font, px}; @@ -909,22 +1023,22 @@ mod tests { [ Inlay::edit_prediction( post_inc(&mut id), - buffer_snapshot.anchor_at(offset, Bias::Left), + buffer_snapshot.anchor_before(offset), "test", ), Inlay::edit_prediction( post_inc(&mut id), - buffer_snapshot.anchor_at(offset, Bias::Right), + buffer_snapshot.anchor_after(offset), "test", ), Inlay::mock_hint( post_inc(&mut id), - buffer_snapshot.anchor_at(offset, Bias::Left), + buffer_snapshot.anchor_before(offset), "test", ), Inlay::mock_hint( post_inc(&mut id), - buffer_snapshot.anchor_at(offset, Bias::Right), + buffer_snapshot.anchor_after(offset), "test", ), ] @@ -943,7 +1057,7 @@ mod tests { |left, _| left == 'e', ), snapshot - .buffer_snapshot + .buffer_snapshot() .offset_to_point(5) .to_display_point(&snapshot), "Should not stop at inlays when looking for boundaries" @@ -1111,13 +1225,13 @@ mod tests { up( &snapshot, DisplayPoint::new(DisplayRow(0), 2), - SelectionGoal::HorizontalPosition(col_2_x.0), + SelectionGoal::HorizontalPosition(f64::from(col_2_x)), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(0), 0), - SelectionGoal::HorizontalPosition(col_2_x.0), + SelectionGoal::HorizontalPosition(f64::from(col_2_x)), ), ); assert_eq!( @@ -1142,26 +1256,26 @@ mod tests { up( &snapshot, DisplayPoint::new(DisplayRow(1), 4), - SelectionGoal::HorizontalPosition(col_4_x.0), + SelectionGoal::HorizontalPosition(col_4_x.into()), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(0), 3), - SelectionGoal::HorizontalPosition(col_4_x.0) + SelectionGoal::HorizontalPosition(col_4_x.into()) ), ); assert_eq!( down( &snapshot, DisplayPoint::new(DisplayRow(0), 3), - SelectionGoal::HorizontalPosition(col_4_x.0), + SelectionGoal::HorizontalPosition(col_4_x.into()), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(1), 4), - SelectionGoal::HorizontalPosition(col_4_x.0) + SelectionGoal::HorizontalPosition(col_4_x.into()) ), ); @@ -1173,26 +1287,26 @@ mod tests { up( &snapshot, DisplayPoint::new(DisplayRow(3), 5), - SelectionGoal::HorizontalPosition(col_5_x.0), + SelectionGoal::HorizontalPosition(col_5_x.into()), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(1), 4), - SelectionGoal::HorizontalPosition(col_5_x.0) + SelectionGoal::HorizontalPosition(col_5_x.into()) ), ); assert_eq!( down( &snapshot, DisplayPoint::new(DisplayRow(1), 4), - SelectionGoal::HorizontalPosition(col_5_x.0), + SelectionGoal::HorizontalPosition(col_5_x.into()), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(3), 5), - SelectionGoal::HorizontalPosition(col_5_x.0) + SelectionGoal::HorizontalPosition(col_5_x.into()) ), ); @@ -1217,13 +1331,13 @@ mod tests { down( &snapshot, DisplayPoint::new(DisplayRow(4), 2), - SelectionGoal::HorizontalPosition(max_point_x.0), + SelectionGoal::HorizontalPosition(max_point_x.into()), false, &text_layout_details ), ( DisplayPoint::new(DisplayRow(4), 2), - SelectionGoal::HorizontalPosition(max_point_x.0) + SelectionGoal::HorizontalPosition(max_point_x.into()) ), ); }); diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index 88fde539479b3159a2fbcb7e3b0473d4b4b91e76..840398474bb02542e452c79864b722cc91b111b3 100644 --- a/crates/editor/src/persistence.rs +++ b/crates/editor/src/persistence.rs @@ -1,13 +1,17 @@ use anyhow::Result; -use db::sqlez::bindable::{Bind, Column, StaticColumnCount}; -use db::sqlez::statement::Statement; +use db::{ + query, + sqlez::{ + bindable::{Bind, Column, StaticColumnCount}, + domain::Domain, + statement::Statement, + }, + sqlez_macros::sql, +}; use fs::MTime; use itertools::Itertools as _; use std::path::PathBuf; -use db::sqlez_macros::sql; -use db::{define_connection, query}; - use workspace::{ItemId, WorkspaceDb, WorkspaceId}; #[derive(Clone, Debug, PartialEq, Default)] @@ -31,7 +35,7 @@ impl Bind for SerializedEditor { &self .abs_path .as_ref() - .map(|p| p.to_string_lossy().to_string()), + .map(|p| p.to_string_lossy().into_owned()), start_index, )?; let start_index = statement.bind(&self.contents, start_index)?; @@ -83,7 +87,11 @@ impl Column for SerializedEditor { } } -define_connection!( +pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for EditorDb { + const NAME: &str = stringify!(EditorDb); + // Current schema shape using pseudo-rust syntax: // editors( // item_id: usize, @@ -113,7 +121,8 @@ define_connection!( // start: usize, // end: usize, // ) - pub static ref DB: EditorDb = &[ + + const MIGRATIONS: &[&str] = &[ sql! ( CREATE TABLE editors( item_id INTEGER NOT NULL, @@ -189,7 +198,9 @@ define_connection!( ) STRICT; ), ]; -); +} + +db::static_connection!(DB, EditorDb, [WorkspaceDb]); // https://www.sqlite.org/limits.html // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, @@ -224,7 +235,7 @@ impl EditorDb { // Returns the scroll top row, and offset query! { - pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + pub fn get_scroll_position(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { SELECT scroll_top_row, scroll_horizontal_offset, scroll_vertical_offset FROM editors WHERE item_id = ? AND workspace_id = ? @@ -236,8 +247,8 @@ impl EditorDb { item_id: ItemId, workspace_id: WorkspaceId, top_row: u32, - vertical_offset: f32, - horizontal_offset: f32 + vertical_offset: f64, + horizontal_offset: f64 ) -> Result<()> { UPDATE OR IGNORE editors SET diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs deleted file mode 100644 index 1ead45b3de89c0705510f8afc55ecf6176a4d7a2..0000000000000000000000000000000000000000 --- a/crates/editor/src/proposed_changes_editor.rs +++ /dev/null @@ -1,527 +0,0 @@ -use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider}; -use buffer_diff::BufferDiff; -use collections::HashSet; -use futures::{channel::mpsc, future::join_all}; -use gpui::{App, Entity, EventEmitter, Focusable, Render, Subscription, Task}; -use language::{Buffer, BufferEvent, Capability}; -use multi_buffer::{ExcerptRange, MultiBuffer}; -use project::Project; -use smol::stream::StreamExt; -use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; -use text::ToOffset; -use ui::{ButtonLike, KeyBinding, prelude::*}; -use workspace::{ - Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, - item::SaveOptions, searchable::SearchableItemHandle, -}; - -pub struct ProposedChangesEditor { - editor: Entity, - multibuffer: Entity, - title: SharedString, - buffer_entries: Vec, - _recalculate_diffs_task: Task>, - recalculate_diffs_tx: mpsc::UnboundedSender, -} - -pub struct ProposedChangeLocation { - pub buffer: Entity, - pub ranges: Vec>, -} - -struct BufferEntry { - base: Entity, - branch: Entity, - _subscription: Subscription, -} - -pub struct ProposedChangesEditorToolbar { - current_editor: Option>, -} - -struct RecalculateDiff { - buffer: Entity, - debounce: bool, -} - -/// A provider of code semantics for branch buffers. -/// -/// Requests in edited regions will return nothing, but requests in unchanged -/// regions will be translated into the base buffer's coordinates. -struct BranchBufferSemanticsProvider(Rc); - -impl ProposedChangesEditor { - pub fn new( - title: impl Into, - locations: Vec>, - project: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); - let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded(); - let mut this = Self { - editor: cx.new(|cx| { - let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, window, cx); - editor.set_expand_all_diff_hunks(cx); - editor.set_completion_provider(None); - editor.clear_code_action_providers(); - editor.set_semantics_provider( - editor - .semantics_provider() - .map(|provider| Rc::new(BranchBufferSemanticsProvider(provider)) as _), - ); - editor - }), - multibuffer, - title: title.into(), - buffer_entries: Vec::new(), - recalculate_diffs_tx, - _recalculate_diffs_task: cx.spawn_in(window, async move |this, cx| { - let mut buffers_to_diff = HashSet::default(); - while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await { - buffers_to_diff.insert(recalculate_diff.buffer); - - while recalculate_diff.debounce { - cx.background_executor() - .timer(Duration::from_millis(50)) - .await; - let mut had_further_changes = false; - while let Ok(next_recalculate_diff) = recalculate_diffs_rx.try_next() { - let next_recalculate_diff = next_recalculate_diff?; - recalculate_diff.debounce &= next_recalculate_diff.debounce; - buffers_to_diff.insert(next_recalculate_diff.buffer); - had_further_changes = true; - } - if !had_further_changes { - break; - } - } - - let recalculate_diff_futures = this - .update(cx, |this, cx| { - buffers_to_diff - .drain() - .filter_map(|buffer| { - let buffer = buffer.read(cx); - let base_buffer = buffer.base_buffer()?; - let buffer = buffer.text_snapshot(); - let diff = - this.multibuffer.read(cx).diff_for(buffer.remote_id())?; - Some(diff.update(cx, |diff, cx| { - diff.set_base_text_buffer(base_buffer.clone(), buffer, cx) - })) - }) - .collect::>() - }) - .ok()?; - - join_all(recalculate_diff_futures).await; - } - None - }), - }; - this.reset_locations(locations, window, cx); - this - } - - pub fn branch_buffer_for_base(&self, base_buffer: &Entity) -> Option> { - self.buffer_entries.iter().find_map(|entry| { - if &entry.base == base_buffer { - Some(entry.branch.clone()) - } else { - None - } - }) - } - - pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { - self.title = title; - cx.notify(); - } - - pub fn reset_locations( - &mut self, - locations: Vec>, - window: &mut Window, - cx: &mut Context, - ) { - // Undo all branch changes - for entry in &self.buffer_entries { - let base_version = entry.base.read(cx).version(); - entry.branch.update(cx, |buffer, cx| { - let undo_counts = buffer - .operations() - .iter() - .filter_map(|(timestamp, _)| { - if !base_version.observed(*timestamp) { - Some((*timestamp, u32::MAX)) - } else { - None - } - }) - .collect(); - buffer.undo_operations(undo_counts, cx); - }); - } - - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - }); - - let mut buffer_entries = Vec::new(); - let mut new_diffs = Vec::new(); - for location in locations { - let branch_buffer; - if let Some(ix) = self - .buffer_entries - .iter() - .position(|entry| entry.base == location.buffer) - { - let entry = self.buffer_entries.remove(ix); - branch_buffer = entry.branch.clone(); - buffer_entries.push(entry); - } else { - branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); - new_diffs.push(cx.new(|cx| { - let mut diff = BufferDiff::new(&branch_buffer.read(cx).snapshot(), cx); - let _ = diff.set_base_text_buffer( - location.buffer.clone(), - branch_buffer.read(cx).text_snapshot(), - cx, - ); - diff - })); - buffer_entries.push(BufferEntry { - branch: branch_buffer.clone(), - base: location.buffer.clone(), - _subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event), - }); - } - - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.push_excerpts( - branch_buffer, - location - .ranges - .into_iter() - .map(|range| ExcerptRange::new(range)), - cx, - ); - }); - } - - self.buffer_entries = buffer_entries; - self.editor.update(cx, |editor, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.refresh() - }); - editor.buffer.update(cx, |buffer, cx| { - for diff in new_diffs { - buffer.add_diff(diff, cx) - } - }) - }); - } - - pub fn recalculate_all_buffer_diffs(&self) { - for (ix, entry) in self.buffer_entries.iter().enumerate().rev() { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer: entry.branch.clone(), - debounce: ix > 0, - }) - .ok(); - } - } - - fn on_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - _cx: &mut Context, - ) { - match event { - BufferEvent::Operation { .. } => { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer, - debounce: true, - }) - .ok(); - } - // BufferEvent::DiffBaseChanged => { - // self.recalculate_diffs_tx - // .unbounded_send(RecalculateDiff { - // buffer, - // debounce: false, - // }) - // .ok(); - // } - _ => (), - } - } -} - -impl Render for ProposedChangesEditor { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .key_context("ProposedChangesEditor") - .child(self.editor.clone()) - } -} - -impl Focusable for ProposedChangesEditor { - fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl EventEmitter for ProposedChangesEditor {} - -impl Item for ProposedChangesEditor { - type Event = EditorEvent; - - fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::Diff)) - } - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - self.title.clone() - } - - fn as_searchable(&self, _: &Entity) -> Option> { - Some(Box::new(self.editor.clone())) - } - - fn act_as_type<'a>( - &'a self, - type_id: TypeId, - self_handle: &'a Entity, - _: &'a App, - ) -> Option { - if type_id == TypeId::of::() { - Some(self_handle.to_any()) - } else if type_id == TypeId::of::() { - Some(self.editor.to_any()) - } else { - None - } - } - - fn added_to_workspace( - &mut self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - Item::added_to_workspace(editor, workspace, window, cx) - }); - } - - fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { - self.editor - .update(cx, |editor, cx| editor.deactivated(window, cx)); - } - - fn navigate( - &mut self, - data: Box, - window: &mut Window, - cx: &mut Context, - ) -> bool { - self.editor - .update(cx, |editor, cx| Item::navigate(editor, data, window, cx)) - } - - fn set_nav_history( - &mut self, - nav_history: workspace::ItemNavHistory, - window: &mut Window, - cx: &mut Context, - ) { - self.editor.update(cx, |editor, cx| { - Item::set_nav_history(editor, nav_history, window, cx) - }); - } - - fn can_save(&self, cx: &App) -> bool { - self.editor.read(cx).can_save(cx) - } - - fn save( - &mut self, - options: SaveOptions, - project: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - self.editor.update(cx, |editor, cx| { - Item::save(editor, options, project, window, cx) - }) - } -} - -impl ProposedChangesEditorToolbar { - pub fn new() -> Self { - Self { - current_editor: None, - } - } - - fn get_toolbar_item_location(&self) -> ToolbarItemLocation { - if self.current_editor.is_some() { - ToolbarItemLocation::PrimaryRight - } else { - ToolbarItemLocation::Hidden - } - } -} - -impl Render for ProposedChangesEditorToolbar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All")); - - match &self.current_editor { - Some(editor) => { - let focus_handle = editor.focus_handle(cx); - let keybinding = - KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window, cx) - .map(|binding| binding.into_any_element()); - - button_like.children(keybinding).on_click({ - move |_event, window, cx| { - focus_handle.dispatch_action(&ApplyAllDiffHunks, window, cx) - } - }) - } - None => button_like.disabled(true), - } - } -} - -impl EventEmitter for ProposedChangesEditorToolbar {} - -impl ToolbarItemView for ProposedChangesEditorToolbar { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn workspace::ItemHandle>, - _window: &mut Window, - _cx: &mut Context, - ) -> workspace::ToolbarItemLocation { - self.current_editor = - active_pane_item.and_then(|item| item.downcast::()); - self.get_toolbar_item_location() - } -} - -impl BranchBufferSemanticsProvider { - fn to_base( - &self, - buffer: &Entity, - positions: &[text::Anchor], - cx: &App, - ) -> Option> { - let base_buffer = buffer.read(cx).base_buffer()?; - let version = base_buffer.read(cx).version(); - if positions - .iter() - .any(|position| !version.observed(position.timestamp)) - { - return None; - } - Some(base_buffer) - } -} - -impl SemanticsProvider for BranchBufferSemanticsProvider { - fn hover( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>> { - let buffer = self.to_base(buffer, &[position], cx)?; - self.0.hover(&buffer, position, cx) - } - - fn inlay_hints( - &self, - buffer: Entity, - range: Range, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(&buffer, &[range.start, range.end], cx)?; - self.0.inlay_hints(buffer, range, cx) - } - - fn inline_values( - &self, - _: Entity, - _: Range, - _: &mut App, - ) -> Option>>> { - None - } - - fn resolve_inlay_hint( - &self, - hint: project::InlayHint, - buffer: Entity, - server_id: lsp::LanguageServerId, - cx: &mut App, - ) -> Option>> { - let buffer = self.to_base(&buffer, &[], cx)?; - self.0.resolve_inlay_hint(hint, buffer, server_id, cx) - } - - fn supports_inlay_hints(&self, buffer: &Entity, cx: &mut App) -> bool { - if let Some(buffer) = self.to_base(&buffer, &[], cx) { - self.0.supports_inlay_hints(&buffer, cx) - } else { - false - } - } - - fn document_highlights( - &self, - buffer: &Entity, - position: text::Anchor, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(&buffer, &[position], cx)?; - self.0.document_highlights(&buffer, position, cx) - } - - fn definitions( - &self, - buffer: &Entity, - position: text::Anchor, - kind: crate::GotoDefinitionKind, - cx: &mut App, - ) -> Option>>> { - let buffer = self.to_base(&buffer, &[position], cx)?; - self.0.definitions(&buffer, position, kind, cx) - } - - fn range_for_rename( - &self, - _: &Entity, - _: text::Anchor, - _: &mut App, - ) -> Option>>>> { - None - } - - fn perform_rename( - &self, - _: &Entity, - _: text::Anchor, - _: String, - _: &mut App, - ) -> Option>> { - None - } -} diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs index 2b8150de67050ccced22100bfedd02be44f63907..ffa0c017c0eb157df776cc49e0dba51e617e3379 100644 --- a/crates/editor/src/rust_analyzer_ext.rs +++ b/crates/editor/src/rust_analyzer_ext.rs @@ -26,6 +26,17 @@ fn is_rust_language(language: &Language) -> bool { } pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: &mut App) { + if editor.read(cx).project().is_some_and(|project| { + project + .read(cx) + .language_server_statuses(cx) + .any(|(_, status)| status.name == RUST_ANALYZER_NAME) + }) { + register_action(editor, window, cancel_flycheck_action); + register_action(editor, window, run_flycheck_action); + register_action(editor, window, clear_flycheck_action); + } + if editor .read(cx) .buffer() @@ -35,12 +46,9 @@ pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: & .filter_map(|buffer| buffer.read(cx).language()) .any(|language| is_rust_language(language)) { - register_action(&editor, window, go_to_parent_module); - register_action(&editor, window, expand_macro_recursively); - register_action(&editor, window, open_docs); - register_action(&editor, window, cancel_flycheck_action); - register_action(&editor, window, run_flycheck_action); - register_action(&editor, window, clear_flycheck_action); + register_action(editor, window, go_to_parent_module); + register_action(editor, window, expand_macro_recursively); + register_action(editor, window, open_docs); } } @@ -192,7 +200,7 @@ pub fn expand_macro_recursively( } let buffer = project - .update(cx, |project, cx| project.create_buffer(cx))? + .update(cx, |project, cx| project.create_buffer(false, cx))? .await?; workspace.update_in(cx, |workspace, window, cx| { buffer.update(cx, |buffer, cx| { @@ -285,11 +293,11 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu workspace.update(cx, |_workspace, cx| { // Check if the local document exists, otherwise fallback to the online document. // Open with the default browser. - if let Some(local_url) = docs_urls.local { - if fs::metadata(Path::new(&local_url[8..])).is_ok() { - cx.open_url(&local_url); - return; - } + if let Some(local_url) = docs_urls.local + && fs::metadata(Path::new(&local_url[8..])).is_ok() + { + cx.open_url(&local_url); + return; } if let Some(web_url) = docs_urls.web { @@ -309,9 +317,9 @@ fn cancel_flycheck_action( let Some(project) = &editor.project else { return; }; - let Some(buffer_id) = editor + let buffer_id = editor .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .find_map(|selection| { let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; @@ -321,10 +329,7 @@ fn cancel_flycheck_action( .read(cx) .entry_id(cx)?; project.path_for_entry(entry_id, cx) - }) - else { - return; - }; + }); cancel_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx); } @@ -337,9 +342,9 @@ fn run_flycheck_action( let Some(project) = &editor.project else { return; }; - let Some(buffer_id) = editor + let buffer_id = editor .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .find_map(|selection| { let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; @@ -349,10 +354,7 @@ fn run_flycheck_action( .read(cx) .entry_id(cx)?; project.path_for_entry(entry_id, cx) - }) - else { - return; - }; + }); run_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx); } @@ -365,9 +367,9 @@ fn clear_flycheck_action( let Some(project) = &editor.project else { return; }; - let Some(buffer_id) = editor + let buffer_id = editor .selections - .disjoint_anchors() + .disjoint_anchors_arc() .iter() .find_map(|selection| { let buffer_id = selection.start.buffer_id.or(selection.end.buffer_id)?; @@ -377,9 +379,6 @@ fn clear_flycheck_action( .read(cx) .entry_id(cx)?; project.path_for_entry(entry_id, cx) - }) - else { - return; - }; + }); clear_flycheck(project.clone(), buffer_id, cx).detach_and_log_err(cx); } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 08ff23f8f70be4e512826c2793a2d95e2aee1690..001be45ab814e1627dc34abbba342272d3e15750 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -12,7 +12,7 @@ use crate::{ }; pub use autoscroll::{Autoscroll, AutoscrollStrategy}; use core::fmt::Debug; -use gpui::{Along, App, Axis, Context, Global, Pixels, Task, Window, point, px}; +use gpui::{Along, App, Axis, Context, Pixels, Task, Window, point, px}; use language::language_settings::{AllLanguageSettings, SoftWrap}; use language::{Bias, Point}; pub use scroll_amount::ScrollAmount; @@ -21,6 +21,7 @@ use std::{ cmp::Ordering, time::{Duration, Instant}, }; +use ui::scrollbars::ScrollbarAutoHide; use util::ResultExt; use workspace::{ItemId, WorkspaceId}; @@ -29,14 +30,11 @@ const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); pub struct WasScrolled(pub(crate) bool); -#[derive(Default)] -pub struct ScrollbarAutoHide(pub bool); - -impl Global for ScrollbarAutoHide {} - +pub type ScrollOffset = f64; +pub type ScrollPixelOffset = f64; #[derive(Clone, Copy, Debug, PartialEq)] pub struct ScrollAnchor { - pub offset: gpui::Point, + pub offset: gpui::Point, pub anchor: Anchor, } @@ -48,12 +46,12 @@ impl ScrollAnchor { } } - pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point { + pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point { self.offset.apply_along(Axis::Vertical, |offset| { if self.anchor == Anchor::min() { 0. } else { - let scroll_top = self.anchor.to_display_point(snapshot).row().as_f32(); + let scroll_top = self.anchor.to_display_point(snapshot).row().as_f64(); (offset + scroll_top).max(0.) } }) @@ -151,19 +149,24 @@ impl ActiveScrollbarState { } pub struct ScrollManager { - pub(crate) vertical_scroll_margin: f32, + pub(crate) vertical_scroll_margin: ScrollOffset, anchor: ScrollAnchor, ongoing: OngoingScroll, /// The second element indicates whether the autoscroll request is local /// (true) or remote (false). Local requests are initiated by user actions, /// while remote requests come from external sources. autoscroll_request: Option<(Autoscroll, bool)>, - last_autoscroll: Option<(gpui::Point, f32, f32, AutoscrollStrategy)>, + last_autoscroll: Option<( + gpui::Point, + ScrollOffset, + ScrollOffset, + AutoscrollStrategy, + )>, show_scrollbars: bool, hide_scrollbar_task: Option>, active_scrollbar: Option, - visible_line_count: Option, - visible_column_count: Option, + visible_line_count: Option, + visible_column_count: Option, forbid_vertical_scroll: bool, minimap_thumb_state: Option, } @@ -204,13 +207,13 @@ impl ScrollManager { self.ongoing.axis = axis; } - pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point { + pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point { self.anchor.scroll_position(snapshot) } fn set_scroll_position( &mut self, - scroll_position: gpui::Point, + scroll_position: gpui::Point, map: &DisplaySnapshot, local: bool, autoscroll: bool, @@ -223,7 +226,7 @@ impl ScrollManager { ScrollBeyondLastLine::OnePage => scroll_top, ScrollBeyondLastLine::Off => { if let Some(height_in_lines) = self.visible_line_count { - let max_row = map.max_point().row().0 as f32; + let max_row = map.max_point().row().as_f64(); scroll_top.min(max_row - height_in_lines + 1.).max(0.) } else { scroll_top @@ -231,7 +234,7 @@ impl ScrollManager { } ScrollBeyondLastLine::VerticalScrollMargin => { if let Some(height_in_lines) = self.visible_line_count { - let max_row = map.max_point().row().0 as f32; + let max_row = map.max_point().row().as_f64(); scroll_top .min(max_row - height_in_lines + 1. + self.vertical_scroll_margin) .max(0.) @@ -248,16 +251,14 @@ impl ScrollManager { Bias::Left, ) .to_point(map); - let top_anchor = map - .buffer_snapshot - .anchor_at(scroll_top_buffer_point, Bias::Right); + let top_anchor = map.buffer_snapshot().anchor_after(scroll_top_buffer_point); self.set_anchor( ScrollAnchor { anchor: top_anchor, offset: point( scroll_position.x.max(0.), - scroll_top - top_anchor.to_display_point(map).row().as_f32(), + scroll_top - top_anchor.to_display_point(map).row().as_f64(), ), }, scroll_top_buffer_point.row, @@ -327,7 +328,7 @@ impl ScrollManager { cx.notify(); } - if cx.default_global::().0 { + if cx.default_global::().should_hide() { self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |editor, cx| { cx.background_executor() .timer(SCROLLBAR_SHOW_INTERVAL) @@ -443,7 +444,7 @@ impl ScrollManager { self.minimap_thumb_state } - pub fn clamp_scroll_left(&mut self, max: f32) -> bool { + pub fn clamp_scroll_left(&mut self, max: f64) -> bool { if max < self.anchor.offset.x { self.anchor.offset.x = max; true @@ -467,11 +468,11 @@ impl Editor { } pub fn set_vertical_scroll_margin(&mut self, margin_rows: usize, cx: &mut Context) { - self.scroll_manager.vertical_scroll_margin = margin_rows as f32; + self.scroll_manager.vertical_scroll_margin = margin_rows as f64; cx.notify(); } - pub fn visible_line_count(&self) -> Option { + pub fn visible_line_count(&self) -> Option { self.scroll_manager.visible_line_count } @@ -480,32 +481,32 @@ impl Editor { .map(|line_count| line_count as u32 - 1) } - pub fn visible_column_count(&self) -> Option { + pub fn visible_column_count(&self) -> Option { self.scroll_manager.visible_column_count } pub(crate) fn set_visible_line_count( &mut self, - lines: f32, + lines: f64, window: &mut Window, cx: &mut Context, ) { let opened_first_time = self.scroll_manager.visible_line_count.is_none(); self.scroll_manager.visible_line_count = Some(lines); if opened_first_time { - cx.spawn_in(window, async move |editor, cx| { + self.post_scroll_update = cx.spawn_in(window, async move |editor, cx| { editor .update_in(cx, |editor, window, cx| { + editor.register_visible_buffers(cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - editor.refresh_colors(false, None, window, cx); + editor.update_lsp_data(None, window, cx); }) - .ok() - }) - .detach() + .ok(); + }); } } - pub(crate) fn set_visible_column_count(&mut self, columns: f32) { + pub(crate) fn set_visible_column_count(&mut self, columns: f64) { self.scroll_manager.visible_column_count = Some(columns); } @@ -520,13 +521,14 @@ impl Editor { delta.y = 0.0; } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let position = self.scroll_manager.anchor.scroll_position(&display_map) + delta; + let position = + self.scroll_manager.anchor.scroll_position(&display_map) + delta.map(f64::from); self.set_scroll_position_taking_display_map(position, true, false, display_map, window, cx); } pub fn set_scroll_position( &mut self, - scroll_position: gpui::Point, + scroll_position: gpui::Point, window: &mut Window, cx: &mut Context, ) -> WasScrolled { @@ -548,7 +550,7 @@ impl Editor { let snapshot = self.snapshot(window, cx).display_snapshot; let new_screen_top = DisplayPoint::new(row, 0); let new_screen_top = new_screen_top.to_offset(&snapshot, Bias::Left); - let new_anchor = snapshot.buffer_snapshot.anchor_before(new_screen_top); + let new_anchor = snapshot.buffer_snapshot().anchor_before(new_screen_top); self.set_scroll_anchor( ScrollAnchor { @@ -562,7 +564,7 @@ impl Editor { pub(crate) fn set_scroll_position_internal( &mut self, - scroll_position: gpui::Point, + scroll_position: gpui::Point, local: bool, autoscroll: bool, window: &mut Window, @@ -581,7 +583,7 @@ impl Editor { fn set_scroll_position_taking_display_map( &mut self, - scroll_position: gpui::Point, + scroll_position: gpui::Point, local: bool, autoscroll: bool, display_map: DisplaySnapshot, @@ -611,12 +613,23 @@ impl Editor { cx, ); - self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); - self.refresh_colors(false, None, window, cx); + self.post_scroll_update = cx.spawn_in(window, async move |editor, cx| { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + editor + .update_in(cx, |editor, window, cx| { + editor.register_visible_buffers(cx); + editor.refresh_colors_for_visible_range(None, window, cx); + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + }) + .ok(); + }); + editor_was_scrolled } - pub fn scroll_position(&self, cx: &mut Context) -> gpui::Point { + pub fn scroll_position(&self, cx: &mut Context) -> gpui::Point { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); self.scroll_manager.anchor.scroll_position(&display_map) } @@ -675,7 +688,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -703,20 +716,21 @@ impl Editor { if matches!( settings.defaults.soft_wrap, SoftWrap::PreferredLineLength | SoftWrap::Bounded - ) { - if (settings.defaults.preferred_line_length as f32) < visible_column_count { - visible_column_count = settings.defaults.preferred_line_length as f32; - } + ) && (settings.defaults.preferred_line_length as f64) < visible_column_count + { + visible_column_count = settings.defaults.preferred_line_length as f64; } // If the scroll position is currently at the left edge of the document // (x == 0.0) and the intent is to scroll right, the gutter's margin // should first be added to the current position, otherwise the cursor // will end at the column position minus the margin, which looks off. - if current_position.x == 0.0 && amount.columns(visible_column_count) > 0. { - if let Some(last_position_map) = &self.last_position_map { - current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance; - } + if current_position.x == 0.0 + && amount.columns(visible_column_count) > 0. + && let Some(last_position_map) = &self.last_position_map + { + current_position.x += + f64::from(self.gutter_dimensions.margin / last_position_map.em_advance); } let new_position = current_position + point( @@ -749,12 +763,10 @@ impl Editor { if let (Some(visible_lines), Some(visible_columns)) = (self.visible_line_count(), self.visible_column_count()) + && newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) + && newest_head.column() <= screen_top.column() + visible_columns as u32 { - if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) - && newest_head.column() <= screen_top.column() + visible_columns as u32 - { - return Ordering::Equal; - } + return Ordering::Equal; } Ordering::Greater @@ -773,7 +785,7 @@ impl Editor { .buffer() .read(cx) .snapshot(cx) - .anchor_at(Point::new(top_row, 0), Bias::Left); + .anchor_before(Point::new(top_row, 0)); let scroll_anchor = ScrollAnchor { offset: gpui::Point::new(x, y), anchor: top_anchor, diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index 72827b2fee48c424a632018b5f66015cd058ed79..3b2ed55df724485ee72e6afbc02c7111817869fb 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -2,7 +2,7 @@ use super::Axis; use crate::{ Autoscroll, Editor, EditorMode, NextScreen, NextScrollCursorCenterTopBottom, SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT, ScrollCursorBottom, ScrollCursorCenter, - ScrollCursorCenterTopBottom, ScrollCursorTop, display_map::DisplayRow, + ScrollCursorCenterTopBottom, ScrollCursorTop, display_map::DisplayRow, scroll::ScrollOffset, }; use gpui::{Context, Point, Window}; @@ -16,7 +16,7 @@ impl Editor { return; } - if matches!(self.mode, EditorMode::SingleLine { .. }) { + if matches!(self.mode, EditorMode::SingleLine) { cx.propagate(); return; } @@ -25,7 +25,7 @@ impl Editor { pub fn scroll( &mut self, - scroll_position: Point, + scroll_position: Point, axis: Option, window: &mut Window, cx: &mut Context, @@ -72,7 +72,12 @@ impl Editor { cx: &mut Context, ) { let scroll_margin_rows = self.vertical_scroll_margin() as u32; - let new_screen_top = self.selections.newest_display(cx).head().row().0; + let new_screen_top = self + .selections + .newest_display(&self.display_snapshot(cx)) + .head() + .row() + .0; let new_screen_top = new_screen_top.saturating_sub(scroll_margin_rows); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); } @@ -86,7 +91,12 @@ impl Editor { let Some(visible_rows) = self.visible_line_count().map(|count| count as u32) else { return; }; - let new_screen_top = self.selections.newest_display(cx).head().row().0; + let new_screen_top = self + .selections + .newest_display(&self.display_snapshot(cx)) + .head() + .row() + .0; let new_screen_top = new_screen_top.saturating_sub(visible_rows / 2); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); } @@ -101,7 +111,12 @@ impl Editor { let Some(visible_rows) = self.visible_line_count().map(|count| count as u32) else { return; }; - let new_screen_top = self.selections.newest_display(cx).head().row().0; + let new_screen_top = self + .selections + .newest_display(&self.display_snapshot(cx)) + .head() + .row() + .0; let new_screen_top = new_screen_top.saturating_sub(visible_rows.saturating_sub(scroll_margin_rows)); self.set_scroll_top_row(DisplayRow(new_screen_top), window, cx); diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 88d3b52d764d15280c8ed03dd87f42b8c32d0911..28fd9442193bbec663d3f72eaa805214375dd8ca 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -1,11 +1,12 @@ use crate::{ DisplayRow, Editor, EditorMode, LineWithInvisibles, RowExt, SelectionEffects, - display_map::ToDisplayPoint, scroll::WasScrolled, + display_map::ToDisplayPoint, + scroll::{ScrollOffset, WasScrolled}, }; -use gpui::{Bounds, Context, Pixels, Window, px}; +use gpui::{Bounds, Context, Pixels, Window}; use language::Point; use multi_buffer::Anchor; -use std::{cmp, f32}; +use std::cmp; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum Autoscroll { @@ -106,22 +107,23 @@ impl Editor { &mut self, bounds: Bounds, line_height: Pixels, - max_scroll_top: f32, + max_scroll_top: ScrollOffset, autoscroll_request: Option<(Autoscroll, bool)>, window: &mut Window, cx: &mut Context, ) -> (NeedsHorizontalAutoscroll, WasScrolled) { let viewport_height = bounds.size.height; - let visible_lines = viewport_height / line_height; + let visible_lines = ScrollOffset::from(viewport_height / line_height); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut scroll_position = self.scroll_manager.scroll_position(&display_map); let original_y = scroll_position.y; - if let Some(last_bounds) = self.expect_bounds_change.take() { - if scroll_position.y != 0. { - scroll_position.y += (bounds.top() - last_bounds.top()) / line_height; - if scroll_position.y < 0. { - scroll_position.y = 0.; - } + if let Some(last_bounds) = self.expect_bounds_change.take() + && scroll_position.y != 0. + { + scroll_position.y += + ScrollOffset::from((bounds.top() - last_bounds.top()) / line_height); + if scroll_position.y < 0. { + scroll_position.y = 0.; } } if scroll_position.y > max_scroll_top { @@ -143,10 +145,10 @@ impl Editor { if let Some(first_highlighted_row) = self.highlighted_display_row_for_autoscroll(&display_map) { - target_top = first_highlighted_row.as_f32(); + target_top = first_highlighted_row.as_f64(); target_bottom = target_top + 1.; } else { - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); target_top = selections .first() @@ -154,7 +156,7 @@ impl Editor { .head() .to_display_point(&display_map) .row() - .as_f32(); + .as_f64(); target_bottom = selections .last() .unwrap() @@ -162,7 +164,7 @@ impl Editor { .to_display_point(&display_map) .row() .next_row() - .as_f32(); + .as_f64(); let selections_fit = target_bottom - target_top <= visible_lines; if matches!( @@ -178,7 +180,7 @@ impl Editor { .head() .to_display_point(&display_map) .row() - .as_f32(); + .as_f64(); target_top = newest_selection_top; target_bottom = newest_selection_top + 1.; } @@ -209,7 +211,7 @@ impl Editor { } }; if let Autoscroll::Strategy(_, Some(anchor)) = autoscroll { - target_top = anchor.to_display_point(&display_map).row().as_f32(); + target_top = anchor.to_display_point(&display_map).row().as_f64(); target_bottom = target_top + 1.; } @@ -254,11 +256,11 @@ impl Editor { self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::TopRelative(lines) => { - scroll_position.y = target_top - lines as f32; + scroll_position.y = target_top - lines as ScrollOffset; self.set_scroll_position_internal(scroll_position, local, true, window, cx) } AutoscrollStrategy::BottomRelative(lines) => { - scroll_position.y = target_bottom + lines as f32; + scroll_position.y = target_bottom + lines as ScrollOffset; self.set_scroll_position_internal(scroll_position, local, true, window, cx) } }; @@ -284,22 +286,25 @@ impl Editor { autoscroll_request: Option<(Autoscroll, bool)>, window: &mut Window, cx: &mut Context, - ) -> Option> { + ) -> Option> { let (_, local) = autoscroll_request?; + let em_advance = ScrollOffset::from(em_advance); + let viewport_width = ScrollOffset::from(viewport_width); + let scroll_width = ScrollOffset::from(scroll_width); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(cx); + let selections = self.selections.all::(&display_map); let mut scroll_position = self.scroll_manager.scroll_position(&display_map); let mut target_left; - let mut target_right; + let mut target_right: f64; if self .highlighted_display_row_for_autoscroll(&display_map) .is_none() { - target_left = px(f32::INFINITY); - target_right = px(0.); + target_left = f64::INFINITY; + target_right = 0.; for selection in selections { let head = selection.head().to_display_point(&display_map); if head.row() >= start_row @@ -307,21 +312,22 @@ impl Editor { { let start_column = head.column(); let end_column = cmp::min(display_map.line_len(head.row()), head.column()); - target_left = target_left.min( + target_left = target_left.min(ScrollOffset::from( layouts[head.row().minus(start_row) as usize] .x_for_index(start_column as usize) + self.gutter_dimensions.margin, - ); + )); target_right = target_right.max( - layouts[head.row().minus(start_row) as usize] - .x_for_index(end_column as usize) - + em_advance, + ScrollOffset::from( + layouts[head.row().minus(start_row) as usize] + .x_for_index(end_column as usize), + ) + em_advance, ); } } } else { - target_left = px(0.); - target_right = px(0.); + target_left = 0.; + target_right = 0.; } target_right = target_right.min(scroll_width); diff --git a/crates/editor/src/scroll/scroll_amount.rs b/crates/editor/src/scroll/scroll_amount.rs index b2af4f8e4fbce899c6aee317402ee1365cee8600..3280290d6e5ccc1ca3eecc3755c2039bd024cc24 100644 --- a/crates/editor/src/scroll/scroll_amount.rs +++ b/crates/editor/src/scroll/scroll_amount.rs @@ -1,5 +1,5 @@ use serde::Deserialize; -use ui::{Pixels, px}; +use ui::Pixels; #[derive(Debug)] pub enum ScrollDirection { @@ -15,7 +15,7 @@ impl ScrollDirection { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] pub enum ScrollAmount { // Scroll N lines (positive is towards the end of the document) Line(f32), @@ -28,49 +28,46 @@ pub enum ScrollAmount { } impl ScrollAmount { - pub fn lines(&self, mut visible_line_count: f32) -> f32 { + pub fn lines(&self, mut visible_line_count: f64) -> f64 { match self { - Self::Line(count) => *count, + Self::Line(count) => *count as f64, Self::Page(count) => { // for full pages subtract one to leave an anchor line if self.is_full_page() { visible_line_count -= 1.0 } - (visible_line_count * count).trunc() + (visible_line_count * (*count as f64)).trunc() } Self::Column(_count) => 0.0, Self::PageWidth(_count) => 0.0, } } - pub fn columns(&self, visible_column_count: f32) -> f32 { + pub fn columns(&self, visible_column_count: f64) -> f64 { match self { Self::Line(_count) => 0.0, Self::Page(_count) => 0.0, - Self::Column(count) => *count, - Self::PageWidth(count) => (visible_column_count * count).trunc(), + Self::Column(count) => *count as f64, + Self::PageWidth(count) => (visible_column_count * *count as f64).trunc(), } } pub fn pixels(&self, line_height: Pixels, height: Pixels) -> Pixels { match self { - ScrollAmount::Line(x) => px(line_height.0 * x), - ScrollAmount::Page(x) => px(height.0 * x), + ScrollAmount::Line(x) => line_height * *x, + ScrollAmount::Page(x) => height * *x, // This function seems to only be leveraged by the popover that is // displayed by the editor when, for example, viewing a function's // documentation. Right now that only supports vertical scrolling, // so I'm leaving this at 0.0 for now to try and make it clear that // this should not have an impact on that? - ScrollAmount::Column(_) => px(0.0), - ScrollAmount::PageWidth(_) => px(0.0), + ScrollAmount::Column(_) => Pixels::ZERO, + ScrollAmount::PageWidth(_) => Pixels::ZERO, } } pub fn is_full_page(&self) -> bool { - match self { - ScrollAmount::Page(count) if count.abs() == 1.0 => true, - _ => false, - } + matches!(self, ScrollAmount::Page(count) if count.abs() == 1.0) } pub fn direction(&self) -> ScrollDirection { diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 73c5f1c076e510b2aeb7d648b7ce066b65f9094c..72a0fcf045955805efaa5f8dea7e6ef78525d26c 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -1,6 +1,6 @@ use std::{ cell::Ref, - cmp, iter, mem, + cmp, fmt, iter, mem, ops::{Deref, DerefMut, Range, Sub}, sync::Arc, }; @@ -28,13 +28,15 @@ pub struct PendingSelection { pub struct SelectionsCollection { display_map: Entity, buffer: Entity, - pub next_selection_id: usize, - pub line_mode: bool, + next_selection_id: usize, + line_mode: bool, /// The non-pending, non-overlapping selections. /// The [SelectionsCollection::pending] selection could possibly overlap these - pub disjoint: Arc<[Selection]>, + disjoint: Arc<[Selection]>, /// A pending selection, such as when the mouse is being dragged - pub pending: Option, + pending: Option, + select_mode: SelectMode, + is_extending: bool, } impl SelectionsCollection { @@ -55,6 +57,8 @@ impl SelectionsCollection { }, mode: SelectMode::Character, }), + select_mode: SelectMode::Character, + is_extending: false, } } @@ -84,22 +88,29 @@ impl SelectionsCollection { /// The non-pending, non-overlapping selections. There could be a pending selection that /// overlaps these if the mouse is being dragged, etc. This could also be empty if there is a /// pending selection. Returned as selections over Anchors. - pub fn disjoint_anchors(&self) -> Arc<[Selection]> { + pub fn disjoint_anchors_arc(&self) -> Arc<[Selection]> { self.disjoint.clone() } + /// The non-pending, non-overlapping selections. There could be a pending selection that + /// overlaps these if the mouse is being dragged, etc. This could also be empty if there is a + /// pending selection. Returned as selections over Anchors. + pub fn disjoint_anchors(&self) -> &[Selection] { + &self.disjoint + } + pub fn disjoint_anchor_ranges(&self) -> impl Iterator> { // Mapping the Arc slice would borrow it, whereas indexing captures it. - let disjoint = self.disjoint_anchors(); + let disjoint = self.disjoint_anchors_arc(); (0..disjoint.len()).map(move |ix| disjoint[ix].range()) } /// Non-overlapping selections using anchors, including the pending selection. pub fn all_anchors(&self, cx: &mut App) -> Arc<[Selection]> { if self.pending.is_none() { - self.disjoint_anchors() + self.disjoint_anchors_arc() } else { - let all_offset_selections = self.all::(cx); + let all_offset_selections = self.all::(&self.display_map(cx)); let buffer = self.buffer(cx); all_offset_selections .into_iter() @@ -108,33 +119,34 @@ impl SelectionsCollection { } } - pub fn pending_anchor(&self) -> Option> { - self.pending - .as_ref() - .map(|pending| pending.selection.clone()) + pub fn pending_anchor(&self) -> Option<&Selection> { + self.pending.as_ref().map(|pending| &pending.selection) + } + + pub fn pending_anchor_mut(&mut self) -> Option<&mut Selection> { + self.pending.as_mut().map(|pending| &mut pending.selection) } pub fn pending>( &self, - cx: &mut App, + snapshot: &DisplaySnapshot, ) -> Option> { - let map = self.display_map(cx); - let selection = resolve_selections(self.pending_anchor().as_ref(), &map).next(); - selection + resolve_selections_wrapping_blocks(self.pending_anchor(), &snapshot).next() } pub(crate) fn pending_mode(&self) -> Option { self.pending.as_ref().map(|pending| pending.mode.clone()) } - pub fn all<'a, D>(&self, cx: &mut App) -> Vec> + pub fn all<'a, D>(&self, snapshot: &DisplaySnapshot) -> Vec> where D: 'a + TextDimension + Ord + Sub, { - let map = self.display_map(cx); let disjoint_anchors = &self.disjoint; - let mut disjoint = resolve_selections::(disjoint_anchors.iter(), &map).peekable(); - let mut pending_opt = self.pending::(cx); + let mut disjoint = + resolve_selections_wrapping_blocks::(disjoint_anchors.iter(), &snapshot) + .peekable(); + let mut pending_opt = self.pending::(&snapshot); iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { @@ -162,12 +174,11 @@ impl SelectionsCollection { } /// Returns all of the selections, adjusted to take into account the selection line_mode - pub fn all_adjusted(&self, cx: &mut App) -> Vec> { - let mut selections = self.all::(cx); + pub fn all_adjusted(&self, snapshot: &DisplaySnapshot) -> Vec> { + let mut selections = self.all::(&snapshot); if self.line_mode { - let map = self.display_map(cx); for selection in &mut selections { - let new_range = map.expand_to_line(selection.range()); + let new_range = snapshot.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; } @@ -176,11 +187,10 @@ impl SelectionsCollection { } /// Returns the newest selection, adjusted to take into account the selection line_mode - pub fn newest_adjusted(&self, cx: &mut App) -> Selection { - let mut selection = self.newest::(cx); + pub fn newest_adjusted(&self, snapshot: &DisplaySnapshot) -> Selection { + let mut selection = self.newest::(&snapshot); if self.line_mode { - let map = self.display_map(cx); - let new_range = map.expand_to_line(selection.range()); + let new_range = snapshot.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; } @@ -189,54 +199,55 @@ impl SelectionsCollection { pub fn all_adjusted_display( &self, - cx: &mut App, - ) -> (DisplaySnapshot, Vec>) { + display_map: &DisplaySnapshot, + ) -> Vec> { if self.line_mode { - let selections = self.all::(cx); - let map = self.display_map(cx); + let selections = self.all::(&display_map); let result = selections .into_iter() .map(|mut selection| { - let new_range = map.expand_to_line(selection.range()); + let new_range = display_map.expand_to_line(selection.range()); selection.start = new_range.start; selection.end = new_range.end; - selection.map(|point| point.to_display_point(&map)) + selection.map(|point| point.to_display_point(&display_map)) }) .collect(); - (map, result) + result } else { - self.all_display(cx) + self.all_display(display_map) } } - pub fn disjoint_in_range<'a, D>(&self, range: Range, cx: &mut App) -> Vec> + pub fn disjoint_in_range<'a, D>( + &self, + range: Range, + snapshot: &DisplaySnapshot, + ) -> Vec> where D: 'a + TextDimension + Ord + Sub + std::fmt::Debug, { - let map = self.display_map(cx); let start_ix = match self .disjoint - .binary_search_by(|probe| probe.end.cmp(&range.start, &map.buffer_snapshot)) + .binary_search_by(|probe| probe.end.cmp(&range.start, snapshot.buffer_snapshot())) { Ok(ix) | Err(ix) => ix, }; let end_ix = match self .disjoint - .binary_search_by(|probe| probe.start.cmp(&range.end, &map.buffer_snapshot)) + .binary_search_by(|probe| probe.start.cmp(&range.end, snapshot.buffer_snapshot())) { Ok(ix) => ix + 1, Err(ix) => ix, }; - resolve_selections(&self.disjoint[start_ix..end_ix], &map).collect() + resolve_selections_wrapping_blocks(&self.disjoint[start_ix..end_ix], snapshot).collect() } - pub fn all_display(&self, cx: &mut App) -> (DisplaySnapshot, Vec>) { - let map = self.display_map(cx); + pub fn all_display(&self, snapshot: &DisplaySnapshot) -> Vec> { let disjoint_anchors = &self.disjoint; - let mut disjoint = resolve_selections_display(disjoint_anchors.iter(), &map).peekable(); - let mut pending_opt = - resolve_selections_display(self.pending_anchor().as_ref(), &map).next(); - let selections = iter::from_fn(move || { + let mut disjoint = + resolve_selections_display(disjoint_anchors.iter(), &snapshot).peekable(); + let mut pending_opt = resolve_selections_display(self.pending_anchor(), &snapshot).next(); + iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { if pending.start <= next_selection.end && pending.end >= next_selection.start { @@ -259,8 +270,7 @@ impl SelectionsCollection { disjoint.next() } }) - .collect(); - (map, selections) + .collect() } pub fn newest_anchor(&self) -> &Selection { @@ -273,21 +283,17 @@ impl SelectionsCollection { pub fn newest>( &self, - cx: &mut App, + snapshot: &DisplaySnapshot, ) -> Selection { - let map = self.display_map(cx); - let selection = resolve_selections([self.newest_anchor()], &map) + resolve_selections_wrapping_blocks([self.newest_anchor()], &snapshot) .next() - .unwrap(); - selection + .unwrap() } - pub fn newest_display(&self, cx: &mut App) -> Selection { - let map = self.display_map(cx); - let selection = resolve_selections_display([self.newest_anchor()], &map) + pub fn newest_display(&self, snapshot: &DisplaySnapshot) -> Selection { + resolve_selections_display([self.newest_anchor()], &snapshot) .next() - .unwrap(); - selection + .unwrap() } pub fn oldest_anchor(&self) -> &Selection { @@ -300,13 +306,11 @@ impl SelectionsCollection { pub fn oldest>( &self, - cx: &mut App, + snapshot: &DisplaySnapshot, ) -> Selection { - let map = self.display_map(cx); - let selection = resolve_selections([self.oldest_anchor()], &map) + resolve_selections_wrapping_blocks([self.oldest_anchor()], &snapshot) .next() - .unwrap(); - selection + .unwrap() } pub fn first_anchor(&self) -> Selection { @@ -316,19 +320,28 @@ impl SelectionsCollection { .unwrap_or_else(|| self.disjoint.first().cloned().unwrap()) } - pub fn first>(&self, cx: &mut App) -> Selection { - self.all(cx).first().unwrap().clone() + pub fn first>( + &self, + snapshot: &DisplaySnapshot, + ) -> Selection { + self.all(snapshot).first().unwrap().clone() } - pub fn last>(&self, cx: &mut App) -> Selection { - self.all(cx).last().unwrap().clone() + pub fn last>( + &self, + snapshot: &DisplaySnapshot, + ) -> Selection { + self.all(snapshot).last().unwrap().clone() } + /// Returns a list of (potentially backwards!) ranges representing the selections. + /// Useful for test assertions, but prefer `.all()` instead. + #[cfg(any(test, feature = "test-support"))] pub fn ranges>( &self, - cx: &mut App, + snapshot: &DisplaySnapshot, ) -> Vec> { - self.all::(cx) + self.all::(snapshot) .iter() .map(|s| { if s.reversed { @@ -343,9 +356,9 @@ impl SelectionsCollection { #[cfg(any(test, feature = "test-support"))] pub fn display_ranges(&self, cx: &mut App) -> Vec> { let display_map = self.display_map(cx); - self.disjoint_anchors() + self.disjoint_anchors_arc() .iter() - .chain(self.pending_anchor().as_ref()) + .chain(self.pending_anchor()) .map(|s| { if s.reversed { s.end.to_display_point(&display_map)..s.start.to_display_point(&display_map) @@ -356,6 +369,11 @@ impl SelectionsCollection { .collect() } + /// Attempts to build a selection in the provided `DisplayRow` within the + /// same range as the provided range of `Pixels`. + /// Returns `None` if the range is not empty but it starts past the line's + /// length, meaning that the line isn't long enough to be contained within + /// part of the provided range. pub fn build_columnar_selection( &mut self, display_map: &DisplaySnapshot, @@ -412,6 +430,34 @@ impl SelectionsCollection { ); (mutable_collection.selections_changed, result) } + + pub fn next_selection_id(&self) -> usize { + self.next_selection_id + } + + pub fn line_mode(&self) -> bool { + self.line_mode + } + + pub fn set_line_mode(&mut self, line_mode: bool) { + self.line_mode = line_mode; + } + + pub fn select_mode(&self) -> &SelectMode { + &self.select_mode + } + + pub fn set_select_mode(&mut self, select_mode: SelectMode) { + self.select_mode = select_mode; + } + + pub fn is_extending(&self) -> bool { + self.is_extending + } + + pub fn set_is_extending(&mut self, is_extending: bool) { + self.is_extending = is_extending; + } } pub struct MutableSelectionsCollection<'a> { @@ -420,6 +466,15 @@ pub struct MutableSelectionsCollection<'a> { cx: &'a mut App, } +impl<'a> fmt::Debug for MutableSelectionsCollection<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MutableSelectionsCollection") + .field("collection", &self.collection) + .field("selections_changed", &self.selections_changed) + .finish() + } +} + impl<'a> MutableSelectionsCollection<'a> { pub fn display_map(&mut self) -> DisplaySnapshot { self.collection.display_map(self.cx) @@ -457,13 +512,24 @@ impl<'a> MutableSelectionsCollection<'a> { } pub(crate) fn set_pending_anchor_range(&mut self, range: Range, mode: SelectMode) { + let buffer = self.buffer.read(self.cx).snapshot(self.cx); self.collection.pending = Some(PendingSelection { - selection: Selection { - id: post_inc(&mut self.collection.next_selection_id), - start: range.start, - end: range.end, - reversed: false, - goal: SelectionGoal::None, + selection: { + let mut start = range.start; + let mut end = range.end; + let reversed = if start.cmp(&end, &buffer).is_gt() { + mem::swap(&mut start, &mut end); + true + } else { + false + }; + Selection { + id: post_inc(&mut self.collection.next_selection_id), + start, + end, + reversed, + goal: SelectionGoal::None, + } }, mode, }); @@ -507,7 +573,8 @@ impl<'a> MutableSelectionsCollection<'a> { where T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub + std::marker::Copy, { - let mut selections = self.collection.all(self.cx); + let display_map = self.display_map(); + let mut selections = self.collection.all(&display_map); let mut start = range.start.to_offset(&self.buffer()); let mut end = range.end.to_offset(&self.buffer()); let reversed = if start > end { @@ -526,21 +593,32 @@ impl<'a> MutableSelectionsCollection<'a> { self.select(selections); } - pub fn select(&mut self, mut selections: Vec>) + pub fn select(&mut self, selections: Vec>) where - T: ToOffset + ToPoint + Ord + std::marker::Copy + std::fmt::Debug, + T: ToOffset + std::marker::Copy + std::fmt::Debug, { let buffer = self.buffer.read(self.cx).snapshot(self.cx); + let mut selections = selections + .into_iter() + .map(|selection| selection.map(|it| it.to_offset(&buffer))) + .map(|mut selection| { + if selection.start > selection.end { + mem::swap(&mut selection.start, &mut selection.end); + selection.reversed = true + } + selection + }) + .collect::>(); selections.sort_unstable_by_key(|s| s.start); // Merge overlapping selections. let mut i = 1; while i < selections.len() { - if selections[i - 1].end >= selections[i].start { + if selections[i].start <= selections[i - 1].end { let removed = selections.remove(i); if removed.start < selections[i - 1].start { selections[i - 1].start = removed.start; } - if removed.end > selections[i - 1].end { + if selections[i - 1].end < removed.end { selections[i - 1].end = removed.end; } } else { @@ -560,7 +638,7 @@ impl<'a> MutableSelectionsCollection<'a> { pub fn select_anchors(&mut self, selections: Vec>) { let map = self.display_map(); let resolved_selections = - resolve_selections::(&selections, &map).collect::>(); + resolve_selections_wrapping_blocks::(&selections, &map).collect::>(); self.select(resolved_selections); } @@ -690,7 +768,7 @@ impl<'a> MutableSelectionsCollection<'a> { ) { let mut changed = false; let display_map = self.display_map(); - let (_, selections) = self.collection.all_display(self.cx); + let selections = self.collection.all_display(&display_map); let selections = selections .into_iter() .map(|selection| { @@ -714,9 +792,10 @@ impl<'a> MutableSelectionsCollection<'a> { ) { let mut changed = false; let snapshot = self.buffer().clone(); + let display_map = self.display_map(); let selections = self .collection - .all::(self.cx) + .all::(&display_map) .into_iter() .map(|selection| { let mut moved_selection = selection.clone(); @@ -841,7 +920,8 @@ impl<'a> MutableSelectionsCollection<'a> { if !adjusted_disjoint.is_empty() { let map = self.display_map(); - let resolved_selections = resolve_selections(adjusted_disjoint.iter(), &map).collect(); + let resolved_selections = + resolve_selections_wrapping_blocks(adjusted_disjoint.iter(), &map).collect(); self.select::(resolved_selections); } @@ -884,17 +964,14 @@ impl DerefMut for MutableSelectionsCollection<'_> { } } -fn selection_to_anchor_selection( - selection: Selection, +fn selection_to_anchor_selection( + selection: Selection, buffer: &MultiBufferSnapshot, -) -> Selection -where - T: ToOffset + Ord, -{ - let end_bias = if selection.end > selection.start { - Bias::Left - } else { +) -> Selection { + let end_bias = if selection.start == selection.end { Bias::Right + } else { + Bias::Left }; Selection { id: selection.id, @@ -905,53 +982,69 @@ where } } -// Panics if passed selections are not in order -fn resolve_selections_display<'a>( +fn resolve_selections_point<'a>( selections: impl 'a + IntoIterator>, map: &'a DisplaySnapshot, -) -> impl 'a + Iterator> { +) -> impl 'a + Iterator> { let (to_summarize, selections) = selections.into_iter().tee(); let mut summaries = map - .buffer_snapshot + .buffer_snapshot() .summaries_for_anchors::(to_summarize.flat_map(|s| [&s.start, &s.end])) .into_iter(); - let mut selections = selections - .map(move |s| { - let start = summaries.next().unwrap(); - let end = summaries.next().unwrap(); - - let display_start = map.point_to_display_point(start, Bias::Left); - let display_end = if start == end { - map.point_to_display_point(end, Bias::Right) - } else { - map.point_to_display_point(end, Bias::Left) - }; + selections.map(move |s| { + let start = summaries.next().unwrap(); + let end = summaries.next().unwrap(); + assert!(start <= end, "start: {:?}, end: {:?}", start, end); + Selection { + id: s.id, + start, + end, + reversed: s.reversed, + goal: s.goal, + } + }) +} - Selection { - id: s.id, - start: display_start, - end: display_end, - reversed: s.reversed, - goal: s.goal, - } - }) - .peekable(); - iter::from_fn(move || { - let mut selection = selections.next()?; - while let Some(next_selection) = selections.peek() { - if selection.end >= next_selection.start { - selection.end = cmp::max(selection.end, next_selection.end); - selections.next(); +/// Panics if passed selections are not in order +/// Resolves the anchors to display positions +fn resolve_selections_display<'a>( + selections: impl 'a + IntoIterator>, + map: &'a DisplaySnapshot, +) -> impl 'a + Iterator> { + let selections = resolve_selections_point(selections, map).map(move |s| { + let display_start = map.point_to_display_point(s.start, Bias::Left); + let display_end = map.point_to_display_point( + s.end, + if s.start == s.end { + Bias::Right } else { - break; - } + Bias::Left + }, + ); + assert!( + display_start <= display_end, + "display_start: {:?}, display_end: {:?}", + display_start, + display_end + ); + Selection { + id: s.id, + start: display_start, + end: display_end, + reversed: s.reversed, + goal: s.goal, } - Some(selection) - }) + }); + coalesce_selections(selections) } -// Panics if passed selections are not in order -pub(crate) fn resolve_selections<'a, D, I>( +/// Resolves the passed in anchors to [`TextDimension`]s `D` +/// wrapping around blocks inbetween. +/// +/// # Panics +/// +/// Panics if passed selections are not in order +pub(crate) fn resolve_selections_wrapping_blocks<'a, D, I>( selections: I, map: &'a DisplaySnapshot, ) -> impl 'a + Iterator> @@ -959,17 +1052,21 @@ where D: TextDimension + Ord + Sub, I: 'a + IntoIterator>, { + // Transforms `Anchor -> DisplayPoint -> Point -> DisplayPoint -> D` + // todo(lw): We should be able to short circuit the `Anchor -> DisplayPoint -> Point` to `Anchor -> Point` let (to_convert, selections) = resolve_selections_display(selections, map).tee(); let mut converted_endpoints = - map.buffer_snapshot + map.buffer_snapshot() .dimensions_from_points::(to_convert.flat_map(|s| { let start = map.display_point_to_point(s.start, Bias::Left); let end = map.display_point_to_point(s.end, Bias::Right); + assert!(start <= end, "start: {:?}, end: {:?}", start, end); [start, end] })); selections.map(move |s| { let start = converted_endpoints.next().unwrap(); let end = converted_endpoints.next().unwrap(); + assert!(start <= end, "start: {:?}, end: {:?}", start, end); Selection { id: s.id, start, @@ -979,3 +1076,33 @@ where } }) } + +fn coalesce_selections( + selections: impl Iterator>, +) -> impl Iterator> { + let mut selections = selections.peekable(); + iter::from_fn(move || { + let mut selection = selections.next()?; + while let Some(next_selection) = selections.peek() { + if selection.end >= next_selection.start { + if selection.reversed == next_selection.reversed { + selection.end = cmp::max(selection.end, next_selection.end); + selections.next(); + } else { + selection.end = cmp::max(selection.start, next_selection.start); + break; + } + } else { + break; + } + } + assert!( + selection.start <= selection.end, + "selection.start: {:?}, selection.end: {:?}, selection.reversed: {:?}", + selection.start, + selection.end, + selection.reversed + ); + Some(selection) + }) +} diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index e0736a6e9f1973fba8f34e88fd4b06bfce59e6c2..8d74638e4c2aaf356ffabdeef717b9b105487ee3 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -2,8 +2,8 @@ use crate::actions::ShowSignatureHelp; use crate::hover_popover::open_markdown_url; use crate::{Editor, EditorSettings, ToggleAutoSignatureHelp, hover_markdown_style}; use gpui::{ - App, Context, Div, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, Stateful, - StyledText, Task, TextStyle, Window, combine_highlights, + App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, StyledText, Task, + TextStyle, Window, combine_highlights, }; use language::BufferSnapshot; use markdown::{Markdown, MarkdownElement}; @@ -15,8 +15,8 @@ use theme::ThemeSettings; use ui::{ ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton, IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, - LabelSize, ParentElement, Pixels, Scrollbar, ScrollbarState, SharedString, - StatefulInteractiveElement, Styled, StyledExt, div, px, relative, + LabelSize, ParentElement, Pixels, SharedString, StatefulInteractiveElement, Styled, StyledExt, + WithScrollbar, div, relative, }; // Language-specific settings may define quotes as "brackets", so filter them out separately. @@ -82,7 +82,7 @@ impl Editor { if !(self.signature_help_state.is_shown() || self.auto_signature_help_enabled(cx)) { return false; } - let newest_selection = self.selections.newest::(cx); + let newest_selection = self.selections.newest::(&self.display_snapshot(cx)); let head = newest_selection.head(); if !newest_selection.is_empty() && head != newest_selection.tail() { @@ -182,7 +182,9 @@ impl Editor { let signature_help = task.await; editor .update(cx, |editor, cx| { - let Some(mut signature_help) = signature_help.into_iter().next() else { + let Some(mut signature_help) = + signature_help.unwrap_or_default().into_iter().next() + else { editor .signature_help_state .hide(SignatureHelpHiddenBy::AutoClose); @@ -196,7 +198,7 @@ impl Editor { .highlight_text(&text, 0..signature.label.len()) .into_iter() .flat_map(|(range, highlight_id)| { - Some((range, highlight_id.style(&cx.theme().syntax())?)) + Some((range, highlight_id.style(cx.theme().syntax())?)) }); signature.highlights = combine_highlights(signature.highlights.clone(), highlights) @@ -241,7 +243,6 @@ impl Editor { .min(signatures.len().saturating_sub(1)); let signature_help_popover = SignatureHelpPopover { - scrollbar_state: ScrollbarState::new(scroll_handle.clone()), style, signatures, current_signature, @@ -328,7 +329,6 @@ pub struct SignatureHelpPopover { pub signatures: Vec, pub current_signature: usize, scroll_handle: ScrollHandle, - scrollbar_state: ScrollbarState, } impl SignatureHelpPopover { @@ -389,19 +389,15 @@ impl SignatureHelpPopover { ) }), ) - .child(self.render_vertical_scrollbar(cx)); + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx); + let controls = if self.signatures.len() > 1 { let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp) .shape(IconButtonShape::Square) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .tooltip(move |window, cx| { - ui::Tooltip::for_action( - "Previous Signature", - &crate::SignatureHelpPrevious, - window, - cx, - ) + .tooltip(move |_window, cx| { + ui::Tooltip::for_action("Previous Signature", &crate::SignatureHelpPrevious, cx) }) .on_click(cx.listener(|editor, _, window, cx| { editor.signature_help_prev(&crate::SignatureHelpPrevious, window, cx); @@ -411,8 +407,8 @@ impl SignatureHelpPopover { .shape(IconButtonShape::Square) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .tooltip(move |window, cx| { - ui::Tooltip::for_action("Next Signature", &crate::SignatureHelpNext, window, cx) + .tooltip(move |_window, cx| { + ui::Tooltip::for_action("Next Signature", &crate::SignatureHelpNext, cx) }) .on_click(cx.listener(|editor, _, window, cx| { editor.signature_help_next(&crate::SignatureHelpNext, window, cx); @@ -458,26 +454,4 @@ impl SignatureHelpPopover { .child(main_content) .into_any_element() } - - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ - div() - .occlude() - .id("signature_help_scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| cx.stop_propagation()) - .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) - .on_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_scroll_wheel(cx.listener(|_, _, _, cx| cx.notify())) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_1() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())) - } } diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs index 0d497e4cac779a65b7a6593d3b82f786d10321ce..e39880ddc1f575a7b12f40c5496c75c1f473c6e9 100644 --- a/crates/editor/src/tasks.rs +++ b/crates/editor/src/tasks.rs @@ -14,7 +14,7 @@ impl Editor { return Task::ready(None); }; let (selection, buffer, editor_snapshot) = { - let selection = self.selections.newest_adjusted(cx); + let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); let Some((buffer, _)) = self .buffer() .read(cx) @@ -28,12 +28,12 @@ impl Editor { let selection_range = selection.range(); let start = editor_snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(selection_range.start) .text_anchor; let end = editor_snapshot .display_snapshot - .buffer_snapshot + .buffer_snapshot() .anchor_after(selection_range.end) .text_anchor; let location = Location { @@ -89,7 +89,7 @@ impl Editor { .lsp_task_source()?; if lsp_settings .get(&lsp_tasks_source) - .map_or(true, |s| s.enable_lsp_tasks) + .is_none_or(|s| s.enable_lsp_tasks) { let buffer_id = buffer.read(cx).remote_id(); Some((lsp_tasks_source, buffer_id)) diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index f328945dbe6ae961d3fcb1ef5c80055b6adb0afb..9d1003e8c08b3d725ffa13b90eb0ee405520d8cd 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -20,7 +20,7 @@ use multi_buffer::ToPoint; use pretty_assertions::assert_eq; use project::{Project, project_settings::DiagnosticSeverity}; use ui::{App, BorrowAppContext, px}; -use util::test::{marked_text_offsets, marked_text_ranges}; +use util::test::{generate_marked_text, marked_text_offsets, marked_text_ranges}; #[cfg(test)] #[ctor::ctor] @@ -104,13 +104,14 @@ pub fn assert_text_with_selections( marked_text: &str, cx: &mut Context, ) { - let (unmarked_text, text_ranges) = marked_text_ranges(marked_text, true); + let (unmarked_text, _text_ranges) = marked_text_ranges(marked_text, true); assert_eq!(editor.text(cx), unmarked_text, "text doesn't match"); - assert_eq!( - editor.selections.ranges(cx), - text_ranges, - "selections don't match", + let actual = generate_marked_text( + &editor.text(cx), + &editor.selections.ranges(&editor.display_snapshot(cx)), + marked_text.contains("«"), ); + assert_eq!(actual, marked_text, "Selections don't match"); } // RA thinks this is dead code even though it is used in a whole lot of tests @@ -184,12 +185,12 @@ pub fn editor_content_with_blocks(editor: &Entity, cx: &mut VisualTestCo for (row, block) in blocks { match block { Block::Custom(custom_block) => { - if let BlockPlacement::Near(x) = &custom_block.placement { - if snapshot.intersects_fold(x.to_point(&snapshot.buffer_snapshot)) { - continue; - } + if let BlockPlacement::Near(x) = &custom_block.placement + && snapshot.intersects_fold(x.to_point(&snapshot.buffer_snapshot())) + { + continue; }; - let content = block_content_for_tests(&editor, custom_block.id, cx) + let content = block_content_for_tests(editor, custom_block.id, cx) .expect("block content not found"); // 2: "related info 1 for diagnostic 0" if let Some(height) = custom_block.height { @@ -216,40 +217,23 @@ pub fn editor_content_with_blocks(editor: &Entity, cx: &mut VisualTestCo height, } => { lines[row.0 as usize].push_str(&cx.update(|_, cx| { - format!( - "§ {}", - first_excerpt - .buffer - .file() - .unwrap() - .file_name(cx) - .to_string_lossy() - ) + format!("§ {}", first_excerpt.buffer.file().unwrap().file_name(cx)) })); for row in row.0 + 1..row.0 + height { lines[row as usize].push_str("§ -----"); } } - Block::ExcerptBoundary { - excerpt, - height, - starts_new_buffer, - } => { - if starts_new_buffer { - lines[row.0 as usize].push_str(&cx.update(|_, cx| { - format!( - "§ {}", - excerpt - .buffer - .file() - .unwrap() - .file_name(cx) - .to_string_lossy() - ) - })); - } else { - lines[row.0 as usize].push_str("§ -----") + Block::ExcerptBoundary { height, .. } => { + for row in row.0..row.0 + height { + lines[row as usize].push_str("§ -----"); } + } + Block::BufferHeader { excerpt, height } => { + lines[row.0 as usize].push_str( + &cx.update(|_, cx| { + format!("§ {}", excerpt.buffer.file().unwrap().file_name(cx)) + }), + ); for row in row.0 + 1..row.0 + height { lines[row as usize].push_str("§ -----"); } diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index c59786b1eb387835a21e2c155efaf6acefd4ff4a..3132e2e6d5976754d0bdb7fea312fa152d4c35ac 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -6,6 +6,7 @@ use std::{ }; use anyhow::Result; +use language::rust_lang; use serde_json::json; use crate::{Editor, ToPoint}; @@ -18,7 +19,6 @@ use language::{ point_to_lsp, }; use lsp::{notification, request}; -use multi_buffer::ToPointUtf16; use project::Project; use smol::stream::StreamExt; use workspace::{AppState, Workspace, WorkspaceHandle}; @@ -29,56 +29,7 @@ pub struct EditorLspTestContext { pub cx: EditorTestContext, pub lsp: lsp::FakeLanguageServer, pub workspace: Entity, - pub buffer_lsp_url: lsp::Url, -} - -pub(crate) fn rust_lang() -> Arc { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_queries(LanguageQueries { - indents: Some(Cow::from(indoc! {r#" - [ - ((where_clause) _ @end) - (field_expression) - (call_expression) - (assignment_expression) - (let_declaration) - (let_chain) - (await_expression) - ] @indent - - (_ "[" "]" @end) @indent - (_ "<" ">" @end) @indent - (_ "{" "}" @end) @indent - (_ "(" ")" @end) @indent"#})), - brackets: Some(Cow::from(indoc! {r#" - ("(" @open ")" @close) - ("[" @open "]" @close) - ("{" @open "}" @close) - ("<" @open ">" @close) - ("\"" @open "\"" @close) - (closure_parameters "|" @open "|" @close)"#})), - text_objects: Some(Cow::from(indoc! {r#" - (function_item - body: (_ - "{" - (_)* @function.inside - "}" )) @function.around - "#})), - ..Default::default() - }) - .expect("Could not parse queries"); - Arc::new(language) + pub buffer_lsp_url: lsp::Uri, } #[cfg(test)] @@ -189,7 +140,7 @@ impl EditorLspTestContext { }, lsp, workspace, - buffer_lsp_url: lsp::Url::from_file_path(root.join("dir").join(file_name)).unwrap(), + buffer_lsp_url: lsp::Uri::from_file_path(root.join("dir").join(file_name)).unwrap(), } } @@ -262,6 +213,77 @@ impl EditorLspTestContext { Self::new(language, capabilities, cx).await } + pub async fn new_tsx( + capabilities: lsp::ServerCapabilities, + cx: &mut gpui::TestAppContext, + ) -> EditorLspTestContext { + let mut word_characters: HashSet = Default::default(); + word_characters.insert('$'); + word_characters.insert('#'); + let language = Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, + brackets: language::BracketPairConfig { + pairs: vec![language::BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: true, + }], + disabled_scopes_by_bracket_ix: Default::default(), + }, + word_characters, + ..Default::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_queries(LanguageQueries { + brackets: Some(Cow::from(indoc! {r#" + ("(" @open ")" @close) + ("[" @open "]" @close) + ("{" @open "}" @close) + ("<" @open ">" @close) + ("<" @open "/>" @close) + ("" @close) + ("\"" @open "\"" @close) + ("'" @open "'" @close) + ("`" @open "`" @close) + ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})), + indents: Some(Cow::from(indoc! {r#" + [ + (call_expression) + (assignment_expression) + (member_expression) + (lexical_declaration) + (variable_declaration) + (assignment_expression) + (if_statement) + (for_statement) + ] @indent + + (_ "[" "]" @end) @indent + (_ "<" ">" @end) @indent + (_ "{" "}" @end) @indent + (_ "(" ")" @end) @indent + + (jsx_opening_element ">" @end) @indent + + (jsx_element + (jsx_opening_element) @start + (jsx_closing_element)? @end) @indent + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + Self::new(language, capabilities, cx).await + } + pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self { let language = Language::new( LanguageConfig { @@ -300,10 +322,11 @@ impl EditorLspTestContext { self.to_lsp_range(ranges[0].clone()) } + #[expect(clippy::wrong_self_convention, reason = "This is test code")] pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); - let start_point = range.start.to_point(&snapshot.buffer_snapshot); - let end_point = range.end.to_point(&snapshot.buffer_snapshot); + let start_point = range.start.to_point(&snapshot.buffer_snapshot()); + let end_point = range.end.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); @@ -326,9 +349,10 @@ impl EditorLspTestContext { }) } + #[expect(clippy::wrong_self_convention, reason = "This is test code")] pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); - let point = offset.to_point(&snapshot.buffer_snapshot); + let point = offset.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); @@ -356,7 +380,7 @@ impl EditorLspTestContext { where T: 'static + request::Request, T::Params: 'static + Send, - F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncApp) -> Fut, + F: 'static + Send + FnMut(lsp::Uri, T::Params, gpui::AsyncApp) -> Fut, Fut: 'static + Future>, { let url = self.buffer_lsp_url.clone(); @@ -367,7 +391,7 @@ impl EditorLspTestContext { } pub fn notify(&self, params: T::Params) { - self.lsp.notify::(¶ms); + self.lsp.notify::(params); } #[cfg(target_os = "windows")] diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index dbb519c40e544585b82c3f8aa9b1312fe7078590..c6779d1e564deb57233dd9e4719ca87f8d6a2da1 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -1,5 +1,5 @@ use crate::{ - AnchorRangeExt, DisplayPoint, Editor, MultiBuffer, RowExt, + AnchorRangeExt, DisplayPoint, Editor, ExcerptId, MultiBuffer, MultiBufferSnapshot, RowExt, display_map::{HighlightKey, ToDisplayPoint}, }; use buffer_diff::DiffHunkStatusKind; @@ -24,6 +24,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, }, }; +use text::Selection; use util::{ assert_set_eq, test::{generate_marked_text, marked_text_ranges}, @@ -119,13 +120,7 @@ impl EditorTestContext { for excerpt in excerpts.into_iter() { let (text, ranges) = marked_text_ranges(excerpt, false); let buffer = cx.new(|cx| Buffer::local(text, cx)); - multibuffer.push_excerpts( - buffer, - ranges - .into_iter() - .map(|range| ExcerptRange::new(range.clone())), - cx, - ); + multibuffer.push_excerpts(buffer, ranges.into_iter().map(ExcerptRange::new), cx); } multibuffer }); @@ -270,7 +265,10 @@ impl EditorTestContext { pub fn pixel_position_for(&mut self, display_point: DisplayPoint) -> Point { self.update_editor(|editor, window, cx| { - let newest_point = editor.selections.newest_display(cx).head(); + let newest_point = editor + .selections + .newest_display(&editor.display_snapshot(cx)) + .head(); let pixel_position = editor.pixel_position_of_newest_cursor.unwrap(); let line_height = editor .style() @@ -281,7 +279,8 @@ impl EditorTestContext { let details = editor.text_layout_details(window); let y = pixel_position.y - + line_height * (display_point.row().as_f32() - newest_point.row().as_f32()); + + f32::from(line_height) + * Pixels::from(display_point.row().as_f64() - newest_point.row().as_f64()); let x = pixel_position.x + snapshot.x_for_display_point(display_point, &details) - snapshot.x_for_display_point(newest_point, &details); Point::new(x, y) @@ -302,7 +301,7 @@ impl EditorTestContext { let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); fs.set_head_for_repo( &Self::root_path().join(".git"), - &[(path.into(), diff_base.to_string())], + &[(path.as_unix_str(), diff_base.to_string())], "deadbeef", ); self.cx.run_until_parked(); @@ -323,7 +322,7 @@ impl EditorTestContext { let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); fs.set_index_for_repo( &Self::root_path().join(".git"), - &[(path.into(), diff_base.to_string())], + &[(path.as_unix_str(), diff_base.to_string())], ); self.cx.run_until_parked(); } @@ -335,7 +334,7 @@ impl EditorTestContext { let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); let mut found = None; fs.with_git_state(&Self::root_path().join(".git"), false, |git_state| { - found = git_state.index_contents.get(path.as_ref()).cloned(); + found = git_state.index_contents.get(&path.into()).cloned(); }) .unwrap(); assert_eq!(expected, found.as_deref()); @@ -393,6 +392,23 @@ impl EditorTestContext { #[track_caller] pub fn assert_excerpts_with_selections(&mut self, marked_text: &str) { + let actual_text = self.to_format_multibuffer_as_marked_text(); + let fmt_additional_notes = || { + struct Format<'a, T: std::fmt::Display>(&'a str, &'a T); + + impl std::fmt::Display for Format<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "\n\n----- EXPECTED: -----\n\n{}\n\n----- ACTUAL: -----\n\n{}\n\n", + self.0, self.1 + ) + } + } + + Format(marked_text, &actual_text) + }; + let expected_excerpts = marked_text .strip_prefix("[EXCERPT]\n") .unwrap() @@ -402,7 +418,7 @@ impl EditorTestContext { let (multibuffer_snapshot, selections, excerpts) = self.update_editor(|editor, _, cx| { let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - let selections = editor.selections.disjoint_anchors(); + let selections = editor.selections.disjoint_anchors_arc(); let excerpts = multibuffer_snapshot .excerpts() .map(|(e_id, snapshot, range)| (e_id, snapshot.clone(), range)) @@ -413,9 +429,10 @@ impl EditorTestContext { assert!( excerpts.len() == expected_excerpts.len(), - "should have {} excerpts, got {}", + "should have {} excerpts, got {}{}", expected_excerpts.len(), - excerpts.len() + excerpts.len(), + fmt_additional_notes(), ); for (ix, (excerpt_id, snapshot, range)) in excerpts.into_iter().enumerate() { @@ -426,21 +443,28 @@ impl EditorTestContext { if expected_text == "[FOLDED]\n" { assert!(is_folded, "excerpt {} should be folded", ix); let is_selected = selections.iter().any(|s| s.head().excerpt_id == excerpt_id); - if expected_selections.len() > 0 { + if !expected_selections.is_empty() { assert!( is_selected, - "excerpt {ix} should be selected. got {:?}", + "excerpt {ix} should contain selections. got {:?}{}", self.editor_state(), + fmt_additional_notes(), ); } else { assert!( !is_selected, - "excerpt {ix} should not be selected, got: {selections:?}", + "excerpt {ix} should not contain selections, got: {selections:?}{}", + fmt_additional_notes(), ); } continue; } - assert!(!is_folded, "excerpt {} should not be folded", ix); + assert!( + !is_folded, + "excerpt {} should not be folded{}", + ix, + fmt_additional_notes() + ); assert_eq!( multibuffer_snapshot .text_for_range(Anchor::range_in_buffer( @@ -449,7 +473,9 @@ impl EditorTestContext { range.context.clone() )) .collect::(), - expected_text + expected_text, + "{}", + fmt_additional_notes(), ); let selections = selections @@ -465,13 +491,38 @@ impl EditorTestContext { .collect::>(); // todo: selections that cross excerpt boundaries.. assert_eq!( - selections, expected_selections, - "excerpt {} has incorrect selections", + selections, + expected_selections, + "excerpt {} has incorrect selections{}", ix, + fmt_additional_notes() ); } } + fn to_format_multibuffer_as_marked_text(&mut self) -> FormatMultiBufferAsMarkedText { + let (multibuffer_snapshot, selections, excerpts) = self.update_editor(|editor, _, cx| { + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + + let selections = editor.selections.disjoint_anchors_arc().to_vec(); + let excerpts = multibuffer_snapshot + .excerpts() + .map(|(e_id, snapshot, range)| { + let is_folded = editor.is_buffer_folded(snapshot.remote_id(), cx); + (e_id, snapshot.clone(), range, is_folded) + }) + .collect::>(); + + (multibuffer_snapshot, selections, excerpts) + }); + + FormatMultiBufferAsMarkedText { + multibuffer_snapshot, + selections, + excerpts, + } + } + /// Make an assertion about the editor's text and the ranges and directions /// of its selections using a string containing embedded range markers. /// @@ -509,7 +560,7 @@ impl EditorTestContext { .map(|h| h.1.clone()) .unwrap_or_default() .iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .map(|range| range.to_offset(&snapshot.buffer_snapshot())) .collect() }); assert_set_eq!(actual_ranges, expected_ranges); @@ -524,7 +575,7 @@ impl EditorTestContext { .map(|ranges| ranges.as_ref().clone().1) .unwrap_or_default() .into_iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .map(|range| range.to_offset(&snapshot.buffer_snapshot())) .collect(); assert_set_eq!(actual_ranges, expected_ranges); } @@ -542,7 +593,7 @@ impl EditorTestContext { fn editor_selections(&mut self) -> Vec> { self.editor .update(&mut self.cx, |editor, cx| { - editor.selections.all::(cx) + editor.selections.all::(&editor.display_snapshot(cx)) }) .into_iter() .map(|s| { @@ -576,6 +627,63 @@ impl EditorTestContext { } } +struct FormatMultiBufferAsMarkedText { + multibuffer_snapshot: MultiBufferSnapshot, + selections: Vec>, + excerpts: Vec<(ExcerptId, BufferSnapshot, ExcerptRange, bool)>, +} + +impl std::fmt::Display for FormatMultiBufferAsMarkedText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + multibuffer_snapshot, + selections, + excerpts, + } = self; + + for (excerpt_id, snapshot, range, is_folded) in excerpts.into_iter() { + write!(f, "[EXCERPT]\n")?; + if *is_folded { + write!(f, "[FOLDED]\n")?; + } + + let mut text = multibuffer_snapshot + .text_for_range(Anchor::range_in_buffer( + *excerpt_id, + snapshot.remote_id(), + range.context.clone(), + )) + .collect::(); + + let selections = selections + .iter() + .filter(|&s| s.head().excerpt_id == *excerpt_id) + .map(|s| { + let head = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot) + - text::ToOffset::to_offset(&range.context.start, &snapshot); + let tail = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot) + - text::ToOffset::to_offset(&range.context.start, &snapshot); + tail..head + }) + .rev() + .collect::>(); + + for selection in selections { + if selection.is_empty() { + text.insert(selection.start, 'ˇ'); + continue; + } + text.insert(selection.end, '»'); + text.insert(selection.start, '«'); + } + + write!(f, "{text}")?; + } + + Ok(()) + } +} + #[track_caller] pub fn assert_state_with_diff( editor: &Entity, @@ -583,9 +691,12 @@ pub fn assert_state_with_diff( expected_diff_text: &str, ) { let (snapshot, selections) = editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); ( - editor.snapshot(window, cx).buffer_snapshot.clone(), - editor.selections.ranges::(cx), + snapshot.buffer_snapshot().clone(), + editor + .selections + .ranges::(&snapshot.display_snapshot), ) }); diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml index a0214c76a1c7230e071cbc65c1eadbc44c7d6ca8..30908be1e2fde15c0c32894b266d971b7f0ca54f 100644 --- a/crates/eval/Cargo.toml +++ b/crates/eval/Cargo.toml @@ -18,18 +18,17 @@ name = "explorer" path = "src/explorer.rs" [dependencies] -agent.workspace = true +acp_thread.workspace = true +agent = { workspace = true, features = ["eval"] } +agent-client-protocol.workspace = true agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true -assistant_tool.workspace = true -assistant_tools.workspace = true async-trait.workspace = true buffer_diff.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true -cloud_llm_client.workspace = true collections.workspace = true debug_adapter_extension.workspace = true dirs.workspace = true @@ -54,13 +53,13 @@ pretty_assertions.workspace = true project.workspace = true prompt_store.workspace = true regex.workspace = true +rand.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true shellexpand.workspace = true -smol.workspace = true telemetry.workspace = true terminal_view.workspace = true toml.workspace = true @@ -68,4 +67,3 @@ unindent.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true -workspace-hack.workspace = true diff --git a/crates/eval/runner_settings.json b/crates/eval/runner_settings.json index 91f193d7b3359bdc9ca5a2255f0fb51c4484f344..ea2ccb051164c4a6c40aed9d6607db0a8911c5d6 100644 --- a/crates/eval/runner_settings.json +++ b/crates/eval/runner_settings.json @@ -1,7 +1,5 @@ { - "assistant": { - "always_allow_tool_actions": true, - "stream_edits": true, - "version": "2" + "agent": { + "always_allow_tool_actions": true } } diff --git a/crates/eval/src/assertions.rs b/crates/eval/src/assertions.rs index 489e4aa22ecdc6633a0002238a2287ca0a5105f0..01fac186d33a8b5b156121acf924d37c90c64679 100644 --- a/crates/eval/src/assertions.rs +++ b/crates/eval/src/assertions.rs @@ -54,7 +54,7 @@ impl AssertionsReport { pub fn passed_count(&self) -> usize { self.ran .iter() - .filter(|a| a.result.as_ref().map_or(false, |result| result.passed)) + .filter(|a| a.result.as_ref().is_ok_and(|result| result.passed)) .count() } diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 6558222d89769f329ce50c238ad145e5d6aebc0f..c5b34a63eec33a45e6d1c75e73fa473f845c5e36 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -61,9 +61,22 @@ struct Args { /// Maximum number of examples to run concurrently. #[arg(long, default_value = "4")] concurrency: usize, + /// Output current environment variables as JSON to stdout + #[arg(long, hide = true)] + printenv: bool, } fn main() { + let args = Args::parse(); + + // This prevents errors showing up in the logs, because + // project::environment::load_shell_environment() calls + // std::env::current_exe().unwrap() --printenv + if args.printenv { + util::shell_env::print_env(); + return; + } + dotenvy::from_filename(CARGO_MANIFEST_DIR.join(".env")).ok(); env_logger::init(); @@ -99,11 +112,10 @@ fn main() { let zed_commit_sha = commit_sha_for_path(&root_dir); let zed_branch_name = git_branch_for_path(&root_dir); - let args = Args::parse(); let languages: HashSet = args.languages.into_iter().collect(); let http_client = Arc::new(ReqwestClient::new()); - let app = Application::headless().with_http_client(http_client.clone()); + let app = Application::headless().with_http_client(http_client); let all_threads = examples::all(&examples_dir); app.run(move |cx| { @@ -112,7 +124,7 @@ fn main() { let telemetry = app_state.client.telemetry(); telemetry.start(system_id, installation_id, session_id, cx); - let enable_telemetry = env::var("ZED_EVAL_TELEMETRY").map_or(false, |value| value == "1") + let enable_telemetry = env::var("ZED_EVAL_TELEMETRY").is_ok_and(|value| value == "1") && telemetry.has_checksum_seed(); if enable_telemetry { println!("Telemetry enabled"); @@ -126,19 +138,20 @@ fn main() { let mut cumulative_tool_metrics = ToolMetrics::default(); - let agent_model = load_model(&args.model, cx).unwrap(); - let judge_model = load_model(&args.judge_model, cx).unwrap(); - - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.set_default_model(Some(agent_model.clone()), cx); + let tasks = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.providers().iter().map(|p| p.authenticate(cx)).collect::>() }); - let auth1 = agent_model.provider.authenticate(cx); - let auth2 = judge_model.provider.authenticate(cx); - cx.spawn(async move |cx| { - auth1.await?; - auth2.await?; + future::join_all(tasks).await; + let judge_model = cx.update(|cx| { + let agent_model = load_model(&args.model, cx).unwrap(); + let judge_model = load_model(&args.judge_model, cx).unwrap(); + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.set_default_model(Some(agent_model.clone()), cx); + }); + judge_model + })?; let mut examples = Vec::new(); @@ -167,15 +180,14 @@ fn main() { continue; } - if let Some(language) = meta.language_server { - if !languages.contains(&language.file_extension) { + if let Some(language) = meta.language_server + && !languages.contains(&language.file_extension) { panic!( "Eval for {:?} could not be run because no language server was found for extension {:?}", meta.name, language.file_extension ); } - } // TODO: This creates a worktree per repetition. Ideally these examples should // either be run sequentially on the same worktree, or reuse worktrees when there @@ -269,7 +281,6 @@ fn main() { future::join_all((0..args.concurrency).map(|_| { let app_state = app_state.clone(); - let model = agent_model.model.clone(); let judge_model = judge_model.model.clone(); let zed_commit_sha = zed_commit_sha.clone(); let zed_branch_name = zed_branch_name.clone(); @@ -284,7 +295,7 @@ fn main() { let result = async { example.setup().await?; let run_output = cx - .update(|cx| example.run(model.clone(), app_state.clone(), cx))? + .update(|cx| example.run(app_state.clone(), cx))? .await?; let judge_output = judge_example( example.clone(), @@ -341,10 +352,7 @@ pub fn init(cx: &mut App) -> Arc { release_channel::init(app_version, cx); gpui_tokio::init(cx); - let mut settings_store = SettingsStore::new(cx); - settings_store - .set_default_settings(settings::default_settings().as_ref(), cx) - .unwrap(); + let settings_store = SettingsStore::new(cx, &settings::default_settings()); cx.set_global(settings_store); client::init_settings(cx); @@ -417,14 +425,10 @@ pub fn init(cx: &mut App) -> Arc { language::init(cx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); - language_extension::init( - LspAccess::Noop, - extension_host_proxy.clone(), - languages.clone(), - ); + language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone()); language_model::init(client.clone(), cx); language_models::init(user_store.clone(), client.clone(), cx); - languages::init(languages.clone(), node_runtime.clone(), cx); + languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx); prompt_store::init(cx); terminal_view::init(cx); let stdout_is_a_pty = false; @@ -437,7 +441,6 @@ pub fn init(cx: &mut App) -> Arc { true, cx, ); - assistant_tools::init(client.http_client(), cx); SettingsStore::update_global(cx, |store, cx| { store.set_user_settings(include_str!("../runner_settings.json"), cx) @@ -520,7 +523,7 @@ async fn judge_example( enable_telemetry: bool, cx: &AsyncApp, ) -> JudgeOutput { - let judge_output = example.judge(model.clone(), &run_output, cx).await; + let judge_output = example.judge(model.clone(), run_output, cx).await; if enable_telemetry { telemetry::event!( @@ -531,9 +534,8 @@ async fn judge_example( example_name = example.name.clone(), example_repetition = example.repetition, diff_evaluation = judge_output.diff.clone(), - thread_evaluation = judge_output.thread.clone(), + thread_evaluation = judge_output.thread, tool_metrics = run_output.tool_metrics, - response_count = run_output.response_count, token_usage = run_output.token_usage, model = model.telemetry_id(), model_provider = model.provider_id().to_string(), @@ -711,7 +713,7 @@ fn print_report( println!("Average thread score: {average_thread_score}%"); } - println!(""); + println!(); print_h2("CUMULATIVE TOOL METRICS"); println!("{}", cumulative_tool_metrics); diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 23c8814916da2df4016c4196d7767b748da54280..84c47766e96948bccfc01f3b4472b5100c4b7b64 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -1,25 +1,27 @@ use std::{ error::Error, fmt::{self, Debug}, - path::Path, sync::{Arc, Mutex}, time::Duration, + u32, }; use crate::{ ToolMetrics, assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, }; -use agent::{ContextLoadResult, Thread, ThreadEvent}; +use acp_thread::UserMessageId; +use agent::{Thread, ThreadEvent, UserMessageContent}; +use agent_client_protocol as acp; use agent_settings::AgentProfileId; use anyhow::{Result, anyhow}; use async_trait::async_trait; use buffer_diff::DiffHunkStatus; -use cloud_llm_client::CompletionIntent; use collections::HashMap; -use futures::{FutureExt as _, StreamExt, channel::mpsc, select_biased}; +use futures::{FutureExt as _, StreamExt, select_biased}; use gpui::{App, AppContext, AsyncApp, Entity}; -use language_model::{LanguageModel, Role, StopReason}; +use language_model::Role; +use util::rel_path::RelPath; pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2); @@ -64,7 +66,7 @@ impl ExampleMetadata { self.url .split('/') .next_back() - .unwrap_or(&"") + .unwrap_or("") .trim_end_matches(".git") .into() } @@ -91,7 +93,6 @@ pub struct ExampleContext { log_prefix: String, agent_thread: Entity, app: AsyncApp, - model: Arc, pub assertions: AssertionsReport, pub tool_metrics: Arc>, } @@ -101,7 +102,6 @@ impl ExampleContext { meta: ExampleMetadata, log_prefix: String, agent_thread: Entity, - model: Arc, app: AsyncApp, ) -> Self { let assertions = AssertionsReport::new(meta.max_assertions); @@ -111,26 +111,11 @@ impl ExampleContext { log_prefix, agent_thread, assertions, - model, app, tool_metrics: Arc::new(Mutex::new(ToolMetrics::default())), } } - pub fn push_user_message(&mut self, text: impl ToString) { - self.app - .update_entity(&self.agent_thread, |thread, cx| { - thread.insert_user_message( - text.to_string(), - ContextLoadResult::default(), - None, - Vec::new(), - cx, - ); - }) - .unwrap(); - } - pub fn assert(&mut self, expected: bool, message: impl ToString) -> Result<()> { let message = message.to_string(); self.log_assertion( @@ -202,159 +187,177 @@ impl ExampleContext { result } - pub async fn run_to_end(&mut self) -> Result { - self.run_turns(u32::MAX).await + pub async fn prompt(&mut self, prompt: impl Into) -> Result { + self.prompt_with_max_turns(prompt, u32::MAX).await } - pub async fn run_turn(&mut self) -> Result { - self.run_turns(1).await + pub async fn prompt_with_max_turns( + &mut self, + prompt: impl Into, + max_turns: u32, + ) -> Result { + let content = vec![UserMessageContent::Text(prompt.into())]; + self.run_turns(Some(content), max_turns).await } - pub async fn run_turns(&mut self, iterations: u32) -> Result { - let (mut tx, mut rx) = mpsc::channel(1); + pub async fn proceed_with_max_turns(&mut self, max_turns: u32) -> Result { + self.run_turns(None, max_turns).await + } + async fn run_turns( + &mut self, + prompt: Option>, + max_turns: u32, + ) -> Result { let tool_metrics = self.tool_metrics.clone(); let log_prefix = self.log_prefix.clone(); - let _subscription = self.app.subscribe( - &self.agent_thread, - move |thread, event: &ThreadEvent, cx| match event { - ThreadEvent::ShowError(thread_error) => { - tx.try_send(Err(anyhow!(thread_error.clone()))).ok(); - } - ThreadEvent::Stopped(reason) => match reason { - Ok(StopReason::EndTurn) => { - tx.close_channel(); + + let mut remaining_turns = max_turns; + + let mut event_stream = self.agent_thread.update(&mut self.app, |thread, cx| { + if let Some(prompt) = prompt { + let id = UserMessageId::new(); + thread.send(id, prompt, cx) + } else { + thread.proceed(cx) + } + })??; + + let task = self.app.background_spawn(async move { + let mut messages = Vec::new(); + let mut tool_uses_by_id = HashMap::default(); + while let Some(event) = event_stream.next().await { + match event? { + ThreadEvent::UserMessage(user_message) => { + messages.push(Message { + role: Role::User, + text: user_message.to_markdown(), + tool_use: Vec::new(), + }); } - Ok(StopReason::ToolUse) => { - if thread.read(cx).remaining_turns() == 0 { - tx.close_channel(); + ThreadEvent::AgentThinking(text) | ThreadEvent::AgentText(text) => { + if matches!( + messages.last(), + Some(Message { + role: Role::Assistant, + .. + }) + ) { + messages.last_mut().unwrap().text.push_str(&text); + } else { + messages.push(Message { + role: Role::Assistant, + text, + tool_use: Vec::new(), + }); } } - Ok(StopReason::MaxTokens) => { - tx.try_send(Err(anyhow!("Exceeded maximum tokens"))).ok(); - } - Ok(StopReason::Refusal) => { - tx.try_send(Err(anyhow!("Model refused to generate content"))) - .ok(); - } - Err(err) => { - tx.try_send(Err(anyhow!(err.clone()))).ok(); + ThreadEvent::ToolCall(tool_call) => { + let meta = tool_call.meta.expect("Missing meta field in tool_call"); + let tool_name = meta + .get("tool_name") + .expect("Missing tool_name field in meta") + .as_str() + .expect("Unknown tool_name content in meta"); + + tool_uses_by_id.insert( + tool_call.id, + ToolUse { + name: tool_name.to_string(), + value: tool_call.raw_input.unwrap_or_default(), + }, + ); + if matches!( + tool_call.status, + acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed + ) { + panic!("Tool call completed without update"); + } } - }, - ThreadEvent::NewRequest - | ThreadEvent::StreamedAssistantText(_, _) - | ThreadEvent::StreamedAssistantThinking(_, _) - | ThreadEvent::UsePendingTools { .. } - | ThreadEvent::CompletionCanceled => {} - ThreadEvent::ToolUseLimitReached => {} - ThreadEvent::ToolFinished { - tool_use_id, - pending_tool_use, - .. - } => { - thread.update(cx, |thread, _cx| { - if let Some(tool_use) = pending_tool_use { - let mut tool_metrics = tool_metrics.lock().unwrap(); - if let Some(tool_result) = thread.tool_result(&tool_use_id) { - let message = if tool_result.is_error { - format!("✖︎ {}", tool_use.name) - } else { + ThreadEvent::ToolCallUpdate(tool_call_update) => { + if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update { + if let Some(raw_input) = update.fields.raw_input { + if let Some(tool_use) = tool_uses_by_id.get_mut(&update.id) { + tool_use.value = raw_input; + } + } + + if matches!( + update.fields.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ) { + let succeeded = + update.fields.status == Some(acp::ToolCallStatus::Completed); + + let tool_use = tool_uses_by_id + .remove(&update.id) + .expect("Unrecognized tool call completed"); + + let log_message = if succeeded { format!("✔︎ {}", tool_use.name) + } else { + format!("✖︎ {}", tool_use.name) }; - println!("{log_prefix}{message}"); + println!("{log_prefix}{log_message}"); + tool_metrics - .insert(tool_result.tool_name.clone(), !tool_result.is_error); - } else { - let message = - format!("TOOL FINISHED WITHOUT RESULT: {}", tool_use.name); - println!("{log_prefix}{message}"); - tool_metrics.insert(tool_use.name.clone(), true); + .lock() + .unwrap() + .insert(tool_use.name.clone().into(), succeeded); + + if let Some(message) = messages.last_mut() { + message.tool_use.push(tool_use); + } else { + messages.push(Message { + role: Role::Assistant, + text: "".to_string(), + tool_use: vec![tool_use], + }); + } + + remaining_turns -= 1; + if remaining_turns == 0 { + return Ok(messages); + } } } - }); - } - ThreadEvent::InvalidToolInput { .. } => { - println!("{log_prefix} invalid tool input"); - } - ThreadEvent::MissingToolUse { - tool_use_id: _, - ui_text, - } => { - println!("{log_prefix} {ui_text}"); - } - ThreadEvent::ToolConfirmationNeeded => { - panic!( + } + ThreadEvent::ToolCallAuthorization(_) => panic!( "{}Bug: Tool confirmation should not be required in eval", log_prefix - ); - } - ThreadEvent::StreamedCompletion - | ThreadEvent::MessageAdded(_) - | ThreadEvent::MessageEdited(_) - | ThreadEvent::MessageDeleted(_) - | ThreadEvent::SummaryChanged - | ThreadEvent::SummaryGenerated - | ThreadEvent::ProfileChanged - | ThreadEvent::ReceivedTextChunk - | ThreadEvent::StreamedToolUse { .. } - | ThreadEvent::CheckpointChanged - | ThreadEvent::CancelEditing => { - tx.try_send(Ok(())).ok(); - if std::env::var("ZED_EVAL_DEBUG").is_ok() { - println!("{}Event: {:#?}", log_prefix, event); - } - } - }, - ); - - let model = self.model.clone(); - - let message_count_before = self.app.update_entity(&self.agent_thread, |thread, cx| { - thread.set_remaining_turns(iterations); - thread.send_to_model(model, CompletionIntent::UserPrompt, None, cx); - thread.messages().len() - })?; - - loop { - select_biased! { - result = rx.next() => { - if let Some(result) = result { - result?; - } else { - break; + ), + ThreadEvent::Retry(status) => { + println!("{log_prefix} Got retry: {status:?}"); } - } - _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { - anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events"); + ThreadEvent::Stop(stop_reason) => match stop_reason { + acp::StopReason::EndTurn => {} + acp::StopReason::MaxTokens => { + return Err(anyhow!("Exceeded maximum tokens")); + } + acp::StopReason::MaxTurnRequests => { + return Err(anyhow!("Exceeded maximum turn requests")); + } + acp::StopReason::Refusal => { + return Err(anyhow!("Refusal")); + } + acp::StopReason::Cancelled => return Err(anyhow!("Cancelled")), + }, } } - } + Ok(messages) + }); - let messages = self.app.read_entity(&self.agent_thread, |thread, cx| { - let mut messages = Vec::new(); - for message in thread.messages().skip(message_count_before) { - messages.push(Message { - _role: message.role, - text: message.to_string(), - tool_use: thread - .tool_uses_for_message(message.id, cx) - .into_iter() - .map(|tool_use| ToolUse { - name: tool_use.name.to_string(), - value: tool_use.input, - }) - .collect(), - }); + select_biased! { + result = task.fuse() => { + Ok(Response::new(result?)) } - messages - })?; - - let response = Response::new(messages); - - Ok(response) + _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => { + anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events"); + } + } } - pub fn edits(&self) -> HashMap, FileEdits> { + pub fn edits(&self) -> HashMap, FileEdits> { self.agent_thread .read_with(&self.app, |thread, cx| { let action_log = thread.action_log().read(cx); @@ -486,7 +489,7 @@ impl Response { Self { messages } } - pub fn expect_tool( + pub fn expect_tool_call( &self, tool_name: &'static str, cx: &mut ExampleContext, @@ -503,8 +506,7 @@ impl Response { }) } - #[allow(dead_code)] - pub fn tool_uses(&self) -> impl Iterator { + pub fn tool_calls(&self) -> impl Iterator { self.messages.iter().flat_map(|msg| &msg.tool_use) } @@ -515,7 +517,7 @@ impl Response { #[derive(Debug)] pub struct Message { - _role: Role, + role: Role, text: String, tool_use: Vec, } diff --git a/crates/eval/src/examples/add_arg_to_trait_method.rs b/crates/eval/src/examples/add_arg_to_trait_method.rs index 9c538f926059eb3998eb725168905d148dccdc9d..1692932b3304e07ebce261afb75877400e0493f4 100644 --- a/crates/eval/src/examples/add_arg_to_trait_method.rs +++ b/crates/eval/src/examples/add_arg_to_trait_method.rs @@ -1,8 +1,7 @@ -use std::path::Path; - use agent_settings::AgentProfileId; use anyhow::Result; use async_trait::async_trait; +use util::rel_path::RelPath; use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion, LanguageServer}; @@ -28,14 +27,12 @@ impl Example for AddArgToTraitMethod { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "assistant_tool.rs"; - cx.push_user_message(format!( + let _ = cx.prompt(format!( r#" Add a `window: Option` argument to the `Tool::run` trait method in {FILENAME}, and update all the implementations of the trait and call sites accordingly. "# - )); - - let _ = cx.run_to_end().await?; + )).await?; // Adds ignored argument to all but `batch_tool` @@ -68,12 +65,12 @@ impl Example for AddArgToTraitMethod { for tool_name in add_ignored_window_paths { let path_str = format!("crates/assistant_tools/src/{}.rs", tool_name); - let edits = edits.get(Path::new(&path_str)); + let edits = edits.get(RelPath::unix(&path_str).unwrap()); - let ignored = edits.map_or(false, |edits| { + let ignored = edits.is_some_and(|edits| { edits.has_added_line(" _window: Option,\n") }); - let uningored = edits.map_or(false, |edits| { + let uningored = edits.is_some_and(|edits| { edits.has_added_line(" window: Option,\n") }); @@ -86,10 +83,11 @@ impl Example for AddArgToTraitMethod { // Adds unignored argument to `batch_tool` - let batch_tool_edits = edits.get(Path::new("crates/assistant_tools/src/batch_tool.rs")); + let batch_tool_edits = + edits.get(RelPath::unix("crates/assistant_tools/src/batch_tool.rs").unwrap()); cx.assert( - batch_tool_edits.map_or(false, |edits| { + batch_tool_edits.is_some_and(|edits| { edits.has_added_line(" window: Option,\n") }), "Argument: batch_tool", diff --git a/crates/eval/src/examples/code_block_citations.rs b/crates/eval/src/examples/code_block_citations.rs index 2239ccdfddcc023fdae6f56bd91fd73c1f851ac6..c8ba75e99f019b0b0609743b10573bae712f82cd 100644 --- a/crates/eval/src/examples/code_block_citations.rs +++ b/crates/eval/src/examples/code_block_citations.rs @@ -29,16 +29,19 @@ impl Example for CodeBlockCitations { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "assistant_tool.rs"; - cx.push_user_message(format!( - r#" - Show me the method bodies of all the methods of the `Tool` trait in {FILENAME}. - - Please show each method in a separate code snippet. - "# - )); // Verify that the messages all have the correct formatting. - let texts: Vec = cx.run_to_end().await?.texts().collect(); + let texts: Vec = cx + .prompt(format!( + r#" + Show me the method bodies of all the methods of the `Tool` trait in {FILENAME}. + + Please show each method in a separate code snippet. + "# + )) + .await? + .texts() + .collect(); let closing_fence = format!("\n{FENCE}"); for text in texts.iter() { @@ -65,7 +68,7 @@ impl Example for CodeBlockCitations { thread .project() .read(cx) - .find_project_path(path_range.path, cx) + .find_project_path(path_range.path.as_ref(), cx) }) .ok() .flatten(); diff --git a/crates/eval/src/examples/comment_translation.rs b/crates/eval/src/examples/comment_translation.rs index b6c9f7376f05fdc38e9f8128c78eb1761bc59c37..421999893a5a39b3d6f61c22d405bf90528758e7 100644 --- a/crates/eval/src/examples/comment_translation.rs +++ b/crates/eval/src/examples/comment_translation.rs @@ -1,7 +1,7 @@ use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; +use agent::{EditFileMode, EditFileToolInput}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::{EditFileMode, EditFileToolInput}; use async_trait::async_trait; pub struct CommentTranslation; @@ -22,30 +22,26 @@ impl Example for CommentTranslation { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message(r#" - Edit the following files and translate all their comments to italian, in this exact order: + let response = cx.prompt( + r#" + Edit the following files and translate all their comments to italian, in this exact order: - - font-kit/src/family.rs - - font-kit/src/canvas.rs - - font-kit/src/error.rs - "#); - cx.run_to_end().await?; + - font-kit/src/family.rs + - font-kit/src/canvas.rs + - font-kit/src/error.rs + "# + ).await?; let mut create_or_overwrite_count = 0; - cx.agent_thread().read_with(cx, |thread, cx| { - for message in thread.messages() { - for tool_use in thread.tool_uses_for_message(message.id, cx) { - if tool_use.name == "edit_file" { - let input: EditFileToolInput = serde_json::from_value(tool_use.input)?; - if !matches!(input.mode, EditFileMode::Edit) { - create_or_overwrite_count += 1; - } - } + for tool_call in response.tool_calls() { + if tool_call.name == "edit_file" { + let input = tool_call.parse_input::()?; + if !matches!(input.mode, EditFileMode::Edit) { + create_or_overwrite_count += 1; } } + } - anyhow::Ok(()) - })??; cx.assert_eq(create_or_overwrite_count, 0, "no_creation_or_overwrite")?; Ok(()) diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs index 7879ad6f2ebb782bd4a5620f0fdf562c9aad1360..41ce10cd2240f2e81812a51b2ec581422c102c41 100644 --- a/crates/eval/src/examples/file_change_notification.rs +++ b/crates/eval/src/examples/file_change_notification.rs @@ -48,8 +48,8 @@ impl Example for FileChangeNotificationExample { })?; // Start conversation (specific message is not important) - cx.push_user_message("Find all files in this repo"); - cx.run_turn().await?; + cx.prompt_with_max_turns("Find all files in this repo", 1) + .await?; // Edit the README buffer - the model should get a notification on next turn buffer.update(cx, |buffer, cx| { @@ -58,7 +58,7 @@ impl Example for FileChangeNotificationExample { // Run for some more turns. // The model shouldn't thank us for letting it know about the file change. - cx.run_turns(3).await?; + cx.proceed_with_max_turns(3).await?; Ok(()) } diff --git a/crates/eval/src/examples/file_search.rs b/crates/eval/src/examples/file_search.rs index f1a482a41a952e889b6053e90e9e243ed546d2db..7de7a07d19184b473fd2cb5ba29b270431b71a4c 100644 --- a/crates/eval/src/examples/file_search.rs +++ b/crates/eval/src/examples/file_search.rs @@ -1,6 +1,6 @@ +use agent::FindPathToolInput; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::FindPathToolInput; use async_trait::async_trait; use regex::Regex; @@ -25,18 +25,19 @@ impl Example for FileSearchExample { async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { const FILENAME: &str = "find_replace_file_tool.rs"; - cx.push_user_message(format!( - r#" + + let prompt = format!( + r#" Look at the `{FILENAME}`. I want to implement a card for it. The card should implement the `Render` trait. The card should show a diff. It should be a beautifully presented diff. The card "box" should look like what we show for markdown codeblocks (look at `MarkdownElement`). I want to see a red background for lines that were deleted and a green background for lines that were added. We should have a div per diff line. "# - )); + ); - let response = cx.run_turn().await?; - let tool_use = response.expect_tool("find_path", cx)?; + let response = cx.prompt_with_max_turns(prompt, 1).await?; + let tool_use = response.expect_tool_call("find_path", cx)?; let input = tool_use.parse_input::()?; let glob = input.glob; diff --git a/crates/eval/src/examples/grep_params_escapement.rs b/crates/eval/src/examples/grep_params_escapement.rs index 0532698ba28b45bd8111767eb51ea1336e18fa13..57086a1b9bd217e04072754539ddea20aa38c7a8 100644 --- a/crates/eval/src/examples/grep_params_escapement.rs +++ b/crates/eval/src/examples/grep_params_escapement.rs @@ -1,6 +1,6 @@ +use agent::GrepToolInput; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::GrepToolInput; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata}; @@ -36,9 +36,9 @@ impl Example for GrepParamsEscapementExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - // cx.push_user_message("How does the precedence/specificity work with Keymap contexts? I am seeing that `MessageEditor > Editor` is lower precendence than `Editor` which is surprising to me, but might be how it works"); - cx.push_user_message("Search for files containing the characters `>` or `<`"); - let response = cx.run_turns(2).await?; + let response = cx + .prompt_with_max_turns("Search for files containing the characters `>` or `<`", 2) + .await?; let grep_input = response .find_tool_call("grep") .and_then(|tool_use| tool_use.parse_input::().ok()); diff --git a/crates/eval/src/examples/mod.rs b/crates/eval/src/examples/mod.rs index d74fbdb937bb3fce089ac1e0dd1b4abb75657ca3..aec1bce07957fb81c17666b3e64b00a1fa47240f 100644 --- a/crates/eval/src/examples/mod.rs +++ b/crates/eval/src/examples/mod.rs @@ -106,7 +106,7 @@ impl DeclarativeExample { } pub fn name_from_path(path: &Path) -> String { - path.file_stem().unwrap().to_string_lossy().to_string() + path.file_stem().unwrap().to_string_lossy().into_owned() } } @@ -115,6 +115,10 @@ pub struct ExampleToml { pub url: String, pub revision: String, pub language_extension: Option, + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] pub insert_id: Option, #[serde(default = "default_true")] pub require_lsp: bool, @@ -140,9 +144,8 @@ impl Example for DeclarativeExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message(&self.prompt); let max_turns = self.metadata.max_turns.unwrap_or(1000); - let _ = cx.run_turns(max_turns).await; + let _ = cx.prompt_with_max_turns(&self.prompt, max_turns).await; Ok(()) } diff --git a/crates/eval/src/examples/overwrite_file.rs b/crates/eval/src/examples/overwrite_file.rs index df0b75294c31bf7ff365e96aea18c371b817e710..a4df1e97a3f4d9c66262f8679d93324e53df9d53 100644 --- a/crates/eval/src/examples/overwrite_file.rs +++ b/crates/eval/src/examples/overwrite_file.rs @@ -1,6 +1,6 @@ +use agent::{EditFileMode, EditFileToolInput}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tools::{EditFileMode, EditFileToolInput}; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata}; @@ -36,17 +36,14 @@ impl Example for FileOverwriteExample { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - let response = cx.run_turns(1).await?; - let file_overwritten = if let Some(tool_use) = response.find_tool_call("edit_file") { - let input = tool_use.parse_input::()?; - match input.mode { - EditFileMode::Edit => false, - EditFileMode::Create | EditFileMode::Overwrite => { - input.path.ends_with("src/language_model_selector.rs") - } + let response = cx.proceed_with_max_turns(1).await?; + let tool_use = response.expect_tool_call("edit_file", cx)?; + let input = tool_use.parse_input::()?; + let file_overwritten = match input.mode { + EditFileMode::Edit => false, + EditFileMode::Create | EditFileMode::Overwrite => { + input.path.ends_with("src/language_model_selector.rs") } - } else { - false }; cx.assert(!file_overwritten, "File should be edited, not overwritten") diff --git a/crates/eval/src/examples/planets.rs b/crates/eval/src/examples/planets.rs index f3a69332d2c544479ca4f367699dc3def4d83370..6b6ca0e3fe75633c49f11f24a24835dc58886a01 100644 --- a/crates/eval/src/examples/planets.rs +++ b/crates/eval/src/examples/planets.rs @@ -1,7 +1,6 @@ +use agent::{AgentTool, OpenTool, TerminalTool}; use agent_settings::AgentProfileId; use anyhow::Result; -use assistant_tool::Tool; -use assistant_tools::{OpenTool, TerminalTool}; use async_trait::async_trait; use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion}; @@ -24,23 +23,22 @@ impl Example for Planets { } async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> { - cx.push_user_message( - r#" + let response = cx + .prompt( + r#" Make a plain JavaScript web page which renders an animated 3D solar system. Let me drag to rotate the camera around. Do not use npm. - "# - .to_string(), - ); - - let response = cx.run_to_end().await?; + "#, + ) + .await?; let mut open_tool_uses = 0; let mut terminal_tool_uses = 0; - for tool_use in response.tool_uses() { - if tool_use.name == OpenTool.name() { + for tool_use in response.tool_calls() { + if tool_use.name == OpenTool::name() { open_tool_uses += 1; - } else if tool_use.name == TerminalTool::NAME { + } else if tool_use.name == TerminalTool::name() { terminal_tool_uses += 1; } } diff --git a/crates/eval/src/examples/threads/overwrite-file.json b/crates/eval/src/examples/threads/overwrite-file.json index ffef258193d7b738f2489a8e047cafd76e2dbd05..392ccde5b8e064bdb9d4a124f38e7a99ca6561f3 100644 --- a/crates/eval/src/examples/threads/overwrite-file.json +++ b/crates/eval/src/examples/threads/overwrite-file.json @@ -116,7 +116,7 @@ ], "tool_results": [ { - "content": "[package]\nname = \"language_model_selector\"\nversion = \"0.1.0\"\nedition.workspace = true\npublish.workspace = true\nlicense = \"GPL-3.0-or-later\"\n\n[lints]\nworkspace = true\n\n[lib]\npath = \"src/language_model_selector.rs\"\n\n[dependencies]\ncollections.workspace = true\nfeature_flags.workspace = true\nfuzzy.workspace = true\ngpui.workspace = true\nlanguage_model.workspace = true\nlog.workspace = true\npicker.workspace = true\nproto.workspace = true\nui.workspace = true\nworkspace-hack.workspace = true\nzed_actions.workspace = true\n", + "content": "[package]\nname = \"language_model_selector\"\nversion = \"0.1.0\"\nedition.workspace = true\npublish.workspace = true\nlicense = \"GPL-3.0-or-later\"\n\n[lints]\nworkspace = true\n\n[lib]\npath = \"src/language_model_selector.rs\"\n\n[dependencies]\ncollections.workspace = true\nfeature_flags.workspace = true\nfuzzy.workspace = true\ngpui.workspace = true\nlanguage_model.workspace = true\nlog.workspace = true\npicker.workspace = true\nproto.workspace = true\nui.workspace = true\n\nzed_actions.workspace = true\n", "is_error": false, "output": null, "tool_use_id": "toolu_019Je2MLfJhpJr93g5igoRAH" diff --git a/crates/eval/src/explorer.rs b/crates/eval/src/explorer.rs index ee1dfa95c3840af42bdd134be1110bd2483c97aa..3326070cea4e860210f8ba7e0038fec2f3404c30 100644 --- a/crates/eval/src/explorer.rs +++ b/crates/eval/src/explorer.rs @@ -46,27 +46,25 @@ fn find_target_files_recursive( max_depth, found_files, )?; - } else if path.is_file() { - if let Some(filename_osstr) = path.file_name() { - if let Some(filename_str) = filename_osstr.to_str() { - if filename_str == target_filename { - found_files.push(path); - } - } - } + } else if path.is_file() + && let Some(filename_osstr) = path.file_name() + && let Some(filename_str) = filename_osstr.to_str() + && filename_str == target_filename + { + found_files.push(path); } } Ok(()) } pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result { - if let Some(parent) = output_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent).context(format!( - "Failed to create output directory: {}", - parent.display() - ))?; - } + if let Some(parent) = output_path.parent() + && !parent.exists() + { + fs::create_dir_all(parent).context(format!( + "Failed to create output directory: {}", + parent.display() + ))?; } let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html"); diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 0f2b4c18eade06060f9002615b6b995d9bfdde0d..5317f100456748616dfec63819bc0373aaceb4c1 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -1,37 +1,38 @@ -use agent::{Message, MessageSegment, SerializedThread, ThreadStore}; +use agent::ContextServerRegistry; +use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow, bail}; -use assistant_tool::ToolWorkingSet; use client::proto::LspWorkProgress; use futures::channel::mpsc; +use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{App, AppContext as _, AsyncApp, Entity, Task}; use handlebars::Handlebars; use language::{Buffer, DiagnosticSeverity, OffsetRangeExt as _}; use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelToolResultContent, MessageContent, Role, TokenUsage, + LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResultContent, MessageContent, Role, TokenUsage, }; -use project::lsp_store::OpenLspBufferHandle; -use project::{DiagnosticSummary, Project, ProjectPath}; +use project::{DiagnosticSummary, Project, ProjectPath, lsp_store::OpenLspBufferHandle}; +use prompt_store::{ProjectContext, WorktreeContext}; +use rand::{distr, prelude::*}; use serde::{Deserialize, Serialize}; -use std::cell::RefCell; -use std::fmt::Write as _; -use std::fs; -use std::fs::File; -use std::io::Write as _; -use std::path::Path; -use std::path::PathBuf; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; +use std::{ + fmt::Write as _, + fs::{self, File}, + io::Write as _, + path::{Path, PathBuf}, + rc::Rc, + sync::{Arc, Mutex}, + time::Duration, +}; use unindent::Unindent as _; -use util::ResultExt as _; -use util::command::new_smol_command; -use util::markdown::MarkdownCodeBlock; +use util::{ResultExt as _, command::new_smol_command, markdown::MarkdownCodeBlock}; -use crate::assertions::{AssertionsReport, RanAssertion, RanAssertionResult}; -use crate::example::{Example, ExampleContext, FailedAssertion, JudgeAssertion}; -use crate::{AgentAppState, ToolMetrics}; +use crate::{ + AgentAppState, ToolMetrics, + assertions::{AssertionsReport, RanAssertion, RanAssertionResult}, + example::{Example, ExampleContext, FailedAssertion, JudgeAssertion}, +}; pub const ZED_REPO_URL: &str = "https://github.com/zed-industries/zed.git"; @@ -57,10 +58,9 @@ pub struct RunOutput { pub diagnostic_summary_after: DiagnosticSummary, pub diagnostics_before: Option, pub diagnostics_after: Option, - pub response_count: usize, pub token_usage: TokenUsage, pub tool_metrics: ToolMetrics, - pub all_messages: String, + pub thread_markdown: String, pub programmatic_assertions: AssertionsReport, } @@ -90,11 +90,8 @@ impl ExampleInstance { worktrees_dir: &Path, repetition: usize, ) -> Self { - let name = thread.meta().name.to_string(); - let run_directory = run_dir - .join(&name) - .join(repetition.to_string()) - .to_path_buf(); + let name = thread.meta().name; + let run_directory = run_dir.join(&name).join(repetition.to_string()); let repo_path = repo_path_for_url(repos_dir, &thread.meta().url); @@ -167,7 +164,7 @@ impl ExampleInstance { } else { println!("{}Creating worktree", self.log_prefix); - let worktree_path_string = worktree_path.to_string_lossy().to_string(); + let worktree_path_string = worktree_path.to_string_lossy().into_owned(); run_git( &self.repo_path, @@ -197,12 +194,7 @@ impl ExampleInstance { .join(self.thread.meta().repo_name()) } - pub fn run( - &self, - model: Arc, - app_state: Arc, - cx: &mut App, - ) -> Task> { + pub fn run(&self, app_state: Arc, cx: &mut App) -> Task> { let project = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -217,15 +209,6 @@ impl ExampleInstance { project.create_worktree(self.worktree_path(), true, cx) }); - let tools = cx.new(|_| ToolWorkingSet::default()); - let prompt_store = None; - let thread_store = ThreadStore::load( - project.clone(), - tools, - prompt_store, - app_state.prompt_builder.clone(), - cx, - ); let meta = self.thread.meta(); let this = self.clone(); @@ -253,7 +236,7 @@ impl ExampleInstance { worktree .files(false, 0) .find_map(|e| { - if e.path.clone().extension().and_then(|ext| ext.to_str()) + if e.path.clone().extension() == Some(&language_server.file_extension) { Some(ProjectPath { @@ -304,88 +287,75 @@ impl ExampleInstance { // history using undo/redo. std::fs::write(&last_diff_file_path, "")?; - let thread_store = thread_store.await?; - + let thread = cx.update(|cx| { + //todo: Do we want to load rules files here? + let worktrees = project.read(cx).visible_worktrees(cx).map(|worktree| { + let root_name = worktree.read(cx).root_name_str().into(); + let abs_path = worktree.read(cx).abs_path(); - let thread = - thread_store.update(cx, |thread_store, cx| { - let thread = if let Some(json) = &meta.existing_thread_json { - let serialized = SerializedThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); - thread_store.create_thread_from_serialized(serialized, cx) - } else { - thread_store.create_thread(cx) - }; - thread.update(cx, |thread, cx| { - thread.set_profile(meta.profile_id.clone(), cx); - }); - thread - })?; - - - thread.update(cx, |thread, _cx| { - let mut request_count = 0; - let previous_diff = Rc::new(RefCell::new("".to_string())); - let example_output_dir = this.run_directory.clone(); - let last_diff_file_path = last_diff_file_path.clone(); - let messages_json_file_path = example_output_dir.join("last.messages.json"); - let this = this.clone(); - thread.set_request_callback(move |request, response_events| { - request_count += 1; - let messages_file_path = example_output_dir.join(format!("{request_count}.messages.md")); - let diff_file_path = example_output_dir.join(format!("{request_count}.diff")); - let last_messages_file_path = example_output_dir.join("last.messages.md"); - let request_markdown = RequestMarkdown::new(request); - let response_events_markdown = response_events_to_markdown(response_events); - let dialog = ThreadDialog::new(request, response_events); - let dialog_json = serde_json::to_string_pretty(&dialog.to_combined_request()).unwrap_or_default(); - - let messages = format!("{}\n\n{}", request_markdown.messages, response_events_markdown); - fs::write(&messages_file_path, messages.clone()).expect("failed to write messages file"); - fs::write(&last_messages_file_path, messages).expect("failed to write last messages file"); - fs::write(&messages_json_file_path, dialog_json).expect("failed to write last.messages.json"); - - let diff_result = smol::block_on(this.repository_diff()); - match diff_result { - Ok(diff) => { - if diff != previous_diff.borrow().clone() { - fs::write(&diff_file_path, &diff).expect("failed to write diff file"); - fs::write(&last_diff_file_path, &diff).expect("failed to write last diff file"); - *previous_diff.borrow_mut() = diff; - } - } - Err(err) => { - let error_message = format!("{err:?}"); - fs::write(&diff_file_path, &error_message).expect("failed to write diff error to file"); - fs::write(&last_diff_file_path, &error_message).expect("failed to write last diff file"); - } + WorktreeContext { + root_name, + abs_path, + rules_file: None, } + }).collect::>(); + let project_context = cx.new(|_cx| ProjectContext::new(worktrees, vec![])); + let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + + let thread = if let Some(json) = &meta.existing_thread_json { + let session_id = acp::SessionId( + rand::rng() + .sample_iter(&distr::Alphanumeric) + .take(7) + .map(char::from) + .collect::() + .into(), + ); + + let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); + cx.new(|cx| agent::Thread::from_db(session_id, db_thread, project.clone(), project_context, context_server_registry, agent::Templates::new(), cx)) + } else { + cx.new(|cx| agent::Thread::new(project.clone(), project_context, context_server_registry, agent::Templates::new(), None, cx)) + }; - if request_count == 1 { - let tools_file_path = example_output_dir.join("tools.md"); - fs::write(tools_file_path, request_markdown.tools).expect("failed to write tools file"); - } + thread.update(cx, |thread, cx| { + thread.add_default_tools(Rc::new(EvalThreadEnvironment { + project: project.clone(), + }), cx); + thread.set_profile(meta.profile_id.clone()); + thread.set_model( + LanguageModelInterceptor::new( + LanguageModelRegistry::read_global(cx).default_model().expect("Missing model").model.clone(), + this.run_directory.clone(), + last_diff_file_path.clone(), + this.run_directory.join("last.messages.json"), + this.worktree_path(), + this.repo_url(), + ), + cx, + ); }); - })?; + + thread + }).unwrap(); let mut example_cx = ExampleContext::new( meta.clone(), this.log_prefix.clone(), thread.clone(), - model.clone(), cx.clone(), ); let result = this.thread.conversation(&mut example_cx).await; - if let Err(err) = result { - if !err.is::() { + if let Err(err) = result + && !err.is::() { return Err(err); } - } println!("{}Stopped", this.log_prefix); println!("{}Getting repository diff", this.log_prefix); - let repository_diff = this.repository_diff().await?; + let repository_diff = Self::repository_diff(this.worktree_path(), &this.repo_url()).await?; std::fs::write(last_diff_file_path, &repository_diff)?; @@ -420,34 +390,28 @@ impl ExampleInstance { } thread.update(cx, |thread, _cx| { - let response_count = thread - .messages() - .filter(|message| message.role == language_model::Role::Assistant) - .count(); RunOutput { repository_diff, diagnostic_summary_before, diagnostic_summary_after, diagnostics_before, diagnostics_after, - response_count, - token_usage: thread.cumulative_token_usage(), + token_usage: thread.latest_request_token_usage().unwrap(), tool_metrics: example_cx.tool_metrics.lock().unwrap().clone(), - all_messages: messages_to_markdown(thread.messages()), + thread_markdown: thread.to_markdown(), programmatic_assertions: example_cx.assertions, } }) }) } - async fn repository_diff(&self) -> Result { - let worktree_path = self.worktree_path(); - run_git(&worktree_path, &["add", "."]).await?; + async fn repository_diff(repository_path: PathBuf, repository_url: &str) -> Result { + run_git(&repository_path, &["add", "."]).await?; let mut diff_args = vec!["diff", "--staged"]; - if self.thread.meta().url == ZED_REPO_URL { + if repository_url == ZED_REPO_URL { diff_args.push(":(exclude).rules"); } - run_git(&worktree_path, &diff_args).await + run_git(&repository_path, &diff_args).await } pub async fn judge( @@ -459,8 +423,8 @@ impl ExampleInstance { let mut output_file = File::create(self.run_directory.join("judge.md")).expect("failed to create judge.md"); - let diff_task = self.judge_diff(model.clone(), &run_output, cx); - let thread_task = self.judge_thread(model.clone(), &run_output, cx); + let diff_task = self.judge_diff(model.clone(), run_output, cx); + let thread_task = self.judge_thread(model.clone(), run_output, cx); let (diff_result, thread_result) = futures::join!(diff_task, thread_task); @@ -547,7 +511,7 @@ impl ExampleInstance { hbs.register_template_string(judge_thread_prompt_name, judge_thread_prompt) .unwrap(); - let complete_messages = &run_output.all_messages; + let complete_messages = &run_output.thread_markdown; let to_prompt = |assertion: String| { hbs.render( judge_thread_prompt_name, @@ -639,6 +603,273 @@ impl ExampleInstance { } } +struct EvalThreadEnvironment { + project: Entity, +} + +struct EvalTerminalHandle { + terminal: Entity, +} + +impl agent::TerminalHandle for EvalTerminalHandle { + fn id(&self, cx: &AsyncApp) -> Result { + self.terminal.read_with(cx, |term, _cx| term.id().clone()) + } + + fn wait_for_exit(&self, cx: &AsyncApp) -> Result>> { + self.terminal + .read_with(cx, |term, _cx| term.wait_for_exit()) + } + + fn current_output(&self, cx: &AsyncApp) -> Result { + self.terminal + .read_with(cx, |term, cx| term.current_output(cx)) + } +} + +impl agent::ThreadEnvironment for EvalThreadEnvironment { + fn create_terminal( + &self, + command: String, + cwd: Option, + output_byte_limit: Option, + cx: &mut AsyncApp, + ) -> Task>> { + let project = self.project.clone(); + cx.spawn(async move |cx| { + let language_registry = + project.read_with(cx, |project, _cx| project.languages().clone())?; + let id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal = + acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx) + .await?; + let terminal = cx.new(|cx| { + acp_thread::Terminal::new( + id, + "", + cwd, + output_byte_limit.map(|limit| limit as usize), + terminal, + language_registry, + cx, + ) + })?; + Ok(Rc::new(EvalTerminalHandle { terminal }) as Rc) + }) + } +} + +struct LanguageModelInterceptor { + model: Arc, + request_count: Arc>, + previous_diff: Arc>, + example_output_dir: PathBuf, + last_diff_file_path: PathBuf, + messages_json_file_path: PathBuf, + repository_path: PathBuf, + repository_url: String, +} + +impl LanguageModelInterceptor { + fn new( + model: Arc, + example_output_dir: PathBuf, + last_diff_file_path: PathBuf, + messages_json_file_path: PathBuf, + repository_path: PathBuf, + repository_url: String, + ) -> Arc { + Arc::new(Self { + model, + request_count: Arc::new(Mutex::new(0)), + previous_diff: Arc::new(Mutex::new("".to_string())), + example_output_dir, + last_diff_file_path, + messages_json_file_path, + repository_path, + repository_url, + }) + } +} + +impl language_model::LanguageModel for LanguageModelInterceptor { + fn id(&self) -> language_model::LanguageModelId { + self.model.id() + } + + fn name(&self) -> language_model::LanguageModelName { + self.model.name() + } + + fn provider_id(&self) -> language_model::LanguageModelProviderId { + self.model.provider_id() + } + + fn provider_name(&self) -> language_model::LanguageModelProviderName { + self.model.provider_name() + } + + fn telemetry_id(&self) -> String { + self.model.telemetry_id() + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_tools(&self) -> bool { + self.model.supports_tools() + } + + fn supports_tool_choice(&self, choice: language_model::LanguageModelToolChoice) -> bool { + self.model.supports_tool_choice(choice) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn count_tokens( + &self, + request: LanguageModelRequest, + cx: &App, + ) -> future::BoxFuture<'static, Result> { + self.model.count_tokens(request, cx) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> future::BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + language_model::LanguageModelCompletionError, + >, + > { + let stream = self.model.stream_completion(request.clone(), cx); + let request_count = self.request_count.clone(); + let previous_diff = self.previous_diff.clone(); + let example_output_dir = self.example_output_dir.clone(); + let last_diff_file_path = self.last_diff_file_path.clone(); + let messages_json_file_path = self.messages_json_file_path.clone(); + let repository_path = self.repository_path.clone(); + let repository_url = self.repository_url.clone(); + + Box::pin(async move { + let stream = stream.await?; + + let response_events = Arc::new(Mutex::new(Vec::new())); + let request_clone = request.clone(); + + let wrapped_stream = stream.then(move |event| { + let response_events = response_events.clone(); + let request = request_clone.clone(); + let request_count = request_count.clone(); + let previous_diff = previous_diff.clone(); + let example_output_dir = example_output_dir.clone(); + let last_diff_file_path = last_diff_file_path.clone(); + let messages_json_file_path = messages_json_file_path.clone(); + let repository_path = repository_path.clone(); + let repository_url = repository_url.clone(); + + async move { + let event_result = match &event { + Ok(ev) => Ok(ev.clone()), + Err(err) => Err(err.to_string()), + }; + response_events.lock().unwrap().push(event_result); + + let should_execute = matches!( + &event, + Ok(LanguageModelCompletionEvent::Stop { .. }) | Err(_) + ); + + if should_execute { + let current_request_count = { + let mut count = request_count.lock().unwrap(); + *count += 1; + *count + }; + + let messages_file_path = + example_output_dir.join(format!("{current_request_count}.messages.md")); + let diff_file_path = + example_output_dir.join(format!("{current_request_count}.diff")); + let last_messages_file_path = example_output_dir.join("last.messages.md"); + + let collected_events = response_events.lock().unwrap().clone(); + let request_markdown = RequestMarkdown::new(&request); + let response_events_markdown = + response_events_to_markdown(&collected_events); + let dialog = ThreadDialog::new(&request, &collected_events); + let dialog_json = + serde_json::to_string_pretty(&dialog.to_combined_request()) + .unwrap_or_default(); + + let messages = format!( + "{}\n\n{}", + request_markdown.messages, response_events_markdown + ); + fs::write(&messages_file_path, messages.clone()) + .expect("failed to write messages file"); + fs::write(&last_messages_file_path, messages) + .expect("failed to write last messages file"); + fs::write(&messages_json_file_path, dialog_json) + .expect("failed to write last.messages.json"); + + // Get repository diff + let diff_result = + ExampleInstance::repository_diff(repository_path, &repository_url) + .await; + + match diff_result { + Ok(diff) => { + let prev_diff = previous_diff.lock().unwrap().clone(); + if diff != prev_diff { + fs::write(&diff_file_path, &diff) + .expect("failed to write diff file"); + fs::write(&last_diff_file_path, &diff) + .expect("failed to write last diff file"); + *previous_diff.lock().unwrap() = diff; + } + } + Err(err) => { + let error_message = format!("{err:?}"); + fs::write(&diff_file_path, &error_message) + .expect("failed to write diff error to file"); + fs::write(&last_diff_file_path, &error_message) + .expect("failed to write last diff file"); + } + } + + if current_request_count == 1 { + let tools_file_path = example_output_dir.join("tools.md"); + fs::write(tools_file_path, request_markdown.tools) + .expect("failed to write tools file"); + } + } + + event + } + }); + + Ok(Box::pin(wrapped_stream) + as futures::stream::BoxStream< + 'static, + Result< + LanguageModelCompletionEvent, + language_model::LanguageModelCompletionError, + >, + >) + }) + } +} + pub fn wait_for_lang_server( project: &Entity, buffer: &Entity, @@ -661,7 +892,7 @@ pub fn wait_for_lang_server( .update(cx, |buffer, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store - .language_servers_for_local_buffer(&buffer, cx) + .language_servers_for_local_buffer(buffer, cx) .next() .is_some() }) @@ -679,8 +910,8 @@ pub fn wait_for_lang_server( [ cx.subscribe(&lsp_store, { let log_prefix = log_prefix.clone(); - move |_, event, _| match event { - project::LspStoreEvent::LanguageServerUpdate { + move |_, event, _| { + if let project::LspStoreEvent::LanguageServerUpdate { message: client::proto::update_language_server::Variant::WorkProgress( LspWorkProgress { @@ -689,11 +920,13 @@ pub fn wait_for_lang_server( }, ), .. - } => println!("{}⟲ {message}", log_prefix), - _ => {} + } = event + { + println!("{}⟲ {message}", log_prefix) + } } }), - cx.subscribe(&project, { + cx.subscribe(project, { let buffer = buffer.clone(); move |project, event, cx| match event { project::Event::LanguageServerAdded(_, _, _) => { @@ -771,7 +1004,7 @@ pub async fn query_lsp_diagnostics( } fn parse_assertion_result(response: &str) -> Result { - let analysis = get_tag("analysis", response)?.to_string(); + let analysis = get_tag("analysis", response)?; let passed = match get_tag("passed", response)?.to_lowercase().as_str() { "true" => true, "false" => false, @@ -828,40 +1061,6 @@ pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result { Ok(String::from_utf8(output.stdout)?.trim().to_string()) } -fn messages_to_markdown<'a>(message_iter: impl IntoIterator) -> String { - let mut messages = String::new(); - let mut assistant_message_number: u32 = 1; - - for message in message_iter { - push_role(&message.role, &mut messages, &mut assistant_message_number); - - for segment in &message.segments { - match segment { - MessageSegment::Text(text) => { - messages.push_str(&text); - messages.push_str("\n\n"); - } - MessageSegment::Thinking { text, signature } => { - messages.push_str("**Thinking**:\n\n"); - if let Some(sig) = signature { - messages.push_str(&format!("Signature: {}\n\n", sig)); - } - messages.push_str(&text); - messages.push_str("\n"); - } - MessageSegment::RedactedThinking(items) => { - messages.push_str(&format!( - "**Redacted Thinking**: {} item(s)\n\n", - items.len() - )); - } - } - } - } - - messages -} - fn push_role(role: &Role, buf: &mut String, assistant_message_number: &mut u32) { match role { Role::System => buf.push_str("# ⚙️ SYSTEM\n\n"), @@ -878,7 +1077,7 @@ pub async fn send_language_model_request( request: LanguageModelRequest, cx: &AsyncApp, ) -> anyhow::Result { - match model.stream_completion_text(request, &cx).await { + match model.stream_completion_text(request, cx).await { Ok(mut stream) => { let mut full_response = String::new(); while let Some(chunk_result) = stream.stream.next().await { @@ -915,9 +1114,9 @@ impl RequestMarkdown { for tool in &request.tools { write!(&mut tools, "# {}\n\n", tool.name).unwrap(); write!(&mut tools, "{}\n\n", tool.description).unwrap(); - write!( + writeln!( &mut tools, - "{}\n", + "{}", MarkdownCodeBlock { tag: "json", text: &format!("{:#}", tool.input_schema) @@ -1191,7 +1390,7 @@ mod test { output.analysis, Some("The model did a good job but there were still compilations errors.".into()) ); - assert_eq!(output.passed, true); + assert!(output.passed); let response = r#" Text around ignored @@ -1211,6 +1410,6 @@ mod test { output.analysis, Some("Failed to compile:\n- Error 1\n- Error 2".into()) ); - assert_eq!(output.passed, false); + assert!(!output.passed); } } diff --git a/crates/explorer_command_injector/Cargo.toml b/crates/explorer_command_injector/Cargo.toml index e929ba6fc824d6fa7a9b2f995828d8081cf2c2a0..8530329358dd5006ca883974c4298ad0787e9d42 100644 --- a/crates/explorer_command_injector/Cargo.toml +++ b/crates/explorer_command_injector/Cargo.toml @@ -25,4 +25,3 @@ windows-core.workspace = true windows-registry = "0.5" [dependencies] -workspace-hack.workspace = true diff --git a/crates/explorer_command_injector/src/explorer_command_injector.rs b/crates/explorer_command_injector/src/explorer_command_injector.rs index 57454bc3a87e6574d43bba05ff353c6cffc31601..bfa2a0326c9975037ed860acfdee7cd32e3075d8 100644 --- a/crates/explorer_command_injector/src/explorer_command_injector.rs +++ b/crates/explorer_command_injector/src/explorer_command_injector.rs @@ -77,6 +77,7 @@ impl IExplorerCommand_Impl for ExplorerCommandInjector_Impl { for idx in 0..count { let item = unsafe { items.GetItemAt(idx)? }; let item_path = unsafe { item.GetDisplayName(SIGDN_FILESYSPATH)?.to_string()? }; + #[allow(clippy::disallowed_methods, reason = "no async context in sight..")] std::process::Command::new(&zed_exe) .arg(&item_path) .spawn() @@ -180,7 +181,7 @@ fn get_zed_install_folder() -> Option { #[inline] fn get_zed_exe_path() -> Option { - get_zed_install_folder().map(|path| path.join("Zed.exe").to_string_lossy().to_string()) + get_zed_install_folder().map(|path| path.join("Zed.exe").to_string_lossy().into_owned()) } #[inline] diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 42189f20b3477b4581103807445a397e65dd89eb..59b208cb50ec4183f7a0b8751f85658344d1e742 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -36,7 +36,6 @@ url.workspace = true util.workspace = true wasm-encoder.workspace = true wasmparser.workspace = true -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/extension/src/extension.rs b/crates/extension/src/extension.rs index 35f7f419383cb9f3c6cc518663ad818735eab80e..bd2b37c337dcaca448e2175472ea46c126d2f9a3 100644 --- a/crates/extension/src/extension.rs +++ b/crates/extension/src/extension.rs @@ -16,6 +16,7 @@ use gpui::{App, Task}; use language::LanguageName; use semantic_version::SemanticVersion; use task::{SpawnInTerminal, ZedDebugConfig}; +use util::rel_path::RelPath; pub use crate::capabilities::*; pub use crate::extension_events::*; @@ -33,7 +34,7 @@ pub fn init(cx: &mut App) { pub trait WorktreeDelegate: Send + Sync + 'static { fn id(&self) -> u64; fn root_path(&self) -> String; - async fn read_text_file(&self, path: PathBuf) -> Result; + async fn read_text_file(&self, path: &RelPath) -> Result; async fn which(&self, binary_name: String) -> Option; async fn shell_env(&self) -> Vec<(String, String)>; } @@ -178,16 +179,15 @@ pub fn parse_wasm_extension_version( for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) { if let wasmparser::Payload::CustomSection(s) = part.context("error parsing wasm extension")? + && s.name() == "zed:api-version" { - if s.name() == "zed:api-version" { - version = parse_wasm_extension_version_custom_section(s.data()); - if version.is_none() { - bail!( - "extension {} has invalid zed:api-version section: {:?}", - extension_id, - s.data() - ); - } + version = parse_wasm_extension_version_custom_section(s.data()); + if version.is_none() { + bail!( + "extension {} has invalid zed:api-version section: {:?}", + extension_id, + s.data() + ); } } } diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 621ba9250c12f8edd4ab49bbdef13bc976a239dd..7804910633ad2dbefdbda7a0dfef27a6797eeb97 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -5,7 +5,7 @@ use crate::{ use anyhow::{Context as _, Result, bail}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; -use futures::io::BufReader; +use futures::{AsyncReadExt, io::Cursor}; use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use serde::Deserialize; @@ -142,7 +142,7 @@ impl ExtensionBuilder { manifest: &mut ExtensionManifest, options: CompileExtensionOptions, ) -> anyhow::Result<()> { - self.install_rust_wasm_target_if_needed()?; + self.install_rust_wasm_target_if_needed().await?; let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?; let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?; @@ -151,7 +151,7 @@ impl ExtensionBuilder { "compiling Rust crate for extension {}", extension_dir.display() ); - let output = util::command::new_std_command("cargo") + let output = util::command::new_smol_command("cargo") .args(["build", "--target", RUST_TARGET]) .args(options.release.then_some("--release")) .arg("--target-dir") @@ -160,6 +160,7 @@ impl ExtensionBuilder { .env("RUSTC_WRAPPER", "") .current_dir(extension_dir) .output() + .await .context("failed to run `cargo`")?; if !output.status.success() { bail!( @@ -235,7 +236,8 @@ impl ExtensionBuilder { &grammar_repo_dir, &grammar_metadata.repository, &grammar_metadata.rev, - )?; + ) + .await?; let base_grammar_path = grammar_metadata .path @@ -248,7 +250,7 @@ impl ExtensionBuilder { let scanner_path = src_path.join("scanner.c"); log::info!("compiling {grammar_name} parser"); - let clang_output = util::command::new_std_command(&clang_path) + let clang_output = util::command::new_smol_command(&clang_path) .args(["-fPIC", "-shared", "-Os"]) .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) .arg("-o") @@ -258,6 +260,7 @@ impl ExtensionBuilder { .arg(&parser_path) .args(scanner_path.exists().then_some(scanner_path)) .output() + .await .context("failed to run clang")?; if !clang_output.status.success() { @@ -271,15 +274,16 @@ impl ExtensionBuilder { Ok(()) } - fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> { + async fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> { let git_dir = directory.join(".git"); if directory.exists() { - let remotes_output = util::command::new_std_command("git") + let remotes_output = util::command::new_smol_command("git") .arg("--git-dir") .arg(&git_dir) .args(["remote", "-v"]) - .output()?; + .output() + .await?; let has_remote = remotes_output.status.success() && String::from_utf8_lossy(&remotes_output.stdout) .lines() @@ -298,10 +302,11 @@ impl ExtensionBuilder { fs::create_dir_all(directory).with_context(|| { format!("failed to create grammar directory {}", directory.display(),) })?; - let init_output = util::command::new_std_command("git") + let init_output = util::command::new_smol_command("git") .arg("init") .current_dir(directory) - .output()?; + .output() + .await?; if !init_output.status.success() { bail!( "failed to run `git init` in directory '{}'", @@ -309,11 +314,12 @@ impl ExtensionBuilder { ); } - let remote_add_output = util::command::new_std_command("git") + let remote_add_output = util::command::new_smol_command("git") .arg("--git-dir") .arg(&git_dir) .args(["remote", "add", "origin", url]) .output() + .await .context("failed to execute `git remote add`")?; if !remote_add_output.status.success() { bail!( @@ -323,19 +329,21 @@ impl ExtensionBuilder { } } - let fetch_output = util::command::new_std_command("git") + let fetch_output = util::command::new_smol_command("git") .arg("--git-dir") .arg(&git_dir) .args(["fetch", "--depth", "1", "origin", rev]) .output() + .await .context("failed to execute `git fetch`")?; - let checkout_output = util::command::new_std_command("git") + let checkout_output = util::command::new_smol_command("git") .arg("--git-dir") .arg(&git_dir) .args(["checkout", rev]) .current_dir(directory) .output() + .await .context("failed to execute `git checkout`")?; if !checkout_output.status.success() { if !fetch_output.status.success() { @@ -356,11 +364,12 @@ impl ExtensionBuilder { Ok(()) } - fn install_rust_wasm_target_if_needed(&self) -> Result<()> { - let rustc_output = util::command::new_std_command("rustc") + async fn install_rust_wasm_target_if_needed(&self) -> Result<()> { + let rustc_output = util::command::new_smol_command("rustc") .arg("--print") .arg("sysroot") .output() + .await .context("failed to run rustc")?; if !rustc_output.status.success() { bail!( @@ -374,11 +383,12 @@ impl ExtensionBuilder { return Ok(()); } - let output = util::command::new_std_command("rustup") + let output = util::command::new_smol_command("rustup") .args(["target", "add", RUST_TARGET]) .stderr(Stdio::piped()) .stdout(Stdio::inherit()) .output() + .await .context("failed to run `rustup target add`")?; if !output.status.success() { bail!( @@ -401,7 +411,9 @@ impl ExtensionBuilder { let mut clang_path = wasi_sdk_dir.clone(); clang_path.extend(["bin", &format!("clang{}", env::consts::EXE_SUFFIX)]); - if fs::metadata(&clang_path).map_or(false, |metadata| metadata.is_file()) { + log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); + + if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { return Ok(clang_path); } @@ -413,13 +425,19 @@ impl ExtensionBuilder { log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); let mut response = self.http.get(&url, AsyncBody::default(), true).await?; - let body = BufReader::new(response.body_mut()); - let body = GzipDecoder::new(body); + let body = GzipDecoder::new({ + // stream the entire request into memory at once as the artifact is quite big (100MB+) + let mut b = vec![]; + response.body_mut().read_to_end(&mut b).await?; + Cursor::new(b) + }); let tar = Archive::new(body); + log::info!("un-tarring wasi-sdk to {}", wasi_sdk_dir.display()); tar.unpack(&tar_out_dir) .await .context("failed to unpack wasi-sdk archive")?; + log::info!("finished downloading wasi-sdk"); let inner_dir = fs::read_dir(&tar_out_dir)? .next() @@ -452,7 +470,7 @@ impl ExtensionBuilder { let mut output = Vec::new(); let mut stack = Vec::new(); - for payload in Parser::new(0).parse_all(&input) { + for payload in Parser::new(0).parse_all(input) { let payload = payload?; // Track nesting depth, so that we don't mess with inner producer sections: @@ -484,14 +502,10 @@ impl ExtensionBuilder { _ => {} } - match &payload { - CustomSection(c) => { - if strip_custom_section(c.name()) { - continue; - } - } - - _ => {} + if let CustomSection(c) = &payload + && strip_custom_section(c.name()) + { + continue; } if let Some((id, range)) = payload.as_section() { RawSection { diff --git a/crates/extension/src/extension_events.rs b/crates/extension/src/extension_events.rs index b151b3f412ea523a1c5b97dea210adf68e5bea89..6dc99470dfb5abad524e5a7cbc326b2dd5e0bd26 100644 --- a/crates/extension/src/extension_events.rs +++ b/crates/extension/src/extension_events.rs @@ -19,9 +19,8 @@ pub struct ExtensionEvents; impl ExtensionEvents { /// Returns the global [`ExtensionEvents`]. pub fn try_global(cx: &App) -> Option> { - return cx - .try_global::() - .map(|g| g.0.clone()); + cx.try_global::() + .map(|g| g.0.clone()) } fn new(_cx: &mut Context) -> Self { @@ -33,7 +32,7 @@ impl ExtensionEvents { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum Event { ExtensionInstalled(Arc), ExtensionUninstalled(Arc), diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index f5296198b06ffeeb83dd21be35d27be6b4387294..1e39ceca58fa8b0da450d98db2d6cc8fb0921f12 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result, anyhow, bail}; use collections::{BTreeMap, HashMap}; use fs::Fs; use language::LanguageName; @@ -82,6 +82,8 @@ pub struct ExtensionManifest { #[serde(default)] pub context_servers: BTreeMap, ContextServerManifestEntry>, #[serde(default)] + pub agent_servers: BTreeMap, AgentServerManifestEntry>, + #[serde(default)] pub slash_commands: BTreeMap, SlashCommandManifestEntry>, #[serde(default)] pub snippets: Option, @@ -138,6 +140,48 @@ pub struct LibManifestEntry { pub version: Option, } +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct AgentServerManifestEntry { + /// Display name for the agent (shown in menus). + pub name: String, + /// Environment variables to set when launching the agent server. + #[serde(default)] + pub env: HashMap, + /// Optional icon path (relative to extension root, e.g., "ai.svg"). + /// Should be a small SVG icon for display in menus. + #[serde(default)] + pub icon: Option, + /// Per-target configuration for archive-based installation. + /// The key format is "{os}-{arch}" where: + /// - os: "darwin" (macOS), "linux", "windows" + /// - arch: "aarch64" (arm64), "x86_64" + /// + /// Example: + /// ```toml + /// [agent_servers.myagent.targets.darwin-aarch64] + /// archive = "https://example.com/myagent-darwin-arm64.zip" + /// cmd = "./myagent" + /// args = ["--serve"] + /// sha256 = "abc123..." # optional + /// ``` + pub targets: HashMap, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct TargetConfig { + /// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip") + pub archive: String, + /// Command to run (e.g., "./myagent" or "./myagent.exe") + pub cmd: String, + /// Command-line arguments to pass to the agent server. + #[serde(default)] + pub args: Vec, + /// Optional SHA-256 hash of the archive for verification. + /// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub. + #[serde(default)] + pub sha256: Option, +} + #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub enum ExtensionLibraryKind { Rust, @@ -226,8 +270,9 @@ impl ExtensionManifest { .load(&extension_manifest_path) .await .with_context(|| format!("failed to load {extension_name} extension.toml"))?; - toml::from_str(&manifest_content) - .with_context(|| format!("invalid extension.toml for extension {extension_name}")) + toml::from_str(&manifest_content).map_err(|err| { + anyhow!("Invalid extension.toml for extension {extension_name}:\n{err}") + }) } } } @@ -265,6 +310,7 @@ fn manifest_from_old_manifest( .collect(), language_servers: Default::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -297,6 +343,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], @@ -403,4 +450,31 @@ mod tests { ); assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg } + #[test] + fn parse_manifest_with_agent_server_archive_launcher() { + let toml_src = r#" +id = "example.agent-server-ext" +name = "Agent Server Example" +version = "1.0.0" +schema_version = 0 + +[agent_servers.foo] +name = "Foo Agent" + +[agent_servers.foo.targets.linux-x86_64] +archive = "https://example.com/agent-linux-x64.tar.gz" +cmd = "./agent" +args = ["--serve"] +"#; + + let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse"); + assert_eq!(manifest.id.as_ref(), "example.agent-server-ext"); + assert!(manifest.agent_servers.contains_key("foo")); + let entry = manifest.agent_servers.get("foo").unwrap(); + assert!(entry.targets.contains_key("linux-x86_64")); + let target = entry.targets.get("linux-x86_64").unwrap(); + assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz"); + assert_eq!(target.cmd, "./agent"); + assert_eq!(target.args, vec!["--serve"]); + } } diff --git a/crates/extension_api/Cargo.toml b/crates/extension_api/Cargo.toml index 001df34e7ab23bc2711c7007f29f43d2b92970c0..318a0024bf4d9bae76af888b6668d7c21f37f804 100644 --- a/crates/extension_api/Cargo.toml +++ b/crates/extension_api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_extension_api" -version = "0.6.0" +version = "0.7.0" description = "APIs for creating Zed extensions in Rust" repository = "https://github.com/zed-industries/zed" documentation = "https://docs.rs/zed_extension_api" diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index aacc5d8795202e8d84c043a881933eabefae36bd..723e5442098f1a66b78b86fa7ed980a18944778b 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -232,10 +232,10 @@ pub trait Extension: Send + Sync { /// /// To work through a real-world example, take a `cargo run` task and a hypothetical `cargo` locator: /// 1. We may need to modify the task; in this case, it is problematic that `cargo run` spawns a binary. We should turn `cargo run` into a debug scenario with - /// `cargo build` task. This is the decision we make at `dap_locator_create_scenario` scope. + /// `cargo build` task. This is the decision we make at `dap_locator_create_scenario` scope. /// 2. Then, after the build task finishes, we will run `run_dap_locator` of the locator that produced the build task to find the program to be debugged. This function - /// should give us a debugger-agnostic configuration for launching a debug target (that we end up resolving with [`Extension::dap_config_to_scenario`]). It's almost as if the user - /// found the artifact path by themselves. + /// should give us a debugger-agnostic configuration for launching a debug target (that we end up resolving with [`Extension::dap_config_to_scenario`]). It's almost as if the user + /// found the artifact path by themselves. /// /// Note that you're not obliged to use build tasks with locators. Specifically, it is sufficient to provide a debug configuration directly in the return value of /// `dap_locator_create_scenario` if you're able to do that. Make sure to not fill out `build` field in that case, as that will prevent Zed from running second phase of resolution in such case. @@ -267,9 +267,43 @@ pub trait Extension: Send + Sync { #[macro_export] macro_rules! register_extension { ($extension_type:ty) => { + #[cfg(target_os = "wasi")] + mod wasi_ext { + unsafe extern "C" { + static mut errno: i32; + pub static mut __wasilibc_cwd: *mut std::ffi::c_char; + } + + pub fn init_cwd() { + unsafe { + // Ensure that our chdir function is linked, instead of the + // one from wasi-libc in the chdir.o translation unit. Otherwise + // we risk linking in `__wasilibc_find_relpath_alloc` which + // is a weak symbol and is being used by + // `__wasilibc_find_relpath`, which we do not want on + // Windows. + chdir(std::ptr::null()); + + __wasilibc_cwd = std::ffi::CString::new(std::env::var("PWD").unwrap()) + .unwrap() + .into_raw() + .cast(); + } + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn chdir(raw_path: *const std::ffi::c_char) -> i32 { + // Forbid extensions from changing CWD and so return an appropriate error code. + errno = 58; // NOTSUP + return -1; + } + } + #[unsafe(export_name = "init-extension")] pub extern "C" fn __init_extension() { - std::env::set_current_dir(std::env::var("PWD").unwrap()).unwrap(); + #[cfg(target_os = "wasi")] + wasi_ext::init_cwd(); + zed_extension_api::register_extension(|| { Box::new(<$extension_type as zed_extension_api::Extension>::new()) }); diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index b2909ec6c9c281012f7814a39d5571baadce1bab..b2562a8e82f68b7d4113dec9e01d89183c0a92ec 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -30,4 +30,3 @@ tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true wasmtime.workspace = true -workspace-hack.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index d6c0501efdacff2a9eaf542695ed44325908ea56..1dd65fe446232effc932a497601212cd039b6eed 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeSet, HashMap}; use std::env; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Arc; use ::fs::{CopyOptions, Fs, RealFs, copy_recursive}; @@ -13,6 +12,7 @@ use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use language::LanguageConfig; use reqwest_client::ReqwestClient; use rpc::ExtensionProvides; +use tokio::process::Command; use tree_sitter::{Language, Query, WasmStore}; #[derive(Parser, Debug)] @@ -89,6 +89,7 @@ async fn main() -> Result<()> { .current_dir(&output_dir) .args(["-czvf", "archive.tar.gz", "-C", "archive", "."]) .output() + .await .context("failed to run tar")?; if !tar_output.status.success() { bail!( @@ -234,6 +235,21 @@ async fn copy_extension_resources( .with_context(|| "failed to copy icons")?; } + for (_, agent_entry) in &manifest.agent_servers { + if let Some(icon_path) = &agent_entry.icon { + let source_icon = extension_path.join(icon_path); + let dest_icon = output_dir.join(icon_path); + + // Create parent directory if needed + if let Some(parent) = dest_icon.parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(&source_icon, &dest_icon) + .with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?; + } + } + if !manifest.languages.is_empty() { let output_languages_dir = output_dir.join("languages"); fs::create_dir_all(&output_languages_dir)?; diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c933d253c65b525b29eb072ce6910514b15e5932..16cbd9ac0c0ef938322f2b57789c7542549a570a 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -27,6 +27,7 @@ extension.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true +gpui_tokio.workspace = true http_client.workspace = true language.workspace = true log.workspace = true @@ -37,7 +38,6 @@ paths.workspace = true project.workspace = true remote.workspace = true release_channel.workspace = true -schemars.workspace = true semantic_version.workspace = true serde.workspace = true serde_json.workspace = true @@ -52,7 +52,6 @@ util.workspace = true wasmparser.workspace = true wasmtime-wasi.workspace = true wasmtime.workspace = true -workspace-hack.workspace = true [dev-dependencies] criterion.workspace = true diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index 6f0897af6edbb38acef305ff03b76569a741aca5..9cb57fc1fb800df3f20d277cff5c85ecddadf5ad 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -19,6 +19,7 @@ use util::test::TempTree; fn extension_benchmarks(c: &mut Criterion) { let cx = init(); + cx.update(gpui_tokio::init); let mut group = c.benchmark_group("load"); @@ -37,7 +38,7 @@ fn extension_benchmarks(c: &mut Criterion) { |wasm_bytes| { let _extension = cx .executor() - .block(wasm_host.load_extension(wasm_bytes, &manifest, cx.executor())) + .block(wasm_host.load_extension(wasm_bytes, &manifest, &cx.to_async())) .unwrap(); }, BatchSize::SmallInput, @@ -131,6 +132,7 @@ fn manifest() -> ExtensionManifest { .into_iter() .collect(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![ExtensionCapability::ProcessExec( diff --git a/crates/extension_host/src/capability_granter.rs b/crates/extension_host/src/capability_granter.rs index 5a2093c1dd02008b9b7ee8f0155b3aa675806a77..9f27b5e480bc3c22faefe67cd49a06af21614096 100644 --- a/crates/extension_host/src/capability_granter.rs +++ b/crates/extension_host/src/capability_granter.rs @@ -107,6 +107,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], @@ -145,7 +146,7 @@ mod tests { command: "*".to_string(), args: vec!["**".to_string()], })], - manifest.clone(), + manifest, ); assert!(granter.grant_exec("ls", &["-la"]).is_ok()); } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 46deacfe69f1e00fca3c4b158f8760276339d46b..04b03352d83fd3323770a00a13c4377dc111535a 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -43,7 +43,7 @@ use language::{ use node_runtime::NodeRuntime; use project::ContextProviderWithTasks; use release_channel::ReleaseChannel; -use remote::SshRemoteClient; +use remote::{RemoteClient, RemoteConnectionOptions}; use semantic_version::SemanticVersion; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -73,6 +73,12 @@ const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); /// The current extension [`SchemaVersion`] supported by Zed. const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1); +/// Extensions that should no longer be loaded or downloaded. +/// +/// These snippets should no longer be downloaded or loaded, because their +/// functionality has been integrated into the core editor. +const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright"]; + /// Returns the [`SchemaVersion`] range that is compatible with this version of Zed. pub fn schema_version_range() -> RangeInclusive { SchemaVersion::ZERO..=CURRENT_SCHEMA_VERSION @@ -93,10 +99,9 @@ pub fn is_version_compatible( .wasm_api_version .as_ref() .and_then(|wasm_api_version| SemanticVersion::from_str(wasm_api_version).ok()) + && !is_supported_wasm_api_version(release_channel, wasm_api_version) { - if !is_supported_wasm_api_version(release_channel, wasm_api_version) { - return false; - } + return false; } true @@ -118,7 +123,7 @@ pub struct ExtensionStore { pub wasm_host: Arc, pub wasm_extensions: Vec<(Arc, WasmExtension)>, pub tasks: Vec>, - pub ssh_clients: HashMap>, + pub remote_clients: HashMap>, pub ssh_registered_tx: UnboundedSender<()>, } @@ -271,7 +276,7 @@ impl ExtensionStore { reload_tx, tasks: Vec::new(), - ssh_clients: HashMap::default(), + remote_clients: HashMap::default(), ssh_registered_tx: connection_registered_tx, }; @@ -292,19 +297,17 @@ impl ExtensionStore { // it must be asynchronously rebuilt. let mut extension_index = ExtensionIndex::default(); let mut extension_index_needs_rebuild = true; - if let Ok(index_content) = index_content { - if let Some(index) = serde_json::from_str(&index_content).log_err() { - extension_index = index; - if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) = - (index_metadata, extensions_metadata) - { - if index_metadata - .mtime - .bad_is_greater_than(extensions_metadata.mtime) - { - extension_index_needs_rebuild = false; - } - } + if let Ok(index_content) = index_content + && let Some(index) = serde_json::from_str(&index_content).log_err() + { + extension_index = index; + if let (Ok(Some(index_metadata)), Ok(Some(extensions_metadata))) = + (index_metadata, extensions_metadata) + && index_metadata + .mtime + .bad_is_greater_than(extensions_metadata.mtime) + { + extension_index_needs_rebuild = false; } } @@ -357,7 +360,7 @@ impl ExtensionStore { } extension_id = reload_rx.next() => { let Some(extension_id) = extension_id else { break; }; - this.update( cx, |this, _| { + this.update(cx, |this, _| { this.modified_extensions.extend(extension_id); })?; index_changed = true; @@ -392,10 +395,9 @@ impl ExtensionStore { if let Some(path::Component::Normal(extension_dir_name)) = event_path.components().next() + && let Some(extension_id) = extension_dir_name.to_str() { - if let Some(extension_id) = extension_dir_name.to_str() { - reload_tx.unbounded_send(Some(extension_id.into())).ok(); - } + reload_tx.unbounded_send(Some(extension_id.into())).ok(); } } } @@ -566,12 +568,12 @@ impl ExtensionStore { extensions .into_iter() .filter(|extension| { - this.extension_index.extensions.get(&extension.id).map_or( - true, - |installed_extension| { + this.extension_index + .extensions + .get(&extension.id) + .is_none_or(|installed_extension| { installed_extension.manifest.version != extension.manifest.version - }, - ) + }) }) .collect() }) @@ -591,6 +593,10 @@ impl ExtensionStore { /// This can be used to make certain functionality provided by extensions /// available out-of-the-box. pub fn auto_install_extensions(&mut self, cx: &mut Context) { + if cfg!(test) { + return; + } + let extension_settings = ExtensionSettings::get_global(cx); let extensions_to_install = extension_settings @@ -602,7 +608,7 @@ impl ExtensionStore { .extension_index .extensions .contains_key(extension_id.as_ref()); - !is_already_installed + !is_already_installed && !SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref()) }) .cloned() .collect::>(); @@ -682,7 +688,12 @@ impl ExtensionStore { ); } - let response: GetExtensionsResponse = serde_json::from_slice(&body)?; + let mut response: GetExtensionsResponse = serde_json::from_slice(&body)?; + + response + .data + .retain(|extension| !SUPPRESSED_EXTENSIONS.contains(&extension.id.as_ref())); + Ok(response.data) }) } @@ -763,8 +774,8 @@ impl ExtensionStore { if let ExtensionOperation::Install = operation { this.update( cx, |this, cx| { cx.emit(Event::ExtensionInstalled(extension_id.clone())); - if let Some(events) = ExtensionEvents::try_global(cx) { - if let Some(manifest) = this.extension_manifest_for_id(&extension_id) { + if let Some(events) = ExtensionEvents::try_global(cx) + && let Some(manifest) = this.extension_manifest_for_id(&extension_id) { events.update(cx, |this, cx| { this.emit( extension::Event::ExtensionInstalled(manifest.clone()), @@ -772,7 +783,6 @@ impl ExtensionStore { ) }); } - } }) .ok(); } @@ -912,12 +922,12 @@ impl ExtensionStore { extension_store.update(cx, |_, cx| { cx.emit(Event::ExtensionUninstalled(extension_id.clone())); - if let Some(events) = ExtensionEvents::try_global(cx) { - if let Some(manifest) = extension_manifest { - events.update(cx, |this, cx| { - this.emit(extension::Event::ExtensionUninstalled(manifest.clone()), cx) - }); - } + if let Some(events) = ExtensionEvents::try_global(cx) + && let Some(manifest) = extension_manifest + { + events.update(cx, |this, cx| { + this.emit(extension::Event::ExtensionUninstalled(manifest.clone()), cx) + }); } })?; @@ -939,10 +949,24 @@ impl ExtensionStore { ExtensionManifest::load(fs.clone(), &extension_source_path).await?; let extension_id = extension_manifest.id.clone(); + if let Some(uninstall_task) = this + .update(cx, |this, cx| { + this.extension_index + .extensions + .get(extension_id.as_ref()) + .is_some_and(|index_entry| !index_entry.dev) + .then(|| this.uninstall_extension(extension_id.clone(), cx)) + }) + .ok() + .flatten() + { + uninstall_task.await.log_err(); + } + if !this.update(cx, |this, cx| { match this.outstanding_operations.entry(extension_id.clone()) { btree_map::Entry::Occupied(_) => return false, - btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove), + btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Install), }; cx.notify(); true @@ -987,7 +1011,7 @@ impl ExtensionStore { ) .await?; } else { - bail!("extension {extension_id} is already installed"); + bail!("extension {extension_id} is still installed"); } } @@ -997,12 +1021,12 @@ impl ExtensionStore { this.update(cx, |this, cx| this.reload(None, cx))?.await; this.update(cx, |this, cx| { cx.emit(Event::ExtensionInstalled(extension_id.clone())); - if let Some(events) = ExtensionEvents::try_global(cx) { - if let Some(manifest) = this.extension_manifest_for_id(&extension_id) { - events.update(cx, |this, cx| { - this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx) - }); - } + if let Some(events) = ExtensionEvents::try_global(cx) + && let Some(manifest) = this.extension_manifest_for_id(&extension_id) + { + events.update(cx, |this, cx| { + this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx) + }); } })?; @@ -1063,6 +1087,10 @@ impl ExtensionStore { ) -> Task<()> { let old_index = &self.extension_index; + new_index + .extensions + .retain(|extension_id, _| !SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref())); + // Determine which extensions need to be loaded and unloaded, based // on the changes to the manifest and the extensions that we know have been // modified. @@ -1180,16 +1208,16 @@ impl ExtensionStore { } } - for (server_id, _) in &extension.manifest.context_servers { + for server_id in extension.manifest.context_servers.keys() { self.proxy.unregister_context_server(server_id.clone(), cx); } - for (adapter, _) in &extension.manifest.debug_adapters { + for adapter in extension.manifest.debug_adapters.keys() { self.proxy.unregister_debug_adapter(adapter.clone()); } - for (locator, _) in &extension.manifest.debug_locators { + for locator in extension.manifest.debug_locators.keys() { self.proxy.unregister_debug_locator(locator.clone()); } - for (command_name, _) in &extension.manifest.slash_commands { + for command_name in extension.manifest.slash_commands.keys() { self.proxy.unregister_slash_command(command_name.clone()); } } @@ -1243,7 +1271,7 @@ impl ExtensionStore { self.proxy.register_grammars(grammars_to_add); let languages_to_add = new_index .languages - .iter_mut() + .iter() .filter(|(_, entry)| extensions_to_load.contains(&entry.extension)) .collect::>(); for (language_name, language) in languages_to_add { @@ -1275,6 +1303,7 @@ impl ExtensionStore { queries, context_provider, toolchain_provider: None, + manifest_name: None, }) }), ); @@ -1340,7 +1369,7 @@ impl ExtensionStore { &extension_path, &extension.manifest, wasm_host.clone(), - &cx, + cx, ) .await .with_context(|| format!("Loading extension from {extension_path:?}")); @@ -1390,7 +1419,7 @@ impl ExtensionStore { ); } - for (id, _context_server_entry) in &manifest.context_servers { + for id in manifest.context_servers.keys() { this.proxy .register_context_server(extension.clone(), id.clone(), cx); } @@ -1455,7 +1484,7 @@ impl ExtensionStore { if extension_dir .file_name() - .map_or(false, |file_name| file_name == ".DS_Store") + .is_some_and(|file_name| file_name == ".DS_Store") { continue; } @@ -1492,6 +1521,10 @@ impl ExtensionStore { let mut extension_manifest = ExtensionManifest::load(fs.clone(), &extension_dir).await?; let extension_id = extension_manifest.id.clone(); + if SUPPRESSED_EXTENSIONS.contains(&extension_id.as_ref()) { + return Ok(()); + } + // TODO: distinguish dev extensions more explicitly, by the absence // of a checksum file that we'll create when downloading normal extensions. let is_dev = fs @@ -1679,9 +1712,8 @@ impl ExtensionStore { let schema_path = &extension::build_debug_adapter_schema_path(adapter_name, meta); if fs.is_file(&src_dir.join(schema_path)).await { - match schema_path.parent() { - Some(parent) => fs.create_dir(&tmp_dir.join(parent)).await?, - None => {} + if let Some(parent) = schema_path.parent() { + fs.create_dir(&tmp_dir.join(parent)).await? } fs.copy_file( &src_dir.join(schema_path), @@ -1698,7 +1730,7 @@ impl ExtensionStore { async fn sync_extensions_over_ssh( this: &WeakEntity, - client: WeakEntity, + client: WeakEntity, cx: &mut AsyncApp, ) -> Result<()> { let extensions = this.update(cx, |this, _cx| { @@ -1739,7 +1771,14 @@ impl ExtensionStore { })? .await?; let dest_dir = RemotePathBuf::new( - PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id), + path_style + .join(&response.tmp_dir, &missing_extension.id) + .with_context(|| { + format!( + "failed to construct destination path: {:?}, {:?}", + response.tmp_dir, missing_extension.id, + ) + })?, path_style, ); log::info!("Uploading extension {}", missing_extension.clone().id); @@ -1770,12 +1809,12 @@ impl ExtensionStore { pub async fn update_ssh_clients(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { let clients = this.update(cx, |this, _cx| { - this.ssh_clients.retain(|_k, v| v.upgrade().is_some()); - this.ssh_clients.values().cloned().collect::>() + this.remote_clients.retain(|_k, v| v.upgrade().is_some()); + this.remote_clients.values().cloned().collect::>() })?; for client in clients { - Self::sync_extensions_over_ssh(&this, client, cx) + Self::sync_extensions_over_ssh(this, client, cx) .await .log_err(); } @@ -1783,17 +1822,16 @@ impl ExtensionStore { anyhow::Ok(()) } - pub fn register_ssh_client(&mut self, client: Entity, cx: &mut Context) { - let connection_options = client.read(cx).connection_options(); - let ssh_url = connection_options.ssh_url(); + pub fn register_remote_client(&mut self, client: Entity, cx: &mut Context) { + let options = client.read(cx).connection_options(); - if let Some(existing_client) = self.ssh_clients.get(&ssh_url) { - if existing_client.upgrade().is_some() { - return; - } + if let Some(existing_client) = self.remote_clients.get(&options) + && existing_client.upgrade().is_some() + { + return; } - self.ssh_clients.insert(ssh_url, client.downgrade()); + self.remote_clients.insert(options, client.downgrade()); self.ssh_registered_tx.unbounded_send(()).ok(); } } diff --git a/crates/extension_host/src/extension_settings.rs b/crates/extension_host/src/extension_settings.rs index cfa67990b09de9fda5bf0e26229a9b1b1410de46..2f6b66ed0999a541febf368c7f75f22f89fcd6d0 100644 --- a/crates/extension_host/src/extension_settings.rs +++ b/crates/extension_host/src/extension_settings.rs @@ -1,12 +1,11 @@ -use anyhow::Result; use collections::HashMap; -use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use extension::{ + DownloadFileCapability, ExtensionCapability, NpmInstallPackageCapability, ProcessExecCapability, +}; +use settings::Settings; use std::sync::Arc; -#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)] +#[derive(Debug, Default, Clone)] pub struct ExtensionSettings { /// The extensions that should be automatically installed by Zed. /// @@ -14,10 +13,9 @@ pub struct ExtensionSettings { /// available out-of-the-box. /// /// Default: { "html": true } - #[serde(default)] pub auto_install_extensions: HashMap, bool>, - #[serde(default)] pub auto_update_extensions: HashMap, bool>, + pub granted_capabilities: Vec, } impl ExtensionSettings { @@ -38,22 +36,30 @@ impl ExtensionSettings { } impl Settings for ExtensionSettings { - const KEY: Option<&'static str> = None; - - type FileContent = Self; - - fn load(sources: SettingsSources, _cx: &mut App) -> Result { - SettingsSources::::json_merge_with( - [sources.default] + fn from_settings(content: &settings::SettingsContent) -> Self { + Self { + auto_install_extensions: content.extension.auto_install_extensions.clone(), + auto_update_extensions: content.extension.auto_update_extensions.clone(), + granted_capabilities: content + .extension + .granted_extension_capabilities + .clone() + .unwrap_or_default() .into_iter() - .chain(sources.user) - .chain(sources.server), - ) - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) { - // settingsSync.ignoredExtensions controls autoupdate for vscode extensions, but we - // don't have a mapping to zed-extensions. there's also extensions.autoCheckUpdates - // and extensions.autoUpdate which are global switches, we don't support those yet + .map(|capability| match capability { + settings::ExtensionCapabilityContent::ProcessExec { command, args } => { + ExtensionCapability::ProcessExec(ProcessExecCapability { command, args }) + } + settings::ExtensionCapabilityContent::DownloadFile { host, path } => { + ExtensionCapability::DownloadFile(DownloadFileCapability { host, path }) + } + settings::ExtensionCapabilityContent::NpmInstallPackage { package } => { + ExtensionCapability::NpmInstallPackage(NpmInstallPackageCapability { + package, + }) + } + }) + .collect(), + } } } diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 347a610439c98ae020a7ebf190dd9e1a603df5a1..af09b3e4fb28be1a7f339ac4be6b1e789bcff0f0 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -31,7 +31,8 @@ use util::test::TempTree; #[cfg(test)] #[ctor::ctor] fn init_logger() { - zlog::init_test(); + // show info logs while we debug the extension_store tests hanging. + zlog::init_test_with("info"); } #[gpui::test] @@ -159,6 +160,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { .collect(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -189,6 +191,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -368,6 +371,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -526,12 +530,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { }); } -// todo(windows) -// Disable this test on Windows for now. Because this test hangs at -// `let fake_server = fake_servers.next().await.unwrap();`. -// Reenable this test when we figure out why. #[gpui::test] -#[cfg_attr(target_os = "windows", ignore)] async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { init_test(cx); cx.executor().allow_parking(); @@ -546,7 +545,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { let test_extension_dir = root_dir.join("extensions").join(test_extension_id); let fs = Arc::new(RealFs::new(None, cx.executor())); - let extensions_dir = TempTree::new(json!({ + let extensions_tree = TempTree::new(json!({ "installed": {}, "work": {} })); @@ -554,7 +553,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { "test.gleam": "" })); - let extensions_dir = extensions_dir.path().canonicalize().unwrap(); + let extensions_dir = extensions_tree.path().canonicalize().unwrap(); let project_dir = project_dir.path().canonicalize().unwrap(); let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await; @@ -618,6 +617,10 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { { "name": format!("gleam-{version}-aarch64-unknown-linux-musl.tar.gz"), "browser_download_url": asset_download_uri + }, + { + "name": format!("gleam-{version}-x86_64-pc-windows-msvc.tar.gz"), + "browser_download_url": asset_download_uri } ] } @@ -714,13 +717,17 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { .await .unwrap(); - // todo(windows) - // This test hangs here on Windows. let fake_server = fake_servers.next().await.unwrap(); - let expected_server_path = - extensions_dir.join(format!("work/{test_extension_id}/gleam-v1.2.3/gleam")); + let work_dir = extensions_dir.join(format!("work/{test_extension_id}")); + let expected_server_path = work_dir.join("gleam-v1.2.3/gleam"); let expected_binary_contents = language_server_version.lock().binary_contents.clone(); + // check that IO operations in extension work correctly + assert!(work_dir.join("dir-created-with-rel-path").exists()); + assert!(work_dir.join("dir-created-with-abs-path").exists()); + assert!(work_dir.join("file-created-with-abs-path").exists()); + assert!(work_dir.join("file-created-with-rel-path").exists()); + assert_eq!(fake_server.binary.path, expected_server_path); assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]); assert_eq!( @@ -826,7 +833,9 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { // Reload the extension, clearing its cache. // Start a new instance of the language server. extension_store - .update(cx, |store, cx| store.reload(Some("gleam".into()), cx)) + .update(cx, |store, cx| { + store.reload(Some("test-extension".into()), cx) + }) .await; cx.executor().run_until_parked(); project.update(cx, |project, cx| { @@ -859,5 +868,6 @@ fn init_test(cx: &mut TestAppContext) { Project::init_settings(cx); ExtensionSettings::register(cx); language::init(cx); + gpui_tokio::init(cx); }); } diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index adc9638c2998eb1f122df5137577ca7e0cf4c975..f14bb811a6742a60899ac4301cfac096bb41a07f 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -1,10 +1,7 @@ use std::{path::PathBuf, sync::Arc}; use anyhow::{Context as _, Result}; -use client::{ - TypedEnvelope, - proto::{self, FromProto}, -}; +use client::{TypedEnvelope, proto}; use collections::{HashMap, HashSet}; use extension::{ Extension, ExtensionDebugAdapterProviderProxy, ExtensionHostProxy, ExtensionLanguageProxy, @@ -163,6 +160,7 @@ impl HeadlessExtensionStore { queries: LanguageQueries::default(), context_provider: None, toolchain_provider: None, + manifest_name: None, }) }), ); @@ -174,7 +172,7 @@ impl HeadlessExtensionStore { } let wasm_extension: Arc = - Arc::new(WasmExtension::load(&extension_dir, &manifest, wasm_host.clone(), &cx).await?); + Arc::new(WasmExtension::load(&extension_dir, &manifest, wasm_host.clone(), cx).await?); for (language_server_id, language_server_config) in &manifest.language_servers { for language in language_server_config.languages() { @@ -341,7 +339,7 @@ impl HeadlessExtensionStore { version: extension.version, dev: extension.dev, }, - PathBuf::from_proto(envelope.payload.tmp_dir), + PathBuf::from(envelope.payload.tmp_dir), cx, ) })? diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index d990b670f49221aca2f0af901293c70d341cf029..eb26c44f20519b7cdb3a38859f23ce99365fe505 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -1,15 +1,15 @@ pub mod wit; -use crate::ExtensionManifest; use crate::capability_granter::CapabilityGranter; +use crate::{ExtensionManifest, ExtensionSettings}; use anyhow::{Context as _, Result, anyhow, bail}; use async_trait::async_trait; use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest}; use extension::{ CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary, - DebugTaskDefinition, DownloadFileCapability, ExtensionCapability, ExtensionHostProxy, - KeyValueStoreDelegate, NpmInstallPackageCapability, ProcessExecCapability, ProjectDelegate, - SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate, + DebugTaskDefinition, ExtensionCapability, ExtensionHostProxy, KeyValueStoreDelegate, + ProjectDelegate, SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, + WorktreeDelegate, }; use fs::{Fs, normalize_path}; use futures::future::LocalBoxFuture; @@ -29,14 +29,18 @@ use moka::sync::Cache; use node_runtime::NodeRuntime; use release_channel::ReleaseChannel; use semantic_version::SemanticVersion; -use std::borrow::Cow; -use std::sync::{LazyLock, OnceLock}; -use std::time::Duration; +use settings::Settings; use std::{ + borrow::Cow, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, LazyLock, OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, }; use task::{DebugScenario, SpawnInTerminal, TaskTemplate, ZedDebugConfig}; +use util::paths::SanitizedPath; use wasmtime::{ CacheStore, Engine, Store, component::{Component, ResourceTable}, @@ -65,6 +69,7 @@ pub struct WasmExtension { pub work_dir: Arc, #[allow(unused)] pub zed_api_version: SemanticVersion, + _task: Arc>>, } impl Drop for WasmExtension { @@ -493,6 +498,11 @@ pub struct WasmState { pub(crate) capability_granter: CapabilityGranter, } +std::thread_local! { + /// Used by the crash handler to ignore panics in extension-related threads. + pub static IS_WASM_THREAD: AtomicBool = const { AtomicBool::new(false) }; +} + type MainThreadCall = Box FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>; type ExtensionCall = Box< @@ -527,12 +537,13 @@ fn wasm_engine(executor: &BackgroundExecutor) -> wasmtime::Engine { let engine_ref = engine.weak(); executor .spawn(async move { + IS_WASM_THREAD.with(|v| v.store(true, Ordering::Release)); // Somewhat arbitrary interval, as it isn't a guaranteed interval. // But this is a rough upper bound for how long the extension execution can block on // `Future::poll`. const EPOCH_INTERVAL: Duration = Duration::from_millis(100); let mut timer = Timer::interval(EPOCH_INTERVAL); - while let Some(_) = timer.next().await { + while (timer.next().await).is_some() { // Exit the loop and thread once the engine is dropped. let Some(engine) = engine_ref.upgrade() else { break; @@ -568,6 +579,9 @@ impl WasmHost { message(cx).await; } }); + + let extension_settings = ExtensionSettings::get_global(cx); + Arc::new(Self { engine: wasm_engine(cx.background_executor()), fs, @@ -576,19 +590,7 @@ impl WasmHost { node_runtime, proxy, release_channel: ReleaseChannel::global(cx), - granted_capabilities: vec![ - ExtensionCapability::ProcessExec(ProcessExecCapability { - command: "*".to_string(), - args: vec!["**".to_string()], - }), - ExtensionCapability::DownloadFile(DownloadFileCapability { - host: "*".to_string(), - path: vec!["**".to_string()], - }), - ExtensionCapability::NpmInstallPackage(NpmInstallPackageCapability { - package: "*".to_string(), - }), - ], + granted_capabilities: extension_settings.granted_capabilities.clone(), _main_thread_message_task: task, main_thread_message_tx: tx, }) @@ -598,16 +600,16 @@ impl WasmHost { self: &Arc, wasm_bytes: Vec, manifest: &Arc, - executor: BackgroundExecutor, + cx: &AsyncApp, ) -> Task> { let this = self.clone(); let manifest = manifest.clone(); - executor.clone().spawn(async move { + let executor = cx.background_executor().clone(); + let load_extension_task = async move { let zed_api_version = parse_wasm_extension_version(&manifest.id, &wasm_bytes)?; let component = Component::from_binary(&this.engine, &wasm_bytes) .context("failed to compile wasm component")?; - let mut store = wasmtime::Store::new( &this.engine, WasmState { @@ -640,19 +642,33 @@ impl WasmHost { .context("failed to initialize wasm extension")?; let (tx, mut rx) = mpsc::unbounded::(); - executor - .spawn(async move { - while let Some(call) = rx.next().await { - (call)(&mut extension, &mut store).await; - } - }) - .detach(); + let extension_task = async move { + while let Some(call) = rx.next().await { + (call)(&mut extension, &mut store).await; + } + }; + + anyhow::Ok(( + extension_task, + manifest.clone(), + this.work_dir.join(manifest.id.as_ref()).into(), + tx, + zed_api_version, + )) + }; + cx.spawn(async move |cx| { + let (extension_task, manifest, work_dir, tx, zed_api_version) = + cx.background_executor().spawn(load_extension_task).await?; + // we need to run run the task in an extension context as wasmtime_wasi may + // call into tokio, accessing its runtime handle + let task = Arc::new(gpui_tokio::Tokio::spawn(cx, extension_task)?); Ok(WasmExtension { - manifest: manifest.clone(), - work_dir: this.work_dir.join(manifest.id.as_ref()).into(), + manifest, + work_dir, tx, zed_api_version, + _task: task, }) }) } @@ -666,19 +682,19 @@ impl WasmHost { let file_perms = wasi::FilePerms::all(); let dir_perms = wasi::DirPerms::all(); + let path = SanitizedPath::new(&extension_work_dir).to_string(); + #[cfg(target_os = "windows")] + let path = path.replace('\\', "/"); + + let mut ctx = wasi::WasiCtxBuilder::new(); + ctx.inherit_stdio() + .env("PWD", &path) + .env("RUST_BACKTRACE", "full"); - Ok(wasi::WasiCtxBuilder::new() - .inherit_stdio() - .preopened_dir(&extension_work_dir, ".", dir_perms, file_perms)? - .preopened_dir( - &extension_work_dir, - extension_work_dir.to_string_lossy(), - dir_perms, - file_perms, - )? - .env("PWD", extension_work_dir.to_string_lossy()) - .env("RUST_BACKTRACE", "full") - .build()) + ctx.preopened_dir(&path, ".", dir_perms, file_perms)?; + ctx.preopened_dir(&path, &path, dir_perms, file_perms)?; + + Ok(ctx.build()) } pub fn writeable_path_from_extension(&self, id: &Arc, path: &Path) -> Result { @@ -701,16 +717,15 @@ pub fn parse_wasm_extension_version( for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) { if let wasmparser::Payload::CustomSection(s) = part.context("error parsing wasm extension")? + && s.name() == "zed:api-version" { - if s.name() == "zed:api-version" { - version = parse_wasm_extension_version_custom_section(s.data()); - if version.is_none() { - bail!( - "extension {} has invalid zed:api-version section: {:?}", - extension_id, - s.data() - ); - } + version = parse_wasm_extension_version_custom_section(s.data()); + if version.is_none() { + bail!( + "extension {} has invalid zed:api-version section: {:?}", + extension_id, + s.data() + ); } } } @@ -756,7 +771,7 @@ impl WasmExtension { .context("failed to read wasm")?; wasm_host - .load_extension(wasm_bytes, manifest, cx.background_executor().clone()) + .load_extension(wasm_bytes, manifest, cx) .await .with_context(|| format!("failed to load wasm extension {}", manifest.id)) } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs index 084c24f2ec2d5c0071e894acf1a4a1050ed14c40..2fc29abadb2eb60d051b37e072727931aee72d69 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs @@ -23,6 +23,7 @@ wasmtime::component::bindgen!({ }); mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.0.6/settings.rs")); } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 9c726ebd1c45d868d0794f26044cbc53d87eb00f..6e6eca975d92f9c8cf5eb206f04da5fccc3f097c 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -16,6 +16,8 @@ use std::{ path::{Path, PathBuf}, sync::{Arc, OnceLock}, }; +use util::paths::PathStyle; +use util::rel_path::RelPath; use util::{archive::extract_zip, fs::make_file_executable, maybe}; use wasmtime::component::{Linker, Resource}; @@ -421,11 +423,15 @@ impl ExtensionImports for WasmState { ) -> wasmtime::Result> { self.on_main_thread(|cx| { async move { - let location = location + let path = location.as_ref().and_then(|location| { + RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + }); + let location = path .as_ref() - .map(|location| ::settings::SettingsLocation { + .zip(location.as_ref()) + .map(|(path, location)| ::settings::SettingsLocation { worktree_id: WorktreeId::from_proto(location.worktree_id), - path: Path::new(&location.path), + path, }); cx.update(|cx| match category.as_str() { @@ -514,7 +520,7 @@ impl ExtensionImports for WasmState { anyhow::ensure!( response.status().is_success(), "download failed with status {}", - response.status().to_string() + response.status() ); let body = BufReader::new(response.body_mut()); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index c6c3a8475f4b230947e03285e851c332fcdef7f6..9475438b660d2e126ae6ca24d276795d51d4ce8b 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -30,6 +30,7 @@ wasmtime::component::bindgen!({ pub use self::zed::extension::*; mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.2.0/settings.rs")); } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index a2d02cc07a2496e13b9d344ee339a0a4f1e1e124..b6a75ba7dda6ded2e074a2ece35b4b3f881f1619 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -30,6 +30,7 @@ wasmtime::component::bindgen!({ }); mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.3.0/settings.rs")); } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs index 4e2650bba7d4b44557f091e697bf8f25a9403fa2..7c8be1322f94e35ded911d64e13f5afb4bf3702c 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs @@ -30,6 +30,7 @@ wasmtime::component::bindgen!({ }); mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.4.0/settings.rs")); } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs index bb73d77f7f0470a0b700e87931be9cac2bb51a86..6d04663de7772e9c965cf1b88840727cfdcb4b59 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs @@ -31,6 +31,7 @@ wasmtime::component::bindgen!({ }); mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.5.0/settings.rs")); } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 767b9033ade3c81c6ac149363676513c72996b7e..8b44efdfb196d93df0a609983c2b97147bbe38a8 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -31,11 +31,13 @@ use std::{ }; use task::{SpawnInTerminal, ZedDebugConfig}; use url::Url; -use util::{archive::extract_zip, fs::make_file_executable, maybe}; +use util::{ + archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, +}; use wasmtime::component::{Linker, Resource}; pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 6, 0); -pub const MAX_VERSION: SemanticVersion = SemanticVersion::new(0, 6, 0); +pub const MAX_VERSION: SemanticVersion = SemanticVersion::new(0, 7, 0); wasmtime::component::bindgen!({ async: true, @@ -52,6 +54,7 @@ wasmtime::component::bindgen!({ pub use self::zed::extension::*; mod settings { + #![allow(dead_code)] include!(concat!(env!("OUT_DIR"), "/since_v0.6.0/settings.rs")); } @@ -309,7 +312,14 @@ impl TryFrom for ResolvedTask { command: value.command.context("missing command")?, args: value.args, env: value.env.into_iter().collect(), - cwd: value.cwd.map(|s| s.to_string_lossy().into_owned()), + cwd: value.cwd.map(|s| { + let s = s.to_string_lossy(); + if cfg!(target_os = "windows") { + s.replace('\\', "/") + } else { + s.into_owned() + } + }), }) } } @@ -556,7 +566,7 @@ impl HostWorktree for WasmState { ) -> wasmtime::Result> { let delegate = self.table.get(&delegate)?; Ok(delegate - .read_text_file(path.into()) + .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) .await .map_err(|error| error.to_string())) } @@ -714,7 +724,7 @@ impl nodejs::Host for WasmState { .node_runtime .binary_path() .await - .map(|path| path.to_string_lossy().to_string()) + .map(|path| path.to_string_lossy().into_owned()) .to_wasmtime_result() } @@ -906,11 +916,15 @@ impl ExtensionImports for WasmState { ) -> wasmtime::Result> { self.on_main_thread(|cx| { async move { - let location = location + let path = location.as_ref().and_then(|location| { + RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + }); + let location = path .as_ref() - .map(|location| ::settings::SettingsLocation { + .zip(location.as_ref()) + .map(|(path, location)| ::settings::SettingsLocation { worktree_id: WorktreeId::from_proto(location.worktree_id), - path: Path::new(&location.path), + path, }); cx.update(|cx| match category.as_str() { @@ -938,7 +952,7 @@ impl ExtensionImports for WasmState { binary: settings.binary.map(|binary| settings::CommandSettings { path: binary.path, arguments: binary.arguments, - env: binary.env, + env: binary.env.map(|env| env.into_iter().collect()), }), settings: settings.settings, initialization_options: settings.initialization_options, @@ -1037,7 +1051,7 @@ impl ExtensionImports for WasmState { anyhow::ensure!( response.status().is_success(), "download failed with status {}", - response.status().to_string() + response.status() ); let body = BufReader::new(response.body_mut()); diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index c31483d763d963edbd0e64d5dc26a4aaf2ed6aeb..87c76b684725dd9f88031d70c67bff76670cdcf5 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -38,7 +38,6 @@ theme.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/extensions_ui/src/components.rs b/crates/extensions_ui/src/components.rs index 957980e49f8f4774ce7eb601503db79ce74baceb..bf11abd679c657c6533f5e9e075b1b69c01e8622 100644 --- a/crates/extensions_ui/src/components.rs +++ b/crates/extensions_ui/src/components.rs @@ -1,5 +1,3 @@ mod extension_card; -mod feature_upsell; pub use extension_card::*; -pub use feature_upsell::*; diff --git a/crates/extensions_ui/src/components/extension_card.rs b/crates/extensions_ui/src/components/extension_card.rs index abdd32fee99cd056e9fece60a2ff7646f55cd264..524f90c7f0e32c0cc60143070c10288c441089e9 100644 --- a/crates/extensions_ui/src/components/extension_card.rs +++ b/crates/extensions_ui/src/components/extension_card.rs @@ -32,14 +32,14 @@ impl RenderOnce for ExtensionCard { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div().w_full().child( v_flex() + .mt_4() .w_full() - .h(rems(7.)) + .h(rems_from_px(110.)) .p_3() - .mt_4() .gap_2() - .bg(cx.theme().colors().elevated_surface_background) + .bg(cx.theme().colors().elevated_surface_background.opacity(0.5)) .border_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border_variant) .rounded_md() .children(self.children) .when(self.overridden_by_dev_extension, |card| { @@ -51,7 +51,6 @@ impl RenderOnce for ExtensionCard { .block_mouse_except_scroll() .cursor_default() .size_full() - .items_center() .justify_center() .bg(cx.theme().colors().elevated_surface_background.alpha(0.8)) .child(Label::new("Overridden by dev extension.")), diff --git a/crates/extensions_ui/src/components/feature_upsell.rs b/crates/extensions_ui/src/components/feature_upsell.rs deleted file mode 100644 index 573b0b992d343e04b74531ffeb8579f28c92620c..0000000000000000000000000000000000000000 --- a/crates/extensions_ui/src/components/feature_upsell.rs +++ /dev/null @@ -1,78 +0,0 @@ -use gpui::{AnyElement, Div, StyleRefinement}; -use smallvec::SmallVec; -use ui::prelude::*; - -#[derive(IntoElement)] -pub struct FeatureUpsell { - base: Div, - text: SharedString, - docs_url: Option, - children: SmallVec<[AnyElement; 2]>, -} - -impl FeatureUpsell { - pub fn new(text: impl Into) -> Self { - Self { - base: h_flex(), - text: text.into(), - docs_url: None, - children: SmallVec::new(), - } - } - - pub fn docs_url(mut self, docs_url: impl Into) -> Self { - self.docs_url = Some(docs_url.into()); - self - } -} - -impl ParentElement for FeatureUpsell { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements) - } -} - -// Style methods. -impl FeatureUpsell { - fn style(&mut self) -> &mut StyleRefinement { - self.base.style() - } - - gpui::border_style_methods!({ - visibility: pub - }); -} - -impl RenderOnce for FeatureUpsell { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - self.base - .py_2() - .px_4() - .justify_between() - .flex_wrap() - .border_color(cx.theme().colors().border_variant) - .child(Label::new(self.text)) - .child(h_flex().gap_2().children(self.children).when_some( - self.docs_url, - |el, docs_url| { - el.child( - Button::new("open_docs", "View Documentation") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .on_click({ - let docs_url = docs_url.clone(); - move |_event, _window, cx| { - telemetry::event!( - "Documentation Viewed", - source = "Feature Upsell", - url = docs_url, - ); - cx.open_url(&docs_url) - } - }), - ) - }, - )) - } -} diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 65572eb0241be52dc0fb2ebf9891d3a5858caa83..5dcd1e210527ee89a35a3b89008a901cf1f9f036 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::path::Path; use std::sync::{Arc, OnceLock}; use db::kvp::KEY_VALUE_STORE; @@ -8,6 +7,7 @@ use extension_host::ExtensionStore; use gpui::{AppContext as _, Context, Entity, SharedString, Window}; use language::Buffer; use ui::prelude::*; +use util::rel_path::RelPath; use workspace::notifications::simple_message_notification::MessageNotification; use workspace::{Workspace, notifications::NotificationId}; @@ -100,15 +100,9 @@ struct SuggestedExtension { } /// Returns the suggested extension for the given [`Path`]. -fn suggested_extension(path: impl AsRef) -> Option { - let path = path.as_ref(); - - let file_extension: Option> = path - .extension() - .and_then(|extension| Some(extension.to_str()?.into())); - let file_name: Option> = path - .file_name() - .and_then(|file_name| Some(file_name.to_str()?.into())); +fn suggested_extension(path: &RelPath) -> Option { + let file_extension: Option> = path.extension().map(|extension| extension.into()); + let file_name: Option> = path.file_name().map(|name| name.into()); let (file_name_or_extension, extension_id) = None // We suggest against file names first, as these suggestions will be more @@ -210,39 +204,40 @@ pub(crate) fn suggest(buffer: Entity, window: &mut Window, cx: &mut Cont #[cfg(test)] mod tests { use super::*; + use util::rel_path::rel_path; #[test] pub fn test_suggested_extension() { assert_eq!( - suggested_extension("Cargo.toml"), + suggested_extension(rel_path("Cargo.toml")), Some(SuggestedExtension { extension_id: "toml".into(), file_name_or_extension: "toml".into() }) ); assert_eq!( - suggested_extension("Cargo.lock"), + suggested_extension(rel_path("Cargo.lock")), Some(SuggestedExtension { extension_id: "toml".into(), file_name_or_extension: "Cargo.lock".into() }) ); assert_eq!( - suggested_extension("Dockerfile"), + suggested_extension(rel_path("Dockerfile")), Some(SuggestedExtension { extension_id: "dockerfile".into(), file_name_or_extension: "Dockerfile".into() }) ); assert_eq!( - suggested_extension("a/b/c/d/.gitignore"), + suggested_extension(rel_path("a/b/c/d/.gitignore")), Some(SuggestedExtension { extension_id: "git-firefly".into(), file_name_or_extension: ".gitignore".into() }) ); assert_eq!( - suggested_extension("a/b/c/d/test.gleam"), + suggested_extension(rel_path("a/b/c/d/test.gleam")), Some(SuggestedExtension { extension_id: "gleam".into(), file_name_or_extension: "gleam".into() diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index aaf5d5e8eb8308f3833e2638f1e0e72186f3d983..d38c27375f6c32324d4832d308768af8473869eb 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::sync::Arc; use client::ExtensionMetadata; -use extension_host::{ExtensionSettings, ExtensionStore}; +use extension_host::ExtensionStore; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{App, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, prelude::*}; @@ -183,10 +183,13 @@ impl PickerDelegate for ExtensionVersionSelectorDelegate { let extension_id = extension_version.id.clone(); let version = extension_version.manifest.version.clone(); - update_settings_file::(self.fs.clone(), cx, { + update_settings_file(self.fs.clone(), cx, { let extension_id = extension_id.clone(); move |settings, _| { - settings.auto_update_extensions.insert(extension_id, false); + settings + .extension + .auto_update_extensions + .insert(extension_id, false); } }); @@ -207,8 +210,8 @@ impl PickerDelegate for ExtensionVersionSelectorDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { - let version_match = &self.matches[ix]; - let extension_version = &self.extension_versions[version_match.candidate_id]; + let version_match = &self.matches.get(ix)?; + let extension_version = &self.extension_versions.get(version_match.candidate_id)?; let is_version_compatible = extension_host::is_version_compatible(ReleaseChannel::global(cx), extension_version); diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 49159339205ede0eb2d2db8b16a1c235fcd84303..cf59f7d200962b2e541c429c7918f622d6e06587 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -13,28 +13,28 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ - Action, App, ClipboardItem, Context, Entity, EventEmitter, Flatten, Focusable, - InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, + Action, App, ClipboardItem, Context, Corner, Entity, EventEmitter, Flatten, Focusable, + InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list, }; use num_format::{Locale, ToFormattedString}; use project::DirectoryLister; use release_channel::ReleaseChannel; -use settings::Settings; +use settings::{Settings, SettingsContent}; use strum::IntoEnumIterator as _; use theme::ThemeSettings; use ui::{ - CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState, - ToggleButton, Tooltip, prelude::*, + Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButton, + Tooltip, WithScrollbar, prelude::*, }; use vim_mode_setting::VimModeSetting; use workspace::{ - Workspace, WorkspaceId, + Workspace, item::{Item, ItemEvent}, }; use zed_actions::ExtensionCategoryFilter; -use crate::components::{ExtensionCard, FeatureUpsell}; +use crate::components::ExtensionCard; use crate::extension_version_selector::{ ExtensionVersionSelector, ExtensionVersionSelectorDelegate, }; @@ -66,6 +66,7 @@ pub fn init(cx: &mut App) { ExtensionCategoryFilter::ContextServers => { ExtensionProvides::ContextServers } + ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers, ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands, ExtensionCategoryFilter::IndexedDocsProviders => { ExtensionProvides::IndexedDocsProviders @@ -116,6 +117,7 @@ pub fn init(cx: &mut App) { files: false, directories: true, multiple: false, + prompt: None, }, DirectoryLister::Local( workspace.project().clone(), @@ -188,6 +190,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str { ExtensionProvides::Grammars => "Grammars", ExtensionProvides::LanguageServers => "Language Servers", ExtensionProvides::ContextServers => "MCP Servers", + ExtensionProvides::AgentServers => "Agent Servers", ExtensionProvides::SlashCommands => "Slash Commands", ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers", ExtensionProvides::Snippets => "Snippets", @@ -222,9 +225,9 @@ impl ExtensionFilter { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum Feature { + ExtensionRuff, + ExtensionTailwind, Git, - OpenIn, - Vim, LanguageBash, LanguageC, LanguageCpp, @@ -233,13 +236,28 @@ enum Feature { LanguageReact, LanguageRust, LanguageTypescript, + OpenIn, + Vim, } fn keywords_by_feature() -> &'static BTreeMap> { static KEYWORDS_BY_FEATURE: OnceLock>> = OnceLock::new(); KEYWORDS_BY_FEATURE.get_or_init(|| { BTreeMap::from_iter([ + (Feature::ExtensionRuff, vec!["ruff"]), + (Feature::ExtensionTailwind, vec!["tail", "tailwind"]), (Feature::Git, vec!["git"]), + (Feature::LanguageBash, vec!["sh", "bash"]), + (Feature::LanguageC, vec!["c", "clang"]), + (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]), + (Feature::LanguageGo, vec!["go", "golang"]), + (Feature::LanguagePython, vec!["python", "py"]), + (Feature::LanguageReact, vec!["react"]), + (Feature::LanguageRust, vec!["rust", "rs"]), + ( + Feature::LanguageTypescript, + vec!["type", "typescript", "ts"], + ), ( Feature::OpenIn, vec![ @@ -254,17 +272,6 @@ fn keywords_by_feature() -> &'static BTreeMap> { ], ), (Feature::Vim, vec!["vim"]), - (Feature::LanguageBash, vec!["sh", "bash"]), - (Feature::LanguageC, vec!["c", "clang"]), - (Feature::LanguageCpp, vec!["c++", "cpp", "clang"]), - (Feature::LanguageGo, vec!["go", "golang"]), - (Feature::LanguagePython, vec!["python", "py"]), - (Feature::LanguageReact, vec!["react"]), - (Feature::LanguageRust, vec!["rust", "rs"]), - ( - Feature::LanguageTypescript, - vec!["type", "typescript", "ts"], - ), ]) }) } @@ -289,7 +296,6 @@ pub struct ExtensionsPage { _subscriptions: [gpui::Subscription; 2], extension_fetch_task: Option>, upsells: BTreeSet, - scrollbar_state: ScrollbarState, } impl ExtensionsPage { @@ -326,7 +332,7 @@ impl ExtensionsPage { let query_editor = cx.new(|cx| { let mut input = Editor::single_line(window, cx); - input.set_placeholder_text("Search extensions...", cx); + input.set_placeholder_text("Search extensions...", window, cx); if let Some(id) = focus_extension_id { input.set_text(format!("id:{id}"), window, cx); } @@ -338,7 +344,7 @@ impl ExtensionsPage { let mut this = Self { workspace: workspace.weak_handle(), - list: scroll_handle.clone(), + list: scroll_handle, is_fetching_extensions: false, filter: ExtensionFilter::All, dev_extension_entries: Vec::new(), @@ -350,7 +356,6 @@ impl ExtensionsPage { _subscriptions: subscriptions, query_editor, upsells: BTreeSet::default(), - scrollbar_state: ScrollbarState::new(scroll_handle), }; this.fetch_extensions( this.search_query(cx), @@ -693,7 +698,7 @@ impl ExtensionsPage { cx.open_url(&repository_url); } })) - .tooltip(Tooltip::text(repository_url.clone())) + .tooltip(Tooltip::text(repository_url)) })), ) } @@ -728,7 +733,7 @@ impl ExtensionsPage { .gap_2() .child( Headline::new(extension.manifest.name.clone()) - .size(HeadlineSize::Medium), + .size(HeadlineSize::Small), ) .child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall)) .children( @@ -778,20 +783,12 @@ impl ExtensionsPage { h_flex() .gap_2() .justify_between() - .child( - Label::new(format!( - "{}: {}", - if extension.manifest.authors.len() > 1 { - "Authors" - } else { - "Author" - }, - extension.manifest.authors.join(", ") - )) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ) + .children(extension.manifest.description.as_ref().map(|description| { + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate() + })) .child( Label::new(format!( "Downloads: {}", @@ -804,21 +801,29 @@ impl ExtensionsPage { h_flex() .gap_2() .justify_between() - .children(extension.manifest.description.as_ref().map(|description| { - Label::new(description.clone()) - .size(LabelSize::Small) - .color(Color::Default) - .truncate() - })) .child( h_flex() - .gap_2() + .gap_1() + .child( + Icon::new(IconName::Person) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(extension.manifest.authors.join(", ")) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + h_flex() + .gap_1() .child( IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) - .icon_color(Color::Accent) .icon_size(IconSize::Small) .on_click(cx.listener({ let repository_url = repository_url.clone(); @@ -826,7 +831,7 @@ impl ExtensionsPage { cx.open_url(&repository_url); } })) - .tooltip(Tooltip::text(repository_url.clone())), + .tooltip(Tooltip::text(repository_url)), ) .child( PopoverMenu::new(SharedString::from(format!( @@ -838,9 +843,13 @@ impl ExtensionsPage { SharedString::from(format!("more-{}", extension.id)), IconName::Ellipsis, ) - .icon_color(Color::Accent) .icon_size(IconSize::Small), ) + .anchor(Corner::TopRight) + .offset(Point { + x: px(0.0), + y: px(2.0), + }) .menu(move |window, cx| { Some(Self::render_remote_extension_context_menu( &this, @@ -862,7 +871,7 @@ impl ExtensionsPage { window: &mut Window, cx: &mut App, ) -> Entity { - let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { + ContextMenu::build(window, cx, |context_menu, window, _| { context_menu .entry( "Install Another Version...", @@ -886,9 +895,7 @@ impl ExtensionsPage { cx.write_to_clipboard(ClipboardItem::new_string(authors.join(", "))); } }) - }); - - context_menu + }) } fn show_extension_version_list( @@ -964,6 +971,11 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Install", ) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -981,6 +993,11 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Install", ) + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) .disabled(true), configure: None, upgrade: None, @@ -990,6 +1007,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .disabled(true), configure: is_configurable.then(|| { Button::new( @@ -1007,6 +1025,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1023,6 +1042,7 @@ impl ExtensionsPage { SharedString::from(format!("configure-{}", extension.id)), "Configure", ) + .style(ButtonStyle::OutlinedGhost) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1030,15 +1050,14 @@ impl ExtensionsPage { .read(cx) .extension_manifest_for_id(&extension_id) .cloned() + && let Some(events) = extension::ExtensionEvents::try_global(cx) { - if let Some(events) = extension::ExtensionEvents::try_global(cx) { - events.update(cx, |this, cx| { - this.emit( - extension::Event::ConfigureExtensionRequested(manifest), - cx, - ) - }); - } + events.update(cx, |this, cx| { + this.emit( + extension::Event::ConfigureExtensionRequested(manifest), + cx, + ) + }); } } }) @@ -1048,6 +1067,7 @@ impl ExtensionsPage { } else { Some( Button::new(SharedString::from(extension.id.clone()), "Upgrade") + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .when(!is_compatible, |upgrade_button| { upgrade_button.disabled(true).tooltip({ let version = extension.manifest.version.clone(); @@ -1086,6 +1106,7 @@ impl ExtensionsPage { SharedString::from(extension.id.clone()), "Uninstall", ) + .style(ButtonStyle::OutlinedGhost) .disabled(true), configure: is_configurable.then(|| { Button::new( @@ -1273,17 +1294,17 @@ impl ExtensionsPage { Label::new(message) } - fn update_settings( + fn update_settings( &mut self, selection: &ToggleState, cx: &mut Context, - callback: impl 'static + Send + Fn(&mut T::FileContent, bool), + callback: impl 'static + Send + Fn(&mut SettingsContent, bool), ) { if let Some(workspace) = self.workspace.upgrade() { let fs = workspace.read(cx).app_state().fs.clone(); let selection = *selection; - settings::update_settings_file::(fs, cx, move |settings, _| { + settings::update_settings_file(fs, cx, move |settings, _| { let value = match selection { ToggleState::Unselected => false, ToggleState::Selected => true, @@ -1319,65 +1340,177 @@ impl ExtensionsPage { } } - fn render_feature_upsells(&self, cx: &mut Context) -> impl IntoElement { - let upsells_count = self.upsells.len(); + fn render_feature_upsell_banner( + &self, + label: SharedString, + docs_url: SharedString, + vim: bool, + cx: &mut Context, + ) -> impl IntoElement { + let docs_url_button = Button::new("open_docs", "View Documentation") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .icon_position(IconPosition::End) + .on_click({ + move |_event, _window, cx| { + telemetry::event!( + "Documentation Viewed", + source = "Feature Upsell", + url = docs_url, + ); + cx.open_url(&docs_url) + } + }); - v_flex().children(self.upsells.iter().enumerate().map(|(ix, feature)| { - let upsell = match feature { - Feature::Git => FeatureUpsell::new( - "Zed comes with basic Git support. More Git features are coming in the future.", - ) - .docs_url("https://zed.dev/docs/git"), - Feature::OpenIn => FeatureUpsell::new( - "Zed supports linking to a source line on GitHub and others.", - ) - .docs_url("https://zed.dev/docs/git#git-integrations"), - Feature::Vim => FeatureUpsell::new("Vim support is built-in to Zed!") - .docs_url("https://zed.dev/docs/vim") - .child(CheckboxWithLabel::new( - "enable-vim", - Label::new("Enable vim mode"), - if VimModeSetting::get_global(cx).0 { - ui::ToggleState::Selected + div() + .pt_4() + .px_4() + .child( + Banner::new() + .severity(Severity::Success) + .child(Label::new(label).mt_0p5()) + .map(|this| { + if vim { + this.action_slot( + h_flex() + .gap_1() + .child(docs_url_button) + .child(Divider::vertical().color(ui::DividerColor::Border)) + .child( + h_flex() + .pl_1() + .gap_1() + .child(Label::new("Enable Vim mode")) + .child( + Switch::new( + "enable-vim", + if VimModeSetting::get_global(cx).0 { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + ) + .on_click(cx.listener( + move |this, selection, _, cx| { + telemetry::event!( + "Vim Mode Toggled", + source = "Feature Upsell" + ); + this.update_settings( + selection, + cx, + |setting, value| { + setting.vim_mode = Some(value) + }, + ); + }, + )) + .color(ui::SwitchColor::Accent), + ), + ), + ) } else { - ui::ToggleState::Unselected - }, - cx.listener(move |this, selection, _, cx| { - telemetry::event!("Vim Mode Toggled", source = "Feature Upsell"); - this.update_settings::( - selection, - cx, - |setting, value| *setting = Some(value), - ); - }), - )), - Feature::LanguageBash => FeatureUpsell::new("Shell support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/bash"), - Feature::LanguageC => FeatureUpsell::new("C support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/c"), - Feature::LanguageCpp => FeatureUpsell::new("C++ support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/cpp"), - Feature::LanguageGo => FeatureUpsell::new("Go support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/go"), - Feature::LanguagePython => FeatureUpsell::new("Python support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/python"), - Feature::LanguageReact => FeatureUpsell::new("React support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/typescript"), - Feature::LanguageRust => FeatureUpsell::new("Rust support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/rust"), - Feature::LanguageTypescript => { - FeatureUpsell::new("Typescript support is built-in to Zed!") - .docs_url("https://zed.dev/docs/languages/typescript") - } + this.action_slot(docs_url_button) + } + }), + ) + .into_any_element() + } + + fn render_feature_upsells(&self, cx: &mut Context) -> impl IntoElement { + let mut container = v_flex(); + + for feature in &self.upsells { + let banner = match feature { + Feature::ExtensionRuff => self.render_feature_upsell_banner( + "Ruff (linter for Python) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python#code-formatting--linting".into(), + false, + cx, + ), + Feature::ExtensionTailwind => self.render_feature_upsell_banner( + "Tailwind CSS support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/tailwindcss".into(), + false, + cx, + ), + Feature::Git => self.render_feature_upsell_banner( + "Zed comes with basic Git support—more features are coming in the future." + .into(), + "https://zed.dev/docs/git".into(), + false, + cx, + ), + Feature::LanguageBash => self.render_feature_upsell_banner( + "Shell support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/bash".into(), + false, + cx, + ), + Feature::LanguageC => self.render_feature_upsell_banner( + "C support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/c".into(), + false, + cx, + ), + Feature::LanguageCpp => self.render_feature_upsell_banner( + "C++ support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/cpp".into(), + false, + cx, + ), + Feature::LanguageGo => self.render_feature_upsell_banner( + "Go support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/go".into(), + false, + cx, + ), + Feature::LanguagePython => self.render_feature_upsell_banner( + "Python support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python".into(), + false, + cx, + ), + Feature::LanguageReact => self.render_feature_upsell_banner( + "React support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/typescript".into(), + false, + cx, + ), + Feature::LanguageRust => self.render_feature_upsell_banner( + "Rust support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/rust".into(), + false, + cx, + ), + Feature::LanguageTypescript => self.render_feature_upsell_banner( + "Typescript support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/typescript".into(), + false, + cx, + ), + Feature::OpenIn => self.render_feature_upsell_banner( + "Zed supports linking to a source line on GitHub and others.".into(), + "https://zed.dev/docs/git#git-integrations".into(), + false, + cx, + ), + Feature::Vim => self.render_feature_upsell_banner( + "Vim support is built-in to Zed!".into(), + "https://zed.dev/docs/vim".into(), + true, + cx, + ), }; + container = container.child(banner); + } - upsell.when(ix < upsells_count, |upsell| upsell.border_b_1()) - })) + container } } impl Render for ExtensionsPage { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() .bg(cx.theme().colors().editor_background) @@ -1509,40 +1642,28 @@ impl Render for ExtensionsPage { })), ) .child(self.render_feature_upsells(cx)) - .child( - v_flex() - .pl_4() - .pr_6() - .size_full() - .overflow_y_hidden() - .map(|this| { - let mut count = self.filtered_remote_extension_indices.len(); - if self.filter.include_dev_extensions() { - count += self.dev_extension_entries.len(); - } - - if count == 0 { - return this.py_4().child(self.render_empty_state(cx)); - } + .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| { + let mut count = self.filtered_remote_extension_indices.len(); + if self.filter.include_dev_extensions() { + count += self.dev_extension_entries.len(); + } - let scroll_handle = self.list.clone(); - this.child( - uniform_list("entries", count, cx.processor(Self::render_extensions)) - .flex_grow() - .pb_4() - .track_scroll(scroll_handle), - ) - .child( - div() - .absolute() - .right_1() - .top_0() - .bottom_0() - .w(px(12.)) - .children(Scrollbar::vertical(self.scrollbar_state.clone())), - ) - }), - ) + if count == 0 { + this.py_4() + .child(self.render_empty_state(cx)) + .into_any_element() + } else { + let scroll_handle = self.list.clone(); + this.child( + uniform_list("entries", count, cx.processor(Self::render_extensions)) + .flex_grow() + .pb_4() + .track_scroll(scroll_handle.clone()), + ) + .vertical_scrollbar_for(scroll_handle, window, cx) + .into_any_element() + } + })) } } @@ -1569,15 +1690,6 @@ impl Item for ExtensionsPage { false } - fn clone_on_split( - &self, - _workspace_id: Option, - _window: &mut Window, - _: &mut Context, - ) -> Option> { - None - } - fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { f(*event) } diff --git a/crates/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index e4cc1e9330b90a5fca3933d86825974740864811..65d6942d501137ba9e84892470876a3755fdb69d 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -15,4 +15,3 @@ path = "src/feature_flags.rs" futures.workspace = true gpui.workspace = true smol.workspace = true -workspace-hack.workspace = true diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index ef357adf35997bfb7560f1e1849ef69e780cd1f9..1b66b291b52762266eea1d653475c911ee1f334e 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -1,12 +1,17 @@ -use futures::channel::oneshot; -use futures::{FutureExt, select_biased}; -use gpui::{App, Context, Global, Subscription, Task, Window}; +mod flags; + use std::cell::RefCell; use std::rc::Rc; use std::sync::LazyLock; use std::time::Duration; use std::{future::Future, pin::Pin, task::Poll}; +use futures::channel::oneshot; +use futures::{FutureExt, select_biased}; +use gpui::{App, Context, Global, Subscription, Task, Window}; + +pub use flags::*; + #[derive(Default)] struct FeatureFlags { flags: Vec, @@ -14,7 +19,7 @@ struct FeatureFlags { } pub static ZED_DISABLE_STAFF: LazyLock = LazyLock::new(|| { - std::env::var("ZED_DISABLE_STAFF").map_or(false, |value| !value.is_empty() && value != "0") + std::env::var("ZED_DISABLE_STAFF").is_ok_and(|value| !value.is_empty() && value != "0") }); impl FeatureFlags { @@ -23,7 +28,7 @@ impl FeatureFlags { return true; } - if self.staff && T::enabled_for_staff() { + if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() { return true; } @@ -56,53 +61,6 @@ pub trait FeatureFlag { } } -pub struct PredictEditsRateCompletionsFeatureFlag; -impl FeatureFlag for PredictEditsRateCompletionsFeatureFlag { - const NAME: &'static str = "predict-edits-rate-completions"; -} - -pub struct LlmClosedBetaFeatureFlag {} -impl FeatureFlag for LlmClosedBetaFeatureFlag { - const NAME: &'static str = "llm-closed-beta"; -} - -pub struct ZedProFeatureFlag {} -impl FeatureFlag for ZedProFeatureFlag { - const NAME: &'static str = "zed-pro"; -} - -pub struct NotebookFeatureFlag; - -impl FeatureFlag for NotebookFeatureFlag { - const NAME: &'static str = "notebooks"; -} - -pub struct ThreadAutoCaptureFeatureFlag {} -impl FeatureFlag for ThreadAutoCaptureFeatureFlag { - const NAME: &'static str = "thread-auto-capture"; - - fn enabled_for_staff() -> bool { - false - } -} -pub struct PanicFeatureFlag; - -impl FeatureFlag for PanicFeatureFlag { - const NAME: &'static str = "panic"; -} - -pub struct JjUiFeatureFlag {} - -impl FeatureFlag for JjUiFeatureFlag { - const NAME: &'static str = "jj-ui"; -} - -pub struct AcpFeatureFlag; - -impl FeatureFlag for AcpFeatureFlag { - const NAME: &'static str = "acp"; -} - pub trait FeatureFlagViewExt { fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription where @@ -198,7 +156,10 @@ impl FeatureFlagAppExt for App { fn has_flag(&self) -> bool { self.try_global::() .map(|flags| flags.has_flag::()) - .unwrap_or(false) + .unwrap_or_else(|| { + (cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF) + || T::enabled_for_all() + }) } fn is_staff(&self) -> bool { diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs new file mode 100644 index 0000000000000000000000000000000000000000..47b6f1230ac747c2633327d1be923d33388cf179 --- /dev/null +++ b/crates/feature_flags/src/flags.rs @@ -0,0 +1,19 @@ +use crate::FeatureFlag; + +pub struct PredictEditsRateCompletionsFeatureFlag; + +impl FeatureFlag for PredictEditsRateCompletionsFeatureFlag { + const NAME: &'static str = "predict-edits-rate-completions"; +} + +pub struct NotebookFeatureFlag; + +impl FeatureFlag for NotebookFeatureFlag { + const NAME: &'static str = "notebooks"; +} + +pub struct PanicFeatureFlag; + +impl FeatureFlag for PanicFeatureFlag { + const NAME: &'static str = "panic"; +} diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index 3a2c1fd7131ef7b5d7b07b8ec036fff4f1bba621..0a53a1b6f38d1af0a6b913d61969d4df105a6a10 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -15,19 +15,12 @@ path = "src/feedback.rs" test-support = [] [dependencies] -client.workspace = true gpui.workspace = true -human_bytes = "0.4.1" -menu.workspace = true -release_channel.workspace = true -serde.workspace = true -sysinfo.workspace = true -ui.workspace = true +system_specs.workspace = true urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/feedback/src/feedback.rs b/crates/feedback/src/feedback.rs index 40c2707d34c9f5ab50bdb51c8b82183be2106285..57bddb6ae7ee19e4b6df8fcf7ebcaeb32e5105bb 100644 --- a/crates/feedback/src/feedback.rs +++ b/crates/feedback/src/feedback.rs @@ -1,24 +1,14 @@ use gpui::{App, ClipboardItem, PromptLevel, actions}; -use system_specs::SystemSpecs; +use system_specs::{CopySystemSpecsIntoClipboard, SystemSpecs}; use util::ResultExt; use workspace::Workspace; -use zed_actions::feedback::FileBugReport; - -pub mod feedback_modal; - -pub mod system_specs; +use zed_actions::feedback::{EmailZed, FileBugReport, RequestFeature}; actions!( zed, [ - /// Copies system specifications to the clipboard for bug reports. - CopySystemSpecsIntoClipboard, - /// Opens email client to send feedback to Zed support. - EmailZed, /// Opens the Zed repository on GitHub. OpenZedRepo, - /// Opens the feature request form. - RequestFeature, ] ); @@ -52,11 +42,7 @@ fn email_body(specs: &SystemSpecs) -> String { } pub fn init(cx: &mut App) { - cx.observe_new(|workspace: &mut Workspace, window, cx| { - let Some(window) = window else { - return; - }; - feedback_modal::FeedbackModal::register(workspace, window, cx); + cx.observe_new(|workspace: &mut Workspace, _, _| { workspace .register_action(|_, _: &CopySystemSpecsIntoClipboard, window, cx| { let specs = SystemSpecs::new(window, cx); diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs deleted file mode 100644 index beb879efe718e9709fc5d0e9aad8d2b96ff27066..0000000000000000000000000000000000000000 --- a/crates/feedback/src/feedback_modal.rs +++ /dev/null @@ -1,113 +0,0 @@ -use gpui::{App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, Render, Window}; -use ui::{IconPosition, prelude::*}; -use workspace::{ModalView, Workspace}; -use zed_actions::feedback::GiveFeedback; - -use crate::{EmailZed, FileBugReport, OpenZedRepo, RequestFeature}; - -pub struct FeedbackModal { - focus_handle: FocusHandle, -} - -impl Focusable for FeedbackModal { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} -impl EventEmitter for FeedbackModal {} - -impl ModalView for FeedbackModal {} - -impl FeedbackModal { - pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context) { - let _handle = cx.entity().downgrade(); - workspace.register_action(move |workspace, _: &GiveFeedback, window, cx| { - workspace.toggle_modal(window, cx, move |_, cx| FeedbackModal::new(cx)); - }); - } - - pub fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent) - } -} - -impl Render for FeedbackModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let open_zed_repo = - cx.listener(|_, _, window, cx| window.dispatch_action(Box::new(OpenZedRepo), cx)); - - v_flex() - .key_context("GiveFeedback") - .on_action(cx.listener(Self::cancel)) - .elevation_3(cx) - .w_96() - .h_auto() - .p_4() - .gap_2() - .child( - h_flex() - .w_full() - .justify_between() - .child(Headline::new("Give Feedback")) - .child( - IconButton::new("close-btn", IconName::Close) - .icon_color(Color::Muted) - .on_click(cx.listener(move |_, _, window, cx| { - cx.spawn_in(window, async move |this, cx| { - this.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); - }) - .detach(); - })), - ), - ) - .child(Label::new("Thanks for using Zed! To share your experience with us, reach for the channel that's the most appropriate:")) - .child( - Button::new("file-a-bug-report", "File a Bug Report") - .full_width() - .icon(IconName::Debug) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(FileBugReport), cx); - })), - ) - .child( - Button::new("request-a-feature", "Request a Feature") - .full_width() - .icon(IconName::Sparkle) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(RequestFeature), cx); - })), - ) - .child( - Button::new("send-us_an-email", "Send an Email") - .full_width() - .icon(IconName::Envelope) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action(Box::new(EmailZed), cx); - })), - ) - .child( - Button::new("zed_repository", "GitHub Repository") - .full_width() - .icon(IconName::Github) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(open_zed_repo), - ) - } -} diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index aabfa4362afcf644e5d7e882ef9f9c1b97d261cb..46257b1f49dc4b5e225373d69576d2f54de8c79e 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -27,13 +27,11 @@ schemars.workspace = true search.workspace = true settings.workspace = true serde.workspace = true -serde_derive.workspace = true text.workspace = true theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index c6997ccdc0c89be67442e9ac2b16f61512feb141..d78d789b9b0c8041975da6337620b840896a61f6 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -21,7 +21,9 @@ use gpui::{ }; use open_path_prompt::OpenPathPrompt; use picker::{Picker, PickerDelegate}; -use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; +use project::{ + PathMatchCandidateSet, Project, ProjectPath, WorktreeId, worktree_store::WorktreeStore, +}; use search::ToggleIncludeIgnored; use settings::Settings; use std::{ @@ -39,7 +41,12 @@ use ui::{ ButtonLike, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; -use util::{ResultExt, maybe, paths::PathWithPosition, post_inc}; +use util::{ + ResultExt, maybe, + paths::{PathStyle, PathWithPosition}, + post_inc, + rel_path::RelPath, +}; use workspace::{ ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings, notifications::NotifyResultExt, pane, @@ -126,38 +133,34 @@ impl FileFinder { let project = workspace.project().read(cx); let fs = project.fs(); - let currently_opened_path = workspace - .active_item(cx) - .and_then(|item| item.project_path(cx)) - .map(|project_path| { - let abs_path = project - .worktree_for_id(project_path.worktree_id, cx) - .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path)); - FoundPath::new(project_path, abs_path) - }); + let currently_opened_path = workspace.active_item(cx).and_then(|item| { + let project_path = item.project_path(cx)?; + let abs_path = project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .absolutize(&project_path.path); + Some(FoundPath::new(project_path, abs_path)) + }); let history_items = workspace .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx) .into_iter() .filter_map(|(project_path, abs_path)| { if project.entry_for_path(&project_path, cx).is_some() { - return Some(Task::ready(Some(FoundPath::new(project_path, abs_path)))); + return Some(Task::ready(Some(FoundPath::new(project_path, abs_path?)))); } let abs_path = abs_path?; if project.is_local() { let fs = fs.clone(); Some(cx.background_spawn(async move { if fs.is_file(&abs_path).await { - Some(FoundPath::new(project_path, Some(abs_path))) + Some(FoundPath::new(project_path, abs_path)) } else { None } })) } else { - Some(Task::ready(Some(FoundPath::new( - project_path, - Some(abs_path), - )))) + Some(Task::ready(Some(FoundPath::new(project_path, abs_path)))) } }) .collect::>(); @@ -209,11 +212,11 @@ impl FileFinder { let Some(init_modifiers) = self.init_modifiers.take() else { return; }; - if self.picker.read(cx).delegate.has_changed_selected_index { - if !event.modified() || !init_modifiers.is_subset_of(&event) { - self.init_modifiers = None; - window.dispatch_action(menu::Confirm.boxed_clone(), cx); - } + if self.picker.read(cx).delegate.has_changed_selected_index + && (!event.modified() || !init_modifiers.is_subset_of(event)) + { + self.init_modifiers = None; + window.dispatch_action(menu::Confirm.boxed_clone(), cx); } } @@ -267,10 +270,9 @@ impl FileFinder { ) { self.picker.update(cx, |picker, cx| { picker.delegate.include_ignored = match picker.delegate.include_ignored { - Some(true) => match FileFinderSettings::get_global(cx).include_ignored { - Some(_) => Some(false), - None => None, - }, + Some(true) => FileFinderSettings::get_global(cx) + .include_ignored + .map(|_| false), Some(false) => Some(true), None => Some(true), }; @@ -323,41 +325,41 @@ impl FileFinder { ) { self.picker.update(cx, |picker, cx| { let delegate = &mut picker.delegate; - if let Some(workspace) = delegate.workspace.upgrade() { - if let Some(m) = delegate.matches.get(delegate.selected_index()) { - let path = match &m { - Match::History { path, .. } => { - let worktree_id = path.project.worktree_id; - ProjectPath { - worktree_id, - path: Arc::clone(&path.project.path), - } + if let Some(workspace) = delegate.workspace.upgrade() + && let Some(m) = delegate.matches.get(delegate.selected_index()) + { + let path = match &m { + Match::History { path, .. } => { + let worktree_id = path.project.worktree_id; + ProjectPath { + worktree_id, + path: Arc::clone(&path.project.path), } - Match::Search(m) => ProjectPath { - worktree_id: WorktreeId::from_usize(m.0.worktree_id), - path: m.0.path.clone(), - }, - Match::CreateNew(p) => p.clone(), - }; - let open_task = workspace.update(cx, move |workspace, cx| { - workspace.split_path_preview(path, false, Some(split_direction), window, cx) - }); - open_task.detach_and_log_err(cx); - } + } + Match::Search(m) => ProjectPath { + worktree_id: WorktreeId::from_usize(m.0.worktree_id), + path: m.0.path.clone(), + }, + Match::CreateNew(p) => p.clone(), + }; + let open_task = workspace.update(cx, move |workspace, cx| { + workspace.split_path_preview(path, false, Some(split_direction), window, cx) + }); + open_task.detach_and_log_err(cx); } }) } - pub fn modal_max_width(width_setting: Option, window: &mut Window) -> Pixels { + pub fn modal_max_width(width_setting: FileFinderWidth, window: &mut Window) -> Pixels { let window_width = window.viewport_size().width; let small_width = rems(34.).to_pixels(window.rem_size()); match width_setting { - None | Some(FileFinderWidth::Small) => small_width, - Some(FileFinderWidth::Full) => window_width, - Some(FileFinderWidth::XLarge) => (window_width - Pixels(512.)).max(small_width), - Some(FileFinderWidth::Large) => (window_width - Pixels(768.)).max(small_width), - Some(FileFinderWidth::Medium) => (window_width - Pixels(1024.)).max(small_width), + FileFinderWidth::Small => small_width, + FileFinderWidth::Full => window_width, + FileFinderWidth::XLarge => (window_width - px(512.)).max(small_width), + FileFinderWidth::Large => (window_width - px(768.)).max(small_width), + FileFinderWidth::Medium => (window_width - px(1024.)).max(small_width), } } } @@ -466,7 +468,7 @@ enum Match { } impl Match { - fn relative_path(&self) -> Option<&Arc> { + fn relative_path(&self) -> Option<&Arc> { match self { Match::History { path, .. } => Some(&path.project.path), Match::Search(panel_match) => Some(&panel_match.0.path), @@ -476,20 +478,14 @@ impl Match { fn abs_path(&self, project: &Entity, cx: &App) -> Option { match self { - Match::History { path, .. } => path.absolute.clone().or_else(|| { + Match::History { path, .. } => Some(path.absolute.clone()), + Match::Search(ProjectPanelOrdMatch(path_match)) => Some( project .read(cx) - .worktree_for_id(path.project.worktree_id, cx)? + .worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)? .read(cx) - .absolutize(&path.project.path) - .ok() - }), - Match::Search(ProjectPanelOrdMatch(path_match)) => project - .read(cx) - .worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)? - .read(cx) - .absolutize(&path_match.path) - .ok(), + .absolutize(&path_match.path), + ), Match::CreateNew(_) => None, } } @@ -497,7 +493,7 @@ impl Match { fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> { match self { Match::History { panel_match, .. } => panel_match.as_ref(), - Match::Search(panel_match) => Some(&panel_match), + Match::Search(panel_match) => Some(panel_match), Match::CreateNew(_) => None, } } @@ -537,18 +533,21 @@ impl Matches { self.matches.binary_search_by(|m| { // `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b. // And we want the better entries go first. - Self::cmp_matches(self.separate_history, currently_opened, &m, &entry).reverse() + Self::cmp_matches(self.separate_history, currently_opened, m, entry).reverse() }) } } fn push_new_matches<'a>( &'a mut self, + worktree_store: Entity, + cx: &'a App, history_items: impl IntoIterator + Clone, currently_opened: Option<&'a FoundPath>, query: Option<&FileSearchQuery>, new_search_matches: impl Iterator, extend_old_matches: bool, + path_style: PathStyle, ) { let Some(query) = query else { // assuming that if there's no query, then there's no search matches. @@ -562,10 +561,32 @@ impl Matches { .extend(history_items.into_iter().map(path_to_entry)); return; }; - - let new_history_matches = matching_history_items(history_items, currently_opened, query); + // If several worktress are open we have to set the worktree root names in path prefix + let several_worktrees = worktree_store.read(cx).worktrees().count() > 1; + let worktree_name_by_id = several_worktrees.then(|| { + worktree_store + .read(cx) + .worktrees() + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + (snapshot.id(), snapshot.root_name().into()) + }) + .collect() + }); + let new_history_matches = matching_history_items( + history_items, + currently_opened, + worktree_name_by_id, + query, + path_style, + ); let new_search_matches: Vec = new_search_matches - .filter(|path_match| !new_history_matches.contains_key(&path_match.0.path)) + .filter(|path_match| { + !new_history_matches.contains_key(&ProjectPath { + path: path_match.0.path.clone(), + worktree_id: WorktreeId::from_usize(path_match.0.worktree_id), + }) + }) .map(Match::Search) .collect(); @@ -672,20 +693,19 @@ impl Matches { } if let Some(filename) = panel_match.0.path.file_name() { - let path_str = panel_match.0.path.to_string_lossy(); - let filename_str = filename.to_string_lossy(); - - if let Some(filename_pos) = path_str.rfind(&*filename_str) { - if panel_match.0.positions[0] >= filename_pos { - let mut prev_position = panel_match.0.positions[0]; - for p in &panel_match.0.positions[1..] { - if *p != prev_position + 1 { - return false; - } - prev_position = *p; + let path_str = panel_match.0.path.as_unix_str(); + + if let Some(filename_pos) = path_str.rfind(filename) + && panel_match.0.positions[0] >= filename_pos + { + let mut prev_position = panel_match.0.positions[0]; + for p in &panel_match.0.positions[1..] { + if *p != prev_position + 1 { + return false; } - return true; + prev_position = *p; } + return true; } } @@ -696,8 +716,10 @@ impl Matches { fn matching_history_items<'a>( history_items: impl IntoIterator, currently_opened: Option<&'a FoundPath>, + worktree_name_by_id: Option>>, query: &FileSearchQuery, -) -> HashMap, Match> { + path_style: PathStyle, +) -> HashMap { let mut candidates_paths = HashMap::default(); let history_items_by_worktrees = history_items @@ -715,7 +737,7 @@ fn matching_history_items<'a>( .project .path .file_name()? - .to_string_lossy() + .to_string() .to_lowercase() .chars(), ), @@ -736,13 +758,18 @@ fn matching_history_items<'a>( let mut matching_history_paths = HashMap::default(); for (worktree, candidates) in history_items_by_worktrees { let max_results = candidates.len() + 1; + let worktree_root_name = worktree_name_by_id + .as_ref() + .and_then(|w| w.get(&worktree).cloned()); matching_history_paths.extend( fuzzy::match_fixed_path_set( candidates, worktree.to_usize(), + worktree_root_name, query.path_query(), false, max_results, + path_style, ) .into_iter() .filter_map(|path_match| { @@ -751,9 +778,9 @@ fn matching_history_items<'a>( worktree_id: WorktreeId::from_usize(path_match.worktree_id), path: Arc::clone(&path_match.path), }) - .map(|(_, found_path)| { + .map(|(project_path, found_path)| { ( - Arc::clone(&path_match.path), + project_path.clone(), Match::History { path: found_path.clone(), panel_match: Some(ProjectPanelOrdMatch(path_match)), @@ -769,11 +796,11 @@ fn matching_history_items<'a>( #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct FoundPath { project: ProjectPath, - absolute: Option, + absolute: PathBuf, } impl FoundPath { - fn new(project: ProjectPath, absolute: Option) -> Self { + fn new(project: ProjectPath, absolute: PathBuf) -> Self { Self { project, absolute } } } @@ -868,7 +895,9 @@ impl FileFinderDelegate { let worktrees = self .project .read(cx) - .visible_worktrees(cx) + .worktree_store() + .read(cx) + .visible_worktrees_and_single_files(cx) .collect::>(); let include_root_name = worktrees.len() > 1; let candidate_sets = worktrees @@ -878,9 +907,7 @@ impl FileFinderDelegate { PathMatchCandidateSet { snapshot: worktree.snapshot(), include_ignored: self.include_ignored.unwrap_or_else(|| { - worktree - .root_entry() - .map_or(false, |entry| entry.is_ignored) + worktree.root_entry().is_some_and(|entry| entry.is_ignored) }), include_root_name, candidates: project::Candidates::Files, @@ -889,14 +916,14 @@ impl FileFinderDelegate { .collect::>(); let search_id = util::post_inc(&mut self.search_count); - self.cancel_flag.store(true, atomic::Ordering::Relaxed); + self.cancel_flag.store(true, atomic::Ordering::Release); self.cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_flag = self.cancel_flag.clone(); cx.spawn_in(window, async move |picker, cx| { let matches = fuzzy::match_path_sets( candidate_sets.as_slice(), query.path_query(), - relative_to, + &relative_to, false, 100, &cancel_flag, @@ -905,7 +932,7 @@ impl FileFinderDelegate { .await .into_iter() .map(ProjectPanelOrdMatch); - let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed); + let did_cancel = cancel_flag.load(atomic::Ordering::Acquire); picker .update(cx, |picker, cx| { picker @@ -939,55 +966,55 @@ impl FileFinderDelegate { self.matches.get(self.selected_index).cloned() }; + let path_style = self.project.read(cx).path_style(cx); self.matches.push_new_matches( + self.project.read(cx).worktree_store(), + cx, &self.history_items, self.currently_opened_path.as_ref(), Some(&query), matches.into_iter(), extend_old_matches, + path_style, ); - let filename = &query.raw_query; - let mut query_path = Path::new(filename); - // add option of creating new file only if path is relative - let available_worktree = self - .project - .read(cx) - .visible_worktrees(cx) - .filter(|worktree| !worktree.read(cx).is_single_file()) - .collect::>(); - let worktree_count = available_worktree.len(); - let mut expect_worktree = available_worktree.first().cloned(); - for worktree in available_worktree { - let worktree_root = worktree + let query_path = query.raw_query.as_str(); + if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) { + let available_worktree = self + .project .read(cx) - .abs_path() - .file_name() - .map_or(String::new(), |f| f.to_string_lossy().to_string()); - if worktree_count > 1 && query_path.starts_with(&worktree_root) { - query_path = query_path - .strip_prefix(&worktree_root) - .unwrap_or(query_path); - expect_worktree = Some(worktree); - break; + .visible_worktrees(cx) + .filter(|worktree| !worktree.read(cx).is_single_file()) + .collect::>(); + let worktree_count = available_worktree.len(); + let mut expect_worktree = available_worktree.first().cloned(); + for worktree in available_worktree { + let worktree_root = worktree.read(cx).root_name(); + if worktree_count > 1 { + if let Ok(suffix) = query_path.strip_prefix(worktree_root) { + query_path = Cow::Owned(suffix.to_owned()); + expect_worktree = Some(worktree); + break; + } + } } - } - if let Some(FoundPath { ref project, .. }) = self.currently_opened_path { - let worktree_id = project.worktree_id; - expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx); - } + if let Some(FoundPath { ref project, .. }) = self.currently_opened_path { + let worktree_id = project.worktree_id; + expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx); + } - if let Some(worktree) = expect_worktree { - let worktree = worktree.read(cx); - if query_path.is_relative() - && worktree.entry_for_path(&query_path).is_none() - && !filename.ends_with("/") - { - self.matches.matches.push(Match::CreateNew(ProjectPath { - worktree_id: worktree.id(), - path: Arc::from(query_path), - })); + if let Some(worktree) = expect_worktree { + let worktree = worktree.read(cx); + if worktree.entry_for_path(&query_path).is_none() + && !query.raw_query.ends_with("/") + && !(path_style.is_windows() && query.raw_query.ends_with("\\")) + { + self.matches.matches.push(Match::CreateNew(ProjectPath { + worktree_id: worktree.id(), + path: query_path.into_arc(), + })); + } } } @@ -1012,8 +1039,8 @@ impl FileFinderDelegate { path_match: &Match, window: &mut Window, cx: &App, - ix: usize, ) -> (HighlightedLabel, HighlightedLabel) { + let path_style = self.project.read(cx).path_style(cx); let (file_name, file_name_positions, mut full_path, mut full_path_positions) = match &path_match { Match::History { @@ -1021,57 +1048,43 @@ impl FileFinderDelegate { panel_match, } => { let worktree_id = entry_path.project.worktree_id; - let project_relative_path = &entry_path.project.path; - let has_worktree = self + let worktree = self .project .read(cx) .worktree_for_id(worktree_id, cx) - .is_some(); - - if let Some(absolute_path) = - entry_path.absolute.as_ref().filter(|_| !has_worktree) - { + .filter(|worktree| worktree.read(cx).is_visible()); + + if let Some(panel_match) = panel_match { + self.labels_for_path_match(&panel_match.0, path_style) + } else if let Some(worktree) = worktree { + let full_path = + worktree.read(cx).root_name().join(&entry_path.project.path); + let mut components = full_path.components(); + let filename = components.next_back().unwrap_or(""); + let prefix = components.rest(); ( - absolute_path - .file_name() - .map_or_else( - || project_relative_path.to_string_lossy(), - |file_name| file_name.to_string_lossy(), - ) - .to_string(), + filename.to_string(), Vec::new(), - absolute_path.to_string_lossy().to_string(), + prefix.display(path_style).to_string() + path_style.separator(), Vec::new(), ) } else { - let mut path = Arc::clone(project_relative_path); - if project_relative_path.as_ref() == Path::new("") { - if let Some(absolute_path) = &entry_path.absolute { - path = Arc::from(absolute_path.as_path()); - } - } - - let mut path_match = PathMatch { - score: ix as f64, - positions: Vec::new(), - worktree_id: worktree_id.to_usize(), - path, - is_dir: false, // File finder doesn't support directories - path_prefix: "".into(), - distance_to_relative_ancestor: usize::MAX, - }; - if let Some(found_path_match) = &panel_match { - path_match - .positions - .extend(found_path_match.0.positions.iter()) - } - - self.labels_for_path_match(&path_match) + ( + entry_path + .absolute + .file_name() + .map_or(String::new(), |f| f.to_string_lossy().into_owned()), + Vec::new(), + entry_path.absolute.parent().map_or(String::new(), |path| { + path.to_string_lossy().into_owned() + path_style.separator() + }), + Vec::new(), + ) } } - Match::Search(path_match) => self.labels_for_path_match(&path_match.0), + Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style), Match::CreateNew(project_path) => ( - format!("Create file: {}", project_path.path.display()), + format!("Create file: {}", project_path.path.display(path_style)), vec![], String::from(""), vec![], @@ -1079,22 +1092,18 @@ impl FileFinderDelegate { }; if file_name_positions.is_empty() { - if let Some(user_home_path) = std::env::var("HOME").ok() { - let user_home_path = user_home_path.trim(); - if !user_home_path.is_empty() { - if (&full_path).starts_with(user_home_path) { - full_path.replace_range(0..user_home_path.len(), "~"); - full_path_positions.retain_mut(|pos| { - if *pos >= user_home_path.len() { - *pos -= user_home_path.len(); - *pos += 1; - true - } else { - false - } - }) + let user_home_path = util::paths::home_dir().to_string_lossy(); + if !user_home_path.is_empty() && full_path.starts_with(&*user_home_path) { + full_path.replace_range(0..user_home_path.len(), "~"); + full_path_positions.retain_mut(|pos| { + if *pos >= user_home_path.len() { + *pos -= user_home_path.len(); + *pos += 1; + true + } else { + false } - } + }) } } @@ -1152,17 +1161,13 @@ impl FileFinderDelegate { fn labels_for_path_match( &self, path_match: &PathMatch, + path_style: PathStyle, ) -> (String, Vec, String, Vec) { - let path = &path_match.path; - let path_string = path.to_string_lossy(); - let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join(""); + let full_path = path_match.path_prefix.join(&path_match.path); let mut path_positions = path_match.positions.clone(); - let file_name = path.file_name().map_or_else( - || path_match.path_prefix.to_string(), - |file_name| file_name.to_string_lossy().to_string(), - ); - let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len(); + let file_name = full_path.file_name().unwrap_or(""); + let file_name_start = full_path.as_unix_str().len() - file_name.len(); let file_name_positions = path_positions .iter() .filter_map(|pos| { @@ -1172,26 +1177,54 @@ impl FileFinderDelegate { None } }) - .collect(); + .collect::>(); - let full_path = full_path.trim_end_matches(&file_name).to_string(); + let full_path = full_path + .display(path_style) + .trim_end_matches(&file_name) + .to_string(); path_positions.retain(|idx| *idx < full_path.len()); - (file_name, file_name_positions, full_path, path_positions) + debug_assert!( + file_name_positions + .iter() + .all(|ix| file_name[*ix..].chars().next().is_some()), + "invalid file name positions {file_name:?} {file_name_positions:?}" + ); + debug_assert!( + path_positions + .iter() + .all(|ix| full_path[*ix..].chars().next().is_some()), + "invalid path positions {full_path:?} {path_positions:?}" + ); + + ( + file_name.to_string(), + file_name_positions, + full_path, + path_positions, + ) } + /// Attempts to resolve an absolute file path and update the search matches if found. + /// + /// If the query path resolves to an absolute file that exists in the project, + /// this method will find the corresponding worktree and relative path, create a + /// match for it, and update the picker's search results. + /// + /// Returns `true` if the absolute path exists, otherwise returns `false`. fn lookup_absolute_path( &self, query: FileSearchQuery, window: &mut Window, cx: &mut Context>, - ) -> Task<()> { + ) -> Task { cx.spawn_in(window, async move |picker, cx| { let Some(project) = picker .read_with(cx, |picker, _| picker.delegate.project.clone()) .log_err() else { - return; + return false; }; let query_path = Path::new(query.path_query()); @@ -1215,8 +1248,8 @@ impl FileFinderDelegate { score: 1.0, positions: Vec::new(), worktree_id: worktree.read(cx).id().to_usize(), - path: Arc::from(relative_path), - path_prefix: "".into(), + path: relative_path, + path_prefix: RelPath::empty().into(), is_dir: false, // File finder doesn't support directories distance_to_relative_ancestor: usize::MAX, })); @@ -1224,7 +1257,7 @@ impl FileFinderDelegate { }) .log_err(); if update_result.is_none() { - return; + return abs_file_exists; } } @@ -1237,19 +1270,19 @@ impl FileFinderDelegate { anyhow::Ok(()) }) .log_err(); + abs_file_exists }) } /// Skips first history match (that is displayed topmost) if it's currently opened. fn calculate_selected_index(&self, cx: &mut Context>) -> usize { - if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search { - if let Some(Match::History { path, .. }) = self.matches.get(0) { - if Some(path) == self.currently_opened_path.as_ref() { - let elements_after_first = self.matches.len() - 1; - if elements_after_first > 0 { - return 1; - } - } + if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search + && let Some(Match::History { path, .. }) = self.matches.get(0) + && Some(path) == self.currently_opened_path.as_ref() + { + let elements_after_first = self.matches.len() - 1; + if elements_after_first > 0 { + return 1; } } @@ -1310,10 +1343,10 @@ impl PickerDelegate for FileFinderDelegate { .enumerate() .find(|(_, m)| !matches!(m, Match::History { .. })) .map(|(i, _)| i); - if let Some(first_non_history_index) = first_non_history_index { - if first_non_history_index > 0 { - return vec![first_non_history_index - 1]; - } + if let Some(first_non_history_index) = first_non_history_index + && first_non_history_index > 0 + { + return vec![first_non_history_index - 1]; } } Vec::new() @@ -1329,8 +1362,8 @@ impl PickerDelegate for FileFinderDelegate { let raw_query = raw_query.trim(); let raw_query = match &raw_query.get(0..2) { - Some(".\\") | Some("./") => &raw_query[2..], - Some("a\\") | Some("a/") => { + Some(".\\" | "./") => &raw_query[2..], + Some(prefix @ ("a\\" | "a/" | "b\\" | "b/")) => { if self .workspace .upgrade() @@ -1339,25 +1372,7 @@ impl PickerDelegate for FileFinderDelegate { .all(|worktree| { worktree .read(cx) - .entry_for_path(Path::new("a")) - .is_none_or(|entry| !entry.is_dir()) - }) - { - &raw_query[2..] - } else { - raw_query - } - } - Some("b\\") | Some("b/") => { - if self - .workspace - .upgrade() - .into_iter() - .flat_map(|workspace| workspace.read(cx).worktrees(cx)) - .all(|worktree| { - worktree - .read(cx) - .entry_for_path(Path::new("b")) + .entry_for_path(RelPath::unix(prefix.split_at(1).0).unwrap()) .is_none_or(|entry| !entry.is_dir()) }) { @@ -1382,18 +1397,23 @@ impl PickerDelegate for FileFinderDelegate { separate_history: self.separate_history, ..Matches::default() }; + let path_style = self.project.read(cx).path_style(cx); + self.matches.push_new_matches( + project.worktree_store(), + cx, self.history_items.iter().filter(|history_item| { project .worktree_for_id(history_item.project.worktree_id, cx) .is_some() - || ((project.is_local() || project.is_via_ssh()) - && history_item.absolute.is_some()) + || project.is_local() + || project.is_via_remote_server() }), self.currently_opened_path.as_ref(), None, None.into_iter(), false, + path_style, ); self.first_update = false; @@ -1402,18 +1422,16 @@ impl PickerDelegate for FileFinderDelegate { cx.notify(); Task::ready(()) } else { - let path_position = PathWithPosition::parse_str(&raw_query); - - #[cfg(windows)] - let raw_query = raw_query.trim().to_owned().replace("/", "\\"); - #[cfg(not(windows))] - let raw_query = raw_query.trim().to_owned(); - - let file_query_end = if path_position.path.to_str().unwrap_or(&raw_query) == raw_query { + let path_position = PathWithPosition::parse_str(raw_query); + let raw_query = raw_query.trim().trim_end_matches(':').to_owned(); + let path = path_position.path.clone(); + let path_str = path_position.path.to_str(); + let path_trimmed = path_str.unwrap_or(&raw_query).trim_end_matches(':'); + let file_query_end = if path_trimmed == raw_query { None } else { // Safe to unwrap as we won't get here when the unwrap in if fails - Some(path_position.path.to_str().unwrap().len()) + Some(path_str.unwrap().len()) }; let query = FileSearchQuery { @@ -1422,11 +1440,29 @@ impl PickerDelegate for FileFinderDelegate { path_position, }; - if Path::new(query.path_query()).is_absolute() { - self.lookup_absolute_path(query, window, cx) - } else { - self.spawn_search(query, window, cx) - } + cx.spawn_in(window, async move |this, cx| { + let _ = maybe!(async move { + let is_absolute_path = path.is_absolute(); + let did_resolve_abs_path = is_absolute_path + && this + .update_in(cx, |this, window, cx| { + this.delegate + .lookup_absolute_path(query.clone(), window, cx) + })? + .await; + + // Only check for relative paths if no absolute paths were + // found. + if !did_resolve_abs_path { + this.update_in(cx, |this, window, cx| { + this.delegate.spawn_search(query, window, cx) + })? + .await; + } + anyhow::Ok(()) + }) + .await; + }) } } @@ -1436,158 +1472,134 @@ impl PickerDelegate for FileFinderDelegate { window: &mut Window, cx: &mut Context>, ) { - if let Some(m) = self.matches.get(self.selected_index()) { - if let Some(workspace) = self.workspace.upgrade() { - let open_task = workspace.update(cx, |workspace, cx| { - let split_or_open = - |workspace: &mut Workspace, - project_path, - window: &mut Window, - cx: &mut Context| { - let allow_preview = - PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; - if secondary { - workspace.split_path_preview( - project_path, - allow_preview, - None, - window, - cx, - ) - } else { - workspace.open_path_preview( - project_path, - None, - true, - allow_preview, - true, - window, - cx, - ) - } - }; - match &m { - Match::CreateNew(project_path) => { - // Create a new file with the given filename - if secondary { - workspace.split_path_preview( - project_path.clone(), - false, - None, - window, - cx, - ) - } else { - workspace.open_path_preview( - project_path.clone(), - None, - true, - false, - true, - window, - cx, - ) - } + if let Some(m) = self.matches.get(self.selected_index()) + && let Some(workspace) = self.workspace.upgrade() + { + let open_task = workspace.update(cx, |workspace, cx| { + let split_or_open = + |workspace: &mut Workspace, + project_path, + window: &mut Window, + cx: &mut Context| { + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; + if secondary { + workspace.split_path_preview( + project_path, + allow_preview, + None, + window, + cx, + ) + } else { + workspace.open_path_preview( + project_path, + None, + true, + allow_preview, + true, + window, + cx, + ) } - - Match::History { path, .. } => { - let worktree_id = path.project.worktree_id; - if workspace - .project() - .read(cx) - .worktree_for_id(worktree_id, cx) - .is_some() - { - split_or_open( - workspace, - ProjectPath { - worktree_id, - path: Arc::clone(&path.project.path), - }, - window, - cx, - ) - } else { - match path.absolute.as_ref() { - Some(abs_path) => { - if secondary { - workspace.split_abs_path( - abs_path.to_path_buf(), - false, - window, - cx, - ) - } else { - workspace.open_abs_path( - abs_path.to_path_buf(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - } - } - None => split_or_open( - workspace, - ProjectPath { - worktree_id, - path: Arc::clone(&path.project.path), - }, - window, - cx, - ), - } - } + }; + match &m { + Match::CreateNew(project_path) => { + // Create a new file with the given filename + if secondary { + workspace.split_path_preview( + project_path.clone(), + false, + None, + window, + cx, + ) + } else { + workspace.open_path_preview( + project_path.clone(), + None, + true, + false, + true, + window, + cx, + ) } - Match::Search(m) => split_or_open( - workspace, - ProjectPath { - worktree_id: WorktreeId::from_usize(m.0.worktree_id), - path: m.0.path.clone(), - }, - window, - cx, - ), } - }); - let row = self - .latest_search_query - .as_ref() - .and_then(|query| query.path_position.row) - .map(|row| row.saturating_sub(1)); - let col = self - .latest_search_query - .as_ref() - .and_then(|query| query.path_position.column) - .unwrap_or(0) - .saturating_sub(1); - let finder = self.file_finder.clone(); - - cx.spawn_in(window, async move |_, cx| { - let item = open_task.await.notify_async_err(cx)?; - if let Some(row) = row { - if let Some(active_editor) = item.downcast::() { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point( - Point::new(row, col), - window, - cx, - ); - }) - .log_err(); + Match::History { path, .. } => { + let worktree_id = path.project.worktree_id; + if workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some() + { + split_or_open( + workspace, + ProjectPath { + worktree_id, + path: Arc::clone(&path.project.path), + }, + window, + cx, + ) + } else if secondary { + workspace.split_abs_path(path.absolute.clone(), false, window, cx) + } else { + workspace.open_abs_path( + path.absolute.clone(), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) } } - finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + Match::Search(m) => split_or_open( + workspace, + ProjectPath { + worktree_id: WorktreeId::from_usize(m.0.worktree_id), + path: m.0.path.clone(), + }, + window, + cx, + ), + } + }); - Some(()) - }) - .detach(); - } + let row = self + .latest_search_query + .as_ref() + .and_then(|query| query.path_position.row) + .map(|row| row.saturating_sub(1)); + let col = self + .latest_search_query + .as_ref() + .and_then(|query| query.path_position.column) + .unwrap_or(0) + .saturating_sub(1); + let finder = self.file_finder.clone(); + + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.notify_async_err(cx)?; + if let Some(row) = row + && let Some(active_editor) = item.downcast::() + { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + }) + .log_err(); + } + finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + + Some(()) + }) + .detach(); } } @@ -1606,10 +1618,7 @@ impl PickerDelegate for FileFinderDelegate { ) -> Option { let settings = FileFinderSettings::get_global(cx); - let path_match = self - .matches - .get(ix) - .expect("Invalid matches state: no element for index {ix}"); + let path_match = self.matches.get(ix)?; let history_icon = match &path_match { Match::History { .. } => Icon::new(IconName::HistoryRerun) @@ -1625,7 +1634,7 @@ impl PickerDelegate for FileFinderDelegate { .size(IconSize::Small) .into_any_element(), }; - let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix); + let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx); let file_icon = maybe!({ if !settings.file_icons { @@ -1654,11 +1663,7 @@ impl PickerDelegate for FileFinderDelegate { ) } - fn render_footer( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { let focus_handle = self.focus_handle.clone(); Some( @@ -1687,12 +1692,11 @@ impl PickerDelegate for FileFinderDelegate { }), { let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { Tooltip::for_action_in( "Filter Options", &ToggleFilterMenu, &focus_handle, - window, cx, ) } @@ -1742,14 +1746,13 @@ impl PickerDelegate for FileFinderDelegate { ButtonLike::new("split-trigger") .child(Label::new("Split…")) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .children( + .child( KeyBinding::for_action_in( &ToggleSplitMenu, &focus_handle, - window, cx, ) - .map(|kb| kb.size(rems_from_px(12.))), + .size(rems_from_px(12.)), ), ) .menu({ @@ -1759,7 +1762,7 @@ impl PickerDelegate for FileFinderDelegate { Some(ContextMenu::build(window, cx, { let focus_handle = focus_handle.clone(); move |menu, _, _| { - menu.context(focus_handle.clone()) + menu.context(focus_handle) .action( "Split Left", pane::SplitLeft.boxed_clone(), @@ -1781,13 +1784,8 @@ impl PickerDelegate for FileFinderDelegate { .child( Button::new("open-selection", "Open") .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) diff --git a/crates/file_finder/src/file_finder_settings.rs b/crates/file_finder/src/file_finder_settings.rs index 350e1de3b36c9073d137993ce4fbc50aa43bb36e..4d826211c70b24c9f9bad7e23b8981fa8cb7bdd0 100644 --- a/crates/file_finder/src/file_finder_settings.rs +++ b/crates/file_finder/src/file_finder_settings.rs @@ -1,56 +1,30 @@ -use anyhow::Result; use schemars::JsonSchema; -use serde_derive::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use serde::{Deserialize, Serialize}; +use settings::Settings; #[derive(Deserialize, Debug, Clone, Copy, PartialEq)] pub struct FileFinderSettings { pub file_icons: bool, - pub modal_max_width: Option, + pub modal_max_width: FileFinderWidth, pub skip_focus_for_active_in_search: bool, pub include_ignored: Option, } -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct FileFinderSettingsContent { - /// Whether to show file icons in the file finder. - /// - /// Default: true - pub file_icons: Option, - /// Determines how much space the file finder can take up in relation to the available window width. - /// - /// Default: small - pub modal_max_width: Option, - /// Determines whether the file finder should skip focus for the active file in search results. - /// - /// Default: true - pub skip_focus_for_active_in_search: Option, - /// Determines whether to show the git status in the file finder - /// - /// Default: true - pub git_status: Option, - /// Whether to use gitignored files when searching. - /// Only the file Zed had indexed will be used, not necessary all the gitignored files. - /// - /// Can accept 3 values: - /// * `Some(true)`: Use all gitignored files - /// * `Some(false)`: Use only the files Zed had indexed - /// * `None`: Be smart and search for ignored when called from a gitignored worktree - /// - /// Default: None - pub include_ignored: Option>, -} - impl Settings for FileFinderSettings { - const KEY: Option<&'static str> = Some("file_finder"); + fn from_settings(content: &settings::SettingsContent) -> Self { + let file_finder = content.file_finder.as_ref().unwrap(); - type FileContent = FileFinderSettingsContent; - - fn load(sources: SettingsSources, _: &mut gpui::App) -> Result { - sources.json_merge() + Self { + file_icons: file_finder.file_icons.unwrap(), + modal_max_width: file_finder.modal_max_width.unwrap().into(), + skip_focus_for_active_in_search: file_finder.skip_focus_for_active_in_search.unwrap(), + include_ignored: match file_finder.include_ignored.unwrap() { + settings::IncludeIgnoredContent::All => Some(true), + settings::IncludeIgnoredContent::Indexed => Some(false), + settings::IncludeIgnoredContent::Smart => None, + }, + } } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } #[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] @@ -63,3 +37,15 @@ pub enum FileFinderWidth { XLarge, Full, } + +impl From for FileFinderWidth { + fn from(content: settings::FileFinderWidthContent) -> Self { + match content { + settings::FileFinderWidthContent::Small => FileFinderWidth::Small, + settings::FileFinderWidthContent::Medium => FileFinderWidth::Medium, + settings::FileFinderWidthContent::Large => FileFinderWidth::Large, + settings::FileFinderWidthContent::XLarge => FileFinderWidth::XLarge, + settings::FileFinderWidthContent::Full => FileFinderWidth::Full, + } + } +} diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index db259ccef854b1d3c5c4fae3bc9ebad08e398891..9670de072a5d7c10c2a82c2e384bd7bc4adcd848 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -4,11 +4,11 @@ use super::*; use editor::Editor; use gpui::{Entity, TestAppContext, VisualTestContext}; use menu::{Confirm, SelectNext, SelectPrevious}; -use pretty_assertions::assert_eq; +use pretty_assertions::{assert_eq, assert_matches}; use project::{FS_WATCH_LATENCY, RemoveOptions}; use serde_json::json; -use util::path; -use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace}; +use util::{path, rel_path::rel_path}; +use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths}; #[ctor::ctor] fn init_logger() { @@ -77,8 +77,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 0.5, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("b0.5")), - path_prefix: Arc::default(), + path: rel_path("b0.5").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -86,8 +86,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("c1.0")), - path_prefix: Arc::default(), + path: rel_path("c1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -95,8 +95,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("a1.0")), - path_prefix: Arc::default(), + path: rel_path("a1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -104,8 +104,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 0.5, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("a0.5")), - path_prefix: Arc::default(), + path: rel_path("a0.5").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -113,8 +113,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("b1.0")), - path_prefix: Arc::default(), + path: rel_path("b1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -128,8 +128,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("a1.0")), - path_prefix: Arc::default(), + path: rel_path("a1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -137,8 +137,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("b1.0")), - path_prefix: Arc::default(), + path: rel_path("b1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -146,8 +146,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 1.0, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("c1.0")), - path_prefix: Arc::default(), + path: rel_path("c1.0").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -155,8 +155,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 0.5, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("a0.5")), - path_prefix: Arc::default(), + path: rel_path("a0.5").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -164,8 +164,8 @@ fn test_custom_project_search_ordering_in_file_finder() { score: 0.5, positions: Vec::new(), worktree_id: 0, - path: Arc::from(Path::new("b0.5")), - path_prefix: Arc::default(), + path: rel_path("b0.5").into(), + path_prefix: rel_path("").into(), distance_to_relative_ancestor: 0, is_dir: false, }), @@ -218,6 +218,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) { " ndan ", " band ", "a bandana", + "bandana:", ] { picker .update_in(cx, |picker, window, cx| { @@ -252,6 +253,53 @@ async fn test_matching_paths(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "foo:bar.rs": "", + "foo.rs": "", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + + let (picker, _, cx) = build_find_picker(project, cx); + + // 'foo:' matches both files + cx.simulate_input("foo:"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 3); + assert_match_at_position(picker, 0, "foo.rs"); + assert_match_at_position(picker, 1, "foo:bar.rs"); + }); + + // 'foo:b' matches one of the files + cx.simulate_input("b"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 2); + assert_match_at_position(picker, 0, "foo:bar.rs"); + }); + + cx.dispatch_action(editor::actions::Backspace); + + // 'foo:1' matches both files, specifying which row to jump to + cx.simulate_input("1"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 3); + assert_match_at_position(picker, 0, "foo.rs"); + assert_match_at_position(picker, 1, "foo:bar.rs"); + }); +} + #[gpui::test] async fn test_unicode_paths(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -318,7 +366,7 @@ async fn test_absolute_paths(cx: &mut TestAppContext) { picker.update(cx, |picker, _| { assert_eq!( collect_search_matches(picker).search_paths_only(), - vec![PathBuf::from("a/b/file2.txt")], + vec![rel_path("a/b/file2.txt").into()], "Matching abs path should be the only match" ) }); @@ -340,7 +388,7 @@ async fn test_absolute_paths(cx: &mut TestAppContext) { picker.update(cx, |picker, _| { assert_eq!( collect_search_matches(picker).search_paths_only(), - Vec::::new(), + Vec::new(), "Mismatching abs path should produce no matches" ) }); @@ -373,7 +421,7 @@ async fn test_complex_path(cx: &mut TestAppContext) { assert_eq!(picker.delegate.matches.len(), 2); assert_eq!( collect_search_matches(picker).search_paths_only(), - vec![PathBuf::from("其他/S数据表格/task.xlsx")], + vec![rel_path("其他/S数据表格/task.xlsx").into()], ) }); cx.dispatch_action(Confirm); @@ -442,7 +490,7 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(2)); editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all_adjusted(cx); + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); assert_eq!( all_selections.len(), 1, @@ -517,7 +565,7 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(2)); editor.update(cx, |editor, cx| { - let all_selections = editor.selections.all_adjusted(cx); + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); assert_eq!( all_selections.len(), 1, @@ -665,13 +713,13 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("ignored-root/hi"), - PathBuf::from("tracked-root/hi"), - PathBuf::from("ignored-root/hiccup"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("ignored-root/height"), - PathBuf::from("ignored-root/happiness"), - PathBuf::from("tracked-root/happiness"), + rel_path("ignored-root/hi").into(), + rel_path("tracked-root/hi").into(), + rel_path("ignored-root/hiccup").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("ignored-root/height").into(), + rel_path("ignored-root/happiness").into(), + rel_path("tracked-root/happiness").into(), ], "All ignored files that were indexed are found for default ignored mode" ); @@ -690,14 +738,14 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("ignored-root/hi"), - PathBuf::from("tracked-root/hi"), - PathBuf::from("ignored-root/hiccup"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("ignored-root/height"), - PathBuf::from("tracked-root/height"), - PathBuf::from("ignored-root/happiness"), - PathBuf::from("tracked-root/happiness"), + rel_path("ignored-root/hi").into(), + rel_path("tracked-root/hi").into(), + rel_path("ignored-root/hiccup").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("ignored-root/height").into(), + rel_path("tracked-root/height").into(), + rel_path("ignored-root/happiness").into(), + rel_path("tracked-root/happiness").into(), ], "All ignored files should be found, for the toggled on ignored mode" ); @@ -717,9 +765,9 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("tracked-root/hi"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("tracked-root/happiness"), + rel_path("tracked-root/hi").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("tracked-root/happiness").into(), ], "Only non-ignored files should be found for the turned off ignored mode" ); @@ -764,13 +812,13 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("ignored-root/hi"), - PathBuf::from("tracked-root/hi"), - PathBuf::from("ignored-root/hiccup"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("ignored-root/height"), - PathBuf::from("ignored-root/happiness"), - PathBuf::from("tracked-root/happiness"), + rel_path("ignored-root/hi").into(), + rel_path("tracked-root/hi").into(), + rel_path("ignored-root/hiccup").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("ignored-root/height").into(), + rel_path("ignored-root/happiness").into(), + rel_path("tracked-root/happiness").into(), ], "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode" ); @@ -790,16 +838,16 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("ignored-root/hi"), - PathBuf::from("tracked-root/hi"), - PathBuf::from("ignored-root/hiccup"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("ignored-root/height"), - PathBuf::from("tracked-root/height"), - PathBuf::from("tracked-root/heights/height_1"), - PathBuf::from("tracked-root/heights/height_2"), - PathBuf::from("ignored-root/happiness"), - PathBuf::from("tracked-root/happiness"), + rel_path("ignored-root/hi").into(), + rel_path("tracked-root/hi").into(), + rel_path("ignored-root/hiccup").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("ignored-root/height").into(), + rel_path("tracked-root/height").into(), + rel_path("tracked-root/heights/height_1").into(), + rel_path("tracked-root/heights/height_2").into(), + rel_path("ignored-root/happiness").into(), + rel_path("tracked-root/happiness").into(), ], "All ignored files that were indexed are found in the turned on ignored mode" ); @@ -819,9 +867,9 @@ async fn test_ignored_root(cx: &mut TestAppContext) { assert_eq!( matches.search, vec![ - PathBuf::from("tracked-root/hi"), - PathBuf::from("tracked-root/hiccup"), - PathBuf::from("tracked-root/happiness"), + rel_path("tracked-root/hi").into(), + rel_path("tracked-root/hiccup").into(), + rel_path("tracked-root/happiness").into(), ], "Only non-ignored files should be found for the turned off ignored mode" ); @@ -862,7 +910,7 @@ async fn test_single_file_worktrees(cx: &mut TestAppContext) { assert_eq!(matches.len(), 1); let (file_name, file_name_positions, full_path, full_path_positions) = - delegate.labels_for_path_match(&matches[0]); + delegate.labels_for_path_match(&matches[0], PathStyle::local()); assert_eq!(file_name, "the-file"); assert_eq!(file_name_positions, &[0, 1, 4]); assert_eq!(full_path, ""); @@ -881,6 +929,114 @@ async fn test_single_file_worktrees(cx: &mut TestAppContext) { picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0)); } +#[gpui::test] +async fn test_history_items_uniqueness_for_multiple_worktree(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo1"), + json!({ + "package.json": r#"{"name": "repo1"}"#, + "src": { + "index.js": "// Repo 1 index", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo2"), + json!({ + "package.json": r#"{"name": "repo2"}"#, + "src": { + "index.js": "// Repo 2 index", + } + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/repo1").as_ref(), path!("/repo2").as_ref()], + cx, + ) + .await; + + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (worktree_id1, worktree_id2) = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: worktree_id1, + path: rel_path("package.json").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); + + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("package.json"); + + picker.update(cx, |finder, _| { + let matches = &finder.delegate.matches.matches; + + assert_eq!( + matches.len(), + 2, + "Expected 1 history match + 1 search matches, but got {} matches: {:?}", + matches.len(), + matches + ); + + assert_matches!(matches[0], Match::History { .. }); + + let search_matches = collect_search_matches(finder); + assert_eq!( + search_matches.history.len(), + 1, + "Should have exactly 1 history match" + ); + assert_eq!( + search_matches.search.len(), + 1, + "Should have exactly 1 search match (the other package.json)" + ); + + if let Match::History { path, .. } = &matches[0] { + assert_eq!(path.project.worktree_id, worktree_id1); + assert_eq!(path.project.path.as_ref(), rel_path("package.json")); + } + + if let Match::Search(path_match) = &matches[1] { + assert_eq!( + WorktreeId::from_usize(path_match.0.worktree_id), + worktree_id2 + ); + assert_eq!(path_match.0.path.as_ref(), rel_path("package.json")); + } + }); +} + #[gpui::test] async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -920,7 +1076,7 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { let b_path = ProjectPath { worktree_id: worktree_id2, - path: Arc::from(Path::new(path!("the-parent-dirb/fileb"))), + path: rel_path("the-parent-dirb/fileb").into(), }; workspace .update_in(cx, |workspace, window, cx| { @@ -953,7 +1109,7 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { project_path, Some(ProjectPath { worktree_id: worktree_id2, - path: Arc::from(Path::new(path!("the-parent-dirb/filec"))) + path: rel_path("the-parent-dirb/filec").into() }) ); }); @@ -990,10 +1146,7 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); let (_worktree_id1, worktree_id2) = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); - ( - WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize), - WorktreeId::from_usize(worktrees[1].entity_id().as_u64() as usize), - ) + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) }); let finder = open_file_picker(&workspace, cx); @@ -1017,7 +1170,7 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon project_path, Some(ProjectPath { worktree_id: worktree_id2, - path: Arc::from(Path::new("filec")) + path: rel_path("filec").into() }) ); }); @@ -1055,7 +1208,7 @@ async fn test_path_distance_ordering(cx: &mut TestAppContext) { // so that one should be sorted earlier let b_path = ProjectPath { worktree_id, - path: Arc::from(Path::new("dir2/b.txt")), + path: rel_path("dir2/b.txt").into(), }; workspace .update_in(cx, |workspace, window, cx| { @@ -1073,8 +1226,8 @@ async fn test_path_distance_ordering(cx: &mut TestAppContext) { finder.update(cx, |picker, _| { let matches = collect_search_matches(picker).search_paths_only(); - assert_eq!(matches[0].as_path(), Path::new("dir2/a.txt")); - assert_eq!(matches[1].as_path(), Path::new("dir1/a.txt")); + assert_eq!(matches[0].as_ref(), rel_path("dir2/a.txt")); + assert_eq!(matches[1].as_ref(), rel_path("dir1/a.txt")); }); } @@ -1159,9 +1312,9 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { vec![FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")) )], "Should show 1st opened item in the history when opening the 2nd item" ); @@ -1174,16 +1327,16 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/second.rs")), + path: rel_path("test/second.rs").into(), }, - Some(PathBuf::from(path!("/src/test/second.rs"))) + PathBuf::from(path!("/src/test/second.rs")) ), FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")) ), ], "Should show 1st and 2nd opened items in the history when opening the 3rd item. \ @@ -1198,23 +1351,23 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/third.rs")), + path: rel_path("test/third.rs").into(), }, - Some(PathBuf::from(path!("/src/test/third.rs"))) + PathBuf::from(path!("/src/test/third.rs")) ), FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/second.rs")), + path: rel_path("test/second.rs").into(), }, - Some(PathBuf::from(path!("/src/test/second.rs"))) + PathBuf::from(path!("/src/test/second.rs")) ), FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")) ), ], "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \ @@ -1229,23 +1382,23 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/second.rs")), + path: rel_path("test/second.rs").into(), }, - Some(PathBuf::from(path!("/src/test/second.rs"))) + PathBuf::from(path!("/src/test/second.rs")) ), FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/third.rs")), + path: rel_path("test/third.rs").into(), }, - Some(PathBuf::from(path!("/src/test/third.rs"))) + PathBuf::from(path!("/src/test/third.rs")) ), FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")) ), ], "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \ @@ -1253,6 +1406,62 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + workspace.update_in(cx, |_workspace, window, cx| window.focused(cx)); + + open_close_queried_buffer("efir", 1, "first.rs", &workspace, cx).await; + let history = open_close_queried_buffer("second", 1, "second.rs", &workspace, cx).await; + assert_eq!(history.len(), 1); + + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("fir"); + picker.update_in(cx, |finder, window, cx| { + let matches = &finder.delegate.matches.matches; + assert_matches!( + matches.as_slice(), + [Match::History { .. }, Match::CreateNew { .. }] + ); + assert_eq!( + matches[0].panel_match().unwrap().0.path.as_ref(), + rel_path("test/first.rs") + ); + assert_eq!(matches[0].panel_match().unwrap().0.positions, &[5, 6, 7]); + + let (file_label, path_label) = + finder + .delegate + .labels_for_match(&finder.delegate.matches.matches[0], window, cx); + assert_eq!(file_label.text(), "first.rs"); + assert_eq!(file_label.highlight_indices(), &[0, 1, 2]); + assert_eq!( + path_label.text(), + format!("test{}", PathStyle::local().separator()) + ); + assert_eq!(path_label.highlight_indices(), &[] as &[usize]); + }); +} + #[gpui::test] async fn test_external_files_history(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); @@ -1344,9 +1553,9 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) { vec![FoundPath::new( ProjectPath { worktree_id: external_worktree_id, - path: Arc::from(Path::new("")), + path: rel_path("").into(), }, - Some(PathBuf::from(path!("/external-src/test/third.rs"))) + PathBuf::from(path!("/external-src/test/third.rs")) )], "Should show external file with its full path in the history after it was open" ); @@ -1359,16 +1568,16 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) { FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/second.rs")), + path: rel_path("test/second.rs").into(), }, - Some(PathBuf::from(path!("/src/test/second.rs"))) + PathBuf::from(path!("/src/test/second.rs")) ), FoundPath::new( ProjectPath { worktree_id: external_worktree_id, - path: Arc::from(Path::new("")), + path: rel_path("").into(), }, - Some(PathBuf::from(path!("/external-src/test/third.rs"))) + PathBuf::from(path!("/external-src/test/third.rs")) ), ], "Should keep external file with history updates", @@ -1481,12 +1690,12 @@ async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { assert_eq!(history_match, &FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")), )); assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present"); - assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs")); + assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs")); }); let second_query = "fsdasdsa"; @@ -1524,12 +1733,12 @@ async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { assert_eq!(history_match, &FoundPath::new( ProjectPath { worktree_id, - path: Arc::from(Path::new("test/first.rs")), + path: rel_path("test/first.rs").into(), }, - Some(PathBuf::from(path!("/src/test/first.rs"))) + PathBuf::from(path!("/src/test/first.rs")) )); assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query"); - assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs")); + assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs")); }); } @@ -1578,13 +1787,16 @@ async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) { let search_matches = collect_search_matches(finder); assert_eq!( search_matches.history, - vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),], + vec![ + rel_path("test/1_qw").into(), + rel_path("test/6_qwqwqw").into() + ], ); assert_eq!( search_matches.search, vec![ - PathBuf::from("test/5_qwqwqw"), - PathBuf::from("test/7_qwqwqw"), + rel_path("test/5_qwqwqw").into(), + rel_path("test/7_qwqwqw").into() ], ); }); @@ -1614,7 +1826,7 @@ async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppCon let picker = open_file_picker(&workspace, cx); picker.update(cx, |finder, _| { - assert_match_selection(&finder, 0, "1_qw"); + assert_match_selection(finder, 0, "1_qw"); }); } @@ -2035,10 +2247,10 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo assert_eq!( search_entries, vec![ - PathBuf::from("collab_ui/collab_ui.rs"), - PathBuf::from("collab_ui/first.rs"), - PathBuf::from("collab_ui/third.rs"), - PathBuf::from("collab_ui/second.rs"), + rel_path("collab_ui/collab_ui.rs").into(), + rel_path("collab_ui/first.rs").into(), + rel_path("collab_ui/third.rs").into(), + rel_path("collab_ui/second.rs").into(), ], "Despite all search results having the same directory name, the most matching one should be on top" ); @@ -2087,8 +2299,8 @@ async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) assert_eq!( collect_search_matches(picker).history, vec![ - PathBuf::from("test/first.rs"), - PathBuf::from("test/third.rs"), + rel_path("test/first.rs").into(), + rel_path("test/third.rs").into() ], "Should have all opened files in the history, except the ones that do not exist on disk" ); @@ -2125,7 +2337,6 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp assert_match_at_position(finder, 1, "main.rs"); assert_match_at_position(finder, 2, "rs"); }); - // Delete main.rs app_state .fs @@ -2158,6 +2369,64 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp }); } +#[gpui::test] +async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "lib.rs": "// Lib file", + "main.rs": "// Bar file", + "read.me": "// Readme file", + }), + ) + .await; + app_state + .fs + .as_fake() + .insert_tree( + "/test", + json!({ + "new.rs": "// New file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + open_paths( + &[PathBuf::from(path!("/test/new.rs"))], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|_, cx| cx.windows().len()), 1); + + let initial_history = open_close_queried_buffer("new", 1, "new.rs", &workspace, cx).await; + assert_eq!( + initial_history.first().unwrap().absolute, + PathBuf::from(path!("/test/new.rs")), + "Should show 1st opened item in the history when opening the 2nd item" + ); + + let history_after_first = open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await; + assert_eq!( + history_after_first.first().unwrap().absolute, + PathBuf::from(path!("/test/new.rs")), + "Should show 1st opened item in the history when opening the 2nd item" + ); +} + #[gpui::test] async fn test_search_results_refreshed_on_adding_and_removing_worktrees( cx: &mut gpui::TestAppContext, @@ -2234,6 +2503,147 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( }); } +#[gpui::test] +async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( + cx: &mut TestAppContext, +) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo1"), + json!({ + "package.json": r#"{"name": "repo1"}"#, + "src": { + "index.js": "// Repo 1 index", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + path!("/repo2"), + json!({ + "package.json": r#"{"name": "repo2"}"#, + "src": { + "index.js": "// Repo 2 index", + } + }), + ) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/repo1").as_ref(), path!("/repo2").as_ref()], + cx, + ) + .await; + + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (worktree_id1, worktree_id2) = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: worktree_id1, + path: rel_path("package.json").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: worktree_id2, + path: rel_path("package.json").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); + + let picker = open_file_picker(&workspace, cx); + cx.simulate_input("package.json"); + + picker.update(cx, |finder, _| { + let matches = &finder.delegate.matches.matches; + + assert_eq!( + matches.len(), + 2, + "Expected 1 history match + 1 search matches, but got {} matches: {:?}", + matches.len(), + matches + ); + + assert_matches!(matches[0], Match::History { .. }); + + let search_matches = collect_search_matches(finder); + assert_eq!( + search_matches.history.len(), + 2, + "Should have exactly 2 history match" + ); + assert_eq!( + search_matches.search.len(), + 0, + "Should have exactly 0 search match (because we already opened the 2 package.json)" + ); + + if let Match::History { path, panel_match } = &matches[0] { + assert_eq!(path.project.worktree_id, worktree_id2); + assert_eq!(path.project.path.as_ref(), rel_path("package.json")); + let panel_match = panel_match.as_ref().unwrap(); + assert_eq!(panel_match.0.path_prefix, rel_path("repo2").into()); + assert_eq!(panel_match.0.path, rel_path("package.json").into()); + assert_eq!( + panel_match.0.positions, + vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] + ); + } + + if let Match::History { path, panel_match } = &matches[1] { + assert_eq!(path.project.worktree_id, worktree_id1); + assert_eq!(path.project.path.as_ref(), rel_path("package.json")); + let panel_match = panel_match.as_ref().unwrap(); + assert_eq!(panel_match.0.path_prefix, rel_path("repo1").into()); + assert_eq!(panel_match.0.path, rel_path("package.json").into()); + assert_eq!( + panel_match.0.positions, + vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] + ); + } + }); +} + #[gpui::test] async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); @@ -2623,7 +3033,7 @@ async fn open_queried_buffer( workspace: &Entity, cx: &mut gpui::VisualTestContext, ) -> Vec { - let picker = open_file_picker(&workspace, cx); + let picker = open_file_picker(workspace, cx); cx.simulate_input(input); let history_items = picker.update(cx, |finder, _| { @@ -2718,15 +3128,15 @@ fn active_file_picker( #[derive(Debug, Default)] struct SearchEntries { - history: Vec, + history: Vec>, history_found_paths: Vec, - search: Vec, + search: Vec>, search_matches: Vec, } impl SearchEntries { #[track_caller] - fn search_paths_only(self) -> Vec { + fn search_paths_only(self) -> Vec> { assert!( self.history.is_empty(), "Should have no history matches, but got: {:?}", @@ -2754,20 +3164,15 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries path: history_path, panel_match: path_match, } => { - search_entries.history.push( - path_match - .as_ref() - .map(|path_match| { - Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path) - }) - .unwrap_or_else(|| { - history_path - .absolute - .as_deref() - .unwrap_or_else(|| &history_path.project.path) - .to_path_buf() - }), - ); + if let Some(path_match) = path_match.as_ref() { + search_entries + .history + .push(path_match.0.path_prefix.join(&path_match.0.path)); + } else { + // This occurs when the query is empty and we show history matches + // that are outside the project. + panic!("currently not exercised in tests"); + } search_entries .history_found_paths .push(history_path.clone()); @@ -2775,7 +3180,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries Match::Search(path_match) => { search_entries .search - .push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)); + .push(path_match.0.path_prefix.join(&path_match.0.path)); search_entries.search_matches.push(path_match.0.clone()); } Match::CreateNew(_) => {} @@ -2810,12 +3215,11 @@ fn assert_match_at_position( .get(match_index) .unwrap_or_else(|| panic!("Finder has no match for index {match_index}")); let match_file_name = match &match_item { - Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(), + Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()), Match::Search(path_match) => path_match.0.path.file_name(), Match::CreateNew(project_path) => project_path.path.file_name(), } - .unwrap() - .to_string_lossy(); + .unwrap(); assert_eq!(match_file_name, expected_file_name); } @@ -2853,13 +3257,59 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { assert_eq!( search_matches, vec![ - PathBuf::from("routes/+layout.svelte"), - PathBuf::from("layout/app.css"), - PathBuf::from("layout/app.d.ts"), - PathBuf::from("layout/app.html"), - PathBuf::from("layout/+page.svelte"), + rel_path("routes/+layout.svelte").into(), + rel_path("layout/app.css").into(), + rel_path("layout/app.d.ts").into(), + rel_path("layout/app.html").into(), + rel_path("layout/+page.svelte").into(), ], "File with 'layout' in filename should be prioritized over files in 'layout' directory" ); }); } + +#[gpui::test] +async fn test_paths_with_starting_slash(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "file1.txt": "", + "b": { + "file2.txt": "", + }, + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + let matching_abs_path = "/file1.txt".to_string(); + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .update_matches(matching_abs_path, window, cx) + }) + .await; + picker.update(cx, |picker, _| { + assert_eq!( + collect_search_matches(picker).search_paths_only(), + vec![rel_path("a/file1.txt").into()], + "Relative path starting with slash should match" + ) + }); + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!(active_editor.read(cx).title(cx), "file1.txt"); + }); +} diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 68ba7a78b52fee42588b732d7a6a3c582a80061f..694ef1eaceb720c3b63d4ca9d243ab73e9442970 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -1,13 +1,13 @@ use crate::file_finder_settings::FileFinderSettings; use file_icons::FileIcons; use futures::channel::oneshot; -use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy::{CharBag, StringMatch, StringMatchCandidate}; use gpui::{HighlightStyle, StyledText, Task}; use picker::{Picker, PickerDelegate}; use project::{DirectoryItem, DirectoryLister}; use settings::Settings; use std::{ - path::{self, MAIN_SEPARATOR_STR, Path, PathBuf}, + path::{self, Path, PathBuf}, sync::{ Arc, atomic::{self, AtomicBool}, @@ -23,7 +23,6 @@ use workspace::Workspace; pub(crate) struct OpenPathPrompt; -#[derive(Debug)] pub struct OpenPathDelegate { tx: Option>>>, lister: DirectoryLister, @@ -35,6 +34,9 @@ pub struct OpenPathDelegate { prompt_root: String, path_style: PathStyle, replace_prompt: Task<()>, + render_footer: + Arc>) -> Option + 'static>, + hidden_entries: bool, } impl OpenPathDelegate { @@ -60,9 +62,25 @@ impl OpenPathDelegate { }, path_style, replace_prompt: Task::ready(()), + render_footer: Arc::new(|_, _| None), + hidden_entries: false, } } + pub fn with_footer( + mut self, + footer: Arc< + dyn Fn(&mut Window, &mut Context>) -> Option + 'static, + >, + ) -> Self { + self.render_footer = footer; + self + } + + pub fn show_hidden(mut self) -> Self { + self.hidden_entries = true; + self + } fn get_entry(&self, selected_match_index: usize) -> Option { match &self.directory_state { DirectoryState::List { entries, .. } => { @@ -75,16 +93,16 @@ impl OpenPathDelegate { .. } => { let mut i = selected_match_index; - if let Some(user_input) = user_input { - if !user_input.exists || !user_input.is_dir { - if i == 0 { - return Some(CandidateInfo { - path: user_input.file.clone(), - is_dir: false, - }); - } else { - i -= 1; - } + if let Some(user_input) = user_input + && (!user_input.exists || !user_input.is_dir) + { + if i == 0 { + return Some(CandidateInfo { + path: user_input.file.clone(), + is_dir: false, + }); + } else { + i -= 1; } } let id = self.string_matches.get(i)?.candidate_id; @@ -112,7 +130,7 @@ impl OpenPathDelegate { entries, .. } => user_input - .into_iter() + .iter() .filter(|user_input| !user_input.exists || !user_input.is_dir) .map(|user_input| user_input.file.string.clone()) .chain(self.string_matches.iter().filter_map(|string_match| { @@ -125,6 +143,13 @@ impl OpenPathDelegate { DirectoryState::None { .. } => Vec::new(), } } + + fn current_dir(&self) -> &'static str { + match self.path_style { + PathStyle::Posix => "./", + PathStyle::Windows => ".\\", + } + } } #[derive(Debug)] @@ -192,7 +217,7 @@ impl OpenPathPrompt { ) { workspace.toggle_modal(window, cx, |window, cx| { let delegate = - OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::current()); + OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::local()); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); @@ -233,6 +258,7 @@ impl PickerDelegate for OpenPathDelegate { cx: &mut Context>, ) -> Task<()> { let lister = &self.lister; + let input_is_empty = query.is_empty(); let (dir, suffix) = get_dir_and_suffix(query, self.path_style); let query = match &self.directory_state { @@ -261,8 +287,9 @@ impl PickerDelegate for OpenPathDelegate { self.cancel_flag.store(true, atomic::Ordering::Release); self.cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_flag = self.cancel_flag.clone(); - + let hidden_entries = self.hidden_entries; let parent_path_is_root = self.prompt_root == dir; + let current_dir = self.current_dir(); cx.spawn_in(window, async move |this, cx| { if let Some(query) = query { let paths = query.await; @@ -353,10 +380,39 @@ impl PickerDelegate for OpenPathDelegate { return; }; - if !suffix.starts_with('.') { - new_entries.retain(|entry| !entry.path.string.starts_with('.')); + let mut max_id = 0; + if !suffix.starts_with('.') && !hidden_entries { + new_entries.retain(|entry| { + max_id = max_id.max(entry.path.id); + !entry.path.string.starts_with('.') + }); } + if suffix.is_empty() { + let should_prepend_with_current_dir = this + .read_with(cx, |picker, _| { + !input_is_empty + && match &picker.delegate.directory_state { + DirectoryState::List { error, .. } => error.is_none(), + DirectoryState::Create { .. } => false, + DirectoryState::None { .. } => false, + } + }) + .unwrap_or(false); + if should_prepend_with_current_dir { + new_entries.insert( + 0, + CandidateInfo { + path: StringMatchCandidate { + id: max_id + 1, + string: current_dir.to_string(), + char_bag: CharBag::from(current_dir), + }, + is_dir: true, + }, + ); + } + this.update(cx, |this, cx| { this.delegate.selected_index = 0; this.delegate.string_matches = new_entries @@ -485,6 +541,10 @@ impl PickerDelegate for OpenPathDelegate { _: &mut Context>, ) -> Option { let candidate = self.get_entry(self.selected_index)?; + if candidate.path.string.is_empty() || candidate.path.string == self.current_dir() { + return None; + } + let path_style = self.path_style; Some( maybe!({ @@ -609,7 +669,7 @@ impl PickerDelegate for OpenPathDelegate { ) -> Option { let settings = FileFinderSettings::get_global(cx); let candidate = self.get_entry(ix)?; - let match_positions = match &self.directory_state { + let mut match_positions = match &self.directory_state { DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(), DirectoryState::Create { user_input, .. } => { if let Some(user_input) = user_input { @@ -629,41 +689,59 @@ impl PickerDelegate for OpenPathDelegate { DirectoryState::None { .. } => Vec::new(), }; + let is_current_dir_candidate = candidate.path.string == self.current_dir(); + let file_icon = maybe!({ if !settings.file_icons { return None; } + + let path = path::Path::new(&candidate.path.string); let icon = if candidate.is_dir { - FileIcons::get_folder_icon(false, cx)? + if is_current_dir_candidate { + return Some(Icon::new(IconName::ReplyArrowRight).color(Color::Muted)); + } else { + FileIcons::get_folder_icon(false, path, cx)? + } } else { - let path = path::Path::new(&candidate.path.string); - FileIcons::get_icon(&path, cx)? + FileIcons::get_icon(path, cx)? }; Some(Icon::from_path(icon).color(Color::Muted)) }); match &self.directory_state { - DirectoryState::List { parent_path, .. } => Some( - ListItem::new(ix) - .spacing(ListItemSpacing::Sparse) - .start_slot::(file_icon) - .inset(true) - .toggle_state(selected) - .child(HighlightedLabel::new( - if parent_path == &self.prompt_root { - format!("{}{}", self.prompt_root, candidate.path.string) - } else { - candidate.path.string.clone() - }, + DirectoryState::List { parent_path, .. } => { + let (label, indices) = if *parent_path == self.prompt_root { + match_positions.iter_mut().for_each(|position| { + *position += self.prompt_root.len(); + }); + ( + format!("{}{}", self.prompt_root, candidate.path.string), match_positions, - )), - ), + ) + } else if is_current_dir_candidate { + ("open this directory".to_string(), vec![]) + } else { + (candidate.path.string, match_positions) + }; + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .start_slot::(file_icon) + .inset(true) + .toggle_state(selected) + .child(HighlightedLabel::new(label, indices)), + ) + } DirectoryState::Create { parent_path, user_input, .. } => { - let (label, delta) = if parent_path == &self.prompt_root { + let (label, delta) = if *parent_path == self.prompt_root { + match_positions.iter_mut().for_each(|position| { + *position += self.prompt_root.len(); + }); ( format!("{}{}", self.prompt_root, candidate.path.string), self.prompt_root.len(), @@ -671,10 +749,10 @@ impl PickerDelegate for OpenPathDelegate { } else { (candidate.path.string.clone(), 0) }; - let label_len = label.len(); let label_with_highlights = match user_input { Some(user_input) => { + let label_len = label.len(); if user_input.file.string == candidate.path.string { if user_input.exists { let label = if user_input.is_dir { @@ -684,9 +762,9 @@ impl PickerDelegate for OpenPathDelegate { }; StyledText::new(label) .with_default_highlights( - &window.text_style().clone(), + &window.text_style(), vec![( - delta..delta + label_len, + delta..label_len, HighlightStyle::color(Color::Conflict.color(cx)), )], ) @@ -694,29 +772,19 @@ impl PickerDelegate for OpenPathDelegate { } else { StyledText::new(format!("{label} (create)")) .with_default_highlights( - &window.text_style().clone(), + &window.text_style(), vec![( - delta..delta + label_len, + delta..label_len, HighlightStyle::color(Color::Created.color(cx)), )], ) .into_any_element() } } else { - let mut highlight_positions = match_positions; - highlight_positions.iter_mut().for_each(|position| { - *position += delta; - }); - HighlightedLabel::new(label, highlight_positions).into_any_element() + HighlightedLabel::new(label, match_positions).into_any_element() } } - None => { - let mut highlight_positions = match_positions; - highlight_positions.iter_mut().for_each(|position| { - *position += delta; - }); - HighlightedLabel::new(label, highlight_positions).into_any_element() - } + None => HighlightedLabel::new(label, match_positions).into_any_element(), }; Some( @@ -728,10 +796,18 @@ impl PickerDelegate for OpenPathDelegate { .child(LabelLike::new().child(label_with_highlights)), ) } - DirectoryState::None { .. } => return None, + DirectoryState::None { .. } => None, } } + fn render_footer( + &self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + (self.render_footer)(window, cx) + } + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { Some(match &self.directory_state { DirectoryState::Create { .. } => SharedString::from("Type a path…"), @@ -745,7 +821,18 @@ impl PickerDelegate for OpenPathDelegate { } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext")) + Arc::from(format!("[directory{}]filename.ext", self.path_style.separator()).as_str()) + } + + fn separators_after_indices(&self) -> Vec { + let Some(m) = self.string_matches.first() else { + return Vec::new(); + }; + if m.string == self.current_dir() { + vec![0] + } else { + Vec::new() + } } } diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index a69ac6992dc280fd6537b16087302c2fbb9f8f4c..5e8874cd01e06bb05f4ff6918bc02ea6883ea064 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -37,18 +37,26 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + + insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), Vec::::new()); let query = path!("/root"); insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); + #[cfg(not(windows))] + let expected_separator = "./"; + #[cfg(windows)] + let expected_separator = ".\\"; + // If the query ends with a slash, the picker should show the contents of the directory. let query = path!("/root/"); insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["a1", "a2", "a3", "dir1", "dir2"] + vec![expected_separator, "a1", "a2", "a3", "dir1", "dir2"] ); // Show candidates for the query "a". @@ -72,7 +80,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["c", "d1", "d2", "d3", "dir3", "dir4"] + vec![expected_separator, "c", "d1", "d2", "d3", "dir3", "dir4"] ); // Show candidates for the query "d". @@ -111,76 +119,91 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); insert_query(query, &picker, cx).await; - assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/")); + assert_eq!( + confirm_completion(query, 0, &picker, cx).unwrap(), + path!("/root/") + ); // Confirm completion for the query "/root/", selecting the first candidate "a", since it's a file, it should not add a trailing slash. let query = path!("/root/"); insert_query(query, &picker, cx).await; - assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/a")); + assert_eq!( + confirm_completion(query, 0, &picker, cx), + None, + "First entry is `./` and when we confirm completion, it is tabbed below" + ); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + path!("/root/a"), + "Second entry is the first entry of a directory that we want to be completed" + ); // Confirm completion for the query "/root/", selecting the second candidate "dir1", since it's a directory, it should add a trailing slash. let query = path!("/root/"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 1, &picker, cx), + confirm_completion(query, 2, &picker, cx).unwrap(), path!("/root/dir1/") ); let query = path!("/root/a"); insert_query(query, &picker, cx).await; - assert_eq!(confirm_completion(query, 0, &picker, cx), path!("/root/a")); + assert_eq!( + confirm_completion(query, 0, &picker, cx).unwrap(), + path!("/root/a") + ); let query = path!("/root/d"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 1, &picker, cx), + confirm_completion(query, 1, &picker, cx).unwrap(), path!("/root/dir2/") ); let query = path!("/root/dir2"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 0, &picker, cx), + confirm_completion(query, 0, &picker, cx).unwrap(), path!("/root/dir2/") ); let query = path!("/root/dir2/"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 0, &picker, cx), + confirm_completion(query, 1, &picker, cx).unwrap(), path!("/root/dir2/c") ); let query = path!("/root/dir2/"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 2, &picker, cx), + confirm_completion(query, 3, &picker, cx).unwrap(), path!("/root/dir2/dir3/") ); let query = path!("/root/dir2/d"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 0, &picker, cx), + confirm_completion(query, 0, &picker, cx).unwrap(), path!("/root/dir2/d") ); let query = path!("/root/dir2/d"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 1, &picker, cx), + confirm_completion(query, 1, &picker, cx).unwrap(), path!("/root/dir2/dir3/") ); let query = path!("/root/dir2/di"); insert_query(query, &picker, cx).await; assert_eq!( - confirm_completion(query, 1, &picker, cx), + confirm_completion(query, 1, &picker, cx).unwrap(), path!("/root/dir2/dir4/") ); } @@ -204,49 +227,70 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); // Support both forward and backward slashes. let query = "C:/root/"; insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["a", "dir1", "dir2"] + vec![".\\", "a", "dir1", "dir2"] + ); + assert_eq!( + confirm_completion(query, 0, &picker, cx), + None, + "First entry is `.\\` and when we confirm completion, it is tabbed below" + ); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "C:/root/a", + "Second entry is the first entry of a directory that we want to be completed" ); - assert_eq!(confirm_completion(query, 0, &picker, cx), "C:/root/a"); let query = "C:\\root/"; insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["a", "dir1", "dir2"] + vec![".\\", "a", "dir1", "dir2"] + ); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "C:\\root/a" ); - assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root/a"); let query = "C:\\root\\"; insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["a", "dir1", "dir2"] + vec![".\\", "a", "dir1", "dir2"] + ); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "C:\\root\\a" ); - assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root\\a"); // Confirm completion for the query "C:/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. let query = "C:/root/d"; insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!(confirm_completion(query, 1, &picker, cx), "C:/root/dir2\\"); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "C:/root/dir2\\" + ); let query = "C:\\root/d"; insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!(confirm_completion(query, 0, &picker, cx), "C:\\root/dir1\\"); + assert_eq!( + confirm_completion(query, 0, &picker, cx).unwrap(), + "C:\\root/dir1\\" + ); let query = "C:\\root\\d"; insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); assert_eq!( - confirm_completion(query, 0, &picker, cx), + confirm_completion(query, 0, &picker, cx).unwrap(), "C:\\root\\dir1\\" ); } @@ -276,20 +320,29 @@ async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { insert_query(query, &picker, cx).await; assert_eq!( collect_match_candidates(&picker, cx), - vec!["a", "dir1", "dir2"] + vec!["./", "a", "dir1", "dir2"] + ); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "/root/a" ); - assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/a"); // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. let query = "/root/d"; insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!(confirm_completion(query, 1, &picker, cx), "/root/dir2/"); + assert_eq!( + confirm_completion(query, 1, &picker, cx).unwrap(), + "/root/dir2/" + ); let query = "/root/d"; insert_query(query, &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/dir1/"); + assert_eq!( + confirm_completion(query, 0, &picker, cx).unwrap(), + "/root/dir1/" + ); } #[gpui::test] @@ -319,7 +372,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, PathStyle::current(), cx); + let (picker, cx) = build_open_path_prompt(project, true, PathStyle::local(), cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -396,15 +449,13 @@ fn confirm_completion( select: usize, picker: &Entity>, cx: &mut VisualTestContext, -) -> String { - picker - .update_in(cx, |f, window, cx| { - if f.delegate.selected_index() != select { - f.delegate.set_selected_index(select, window, cx); - } - f.delegate.confirm_completion(query.to_string(), window, cx) - }) - .unwrap() +) -> Option { + picker.update_in(cx, |f, window, cx| { + if f.delegate.selected_index() != select { + f.delegate.set_selected_index(select, window, cx); + } + f.delegate.confirm_completion(query.to_string(), window, cx) + }) } fn collect_match_candidates( diff --git a/crates/file_icons/Cargo.toml b/crates/file_icons/Cargo.toml index 1c271f4132a5a2083cc0072367a32c9850f83802..d45b606e5a8e6ca9c0b20db955e28c0c982c2f38 100644 --- a/crates/file_icons/Cargo.toml +++ b/crates/file_icons/Cargo.toml @@ -15,7 +15,5 @@ doctest = false [dependencies] gpui.workspace = true serde.workspace = true -settings.workspace = true theme.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/file_icons/src/file_icons.rs b/crates/file_icons/src/file_icons.rs index 82a8e05d8571b04ec177c9944a765778684fe2a4..e8650a83b920142a3b6ab2f69bdc6e2eca6b7470 100644 --- a/crates/file_icons/src/file_icons.rs +++ b/crates/file_icons/src/file_icons.rs @@ -2,8 +2,7 @@ use std::sync::Arc; use std::{path::Path, str}; use gpui::{App, SharedString}; -use settings::Settings; -use theme::{IconTheme, ThemeRegistry, ThemeSettings}; +use theme::{GlobalTheme, IconTheme, ThemeRegistry}; use util::paths::PathExt; #[derive(Debug)] @@ -13,10 +12,8 @@ pub struct FileIcons { impl FileIcons { pub fn get(cx: &App) -> Self { - let theme_settings = ThemeSettings::get_global(cx); - Self { - icon_theme: theme_settings.active_icon_theme.clone(), + icon_theme: GlobalTheme::icon_theme(cx).clone(), } } @@ -52,6 +49,15 @@ impl FileIcons { } } + // handle cases where the file extension is made up of multiple important + // parts (e.g Component.stories.tsx) that refer to an alternative icon style + if let Some(suffix) = path.multiple_extensions() { + let maybe_path = get_icon_from_suffix(suffix.as_str()); + if maybe_path.is_some() { + return maybe_path; + } + } + // primary case: check if the files extension or the hidden file name // matches some icon path if let Some(suffix) = path.extension_or_hidden_file_name() { @@ -72,7 +78,7 @@ impl FileIcons { return maybe_path; } } - return this.get_icon_for_type("default", cx); + this.get_icon_for_type("default", cx) } fn default_icon_theme(cx: &App) -> Option> { @@ -88,13 +94,48 @@ impl FileIcons { .map(|icon_definition| icon_definition.path.clone()) } - get_icon_for_type(&ThemeSettings::get_global(cx).active_icon_theme, typ).or_else(|| { + get_icon_for_type(GlobalTheme::icon_theme(cx), typ).or_else(|| { Self::default_icon_theme(cx).and_then(|icon_theme| get_icon_for_type(&icon_theme, typ)) }) } - pub fn get_folder_icon(expanded: bool, cx: &App) -> Option { - fn get_folder_icon(icon_theme: &Arc, expanded: bool) -> Option { + pub fn get_folder_icon(expanded: bool, path: &Path, cx: &App) -> Option { + fn get_folder_icon( + icon_theme: &Arc, + path: &Path, + expanded: bool, + ) -> Option { + let name = path.file_name()?.to_str()?.trim(); + if name.is_empty() { + return None; + } + + let directory_icons = icon_theme.named_directory_icons.get(name)?; + + if expanded { + directory_icons.expanded.clone() + } else { + directory_icons.collapsed.clone() + } + } + + get_folder_icon(GlobalTheme::icon_theme(cx), path, expanded) + .or_else(|| { + Self::default_icon_theme(cx) + .and_then(|icon_theme| get_folder_icon(&icon_theme, path, expanded)) + }) + .or_else(|| { + // If we can't find a specific folder icon for the folder at the given path, fall back to the generic folder + // icon. + Self::get_generic_folder_icon(expanded, cx) + }) + } + + fn get_generic_folder_icon(expanded: bool, cx: &App) -> Option { + fn get_generic_folder_icon( + icon_theme: &Arc, + expanded: bool, + ) -> Option { if expanded { icon_theme.directory_icons.expanded.clone() } else { @@ -102,9 +143,9 @@ impl FileIcons { } } - get_folder_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else(|| { + get_generic_folder_icon(GlobalTheme::icon_theme(cx), expanded).or_else(|| { Self::default_icon_theme(cx) - .and_then(|icon_theme| get_folder_icon(&icon_theme, expanded)) + .and_then(|icon_theme| get_generic_folder_icon(&icon_theme, expanded)) }) } @@ -117,7 +158,7 @@ impl FileIcons { } } - get_chevron_icon(&ThemeSettings::get_global(cx).active_icon_theme, expanded).or_else(|| { + get_chevron_icon(GlobalTheme::icon_theme(cx), expanded).or_else(|| { Self::default_icon_theme(cx) .and_then(|icon_theme| get_chevron_icon(&icon_theme, expanded)) }) diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 1d4161134ee7ff43c15c450284a570a08d7841cd..d6413cb7a07b5aeb72efea012ae7e00f3493837e 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -33,7 +33,6 @@ tempfile.workspace = true text.workspace = true time.workspace = true util.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "macos")'.dependencies] fsevent.workspace = true diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index f0936d400a98eba5fe8c37d946f704b831dfb876..8e9f8501dbcd4858f709dd5bd08f7f4d65aab986 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -1,5 +1,5 @@ use crate::{FakeFs, FakeFsEntry, Fs}; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; use collections::{HashMap, HashSet}; use futures::future::{self, BoxFuture, join_all}; use git::{ @@ -9,14 +9,24 @@ use git::{ AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, }, - status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus}, + status::{ + DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, + UnmergedStatus, + }, }; -use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task}; +use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel}; use ignore::gitignore::GitignoreBuilder; use parking_lot::Mutex; use rope::Rope; use smol::future::FutureExt as _; -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::PathBuf, + sync::{Arc, LazyLock}, +}; +use util::{paths::PathStyle, rel_path::RelPath}; + +pub static LOAD_INDEX_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); +pub static LOAD_HEAD_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); #[derive(Clone)] pub struct FakeGitRepository { @@ -34,6 +44,9 @@ pub struct FakeGitRepositoryState { pub unmerged_paths: HashMap, pub head_contents: HashMap, pub index_contents: HashMap, + // everything in commit contents is in oids + pub merge_base_contents: HashMap, + pub oids: HashMap, pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, @@ -53,6 +66,8 @@ impl FakeGitRepositoryState { branches: Default::default(), simulated_index_write_error_message: Default::default(), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), + merge_base_contents: Default::default(), + oids: Default::default(), } } } @@ -78,32 +93,35 @@ impl GitRepository for FakeGitRepository { fn reload_index(&self) {} fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - async { - self.with_state_async(false, move |state| { - state - .index_contents - .get(path.as_ref()) - .context("not present in index") - .cloned() - }) - .await - .ok() - } - .boxed() + let fut = self.with_state_async(false, move |state| { + state + .index_contents + .get(&path) + .context("not present in index") + .cloned() + }); + self.executor + .spawn_labeled(*LOAD_INDEX_TEXT_TASK, async move { fut.await.ok() }) + .boxed() } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - async { - self.with_state_async(false, move |state| { - state - .head_contents - .get(path.as_ref()) - .context("not present in HEAD") - .cloned() - }) - .await - .ok() - } + let fut = self.with_state_async(false, move |state| { + state + .head_contents + .get(&path) + .context("not present in HEAD") + .cloned() + }); + self.executor + .spawn_labeled(*LOAD_HEAD_TEXT_TASK, async move { fut.await.ok() }) + .boxed() + } + + fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result> { + self.with_state_async(false, move |state| { + state.oids.get(&oid).cloned().context("oid does not exist") + }) .boxed() } @@ -137,6 +155,34 @@ impl GitRepository for FakeGitRepository { None } + fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { + let mut entries = HashMap::default(); + self.with_state_async(false, |state| { + for (path, content) in &state.head_contents { + let status = if let Some((oid, original)) = state + .merge_base_contents + .get(path) + .map(|oid| (oid, &state.oids[oid])) + { + if original == content { + continue; + } + TreeDiffStatus::Modified { old: *oid } + } else { + TreeDiffStatus::Added + }; + entries.insert(path.clone(), status); + } + for (path, oid) in &state.merge_base_contents { + if !entries.contains_key(path) { + entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid }); + } + } + Ok(TreeDiff { entries }) + }) + .boxed() + } + fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { self.with_state_async(false, |state| { Ok(revs @@ -225,6 +271,7 @@ impl GitRepository for FakeGitRepository { .read_file_sync(path) .ok() .map(|content| String::from_utf8(content).unwrap())?; + let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; Some((repo_path.into(), (content, is_ignored))) }) .collect(); @@ -320,6 +367,10 @@ impl GitRepository for FakeGitRepository { }) } + fn stash_entries(&self) -> BoxFuture<'_, Result> { + async { Ok(git::stash::GitStash::default()) }.boxed() + } + fn branches(&self) -> BoxFuture<'_, Result>> { self.with_state_async(false, move |state| { let current_branch = &state.current_branch_name; @@ -345,7 +396,20 @@ impl GitRepository for FakeGitRepository { fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { - state.branches.insert(name.to_owned()); + state.branches.insert(name); + Ok(()) + }) + } + + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&branch) { + bail!("no such branch: {branch}"); + } + state.branches.insert(new_name.clone()); + if state.current_branch_name == Some(branch) { + state.current_branch_name = Some(new_name); + } Ok(()) }) } @@ -369,7 +433,11 @@ impl GitRepository for FakeGitRepository { let contents = paths .into_iter() .map(|path| { - let abs_path = self.dot_git_path.parent().unwrap().join(&path); + let abs_path = self + .dot_git_path + .parent() + .unwrap() + .join(&path.as_std_path()); Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) }) }) .collect::>(); @@ -412,7 +480,27 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn stash_pop(&self, _env: Arc>) -> BoxFuture<'_, Result<()>> { + fn stash_pop( + &self, + _index: Option, + _env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + unimplemented!() + } + + fn stash_apply( + &self, + _index: Option, + _env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + unimplemented!() + } + + fn stash_drop( + &self, + _index: Option, + _env: Arc>, + ) -> BoxFuture<'_, Result<()>> { unimplemented!() } @@ -478,7 +566,7 @@ impl GitRepository for FakeGitRepository { let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf(); async move { executor.simulate_random_delay().await; - let oid = Oid::random(&mut executor.rng()); + let oid = git::Oid::random(&mut executor.rng()); let entry = fs.entry(&repository_dir_path)?; checkpoints.lock().insert(oid, entry); Ok(GitRepositoryCheckpoint { commit_sha: oid }) @@ -534,7 +622,7 @@ impl GitRepository for FakeGitRepository { } fn default_branch(&self) -> BoxFuture<'_, Result>> { - unimplemented!() + async { Ok(Some("main".into())) }.boxed() } } @@ -565,7 +653,9 @@ mod tests { .await; fs.with_git_state(Path::new("/foo/.git"), true, |_git| {}) .unwrap(); - let repository = fs.open_repo(Path::new("/foo/.git")).unwrap(); + let repository = fs + .open_repo(Path::new("/foo/.git"), Some("git".as_ref())) + .unwrap(); let checkpoint_1 = repository.checkpoint().await.unwrap(); fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap(); @@ -590,9 +680,9 @@ mod tests { assert_eq!( fs.files_with_contents(Path::new("")), [ - (Path::new("/bar/baz").into(), b"qux".into()), - (Path::new("/foo/a").into(), b"lorem".into()), - (Path::new("/foo/b").into(), b"ipsum".into()) + (Path::new(path!("/bar/baz")).into(), b"qux".into()), + (Path::new(path!("/foo/a")).into(), b"lorem".into()), + (Path::new(path!("/foo/b")).into(), b"ipsum".into()) ] ); } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 22bfdbcd66ee0b3193ef51e3ec461dfe225fa8f0..c794303ef71232d5a162b51ec8db7d472328b767 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -7,12 +7,13 @@ pub mod fs_watcher; use anyhow::{Context as _, Result, anyhow}; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use ashpd::desktop::trash; +use futures::stream::iter; use gpui::App; use gpui::BackgroundExecutor; use gpui::Global; use gpui::ReadGlobal as _; use std::borrow::Cow; -use util::command::{new_smol_command, new_std_command}; +use util::command::new_smol_command; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; @@ -20,6 +21,9 @@ use std::os::fd::{AsFd, AsRawFd}; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +use std::mem::MaybeUninit; + use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; @@ -44,7 +48,7 @@ use collections::{BTreeMap, btree_map}; use fake_git_repo::FakeGitRepositoryState; #[cfg(any(test, feature = "test-support"))] use git::{ - repository::RepoPath, + repository::{RepoPath, repo_path}, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus}, }; #[cfg(any(test, feature = "test-support"))] @@ -54,6 +58,9 @@ use smol::io::AsyncReadExt; #[cfg(any(test, feature = "test-support"))] use std::ffi::OsStr; +#[cfg(any(test, feature = "test-support"))] +pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK}; + pub trait Watcher: Send + Sync { fn add(&self, path: &Path) -> Result<()>; fn remove(&self, path: &Path) -> Result<()>; @@ -131,9 +138,13 @@ pub trait Fs: Send + Sync { Arc, ); - fn home_dir(&self) -> Option; - fn open_repo(&self, abs_dot_git: &Path) -> Option>; - fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; + fn open_repo( + &self, + abs_dot_git: &Path, + system_git_binary_path: Option<&Path>, + ) -> Option>; + async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) + -> Result<()>; async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; fn is_fake(&self) -> bool; async fn is_case_sensitive(&self) -> Result; @@ -244,7 +255,7 @@ impl From for proto::Timestamp { } pub struct RealFs { - git_binary_path: Option, + bundled_git_binary_path: Option, executor: BackgroundExecutor, } @@ -261,14 +272,15 @@ impl FileHandle for std::fs::File { }; let fd = self.as_fd(); - let mut path_buf: [libc::c_char; libc::PATH_MAX as usize] = [0; libc::PATH_MAX as usize]; + let mut path_buf = MaybeUninit::<[u8; libc::PATH_MAX as usize]>::uninit(); let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETPATH, path_buf.as_mut_ptr()) }; if result == -1 { anyhow::bail!("fcntl returned -1".to_string()); } - let c_str = unsafe { CStr::from_ptr(path_buf.as_ptr()) }; + // SAFETY: `fcntl` will initialize the path buffer. + let c_str = unsafe { CStr::from_ptr(path_buf.as_ptr().cast()) }; let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes())); Ok(path) } @@ -296,22 +308,49 @@ impl FileHandle for std::fs::File { }; let fd = self.as_fd(); - let mut kif: libc::kinfo_file = unsafe { std::mem::zeroed() }; + let mut kif = MaybeUninit::::uninit(); kif.kf_structsize = libc::KINFO_FILE_SIZE; - let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, &mut kif) }; + let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, kif.as_mut_ptr()) }; if result == -1 { anyhow::bail!("fcntl returned -1".to_string()); } - let c_str = unsafe { CStr::from_ptr(kif.kf_path.as_ptr()) }; + // SAFETY: `fcntl` will initialize the kif. + let c_str = unsafe { CStr::from_ptr(kif.assume_init().kf_path.as_ptr()) }; let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes())); Ok(path) } #[cfg(target_os = "windows")] fn current_path(&self, _: &Arc) -> Result { - anyhow::bail!("unimplemented") + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::os::windows::io::AsRawHandle; + + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, + }; + + let handle = HANDLE(self.as_raw_handle() as _); + + // Query required buffer size (in wide chars) + let required_len = + unsafe { GetFinalPathNameByHandleW(handle, &mut [], FILE_NAME_NORMALIZED) }; + if required_len == 0 { + anyhow::bail!("GetFinalPathNameByHandleW returned 0 length"); + } + + // Allocate buffer and retrieve the path + let mut buf: Vec = vec![0u16; required_len as usize + 1]; + let written = unsafe { GetFinalPathNameByHandleW(handle, &mut buf, FILE_NAME_NORMALIZED) }; + if written == 0 { + anyhow::bail!("GetFinalPathNameByHandleW failed to write path"); + } + + let os_str: OsString = OsString::from_wide(&buf[..written as usize]); + Ok(PathBuf::from(os_str)) } } @@ -320,7 +359,7 @@ pub struct RealWatcher {} impl RealFs { pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Self { Self { - git_binary_path, + bundled_git_binary_path: git_binary_path, executor, } } @@ -338,7 +377,19 @@ impl Fs for RealFs { #[cfg(windows)] if smol::fs::metadata(&target).await?.is_dir() { - smol::fs::windows::symlink_dir(target, path).await? + let status = smol::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .args([path, target.as_path()]) + .status() + .await?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Failed to create junction from {:?} to {:?}", + path, + target + )); + } } else { smol::fs::windows::symlink_file(target, path).await? } @@ -420,18 +471,19 @@ impl Fs for RealFs { async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> { #[cfg(windows)] - if let Ok(Some(metadata)) = self.metadata(path).await { - if metadata.is_symlink && metadata.is_dir { - self.remove_dir( - path, - RemoveOptions { - recursive: false, - ignore_if_not_exists: true, - }, - ) - .await?; - return Ok(()); - } + if let Ok(Some(metadata)) = self.metadata(path).await + && metadata.is_symlink + && metadata.is_dir + { + self.remove_dir( + path, + RemoveOptions { + recursive: false, + ignore_if_not_exists: true, + }, + ) + .await?; + return Ok(()); } match smol::fs::remove_file(path).await { @@ -467,11 +519,11 @@ impl Fs for RealFs { #[cfg(any(target_os = "linux", target_os = "freebsd"))] async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> { - if let Ok(Some(metadata)) = self.metadata(path).await { - if metadata.is_symlink { - // TODO: trash_file does not support trashing symlinks yet - https://github.com/bilelmoussaoui/ashpd/issues/255 - return self.remove_file(path, RemoveOptions::default()).await; - } + if let Ok(Some(metadata)) = self.metadata(path).await + && metadata.is_symlink + { + // TODO: trash_file does not support trashing symlinks yet - https://github.com/bilelmoussaoui/ashpd/issues/255 + return self.remove_file(path, RemoveOptions::default()).await; } let file = smol::fs::File::open(path).await?; match trash::trash_file(&file.as_fd()).await { @@ -494,7 +546,8 @@ impl Fs for RealFs { }; // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = SanitizedPath::from(path.canonicalize()?); + let path = path.canonicalize()?; + let path = SanitizedPath::new(&path); let path_string = path.to_string(); let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?; file.DeleteAsync(StorageDeleteOption::Default)?.get()?; @@ -521,7 +574,8 @@ impl Fs for RealFs { // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = SanitizedPath::from(path.canonicalize()?); + let path = path.canonicalize()?; + let path = SanitizedPath::new(&path); let path_string = path.to_string(); let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?; folder.DeleteAsync(StorageDeleteOption::Default)?.get()?; @@ -533,17 +587,29 @@ impl Fs for RealFs { } async fn open_handle(&self, path: &Path) -> Result> { - Ok(Arc::new(std::fs::File::open(path)?)) + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(windows::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS.0); + } + Ok(Arc::new(options.open(path)?)) } async fn load(&self, path: &Path) -> Result { let path = path.to_path_buf(); - let text = smol::unblock(|| std::fs::read_to_string(path)).await?; - Ok(text) + self.executor + .spawn(async move { Ok(std::fs::read_to_string(path)?) }) + .await } + async fn load_bytes(&self, path: &Path) -> Result> { let path = path.to_path_buf(); - let bytes = smol::unblock(|| std::fs::read(path)).await?; + let bytes = self + .executor + .spawn(async move { std::fs::read(path) }) + .await?; Ok(bytes) } @@ -611,30 +677,46 @@ impl Fs for RealFs { if let Some(path) = path.parent() { self.create_dir(path).await?; } - smol::fs::write(path, content).await?; - Ok(()) + let path = path.to_owned(); + let contents = content.to_owned(); + self.executor + .spawn(async move { + std::fs::write(path, contents)?; + Ok(()) + }) + .await } async fn canonicalize(&self, path: &Path) -> Result { - Ok(smol::fs::canonicalize(path) + let path = path.to_owned(); + self.executor + .spawn(async move { + std::fs::canonicalize(&path).with_context(|| format!("canonicalizing {path:?}")) + }) .await - .with_context(|| format!("canonicalizing {path:?}"))?) } async fn is_file(&self, path: &Path) -> bool { - smol::fs::metadata(path) + let path = path.to_owned(); + self.executor + .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) }) .await - .map_or(false, |metadata| metadata.is_file()) } async fn is_dir(&self, path: &Path) -> bool { - smol::fs::metadata(path) + let path = path.to_owned(); + self.executor + .spawn(async move { std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) }) .await - .map_or(false, |metadata| metadata.is_dir()) } async fn metadata(&self, path: &Path) -> Result> { - let symlink_metadata = match smol::fs::symlink_metadata(path).await { + let path_buf = path.to_owned(); + let symlink_metadata = match self + .executor + .spawn(async move { std::fs::symlink_metadata(&path_buf) }) + .await + { Ok(metadata) => metadata, Err(err) => { return match (err.kind(), err.raw_os_error()) { @@ -645,19 +727,28 @@ impl Fs for RealFs { } }; - let path_buf = path.to_path_buf(); - let path_exists = smol::unblock(move || { - path_buf - .try_exists() - .with_context(|| format!("checking existence for path {path_buf:?}")) - }) - .await?; let is_symlink = symlink_metadata.file_type().is_symlink(); - let metadata = match (is_symlink, path_exists) { - (true, true) => smol::fs::metadata(path) - .await - .with_context(|| "accessing symlink for path {path}")?, - _ => symlink_metadata, + let metadata = if is_symlink { + let path_buf = path.to_path_buf(); + let path_exists = self + .executor + .spawn(async move { + path_buf + .try_exists() + .with_context(|| format!("checking existence for path {path_buf:?}")) + }) + .await?; + if path_exists { + let path_buf = path.to_path_buf(); + self.executor + .spawn(async move { std::fs::metadata(path_buf) }) + .await + .with_context(|| "accessing symlink for path {path}")? + } else { + symlink_metadata + } + } else { + symlink_metadata }; #[cfg(unix)] @@ -674,7 +765,7 @@ impl Fs for RealFs { Ok(Some(Metadata { inode, - mtime: MTime(metadata.modified().unwrap()), + mtime: MTime(metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH)), len: metadata.len(), is_symlink, is_dir: metadata.file_type().is_dir(), @@ -683,7 +774,11 @@ impl Fs for RealFs { } async fn read_link(&self, path: &Path) -> Result { - let path = smol::fs::read_link(path).await?; + let path = path.to_owned(); + let path = self + .executor + .spawn(async move { std::fs::read_link(&path) }) + .await?; Ok(path) } @@ -691,7 +786,13 @@ impl Fs for RealFs { &self, path: &Path, ) -> Result>>>> { - let result = smol::fs::read_dir(path).await?.map(|entry| match entry { + let path = path.to_owned(); + let result = iter( + self.executor + .spawn(async move { std::fs::read_dir(path) }) + .await?, + ) + .map(|entry| match entry { Ok(entry) => Ok(entry.path()), Err(error) => Err(anyhow!("failed to read dir entry {error:?}")), }); @@ -725,11 +826,14 @@ impl Fs for RealFs { events .into_iter() .map(|event| { + log::trace!("fs path event: {event:?}"); let kind = if event.flags.contains(StreamFlags::ITEM_REMOVED) { Some(PathEventKind::Removed) } else if event.flags.contains(StreamFlags::ITEM_CREATED) { Some(PathEventKind::Created) - } else if event.flags.contains(StreamFlags::ITEM_MODIFIED) { + } else if event.flags.contains(StreamFlags::ITEM_MODIFIED) + | event.flags.contains(StreamFlags::ITEM_RENAMED) + { Some(PathEventKind::Changed) } else { None @@ -766,24 +870,28 @@ impl Fs for RealFs { let pending_paths: Arc>> = Default::default(); let watcher = Arc::new(fs_watcher::FsWatcher::new(tx, pending_paths.clone())); - if watcher.add(path).is_err() { - // If the path doesn't exist yet (e.g. settings.json), watch the parent dir to learn when it's created. - if let Some(parent) = path.parent() { - if let Err(e) = watcher.add(parent) { - log::warn!("Failed to watch: {e}"); - } - } + // If the path doesn't exist yet (e.g. settings.json), watch the parent dir to learn when it's created. + if let Err(e) = watcher.add(path) + && let Some(parent) = path.parent() + && let Err(parent_e) = watcher.add(parent) + { + log::warn!( + "Failed to watch {} and its parent directory {}:\n{e}\n{parent_e}", + path.display(), + parent.display() + ); } // Check if path is a symlink and follow the target parent - if let Some(mut target) = self.read_link(&path).await.ok() { + if let Some(mut target) = self.read_link(path).await.ok() { + log::trace!("watch symlink {path:?} -> {target:?}"); // Check if symlink target is relative path, if so make it absolute - if target.is_relative() { - if let Some(parent) = path.parent() { - target = parent.join(target); - if let Ok(canonical) = self.canonicalize(&target).await { - target = SanitizedPath::from(canonical).as_path().to_path_buf(); - } + if target.is_relative() + && let Some(parent) = path.parent() + { + target = parent.join(target); + if let Ok(canonical) = self.canonicalize(&target).await { + target = SanitizedPath::new(&canonical).as_path().to_path_buf(); } } watcher.add(&target).ok(); @@ -809,19 +917,29 @@ impl Fs for RealFs { ) } - fn open_repo(&self, dotgit_path: &Path) -> Option> { + fn open_repo( + &self, + dotgit_path: &Path, + system_git_binary_path: Option<&Path>, + ) -> Option> { Some(Arc::new(RealGitRepository::new( dotgit_path, - self.git_binary_path.clone(), + self.bundled_git_binary_path.clone(), + system_git_binary_path.map(|path| path.to_path_buf()), self.executor.clone(), )?)) } - fn git_init(&self, abs_work_directory_path: &Path, fallback_branch_name: String) -> Result<()> { - let config = new_std_command("git") + async fn git_init( + &self, + abs_work_directory_path: &Path, + fallback_branch_name: String, + ) -> Result<()> { + let config = new_smol_command("git") .current_dir(abs_work_directory_path) .args(&["config", "--global", "--get", "init.defaultBranch"]) - .output()?; + .output() + .await?; let branch_name; @@ -831,11 +949,12 @@ impl Fs for RealFs { branch_name = Cow::Borrowed(fallback_branch_name.as_str()); } - new_std_command("git") + new_smol_command("git") .current_dir(abs_work_directory_path) .args(&["init", "-b"]) .arg(branch_name.trim()) - .output()?; + .output() + .await?; Ok(()) } @@ -897,10 +1016,6 @@ impl Fs for RealFs { temp_dir.close()?; case_sensitive } - - fn home_dir(&self) -> Option { - Some(paths::home_dir().clone()) - } } #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] @@ -935,7 +1050,6 @@ struct FakeFsState { read_dir_call_count: usize, path_write_counts: std::collections::HashMap, moves: std::collections::HashMap, - home_dir: Option, } #[cfg(any(test, feature = "test-support"))] @@ -1068,13 +1182,13 @@ impl FakeFsState { let current_entry = *entry_stack.last()?; if let FakeFsEntry::Dir { entries, .. } = current_entry { let entry = entries.get(name.to_str().unwrap())?; - if path_components.peek().is_some() || follow_symlink { - if let FakeFsEntry::Symlink { target, .. } = entry { - let mut target = target.clone(); - target.extend(path_components); - path = target; - continue 'outer; - } + if (path_components.peek().is_some() || follow_symlink) + && let FakeFsEntry::Symlink { target, .. } = entry + { + let mut target = target.clone(); + target.extend(path_components); + path = target; + continue 'outer; } entry_stack.push(entry); canonical_path = canonical_path.join(name); @@ -1101,7 +1215,9 @@ impl FakeFsState { ) -> Option<(&mut FakeFsEntry, PathBuf)> { let canonical_path = self.canonicalize(target, follow_symlink)?; - let mut components = canonical_path.components(); + let mut components = canonical_path + .components() + .skip_while(|component| matches!(component, Component::Prefix(_))); let Some(Component::RootDir) = components.next() else { panic!( "the path {:?} was not canonicalized properly {:?}", @@ -1218,7 +1334,6 @@ impl FakeFs { metadata_call_count: 0, path_write_counts: Default::default(), moves: Default::default(), - home_dir: None, })), }); @@ -1227,7 +1342,7 @@ impl FakeFs { async move { while let Ok(git_event) = rx.recv().await { if let Some(mut state) = this.state.try_lock() { - state.emit_event([(git_event, None)]); + state.emit_event([(git_event, Some(PathEventKind::Changed))]); } else { panic!("Failed to lock file system state, this execution would have caused a test hang"); } @@ -1274,7 +1389,7 @@ impl FakeFs { Ok(()) }) .unwrap(); - state.emit_event([(path.to_path_buf(), None)]); + state.emit_event([(path.to_path_buf(), Some(PathEventKind::Changed))]); } pub async fn insert_file(&self, path: impl AsRef, content: Vec) { @@ -1297,7 +1412,7 @@ impl FakeFs { } }) .unwrap(); - state.emit_event([(path, None)]); + state.emit_event([(path, Some(PathEventKind::Created))]); } fn write_file_internal( @@ -1488,7 +1603,7 @@ impl FakeFs { drop(repo_state); if emit_git_event { - state.emit_event([(dot_git, None)]); + state.emit_event([(dot_git, Some(PathEventKind::Changed))]); } Ok(result) @@ -1539,7 +1654,7 @@ impl FakeFs { if emit_git_event { drop(repo_state); - state.emit_event([(canonical_path, None)]); + state.emit_event([(canonical_path, Some(PathEventKind::Changed))]); } Ok(result) @@ -1566,10 +1681,10 @@ impl FakeFs { pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) { self.with_git_state(dot_git, true, |state| { - if let Some(first) = branches.first() { - if state.current_branch_name.is_none() { - state.current_branch_name = Some(first.to_string()) - } + if let Some(first) = branches.first() + && state.current_branch_name.is_none() + { + state.current_branch_name = Some(first.to_string()) } state .branches @@ -1594,13 +1709,13 @@ impl FakeFs { .unwrap(); } - pub fn set_index_for_repo(&self, dot_git: &Path, index_state: &[(RepoPath, String)]) { + pub fn set_index_for_repo(&self, dot_git: &Path, index_state: &[(&str, String)]) { self.with_git_state(dot_git, true, |state| { state.index_contents.clear(); state.index_contents.extend( index_state .iter() - .map(|(path, content)| (path.clone(), content.clone())), + .map(|(path, content)| (repo_path(path), content.clone())), ); }) .unwrap(); @@ -1609,7 +1724,7 @@ impl FakeFs { pub fn set_head_for_repo( &self, dot_git: &Path, - head_state: &[(RepoPath, String)], + head_state: &[(&str, String)], sha: impl Into, ) { self.with_git_state(dot_git, true, |state| { @@ -1617,50 +1732,42 @@ impl FakeFs { state.head_contents.extend( head_state .iter() - .map(|(path, content)| (path.clone(), content.clone())), + .map(|(path, content)| (repo_path(path), content.clone())), ); state.refs.insert("HEAD".into(), sha.into()); }) .unwrap(); } - pub fn set_git_content_for_repo( - &self, - dot_git: &Path, - head_state: &[(RepoPath, String, Option)], - ) { + pub fn set_head_and_index_for_repo(&self, dot_git: &Path, contents_by_path: &[(&str, String)]) { self.with_git_state(dot_git, true, |state| { state.head_contents.clear(); state.head_contents.extend( - head_state + contents_by_path .iter() - .map(|(path, head_content, _)| (path.clone(), head_content.clone())), + .map(|(path, contents)| (repo_path(path), contents.clone())), ); - state.index_contents.clear(); - state.index_contents.extend(head_state.iter().map( - |(path, head_content, index_content)| { - ( - path.clone(), - index_content.as_ref().unwrap_or(head_content).clone(), - ) - }, - )); + state.index_contents = state.head_contents.clone(); }) .unwrap(); } - pub fn set_head_and_index_for_repo( + pub fn set_merge_base_content_for_repo( &self, dot_git: &Path, - contents_by_path: &[(RepoPath, String)], + contents_by_path: &[(&str, String)], ) { self.with_git_state(dot_git, true, |state| { - state.head_contents.clear(); - state.index_contents.clear(); - state.head_contents.extend(contents_by_path.iter().cloned()); - state - .index_contents - .extend(contents_by_path.iter().cloned()); + use git::Oid; + + state.merge_base_contents.clear(); + let oids = (1..) + .map(|n| n.to_string()) + .map(|n| Oid::from_bytes(n.repeat(20).as_bytes()).unwrap()); + for ((path, content), oid) in contents_by_path.iter().zip(oids) { + state.merge_base_contents.insert(repo_path(path), oid); + state.oids.insert(oid, content.clone()); + } }) .unwrap(); } @@ -1675,18 +1782,20 @@ impl FakeFs { /// Put the given git repository into a state with the given status, /// by mutating the head, index, and unmerged state. - pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&Path, FileStatus)]) { + pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&str, FileStatus)]) { let workdir_path = dot_git.parent().unwrap(); - let workdir_contents = self.files_with_contents(&workdir_path); + let workdir_contents = self.files_with_contents(workdir_path); self.with_git_state(dot_git, true, |state| { state.index_contents.clear(); state.head_contents.clear(); state.unmerged_paths.clear(); for (path, content) in workdir_contents { - let repo_path: RepoPath = path.strip_prefix(&workdir_path).unwrap().into(); + use util::{paths::PathStyle, rel_path::RelPath}; + + let repo_path: RepoPath = RelPath::new(path.strip_prefix(&workdir_path).unwrap(), PathStyle::local()).unwrap().into(); let status = statuses .iter() - .find_map(|(p, status)| (**p == *repo_path.0).then_some(status)); + .find_map(|(p, status)| (*p == repo_path.as_unix_str()).then_some(status)); let mut content = String::from_utf8_lossy(&content).to_string(); let mut index_content = None; @@ -1878,12 +1987,12 @@ impl FakeFs { .unwrap_or(0) } - fn simulate_random_delay(&self) -> impl futures::Future { - self.executor.simulate_random_delay() + pub fn emit_fs_event(&self, path: impl Into, event: Option) { + self.state.lock().emit_event(std::iter::once((path, event))); } - pub fn set_home_dir(&self, home_dir: PathBuf) { - self.state.lock().home_dir = Some(home_dir); + fn simulate_random_delay(&self) -> impl futures::Future { + self.executor.simulate_random_delay() } } @@ -1958,7 +2067,7 @@ impl FileHandle for FakeHandle { }; if state.try_entry(&target, false).is_some() { - return Ok(target.clone()); + return Ok(target); } anyhow::bail!("fake fd target not found") } @@ -2049,7 +2158,7 @@ impl Fs for FakeFs { } }) .unwrap(); - state.emit_event([(path, None)]); + state.emit_event([(path, Some(PathEventKind::Created))]); Ok(()) } @@ -2244,7 +2353,7 @@ impl Fs for FakeFs { async fn open_handle(&self, path: &Path) -> Result> { self.simulate_random_delay().await; let mut state = self.state.lock(); - let inode = match state.entry(&path)? { + let inode = match state.entry(path)? { FakeFsEntry::File { inode, .. } => *inode, FakeFsEntry::Dir { inode, .. } => *inode, _ => unreachable!(), @@ -2254,7 +2363,7 @@ impl Fs for FakeFs { async fn load(&self, path: &Path) -> Result { let content = self.load_internal(path).await?; - Ok(String::from_utf8(content.clone())?) + Ok(String::from_utf8(content)?) } async fn load_bytes(&self, path: &Path) -> Result> { @@ -2410,19 +2519,18 @@ impl Fs for FakeFs { tx, original_path: path.to_owned(), fs_state: self.state.clone(), - prefixes: Mutex::new(vec![path.to_owned()]), + prefixes: Mutex::new(vec![path]), }); ( Box::pin(futures::StreamExt::filter(rx, { let watcher = watcher.clone(); move |events| { let result = events.iter().any(|evt_path| { - let result = watcher + watcher .prefixes .lock() .iter() - .any(|prefix| evt_path.path.starts_with(prefix)); - result + .any(|prefix| evt_path.path.starts_with(prefix)) }); let executor = executor.clone(); async move { @@ -2435,7 +2543,11 @@ impl Fs for FakeFs { ) } - fn open_repo(&self, abs_dot_git: &Path) -> Option> { + fn open_repo( + &self, + abs_dot_git: &Path, + _system_git_binary: Option<&Path>, + ) -> Option> { use util::ResultExt as _; self.with_git_state_and_paths( @@ -2455,12 +2567,12 @@ impl Fs for FakeFs { .log_err() } - fn git_init( + async fn git_init( &self, abs_work_directory_path: &Path, _fallback_branch_name: String, ) -> Result<()> { - smol::block_on(self.create_dir(&abs_work_directory_path.join(".git"))) + self.create_dir(&abs_work_directory_path.join(".git")).await } async fn git_clone(&self, _repo_url: &str, _abs_work_directory: &Path) -> Result<()> { @@ -2479,10 +2591,6 @@ impl Fs for FakeFs { fn as_fake(&self) -> Arc { self.this.upgrade().unwrap() } - - fn home_dir(&self) -> Option { - self.state.lock().home_dir.clone() - } } fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator { @@ -2656,8 +2764,8 @@ fn atomic_replace>( unsafe { ReplaceFileW( - &HSTRING::from(replaced_file.as_ref().to_string_lossy().to_string()), - &HSTRING::from(replacement_file.as_ref().to_string_lossy().to_string()), + &HSTRING::from(replaced_file.as_ref().to_string_lossy().into_owned()), + &HSTRING::from(replacement_file.as_ref().to_string_lossy().into_owned()), None, REPLACE_FILE_FLAGS::default(), None, @@ -3091,7 +3199,7 @@ mod tests { // With the file handle still open, the file should be replaced // https://github.com/zed-industries/zed/issues/30054 let fs = RealFs { - git_binary_path: None, + bundled_git_binary_path: None, executor, }; let temp_dir = TempDir::new().unwrap(); @@ -3109,7 +3217,7 @@ mod tests { #[gpui::test] async fn test_realfs_atomic_write_non_existing_file(executor: BackgroundExecutor) { let fs = RealFs { - git_binary_path: None, + bundled_git_binary_path: None, executor, }; let temp_dir = TempDir::new().unwrap(); diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index a5ce21294fc65e609428ad95fafb43fe578bc698..32be1112d0b235281d33dd14534ebb87d8a3bc55 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -1,7 +1,8 @@ use notify::EventKind; use parking_lot::Mutex; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, + ops::DerefMut, sync::{Arc, OnceLock}, }; use util::{ResultExt, paths::SanitizedPath}; @@ -11,7 +12,7 @@ use crate::{PathEvent, PathEventKind, Watcher}; pub struct FsWatcher { tx: smol::channel::Sender<()>, pending_path_events: Arc>>, - registrations: Mutex, WatcherRegistrationId>>, + registrations: Mutex, WatcherRegistrationId>>, } impl FsWatcher { @@ -29,8 +30,11 @@ impl FsWatcher { impl Drop for FsWatcher { fn drop(&mut self) { - let mut registrations = self.registrations.lock(); - let registrations = registrations.drain(); + let mut registrations = BTreeMap::new(); + { + let old = &mut self.registrations.lock(); + std::mem::swap(old.deref_mut(), &mut registrations); + } let _ = global(|g| { for (_, registration) in registrations { @@ -42,57 +46,83 @@ impl Drop for FsWatcher { impl Watcher for FsWatcher { fn add(&self, path: &std::path::Path) -> anyhow::Result<()> { - let root_path = SanitizedPath::from(path); - + log::trace!("watcher add: {path:?}"); let tx = self.tx.clone(); let pending_paths = self.pending_path_events.clone(); + #[cfg(target_os = "windows")] + { + // Return early if an ancestor of this path was already being watched. + // saves a huge amount of memory + if let Some((watched_path, _)) = self + .registrations + .lock() + .range::(( + std::ops::Bound::Unbounded, + std::ops::Bound::Included(path), + )) + .next_back() + && path.starts_with(watched_path.as_ref()) + { + log::trace!( + "path to watch is covered by existing registration: {path:?}, {watched_path:?}" + ); + return Ok(()); + } + } + #[cfg(target_os = "linux")] + { + log::trace!("path to watch is already watched: {path:?}"); + if self.registrations.lock().contains_key(path) { + return Ok(()); + } + } + + let root_path = SanitizedPath::new_arc(path); let path: Arc = path.into(); - if self.registrations.lock().contains_key(&path) { - return Ok(()); - } + #[cfg(target_os = "windows")] + let mode = notify::RecursiveMode::Recursive; + #[cfg(target_os = "linux")] + let mode = notify::RecursiveMode::NonRecursive; let registration_id = global({ let path = path.clone(); |g| { - g.add( - path, - notify::RecursiveMode::NonRecursive, - move |event: ¬ify::Event| { - let kind = match event.kind { - EventKind::Create(_) => Some(PathEventKind::Created), - EventKind::Modify(_) => Some(PathEventKind::Changed), - EventKind::Remove(_) => Some(PathEventKind::Removed), - _ => None, - }; - let mut path_events = event - .paths - .iter() - .filter_map(|event_path| { - let event_path = SanitizedPath::from(event_path); - event_path.starts_with(&root_path).then(|| PathEvent { - path: event_path.as_path().to_path_buf(), - kind, - }) + g.add(path, mode, move |event: ¬ify::Event| { + log::trace!("watcher received event: {event:?}"); + let kind = match event.kind { + EventKind::Create(_) => Some(PathEventKind::Created), + EventKind::Modify(_) => Some(PathEventKind::Changed), + EventKind::Remove(_) => Some(PathEventKind::Removed), + _ => None, + }; + let mut path_events = event + .paths + .iter() + .filter_map(|event_path| { + let event_path = SanitizedPath::new(event_path); + event_path.starts_with(&root_path).then(|| PathEvent { + path: event_path.as_path().to_path_buf(), + kind, }) - .collect::>(); - - if !path_events.is_empty() { - path_events.sort(); - let mut pending_paths = pending_paths.lock(); - if pending_paths.is_empty() { - tx.try_send(()).ok(); - } - util::extend_sorted( - &mut *pending_paths, - path_events, - usize::MAX, - |a, b| a.path.cmp(&b.path), - ); + }) + .collect::>(); + + if !path_events.is_empty() { + path_events.sort(); + let mut pending_paths = pending_paths.lock(); + if pending_paths.is_empty() { + tx.try_send(()).ok(); } - }, - ) + util::extend_sorted( + &mut *pending_paths, + path_events, + usize::MAX, + |a, b| a.path.cmp(&b.path), + ); + } + }) } })??; @@ -102,6 +132,7 @@ impl Watcher for FsWatcher { } fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> { + log::trace!("remove watched path: {path:?}"); let Some(registration) = self.registrations.lock().remove(path) else { return Ok(()); }; @@ -159,7 +190,7 @@ impl GlobalWatcher { path: path.clone(), }; state.watchers.insert(id, registration_state); - *state.path_registrations.entry(path.clone()).or_insert(0) += 1; + *state.path_registrations.entry(path).or_insert(0) += 1; Ok(id) } @@ -191,6 +222,7 @@ static FS_WATCHER_INSTANCE: OnceLock) { + log::trace!("global handle event: {event:?}"); // Filter out access events, which could lead to a weird bug on Linux after upgrading notify // https://github.com/zed-industries/zed/actions/runs/14085230504/job/39449448832 let Some(event) = event diff --git a/crates/fs/src/mac_watcher.rs b/crates/fs/src/mac_watcher.rs index aa75ad31d9beadada32b62ed4d21a612631d31c3..b781a231ba2bc33a895480ea278a7ccfe3364fe7 100644 --- a/crates/fs/src/mac_watcher.rs +++ b/crates/fs/src/mac_watcher.rs @@ -6,6 +6,7 @@ use parking_lot::Mutex; use std::{ path::{Path, PathBuf}, sync::Weak, + thread, time::Duration, }; @@ -31,6 +32,7 @@ impl MacWatcher { impl Watcher for MacWatcher { fn add(&self, path: &Path) -> Result<()> { + log::trace!("mac watcher add: {:?}", path); let handles = self .handles .upgrade() @@ -41,17 +43,22 @@ impl Watcher for MacWatcher { if let Some((watched_path, _)) = handles .range::((Bound::Unbounded, Bound::Included(path))) .next_back() + && path.starts_with(watched_path) { - if path.starts_with(watched_path) { - return Ok(()); - } + log::trace!( + "mac watched path starts with existing watched path: {watched_path:?}, {path:?}" + ); + return Ok(()); } let (stream, handle) = EventStream::new(&[path], self.latency); let tx = self.events_tx.clone(); - std::thread::spawn(move || { - stream.run(move |events| smol::block_on(tx.send(events)).is_ok()); - }); + thread::Builder::new() + .name("MacWatcher".to_owned()) + .spawn(move || { + stream.run(move |events| smol::block_on(tx.send(events)).is_ok()); + }) + .unwrap(); handles.insert(path.into(), handle); Ok(()) diff --git a/crates/fs_benchmarks/Cargo.toml b/crates/fs_benchmarks/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f207a2db3b7354ca96347aaffb5c1915a514ef7c --- /dev/null +++ b/crates/fs_benchmarks/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fs_benchmarks" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +fs.workspace = true +gpui = {workspace = true, features = ["windows-manifest"]} + +[lints] +workspace = true diff --git a/crates/semantic_index/LICENSE-GPL b/crates/fs_benchmarks/LICENSE-GPL similarity index 100% rename from crates/semantic_index/LICENSE-GPL rename to crates/fs_benchmarks/LICENSE-GPL diff --git a/crates/fs_benchmarks/src/main.rs b/crates/fs_benchmarks/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..12df32f0763e02a95c3f261d2c14fa6e295c304e --- /dev/null +++ b/crates/fs_benchmarks/src/main.rs @@ -0,0 +1,32 @@ +use fs::Fs; +use gpui::{AppContext, Application}; +fn main() { + let Some(path_to_read) = std::env::args().nth(1) else { + println!("Expected path to read as 1st argument."); + return; + }; + + let _ = Application::headless().run(|cx| { + let fs = fs::RealFs::new(None, cx.background_executor().clone()); + cx.background_spawn(async move { + let timer = std::time::Instant::now(); + let result = fs.load_bytes(path_to_read.as_ref()).await; + let elapsed = timer.elapsed(); + if let Err(e) = result { + println!("Failed `load_bytes` after {elapsed:?} with error `{e}`"); + } else { + println!("Took {elapsed:?} to read {} bytes", result.unwrap().len()); + }; + let timer = std::time::Instant::now(); + let result = fs.metadata(path_to_read.as_ref()).await; + let elapsed = timer.elapsed(); + if let Err(e) = result { + println!("Failed `metadata` after {elapsed:?} with error `{e}`"); + } else { + println!("Took {elapsed:?} to query metadata"); + }; + std::process::exit(0); + }) + .detach(); + }); +} diff --git a/crates/fsevent/Cargo.toml b/crates/fsevent/Cargo.toml index c9cec9c1e1a09f73e0464a18ed2e5ac6cadab2d4..635b36ebe14ee6823f8773bb38ff085516e320b9 100644 --- a/crates/fsevent/Cargo.toml +++ b/crates/fsevent/Cargo.toml @@ -15,7 +15,7 @@ doctest = false [dependencies] bitflags.workspace = true parking_lot.workspace = true -workspace-hack.workspace = true +log.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true diff --git a/crates/fsevent/src/fsevent.rs b/crates/fsevent/src/fsevent.rs index 81ca0a4114253fc38b5d120d1c37dfc9233f7fd1..e4060f3ae06a8d9412baf1cd75a9503c1b6d359b 100644 --- a/crates/fsevent/src/fsevent.rs +++ b/crates/fsevent/src/fsevent.rs @@ -70,10 +70,14 @@ impl EventStream { path_bytes.len() as cf::CFIndex, false, ); - let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle); - cf::CFArrayAppendValue(cf_paths, cf_path); - cf::CFRelease(cf_path); - cf::CFRelease(cf_url); + if !cf_url.is_null() { + let cf_path = cf::CFURLCopyFileSystemPath(cf_url, cf::kCFURLPOSIXPathStyle); + cf::CFArrayAppendValue(cf_paths, cf_path); + cf::CFRelease(cf_path); + cf::CFRelease(cf_url); + } else { + log::error!("Failed to create CFURL for path: {}", path.display()); + } } let mut state = Box::new(State { @@ -178,40 +182,39 @@ impl EventStream { flags.contains(StreamFlags::USER_DROPPED) || flags.contains(StreamFlags::KERNEL_DROPPED) }) + && let Some(last_valid_event_id) = state.last_valid_event_id.take() { - if let Some(last_valid_event_id) = state.last_valid_event_id.take() { - fs::FSEventStreamStop(state.stream); - fs::FSEventStreamInvalidate(state.stream); - fs::FSEventStreamRelease(state.stream); - - let stream_context = fs::FSEventStreamContext { - version: 0, - info, - retain: None, - release: None, - copy_description: None, - }; - let stream = fs::FSEventStreamCreate( - cf::kCFAllocatorDefault, - Self::trampoline, - &stream_context, - state.paths, - last_valid_event_id, - state.latency.as_secs_f64(), - fs::kFSEventStreamCreateFlagFileEvents - | fs::kFSEventStreamCreateFlagNoDefer - | fs::kFSEventStreamCreateFlagWatchRoot, - ); - - state.stream = stream; - fs::FSEventStreamScheduleWithRunLoop( - state.stream, - cf::CFRunLoopGetCurrent(), - cf::kCFRunLoopDefaultMode, - ); - fs::FSEventStreamStart(state.stream); - stream_restarted = true; - } + fs::FSEventStreamStop(state.stream); + fs::FSEventStreamInvalidate(state.stream); + fs::FSEventStreamRelease(state.stream); + + let stream_context = fs::FSEventStreamContext { + version: 0, + info, + retain: None, + release: None, + copy_description: None, + }; + let stream = fs::FSEventStreamCreate( + cf::kCFAllocatorDefault, + Self::trampoline, + &stream_context, + state.paths, + last_valid_event_id, + state.latency.as_secs_f64(), + fs::kFSEventStreamCreateFlagFileEvents + | fs::kFSEventStreamCreateFlagNoDefer + | fs::kFSEventStreamCreateFlagWatchRoot, + ); + + state.stream = stream; + fs::FSEventStreamScheduleWithRunLoop( + state.stream, + cf::CFRunLoopGetCurrent(), + cf::kCFRunLoopDefaultMode, + ); + fs::FSEventStreamStart(state.stream); + stream_restarted = true; } if !stream_restarted { diff --git a/crates/fuzzy/Cargo.toml b/crates/fuzzy/Cargo.toml index 534d7d4db5bc2637f7b093f67cead7a3fa52b416..7df2142fa1862a39f83bb74af773a410e5823b4f 100644 --- a/crates/fuzzy/Cargo.toml +++ b/crates/fuzzy/Cargo.toml @@ -16,4 +16,6 @@ doctest = false gpui.workspace = true util.workspace = true log.workspace = true -workspace-hack.workspace = true + +[dev-dependencies] +util = {workspace = true, features = ["test-support"]} diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index e649d47dd646b80e312e2465f0929f630fecf81f..eb844e349821394785bb61a34600f04a6fa985eb 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -1,5 +1,5 @@ use std::{ - borrow::{Borrow, Cow}, + borrow::Borrow, collections::BTreeMap, sync::atomic::{self, AtomicBool}, }; @@ -27,7 +27,7 @@ pub struct Matcher<'a> { pub trait MatchCandidate { fn has_chars(&self, bag: CharBag) -> bool; - fn to_string(&self) -> Cow<'_, str>; + fn candidate_chars(&self) -> impl Iterator; } impl<'a> Matcher<'a> { @@ -76,14 +76,14 @@ impl<'a> Matcher<'a> { continue; } - if cancel_flag.load(atomic::Ordering::Relaxed) { + if cancel_flag.load(atomic::Ordering::Acquire) { break; } candidate_chars.clear(); lowercase_candidate_chars.clear(); extra_lowercase_chars.clear(); - for (i, c) in candidate.borrow().to_string().chars().enumerate() { + for (i, c) in candidate.borrow().candidate_chars().enumerate() { candidate_chars.push(c); let mut char_lowercased = c.to_lowercase().collect::>(); if char_lowercased.len() > 1 { @@ -202,8 +202,6 @@ impl<'a> Matcher<'a> { cur_score: f64, extra_lowercase_chars: &BTreeMap, ) -> f64 { - use std::path::MAIN_SEPARATOR; - if query_idx == self.query.len() { return 1.0; } @@ -245,17 +243,11 @@ impl<'a> Matcher<'a> { None => continue, } }; - let is_path_sep = path_char == MAIN_SEPARATOR; + let is_path_sep = path_char == '/'; if query_idx == 0 && is_path_sep { last_slash = j_regular; } - - #[cfg(not(target_os = "windows"))] - let need_to_score = - query_char == path_char || (is_path_sep && query_char == '_' || query_char == '\\'); - // `query_char == '\\'` breaks `test_match_path_entries` on Windows, `\` is only used as a path separator on Windows. - #[cfg(target_os = "windows")] let need_to_score = query_char == path_char || (is_path_sep && query_char == '_'); if need_to_score { let curr = match prefix.get(j_regular) { @@ -270,7 +262,7 @@ impl<'a> Matcher<'a> { None => path[j_regular - 1 - prefix.len()], }; - if last == MAIN_SEPARATOR { + if last == '/' { char_score = 0.9; } else if (last == '-' || last == '_' || last == ' ' || last.is_numeric()) || (last.is_lowercase() && curr.is_uppercase()) @@ -291,7 +283,7 @@ impl<'a> Matcher<'a> { // Apply a severe penalty if the case doesn't match. // This will make the exact matches have higher score than the case-insensitive and the // path insensitive matches. - if (self.smart_case || curr == MAIN_SEPARATOR) && self.query[query_idx] != curr { + if (self.smart_case || curr == '/') && self.query[query_idx] != curr { char_score *= 0.001; } @@ -348,13 +340,12 @@ impl<'a> Matcher<'a> { #[cfg(test)] mod tests { + use util::rel_path::{RelPath, rel_path}; + use crate::{PathMatch, PathMatchCandidate}; use super::*; - use std::{ - path::{Path, PathBuf}, - sync::Arc, - }; + use std::sync::Arc; #[test] fn test_get_last_positions() { @@ -376,7 +367,6 @@ mod tests { assert_eq!(matcher.last_positions, vec![0, 3, 4, 8]); } - #[cfg(not(target_os = "windows"))] #[test] fn test_match_path_entries() { let paths = vec![ @@ -388,9 +378,9 @@ mod tests { "alphabravocharlie", "AlphaBravoCharlie", "thisisatestdir", - "/////ThisIsATestDir", - "/this/is/a/test/dir", - "/test/tiatd", + "ThisIsATestDir", + "this/is/a/test/dir", + "test/tiatd", ]; assert_eq!( @@ -404,63 +394,15 @@ mod tests { ); assert_eq!( match_single_path_query("t/i/a/t/d", false, &paths), - vec![("/this/is/a/test/dir", vec![1, 5, 6, 8, 9, 10, 11, 15, 16]),] - ); - - assert_eq!( - match_single_path_query("tiatd", false, &paths), - vec![ - ("/test/tiatd", vec![6, 7, 8, 9, 10]), - ("/this/is/a/test/dir", vec![1, 6, 9, 11, 16]), - ("/////ThisIsATestDir", vec![5, 9, 11, 12, 16]), - ("thisisatestdir", vec![0, 2, 6, 7, 11]), - ] - ); - } - - /// todo(windows) - /// Now, on Windows, users can only use the backslash as a path separator. - /// I do want to support both the backslash and the forward slash as path separators on Windows. - #[cfg(target_os = "windows")] - #[test] - fn test_match_path_entries() { - let paths = vec![ - "", - "a", - "ab", - "abC", - "abcd", - "alphabravocharlie", - "AlphaBravoCharlie", - "thisisatestdir", - "\\\\\\\\\\ThisIsATestDir", - "\\this\\is\\a\\test\\dir", - "\\test\\tiatd", - ]; - - assert_eq!( - match_single_path_query("abc", false, &paths), - vec![ - ("abC", vec![0, 1, 2]), - ("abcd", vec![0, 1, 2]), - ("AlphaBravoCharlie", vec![0, 5, 10]), - ("alphabravocharlie", vec![4, 5, 10]), - ] - ); - assert_eq!( - match_single_path_query("t\\i\\a\\t\\d", false, &paths), - vec![( - "\\this\\is\\a\\test\\dir", - vec![1, 5, 6, 8, 9, 10, 11, 15, 16] - ),] + vec![("this/is/a/test/dir", vec![0, 4, 5, 7, 8, 9, 10, 14, 15]),] ); assert_eq!( match_single_path_query("tiatd", false, &paths), vec![ - ("\\test\\tiatd", vec![6, 7, 8, 9, 10]), - ("\\this\\is\\a\\test\\dir", vec![1, 6, 9, 11, 16]), - ("\\\\\\\\\\ThisIsATestDir", vec![5, 9, 11, 12, 16]), + ("test/tiatd", vec![5, 6, 7, 8, 9]), + ("ThisIsATestDir", vec![0, 4, 6, 7, 11]), + ("this/is/a/test/dir", vec![0, 5, 8, 10, 15]), ("thisisatestdir", vec![0, 2, 6, 7, 11]), ] ); @@ -491,7 +433,7 @@ mod tests { "aαbβ/cγdδ", "αβγδ/bcde", "c1️⃣2️⃣3️⃣/d4️⃣5️⃣6️⃣/e7️⃣8️⃣9️⃣/f", - "/d/🆒/h", + "d/🆒/h", ]; assert_eq!("1️⃣".len(), 7); assert_eq!( @@ -602,9 +544,9 @@ mod tests { let query = query.chars().collect::>(); let query_chars = CharBag::from(&lowercase_query[..]); - let path_arcs: Vec> = paths + let path_arcs: Vec> = paths .iter() - .map(|path| Arc::from(PathBuf::from(path))) + .map(|path| Arc::from(rel_path(path))) .collect::>(); let mut path_entries = Vec::new(); for (i, path) in paths.iter().enumerate() { @@ -632,8 +574,8 @@ mod tests { score, worktree_id: 0, positions: positions.clone(), - path: Arc::from(candidate.path), - path_prefix: "".into(), + path: candidate.path.into(), + path_prefix: RelPath::empty().into(), distance_to_relative_ancestor: usize::MAX, is_dir: false, }, @@ -647,7 +589,7 @@ mod tests { paths .iter() .copied() - .find(|p| result.path.as_ref() == Path::new(p)) + .find(|p| result.path.as_ref() == rel_path(p)) .unwrap(), result.positions, ) diff --git a/crates/fuzzy/src/paths.rs b/crates/fuzzy/src/paths.rs index 78030d5f964edb73e0f43f43ad412446dfbc9b34..b35f0c1ce6cec73995838eb82bf782d00f0129af 100644 --- a/crates/fuzzy/src/paths.rs +++ b/crates/fuzzy/src/paths.rs @@ -1,13 +1,12 @@ use gpui::BackgroundExecutor; use std::{ - borrow::Cow, cmp::{self, Ordering}, - path::Path, sync::{ Arc, atomic::{self, AtomicBool}, }, }; +use util::{paths::PathStyle, rel_path::RelPath}; use crate::{ CharBag, @@ -17,7 +16,7 @@ use crate::{ #[derive(Clone, Debug)] pub struct PathMatchCandidate<'a> { pub is_dir: bool, - pub path: &'a Path, + pub path: &'a RelPath, pub char_bag: CharBag, } @@ -26,8 +25,8 @@ pub struct PathMatch { pub score: f64, pub positions: Vec, pub worktree_id: usize, - pub path: Arc, - pub path_prefix: Arc, + pub path: Arc, + pub path_prefix: Arc, pub is_dir: bool, /// Number of steps removed from a shared parent with the relative path /// Used to order closer paths first in the search list @@ -41,8 +40,10 @@ pub trait PathMatchCandidateSet<'a>: Send + Sync { fn is_empty(&self) -> bool { self.len() == 0 } - fn prefix(&self) -> Arc; + fn root_is_file(&self) -> bool; + fn prefix(&self) -> Arc; fn candidates(&'a self, start: usize) -> Self::Candidates; + fn path_style(&self) -> PathStyle; } impl<'a> MatchCandidate for PathMatchCandidate<'a> { @@ -50,8 +51,8 @@ impl<'a> MatchCandidate for PathMatchCandidate<'a> { self.char_bag.is_superset(bag) } - fn to_string(&self) -> Cow<'a, str> { - self.path.to_string_lossy() + fn candidate_chars(&self) -> impl Iterator { + self.path.as_unix_str().chars() } } @@ -87,9 +88,11 @@ impl Ord for PathMatch { pub fn match_fixed_path_set( candidates: Vec, worktree_id: usize, + worktree_root_name: Option>, query: &str, smart_case: bool, max_results: usize, + path_style: PathStyle, ) -> Vec { let lowercase_query = query.to_lowercase().chars().collect::>(); let query = query.chars().collect::>(); @@ -97,10 +100,31 @@ pub fn match_fixed_path_set( let mut matcher = Matcher::new(&query, &lowercase_query, query_char_bag, smart_case, true); - let mut results = Vec::new(); + let mut results = Vec::with_capacity(candidates.len()); + let (path_prefix, path_prefix_chars, lowercase_prefix) = match worktree_root_name { + Some(worktree_root_name) => { + let mut path_prefix_chars = worktree_root_name + .display(path_style) + .chars() + .collect::>(); + path_prefix_chars.extend(path_style.separator().chars()); + let lowercase_pfx = path_prefix_chars + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect::>(); + + (worktree_root_name, path_prefix_chars, lowercase_pfx) + } + None => ( + RelPath::empty().into(), + Default::default(), + Default::default(), + ), + }; + matcher.match_candidates( - &[], - &[], + &path_prefix_chars, + &lowercase_prefix, candidates.into_iter(), &mut results, &AtomicBool::new(false), @@ -109,8 +133,8 @@ pub fn match_fixed_path_set( worktree_id, positions: positions.clone(), is_dir: candidate.is_dir, - path: Arc::from(candidate.path), - path_prefix: Arc::default(), + path: candidate.path.into(), + path_prefix: path_prefix.clone(), distance_to_relative_ancestor: usize::MAX, }, ); @@ -121,7 +145,7 @@ pub fn match_fixed_path_set( pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( candidate_sets: &'a [Set], query: &str, - relative_to: Option>, + relative_to: &Option>, smart_case: bool, max_results: usize, cancel_flag: &AtomicBool, @@ -132,12 +156,27 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( return Vec::new(); } - let lowercase_query = query.to_lowercase().chars().collect::>(); - let query = query.chars().collect::>(); + let path_style = candidate_sets[0].path_style(); + + let query = query + .chars() + .map(|char| { + if path_style.is_windows() && char == '\\' { + '/' + } else { + char + } + }) + .collect::>(); + + let lowercase_query = query + .iter() + .map(|query| query.to_ascii_lowercase()) + .collect::>(); - let lowercase_query = &lowercase_query; let query = &query; - let query_char_bag = CharBag::from(&lowercase_query[..]); + let lowercase_query = &lowercase_query; + let query_char_bag = CharBag::from_iter(lowercase_query.iter().copied()); let num_cpus = executor.num_cpus().min(path_count); let segment_size = path_count.div_ceil(num_cpus); @@ -148,7 +187,6 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( executor .scoped(|scope| { for (segment_idx, results) in segment_results.iter_mut().enumerate() { - let relative_to = relative_to.clone(); scope.spawn(async move { let segment_start = segment_idx * segment_size; let segment_end = segment_start + segment_size; @@ -157,7 +195,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( let mut tree_start = 0; for candidate_set in candidate_sets { - if cancel_flag.load(atomic::Ordering::Relaxed) { + if cancel_flag.load(atomic::Ordering::Acquire) { break; } @@ -169,7 +207,14 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( let candidates = candidate_set.candidates(start).take(end - start); let worktree_id = candidate_set.id(); - let prefix = candidate_set.prefix().chars().collect::>(); + let mut prefix = candidate_set + .prefix() + .as_unix_str() + .chars() + .collect::>(); + if !candidate_set.root_is_file() && !prefix.is_empty() { + prefix.push('/'); + } let lowercase_prefix = prefix .iter() .map(|c| c.to_ascii_lowercase()) @@ -209,7 +254,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( }) .await; - if cancel_flag.load(atomic::Ordering::Relaxed) { + if cancel_flag.load(atomic::Ordering::Acquire) { return Vec::new(); } @@ -220,7 +265,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( /// Compute the distance from a given path to some other path /// If there is no shared path, returns usize::MAX -fn distance_between_paths(path: &Path, relative_to: &Path) -> usize { +fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize { let mut path_components = path.components(); let mut relative_components = relative_to.components(); @@ -235,12 +280,12 @@ fn distance_between_paths(path: &Path, relative_to: &Path) -> usize { #[cfg(test)] mod tests { - use std::path::Path; + use util::rel_path::RelPath; use super::distance_between_paths; #[test] fn test_distance_between_paths_empty() { - distance_between_paths(Path::new(""), Path::new("")); + distance_between_paths(RelPath::empty(), RelPath::empty()); } } diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs index 5bd7b66c0b5352370d010a479e85d01177aac8bd..54539840cfb0ca251428d9f78d5d134f16afdf4c 100644 --- a/crates/fuzzy/src/strings.rs +++ b/crates/fuzzy/src/strings.rs @@ -4,7 +4,7 @@ use crate::{ }; use gpui::BackgroundExecutor; use std::{ - borrow::{Borrow, Cow}, + borrow::Borrow, cmp::{self, Ordering}, iter, ops::Range, @@ -28,13 +28,13 @@ impl StringMatchCandidate { } } -impl<'a> MatchCandidate for &'a StringMatchCandidate { +impl MatchCandidate for &StringMatchCandidate { fn has_chars(&self, bag: CharBag) -> bool { self.char_bag.is_superset(bag) } - fn to_string(&self) -> Cow<'a, str> { - self.string.as_str().into() + fn candidate_chars(&self) -> impl Iterator { + self.string.chars() } } @@ -189,7 +189,7 @@ where }) .await; - if cancel_flag.load(atomic::Ordering::Relaxed) { + if cancel_flag.load(atomic::Ordering::Acquire) { return Vec::new(); } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 74656f1d4c86936b630cb8c1b05030f8d46ccb5c..0a99b0ad27a9e24cee9f59c9180ca5292b050549 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -23,6 +23,7 @@ derive_more.workspace = true git2.workspace = true gpui.workspace = true http_client.workspace = true +itertools.workspace = true log.workspace = true parking_lot.workspace = true regex.workspace = true @@ -36,10 +37,10 @@ text.workspace = true thiserror.workspace = true time.workspace = true url.workspace = true +urlencoding.workspace = true util.workspace = true uuid.workspace = true futures.workspace = true -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 6f12681ea08956b53d9ce298593ce08f0e2a74a9..e58b9cb7e0427bf3af1c88f473debba0b6f94f59 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,4 +1,5 @@ use crate::commit::get_messages; +use crate::repository::RepoPath; use crate::{GitRemote, Oid}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; @@ -33,7 +34,7 @@ impl Blame { pub async fn for_path( git_binary: &Path, working_directory: &Path, - path: &Path, + path: &RepoPath, content: &Rope, remote_url: Option, ) -> Result { @@ -66,7 +67,7 @@ const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; async fn run_git_blame( git_binary: &Path, working_directory: &Path, - path: &Path, + path: &RepoPath, contents: &Rope, ) -> Result { let mut child = util::command::new_smol_command(git_binary) @@ -76,7 +77,7 @@ async fn run_git_blame( .arg("-w") .arg("--contents") .arg("-") - .arg(path.as_os_str()) + .arg(path.as_unix_str()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -289,14 +290,12 @@ fn parse_git_blame(output: &str) -> Result> { } }; - if done { - if let Some(entry) = current_entry.take() { - index.insert(entry.sha, entries.len()); + if done && let Some(entry) = current_entry.take() { + index.insert(entry.sha, entries.len()); - // We only want annotations that have a commit. - if !entry.sha.is_zero() { - entries.push(entry); - } + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + entries.push(entry); } } } diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index aaacdc038a803a51c02e941daafbe68929333706..ece1d76b8ae9c9f40f27178da1ef13fe1a78e659 100644 Binary files a/crates/git/src/commit.rs and b/crates/git/src/commit.rs differ diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index e84014129cf5a423279b84bed897a4fac2528e02..29fa50ddd2bc2a2ae32a60b1b95dd66ca503d9de 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -3,6 +3,7 @@ pub mod commit; mod hosting_provider; mod remote; pub mod repository; +pub mod stash; pub mod status; pub use crate::hosting_provider::*; @@ -10,22 +11,18 @@ pub use crate::remote::*; use anyhow::{Context as _, Result}; pub use git2 as libgit; use gpui::{Action, actions}; -pub use repository::WORK_DIRECTORY_REPO_PATH; +pub use repository::RemoteCommandOutput; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::ffi::OsStr; use std::fmt; use std::str::FromStr; -use std::sync::LazyLock; -pub static DOT_GIT: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new(".git")); -pub static GITIGNORE: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new(".gitignore")); -pub static FSMONITOR_DAEMON: LazyLock<&'static OsStr> = - LazyLock::new(|| OsStr::new("fsmonitor--daemon")); -pub static LFS_DIR: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("lfs")); -pub static COMMIT_MESSAGE: LazyLock<&'static OsStr> = - LazyLock::new(|| OsStr::new("COMMIT_EDITMSG")); -pub static INDEX_LOCK: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("index.lock")); +pub const DOT_GIT: &str = ".git"; +pub const GITIGNORE: &str = ".gitignore"; +pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon"; +pub const LFS_DIR: &str = "lfs"; +pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG"; +pub const INDEX_LOCK: &str = "index.lock"; actions!( git, @@ -59,6 +56,8 @@ actions!( StashAll, /// Pops the most recent stash. StashPop, + /// Apply the most recent stash. + StashApply, /// Restores all tracked files to their last committed state. RestoreTrackedFiles, /// Moves all untracked files to trash. @@ -95,9 +94,23 @@ actions!( OpenModifiedFiles, /// Clones a repository. Clone, + /// Adds a file to .gitignore. + AddToGitignore, ] ); +/// Renames a git branch. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct RenameBranch { + /// The branch to rename. + /// + /// Default: the current branch. + #[serde(default)] + pub branch: Option, +} + /// Restores a file to its last committed state, discarding local changes. #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = git, deprecated_aliases = ["editor::RevertFile"])] diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 5c11cb5504723432c8a041de42749138c4337915..225d4a3e2354fbd11e11b617ecdd9cb4202a63fe 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -5,9 +5,12 @@ use async_trait::async_trait; use derive_more::{Deref, DerefMut}; use gpui::{App, Global, SharedString}; use http_client::HttpClient; +use itertools::Itertools; use parking_lot::RwLock; use url::Url; +use crate::repository::RepoPath; + #[derive(Debug, PartialEq, Eq, Clone)] pub struct PullRequest { pub number: u32, @@ -17,8 +20,8 @@ pub struct PullRequest { #[derive(Clone)] pub struct GitRemote { pub host: Arc, - pub owner: String, - pub repo: String, + pub owner: SharedString, + pub repo: SharedString, } impl std::fmt::Debug for GitRemote { @@ -55,10 +58,21 @@ pub struct BuildCommitPermalinkParams<'a> { pub struct BuildPermalinkParams<'a> { pub sha: &'a str, - pub path: &'a str, + /// URL-escaped path using unescaped `/` as the directory separator. + pub path: String, pub selection: Option>, } +impl<'a> BuildPermalinkParams<'a> { + pub fn new(sha: &'a str, path: &RepoPath, selection: Option>) -> Self { + Self { + sha, + path: path.components().map(urlencoding::encode).join("/"), + selection, + } + } +} + /// A Git hosting provider. #[async_trait] pub trait GitHostingProvider { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 49eee848404a0bc866c55fed404365da26538d8b..eaefd4ba22c34ac2e3c30e822e6dbcd31468f9b8 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,9 +1,11 @@ use crate::commit::parse_git_diff_name_status; -use crate::status::{GitStatus, StatusCode}; +use crate::stash::GitStash; +use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff}; use crate::{Oid, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use collections::HashMap; use futures::future::BoxFuture; +use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; use git2::BranchType; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; @@ -11,22 +13,21 @@ use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; -use std::borrow::{Borrow, Cow}; +use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; +use std::borrow::Cow; use std::ffi::{OsStr, OsString}; -use std::io::prelude::*; -use std::path::Component; use std::process::{ExitStatus, Stdio}; -use std::sync::LazyLock; use std::{ cmp::Ordering, future, - io::{BufRead, BufReader, BufWriter, Read}, path::{Path, PathBuf}, sync::Arc, }; use sum_tree::MapSeekTarget; use thiserror::Error; -use util::command::{new_smol_command, new_std_command}; +use util::command::new_smol_command; +use util::paths::PathStyle; +use util::rel_path::RelPath; use util::{ResultExt, paths}; use uuid::Uuid; @@ -150,6 +151,7 @@ pub struct CommitSummary { pub subject: SharedString, /// This is a unix timestamp pub commit_timestamp: i64, + pub author_name: SharedString, pub has_parent: bool, } @@ -240,8 +242,20 @@ pub struct GitExcludeOverride { } impl GitExcludeOverride { + const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n"; + const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n"; + pub async fn new(git_exclude_path: PathBuf) -> Result { - let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok(); + let original_excludes = + smol::fs::read_to_string(&git_exclude_path) + .await + .ok() + .map(|content| { + // Auto-generated lines are normally cleaned up in + // `restore_original()` or `drop()`, but may stuck in rare cases. + // Make sure to remove them. + Self::remove_auto_generated_block(&content) + }); Ok(GitExcludeOverride { git_exclude_path, @@ -258,9 +272,10 @@ impl GitExcludeOverride { }); let mut content = self.original_excludes.clone().unwrap_or_default(); - content.push_str("\n\n# ====== Auto-added by Zed: =======\n"); + + content.push_str(Self::START_BLOCK_MARKER); content.push_str(self.added_excludes.as_ref().unwrap()); - content.push('\n'); + content.push_str(Self::END_BLOCK_MARKER); smol::fs::write(&self.git_exclude_path, content).await?; Ok(()) @@ -269,16 +284,41 @@ impl GitExcludeOverride { pub async fn restore_original(&mut self) -> Result<()> { if let Some(ref original) = self.original_excludes { smol::fs::write(&self.git_exclude_path, original).await?; - } else { - if self.git_exclude_path.exists() { - smol::fs::remove_file(&self.git_exclude_path).await?; - } + } else if self.git_exclude_path.exists() { + smol::fs::remove_file(&self.git_exclude_path).await?; } self.added_excludes = None; Ok(()) } + + fn remove_auto_generated_block(content: &str) -> String { + let start_marker = Self::START_BLOCK_MARKER; + let end_marker = Self::END_BLOCK_MARKER; + let mut content = content.to_string(); + + let start_index = content.find(start_marker); + let end_index = content.rfind(end_marker); + + if let (Some(start), Some(end)) = (start_index, end_index) { + if end > start { + content.replace_range(start..end + end_marker.len(), ""); + } + } + + // Older versions of Zed didn't have end-of-block markers, + // so it's impossible to determine auto-generated lines. + // Conservatively remove the standard list of excludes + let standard_excludes = format!( + "{}{}", + Self::START_BLOCK_MARKER, + include_str!("./checkpoint.gitignore") + ); + content = content.replace(&standard_excludes, ""); + + content + } } impl Drop for GitExcludeOverride { @@ -310,6 +350,7 @@ pub trait GitRepository: Send + Sync { /// /// Also returns `None` for symlinks. fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; + fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>; fn set_index_text( &self, @@ -339,11 +380,15 @@ pub trait GitRepository: Send + Sync { fn merge_message(&self) -> BoxFuture<'_, Option>; fn status(&self, path_prefixes: &[RepoPath]) -> Task>; + fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result>; + + fn stash_entries(&self) -> BoxFuture<'_, Result>; fn branches(&self) -> BoxFuture<'_, Result>>; fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; fn reset( &self, @@ -401,7 +446,23 @@ pub trait GitRepository: Send + Sync { env: Arc>, ) -> BoxFuture<'_, Result<()>>; - fn stash_pop(&self, env: Arc>) -> BoxFuture<'_, Result<()>>; + fn stash_pop( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>>; + + fn stash_apply( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>>; + + fn stash_drop( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>>; fn push( &self, @@ -486,21 +547,25 @@ impl std::fmt::Debug for dyn GitRepository { pub struct RealGitRepository { pub repository: Arc>, - pub git_binary_path: PathBuf, + pub system_git_binary_path: Option, + pub any_git_binary_path: PathBuf, executor: BackgroundExecutor, } impl RealGitRepository { pub fn new( dotgit_path: &Path, - git_binary_path: Option, + bundled_git_binary_path: Option, + system_git_binary_path: Option, executor: BackgroundExecutor, ) -> Option { + let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?; let workdir_root = dotgit_path.parent()?; let repository = git2::Repository::open(workdir_root).log_err()?; Some(Self { repository: Arc::new(Mutex::new(repository)), - git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")), + system_git_binary_path, + any_git_binary_path, executor, }) } @@ -581,11 +646,12 @@ impl GitRepository for RealGitRepository { } fn show(&self, commit: String) -> BoxFuture<'_, Result> { + let git_binary_path = self.any_git_binary_path.clone(); let working_directory = self.working_directory(); self.executor .spawn(async move { let working_directory = working_directory?; - let output = new_std_command("git") + let output = new_smol_command(git_binary_path) .current_dir(&working_directory) .args([ "--no-optional-locks", @@ -594,7 +660,8 @@ impl GitRepository for RealGitRepository { "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", &commit, ]) - .output()?; + .output() + .await?; let output = std::str::from_utf8(&output.stdout)?; let fields = output.split('\0').collect::>(); if fields.len() != 6 { @@ -621,30 +688,32 @@ impl GitRepository for RealGitRepository { else { return future::ready(Err(anyhow!("no working directory"))).boxed(); }; + let git_binary_path = self.any_git_binary_path.clone(); cx.background_spawn(async move { - let show_output = util::command::new_std_command("git") + let show_output = util::command::new_smol_command(&git_binary_path) .current_dir(&working_directory) .args([ "--no-optional-locks", "show", - "--format=%P", + "--format=", "-z", "--no-renames", "--name-status", + "--first-parent", ]) .arg(&commit) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() + .await .context("starting git show process")?; let show_stdout = String::from_utf8_lossy(&show_output.stdout); - let mut lines = show_stdout.split('\n'); - let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0'); - let changes = parse_git_diff_name_status(lines.next().unwrap_or("")); + let changes = parse_git_diff_name_status(&show_stdout); + let parent_sha = format!("{}^", commit); - let mut cat_file_process = util::command::new_std_command("git") + let mut cat_file_process = util::command::new_smol_command(&git_binary_path) .current_dir(&working_directory) .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) @@ -653,37 +722,53 @@ impl GitRepository for RealGitRepository { .spawn() .context("starting git cat-file process")?; - use std::io::Write as _; let mut files = Vec::::new(); let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap()); let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap()); let mut info_line = String::new(); let mut newline = [b'\0']; for (path, status_code) in changes { + // git-show outputs `/`-delimited paths even on Windows. + let Some(rel_path) = RelPath::unix(path).log_err() else { + continue; + }; + match status_code { StatusCode::Modified => { - writeln!(&mut stdin, "{commit}:{}", path.display())?; - writeln!(&mut stdin, "{parent_sha}:{}", path.display())?; + stdin.write_all(commit.as_bytes()).await?; + stdin.write_all(b":").await?; + stdin.write_all(path.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.write_all(parent_sha.as_bytes()).await?; + stdin.write_all(b":").await?; + stdin.write_all(path.as_bytes()).await?; + stdin.write_all(b"\n").await?; } StatusCode::Added => { - writeln!(&mut stdin, "{commit}:{}", path.display())?; + stdin.write_all(commit.as_bytes()).await?; + stdin.write_all(b":").await?; + stdin.write_all(path.as_bytes()).await?; + stdin.write_all(b"\n").await?; } StatusCode::Deleted => { - writeln!(&mut stdin, "{parent_sha}:{}", path.display())?; + stdin.write_all(parent_sha.as_bytes()).await?; + stdin.write_all(b":").await?; + stdin.write_all(path.as_bytes()).await?; + stdin.write_all(b"\n").await?; } _ => continue, } - stdin.flush()?; + stdin.flush().await?; info_line.clear(); - stdout.read_line(&mut info_line)?; + stdout.read_line(&mut info_line).await?; let len = info_line.trim_end().parse().with_context(|| { format!("invalid object size output from cat-file {info_line}") })?; let mut text = vec![0; len]; - stdout.read_exact(&mut text)?; - stdout.read_exact(&mut newline)?; + stdout.read_exact(&mut text).await?; + stdout.read_exact(&mut newline).await?; let text = String::from_utf8_lossy(&text).to_string(); let mut old_text = None; @@ -691,13 +776,13 @@ impl GitRepository for RealGitRepository { match status_code { StatusCode::Modified => { info_line.clear(); - stdout.read_line(&mut info_line)?; + stdout.read_line(&mut info_line).await?; let len = info_line.trim_end().parse().with_context(|| { format!("invalid object size output from cat-file {}", info_line) })?; let mut parent_text = vec![0; len]; - stdout.read_exact(&mut parent_text)?; - stdout.read_exact(&mut newline)?; + stdout.read_exact(&mut parent_text).await?; + stdout.read_exact(&mut newline).await?; old_text = Some(String::from_utf8_lossy(&parent_text).to_string()); new_text = Some(text); } @@ -707,7 +792,7 @@ impl GitRepository for RealGitRepository { } files.push(CommitFile { - path: path.into(), + path: rel_path.into(), old_text, new_text, }) @@ -732,7 +817,7 @@ impl GitRepository for RealGitRepository { ResetMode::Soft => "--soft", }; - let output = new_smol_command(&self.git_binary_path) + let output = new_smol_command(&self.any_git_binary_path) .envs(env.iter()) .current_dir(&working_directory?) .args(["reset", mode_flag, &commit]) @@ -755,7 +840,7 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); async move { if paths.is_empty() { return Ok(()); @@ -765,7 +850,7 @@ impl GitRepository for RealGitRepository { .current_dir(&working_directory?) .envs(env.iter()) .args(["checkout", &commit, "--"]) - .args(paths.iter().map(|path| path.as_ref())) + .args(paths.iter().map(|path| path.as_unix_str())) .output() .await?; anyhow::ensure!( @@ -787,13 +872,11 @@ impl GitRepository for RealGitRepository { .spawn(async move { fn logic(repo: &git2::Repository, path: &RepoPath) -> Result> { // This check is required because index.get_path() unwraps internally :( - check_path_to_repo_path_errors(path)?; - let mut index = repo.index()?; index.read(false)?; const STAGE_NORMAL: i32 = 0; - let oid = match index.get_path(path, STAGE_NORMAL) { + let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) { Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id, _ => return Ok(None), }; @@ -817,7 +900,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let repo = repo.lock(); let head = repo.head().ok()?.peel_to_tree().log_err()?; - let entry = head.get_path(&path).ok()?; + let entry = head.get_path(path.as_std_path()).ok()?; if entry.filemode() == i32::from(git2::FileMode::Link) { return None; } @@ -827,6 +910,17 @@ impl GitRepository for RealGitRepository { .boxed() } + fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + let content = repo.find_blob(oid.0)?.content().to_owned(); + Ok(String::from_utf8(content)?) + }) + .boxed() + } + fn set_index_text( &self, path: RepoPath, @@ -834,7 +928,7 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, anyhow::Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; @@ -858,8 +952,8 @@ impl GitRepository for RealGitRepository { let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) .envs(env.iter()) - .args(["update-index", "--add", "--cacheinfo", "100644", &sha]) - .arg(path.to_unix_style()) + .args(["update-index", "--add", "--cacheinfo", "100644", sha]) + .arg(path.as_unix_str()) .output() .await?; @@ -874,7 +968,7 @@ impl GitRepository for RealGitRepository { .current_dir(&working_directory) .envs(env.iter()) .args(["update-index", "--force-remove"]) - .arg(path.to_unix_style()) + .arg(path.as_unix_str()) .output() .await?; anyhow::ensure!( @@ -897,10 +991,11 @@ impl GitRepository for RealGitRepository { fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; - let mut process = new_std_command("git") + let mut process = new_smol_command(&git_binary_path) .current_dir(&working_directory) .args([ "--no-optional-locks", @@ -918,12 +1013,13 @@ impl GitRepository for RealGitRepository { .context("no stdin for git cat-file subprocess")?; let mut stdin = BufWriter::new(stdin); for rev in &revs { - write!(&mut stdin, "{rev}\n")?; + stdin.write_all(rev.as_bytes()).await?; + stdin.write_all(b"\n").await?; } - stdin.flush()?; + stdin.flush().await?; drop(stdin); - let output = process.wait_with_output()?; + let output = process.output().await?; let output = std::str::from_utf8(&output.stdout)?; let shas = output .lines() @@ -954,18 +1050,19 @@ impl GitRepository for RealGitRepository { } fn status(&self, path_prefixes: &[RepoPath]) -> Task> { - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let working_directory = match self.working_directory() { Ok(working_directory) => working_directory, Err(e) => return Task::ready(Err(e)), }; - let args = git_status_args(&path_prefixes); + let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { - let output = new_std_command(&git_binary_path) + let output = new_smol_command(&git_binary_path) .current_dir(working_directory) .args(args) - .output()?; + .output() + .await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -976,9 +1073,74 @@ impl GitRepository for RealGitRepository { }) } + fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = match self.working_directory() { + Ok(working_directory) => working_directory, + Err(e) => return Task::ready(Err(e)).boxed(), + }; + + let mut args = vec![ + OsString::from("--no-optional-locks"), + OsString::from("diff-tree"), + OsString::from("-r"), + OsString::from("-z"), + OsString::from("--no-renames"), + ]; + match request { + DiffTreeType::MergeBase { base, head } => { + args.push("--merge-base".into()); + args.push(OsString::from(base.as_str())); + args.push(OsString::from(head.as_str())); + } + DiffTreeType::Since { base, head } => { + args.push(OsString::from(base.as_str())); + args.push(OsString::from(head.as_str())); + } + } + + self.executor + .spawn(async move { + let output = new_smol_command(&git_binary_path) + .current_dir(working_directory) + .args(args) + .output() + .await?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.parse() + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git status failed: {stderr}"); + } + }) + .boxed() + } + + fn stash_entries(&self) -> BoxFuture<'_, Result> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + self.executor + .spawn(async move { + let output = new_smol_command(&git_binary_path) + .current_dir(working_directory?) + .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) + .output() + .await?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.parse() + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git status failed: {stderr}"); + } + }) + .boxed() + } + fn branches(&self) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let fields = [ @@ -989,6 +1151,7 @@ impl GitRepository for RealGitRepository { "%(upstream)", "%(upstream:track)", "%(committerdate:unix)", + "%(authorname)", "%(contents:subject)", ] .join("%00"); @@ -1046,7 +1209,7 @@ impl GitRepository for RealGitRepository { fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); let branch = self.executor.spawn(async move { let repo = repo.lock(); @@ -1060,7 +1223,7 @@ impl GitRepository for RealGitRepository { branch.set_upstream(Some(&name))?; branch } else { - anyhow::bail!("Branch not found"); + anyhow::bail!("Branch '{}' not found", name); }; Ok(branch @@ -1076,7 +1239,6 @@ impl GitRepository for RealGitRepository { GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; - anyhow::Ok(()) }) .boxed() @@ -1094,31 +1256,45 @@ impl GitRepository for RealGitRepository { .boxed() } + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + + self.executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(&["branch", "-m", &branch, &new_name]) + .await?; + anyhow::Ok(()) + }) + .boxed() + } + fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let remote_url = self .remote_url("upstream") .or_else(|| self.remote_url("origin")); - self.executor - .spawn(async move { - crate::blame::Blame::for_path( - &git_binary_path, - &working_directory?, - &path, - &content, - remote_url, - ) - .await - }) - .boxed() + async move { + crate::blame::Blame::for_path( + &git_binary_path, + &working_directory?, + &path, + &content, + remote_url, + ) + .await + } + .boxed() } fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let args = match diff { @@ -1149,7 +1325,7 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { if !paths.is_empty() { @@ -1157,7 +1333,7 @@ impl GitRepository for RealGitRepository { .current_dir(&working_directory?) .envs(env.iter()) .args(["update-index", "--add", "--remove", "--"]) - .args(paths.iter().map(|p| p.to_unix_style())) + .args(paths.iter().map(|p| p.as_unix_str())) .output() .await?; anyhow::ensure!( @@ -1177,7 +1353,7 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { @@ -1186,7 +1362,7 @@ impl GitRepository for RealGitRepository { .current_dir(&working_directory?) .envs(env.iter()) .args(["reset", "--quiet", "--"]) - .args(paths.iter().map(|p| p.as_ref())) + .args(paths.iter().map(|p| p.as_std_path())) .output() .await?; @@ -1207,15 +1383,16 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { - let mut cmd = new_smol_command("git"); + let mut cmd = new_smol_command(&git_binary_path); cmd.current_dir(&working_directory?) .envs(env.iter()) .args(["stash", "push", "--quiet"]) .arg("--include-untracked"); - cmd.args(paths.iter().map(|p| p.as_ref())); + cmd.args(paths.iter().map(|p| p.as_unix_str())); let output = cmd.output().await?; @@ -1229,14 +1406,23 @@ impl GitRepository for RealGitRepository { .boxed() } - fn stash_pop(&self, env: Arc>) -> BoxFuture<'_, Result<()>> { + fn stash_pop( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { - let mut cmd = new_smol_command("git"); + let mut cmd = new_smol_command(git_binary_path); + let mut args = vec!["stash".to_string(), "pop".to_string()]; + if let Some(index) = index { + args.push(format!("stash@{{{}}}", index)); + } cmd.current_dir(&working_directory?) .envs(env.iter()) - .args(["stash", "pop"]); + .args(args); let output = cmd.output().await?; @@ -1250,6 +1436,66 @@ impl GitRepository for RealGitRepository { .boxed() } + fn stash_apply( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let mut cmd = new_smol_command(git_binary_path); + let mut args = vec!["stash".to_string(), "apply".to_string()]; + if let Some(index) = index { + args.push(format!("stash@{{{}}}", index)); + } + cmd.current_dir(&working_directory?) + .envs(env.iter()) + .args(args); + + let output = cmd.output().await?; + + anyhow::ensure!( + output.status.success(), + "Failed to apply stash:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }) + .boxed() + } + + fn stash_drop( + &self, + index: Option, + env: Arc>, + ) -> BoxFuture<'_, Result<()>> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let mut cmd = new_smol_command(git_binary_path); + let mut args = vec!["stash".to_string(), "drop".to_string()]; + if let Some(index) = index { + args.push(format!("stash@{{{}}}", index)); + } + cmd.current_dir(&working_directory?) + .envs(env.iter()) + .args(args); + + let output = cmd.output().await?; + + anyhow::ensure!( + output.status.success(), + "Failed to stash drop:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }) + .boxed() + } + fn commit( &self, message: SharedString, @@ -1258,9 +1504,10 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { - let mut cmd = new_smol_command("git"); + let mut cmd = new_smol_command(git_binary_path); cmd.current_dir(&working_directory?) .envs(env.iter()) .args(["commit", "--quiet", "-m"]) @@ -1302,9 +1549,11 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); + let git_binary_path = self.system_git_binary_path.clone(); async move { + let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?; let working_directory = working_directory?; - let mut command = new_smol_command("git"); + let mut command = new_smol_command(git_binary_path); command .envs(env.iter()) .current_dir(&working_directory) @@ -1334,8 +1583,10 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); + let git_binary_path = self.system_git_binary_path.clone(); async move { - let mut command = new_smol_command("git"); + let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?; + let mut command = new_smol_command(git_binary_path); command .envs(env.iter()) .current_dir(&working_directory?) @@ -1359,9 +1610,11 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let remote_name = format!("{}", fetch_options); + let git_binary_path = self.system_git_binary_path.clone(); let executor = cx.background_executor().clone(); async move { - let mut command = new_smol_command("git"); + let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?; + let mut command = new_smol_command(git_binary_path); command .envs(env.iter()) .current_dir(&working_directory?) @@ -1376,7 +1629,7 @@ impl GitRepository for RealGitRepository { fn get_remotes(&self, branch_name: Option) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; @@ -1422,7 +1675,7 @@ impl GitRepository for RealGitRepository { fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; @@ -1447,12 +1700,11 @@ impl GitRepository for RealGitRepository { let mut remote_branches = vec![]; let mut add_if_matching = async |remote_head: &str| { - if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await { - if merge_base.trim() == head { - if let Some(s) = remote_head.strip_prefix("refs/remotes/") { - remote_branches.push(s.to_owned().into()); - } - } + if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await + && merge_base.trim() == head + && let Some(s) = remote_head.strip_prefix("refs/remotes/") + { + remote_branches.push(s.to_owned().into()); } }; @@ -1482,7 +1734,7 @@ impl GitRepository for RealGitRepository { fn checkpoint(&self) -> BoxFuture<'static, Result> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor .spawn(async move { @@ -1515,7 +1767,7 @@ impl GitRepository for RealGitRepository { fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor @@ -1554,7 +1806,7 @@ impl GitRepository for RealGitRepository { right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor @@ -1574,10 +1826,9 @@ impl GitRepository for RealGitRepository { Err(error) => { if let Some(GitBinaryCommandError { status, .. }) = error.downcast_ref::() + && status.code() == Some(1) { - if status.code() == Some(1) { - return Ok(false); - } + return Ok(false); } Err(error) @@ -1593,7 +1844,7 @@ impl GitRepository for RealGitRepository { target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor @@ -1614,7 +1865,7 @@ impl GitRepository for RealGitRepository { fn default_branch(&self) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor @@ -1632,13 +1883,23 @@ impl GitRepository for RealGitRepository { return Ok(output); } - let output = git - .run(&["symbolic-ref", "refs/remotes/origin/HEAD"]) - .await?; + if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await { + return Ok(output + .strip_prefix("refs/remotes/origin/") + .map(|s| SharedString::from(s.to_owned()))); + } + + if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await { + if git.run(&["rev-parse", &default_branch]).await.is_ok() { + return Ok(Some(default_branch.into())); + } + } + + if git.run(&["rev-parse", "master"]).await.is_ok() { + return Ok(Some("master".into())); + } - Ok(output - .strip_prefix("refs/remotes/origin/") - .map(|s| SharedString::from(s.to_owned()))) + Ok(None) }) .boxed() } @@ -1654,10 +1915,10 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { OsString::from("-z"), ]; args.extend(path_prefixes.iter().map(|path_prefix| { - if path_prefix.0.as_ref() == Path::new("") { + if path_prefix.is_empty() { Path::new(".").into() } else { - path_prefix.as_os_str().into() + path_prefix.as_std_path().into() } })); args @@ -1692,7 +1953,7 @@ async fn exclude_files(git: &GitBinary) -> Result { if !excluded_paths.is_empty() { let exclude_patterns = excluded_paths .into_iter() - .map(|path| path.to_string_lossy().to_string()) + .map(|path| path.to_string_lossy().into_owned()) .collect::>() .join("\n"); excludes.add_excludes(&exclude_patterns).await?; @@ -1818,6 +2079,7 @@ impl GitBinary { output.status.success(), GitBinaryCommandError { stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), status: output.status, } ); @@ -1840,9 +2102,10 @@ impl GitBinary { } #[derive(Error, Debug)] -#[error("Git command failed: {stdout}")] +#[error("Git command failed:\n{stdout}{stderr}\n")] struct GitBinaryCommandError { stdout: String, + stderr: String, status: ExitStatus, } @@ -1906,99 +2169,71 @@ async fn run_askpass_command( } } -pub static WORK_DIRECTORY_REPO_PATH: LazyLock = - LazyLock::new(|| RepoPath(Path::new("").into())); - #[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)] -pub struct RepoPath(pub Arc); +pub struct RepoPath(pub Arc); impl RepoPath { - pub fn new(path: PathBuf) -> Self { - debug_assert!(path.is_relative(), "Repo paths must be relative"); - - RepoPath(path.into()) + pub fn new + ?Sized>(s: &S) -> Result { + let rel_path = RelPath::unix(s.as_ref())?; + Ok(rel_path.into()) } - pub fn from_str(path: &str) -> Self { - let path = Path::new(path); - debug_assert!(path.is_relative(), "Repo paths must be relative"); - - RepoPath(path.into()) + pub fn from_proto(proto: &str) -> Result { + let rel_path = RelPath::from_proto(proto)?; + Ok(rel_path.into()) } - pub fn to_unix_style(&self) -> Cow<'_, OsStr> { - #[cfg(target_os = "windows")] - { - use std::ffi::OsString; - - let path = self.0.as_os_str().to_string_lossy().replace("\\", "/"); - Cow::Owned(OsString::from(path)) - } - #[cfg(not(target_os = "windows"))] - { - Cow::Borrowed(self.0.as_os_str()) - } + pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result { + let rel_path = RelPath::new(path, path_style)?; + Ok(Self(rel_path.as_ref().into())) } } -impl std::fmt::Display for RepoPath { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.to_string_lossy().fmt(f) - } -} - -impl From<&Path> for RepoPath { - fn from(value: &Path) -> Self { - RepoPath::new(value.into()) - } +#[cfg(any(test, feature = "test-support"))] +pub fn repo_path + ?Sized>(s: &S) -> RepoPath { + RepoPath(RelPath::unix(s.as_ref()).unwrap().into()) } -impl From> for RepoPath { - fn from(value: Arc) -> Self { - RepoPath(value) +impl From<&RelPath> for RepoPath { + fn from(value: &RelPath) -> Self { + RepoPath(value.into()) } } -impl From for RepoPath { - fn from(value: PathBuf) -> Self { - RepoPath::new(value) +impl<'a> From> for RepoPath { + fn from(value: Cow<'a, RelPath>) -> Self { + value.as_ref().into() } } -impl From<&str> for RepoPath { - fn from(value: &str) -> Self { - Self::from_str(value) +impl From> for RepoPath { + fn from(value: Arc) -> Self { + RepoPath(value) } } impl Default for RepoPath { fn default() -> Self { - RepoPath(Path::new("").into()) - } -} - -impl AsRef for RepoPath { - fn as_ref(&self) -> &Path { - self.0.as_ref() + RepoPath(RelPath::empty().into()) } } impl std::ops::Deref for RepoPath { - type Target = Path; + type Target = RelPath; fn deref(&self) -> &Self::Target { &self.0 } } -impl Borrow for RepoPath { - fn borrow(&self) -> &Path { - self.0.as_ref() - } -} +// impl AsRef for RepoPath { +// fn as_ref(&self) -> &Path { +// RelPath::as_ref(&self.0) +// } +// } #[derive(Debug)] -pub struct RepoPathDescendants<'a>(pub &'a Path); +pub struct RepoPathDescendants<'a>(pub &'a RepoPath); impl MapSeekTarget for RepoPathDescendants<'_> { fn cmp_cursor(&self, key: &RepoPath) -> Ordering { @@ -2024,6 +2259,7 @@ fn parse_branch_input(input: &str) -> Result> { let upstream_name = fields.next().context("no upstream")?.to_string(); let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?; let commiterdate = fields.next().context("no committerdate")?.parse::()?; + let author_name = fields.next().context("no authorname")?.to_string().into(); let subject: SharedString = fields .next() .context("no contents:subject")? @@ -2032,11 +2268,12 @@ fn parse_branch_input(input: &str) -> Result> { branches.push(Branch { is_head: is_current_branch, - ref_name: ref_name, + ref_name, most_recent_commit: Some(CommitSummary { sha: head_sha, subject, commit_timestamp: commiterdate, + author_name: author_name, has_parent: !parent_sha.is_empty(), }), upstream: if upstream_name.is_empty() { @@ -2054,7 +2291,7 @@ fn parse_branch_input(input: &str) -> Result> { } fn parse_upstream_track(upstream_track: &str) -> Result { - if upstream_track == "" { + if upstream_track.is_empty() { return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, @@ -2082,35 +2319,6 @@ fn parse_upstream_track(upstream_track: &str) -> Result { })) } -fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { - match relative_file_path.components().next() { - None => anyhow::bail!("repo path should not be empty"), - Some(Component::Prefix(_)) => anyhow::bail!( - "repo path `{}` should be relative, not a windows prefix", - relative_file_path.to_string_lossy() - ), - Some(Component::RootDir) => { - anyhow::bail!( - "repo path `{}` should be relative", - relative_file_path.to_string_lossy() - ) - } - Some(Component::CurDir) => { - anyhow::bail!( - "repo path `{}` should not start with `.`", - relative_file_path.to_string_lossy() - ) - } - Some(Component::ParentDir) => { - anyhow::bail!( - "repo path `{}` should not start with `..`", - relative_file_path.to_string_lossy() - ) - } - _ => Ok(()), - } -} - fn checkpoint_author_envs() -> HashMap { HashMap::from_iter([ ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()), @@ -2135,14 +2343,16 @@ mod tests { let file_path = repo_dir.path().join("file"); smol::fs::write(&file_path, "initial").await.unwrap(); - let repo = - RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap(); - repo.stage_paths( - vec![RepoPath::from_str("file")], - Arc::new(HashMap::default()), + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), ) - .await .unwrap(); + repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) + .await + .unwrap(); repo.commit( "Initial commit".into(), None, @@ -2166,12 +2376,9 @@ mod tests { smol::fs::write(&file_path, "modified after checkpoint") .await .unwrap(); - repo.stage_paths( - vec![RepoPath::from_str("file")], - Arc::new(HashMap::default()), - ) - .await - .unwrap(); + repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) + .await + .unwrap(); repo.commit( "Commit after checkpoint".into(), None, @@ -2217,8 +2424,13 @@ mod tests { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); - let repo = - RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap(); + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); smol::fs::write(repo_dir.path().join("foo"), "foo") .await @@ -2256,8 +2468,13 @@ mod tests { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); - let repo = - RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap(); + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); smol::fs::write(repo_dir.path().join("file1"), "content1") .await @@ -2300,16 +2517,18 @@ mod tests { .await .unwrap(); - let repo = - RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap(); - - // initial commit - repo.stage_paths( - vec![RepoPath::from_str("main.rs")], - Arc::new(HashMap::default()), + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), ) - .await .unwrap(); + + // initial commit + repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default())) + .await + .unwrap(); repo.commit( "Initial commit".into(), None, @@ -2347,9 +2566,9 @@ mod tests { fn test_branches_parsing() { // suppress "help: octal escapes are not supported, `\0` is always null" #[allow(clippy::octal_escapes)] - let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n"; + let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n"; assert_eq!( - parse_branch_input(&input).unwrap(), + parse_branch_input(input).unwrap(), vec![Branch { is_head: true, ref_name: "refs/heads/zed-patches".into(), @@ -2364,6 +2583,7 @@ mod tests { sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(), subject: "generated protobuf".into(), commit_timestamp: 1733187470, + author_name: SharedString::new("John Doe"), has_parent: false, }) }] @@ -2374,7 +2594,7 @@ mod tests { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); - let git_binary_path = self.git_binary_path.clone(); + let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); self.executor .spawn(async move { diff --git a/crates/git/src/stash.rs b/crates/git/src/stash.rs new file mode 100644 index 0000000000000000000000000000000000000000..f7379f5212332059ebe639b2dea94f9fb672b1b1 --- /dev/null +++ b/crates/git/src/stash.rs @@ -0,0 +1,223 @@ +use crate::Oid; +use anyhow::{Context, Result, anyhow}; +use std::{str::FromStr, sync::Arc}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct StashEntry { + pub index: usize, + pub oid: Oid, + pub message: String, + pub branch: Option, + pub timestamp: i64, +} + +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] +pub struct GitStash { + pub entries: Arc<[StashEntry]>, +} + +impl GitStash { + pub fn apply(&mut self, other: GitStash) { + self.entries = other.entries; + } +} + +impl FromStr for GitStash { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s.trim().is_empty() { + return Ok(Self::default()); + } + + let mut entries = Vec::new(); + let mut errors = Vec::new(); + + for (line_num, line) in s.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + + match parse_stash_line(line) { + Ok(entry) => entries.push(entry), + Err(e) => { + errors.push(format!("Line {}: {}", line_num + 1, e)); + } + } + } + + // If we have some valid entries but also some errors, log the errors but continue + if !errors.is_empty() && !entries.is_empty() { + log::warn!("Failed to parse some stash entries: {}", errors.join(", ")); + } else if !errors.is_empty() { + return Err(anyhow!( + "Failed to parse stash entries: {}", + errors.join(", ") + )); + } + + Ok(Self { + entries: entries.into(), + }) + } +} + +/// Parse a single stash line in the format: "stash@{N}\0\0\0" +fn parse_stash_line(line: &str) -> Result { + let parts: Vec<&str> = line.splitn(4, '\0').collect(); + + if parts.len() != 4 { + return Err(anyhow!( + "Expected 4 null-separated parts, got {}", + parts.len() + )); + } + + let index = parse_stash_index(parts[0]) + .with_context(|| format!("Failed to parse stash index from '{}'", parts[0]))?; + + let oid = Oid::from_str(parts[1]) + .with_context(|| format!("Failed to parse OID from '{}'", parts[1]))?; + + let timestamp = parts[2] + .parse::() + .with_context(|| format!("Failed to parse timestamp from '{}'", parts[2]))?; + + let (branch, message) = parse_stash_message(parts[3]); + + Ok(StashEntry { + index, + oid, + message: message.to_string(), + branch: branch.map(Into::into), + timestamp, + }) +} + +/// Parse stash index from format "stash@{N}" where N is the index +fn parse_stash_index(input: &str) -> Result { + let trimmed = input.trim(); + + if !trimmed.starts_with("stash@{") || !trimmed.ends_with('}') { + return Err(anyhow!( + "Invalid stash index format: expected 'stash@{{N}}'" + )); + } + + let index_str = trimmed + .strip_prefix("stash@{") + .and_then(|s| s.strip_suffix('}')) + .ok_or_else(|| anyhow!("Failed to extract index from stash reference"))?; + + index_str + .parse::() + .with_context(|| format!("Invalid stash index number: '{}'", index_str)) +} + +/// Parse stash message and extract branch information if present +/// +/// Handles the following formats: +/// - "WIP on : " -> (Some(branch), message) +/// - "On : " -> (Some(branch), message) +/// - "" -> (None, message) +fn parse_stash_message(input: &str) -> (Option<&str>, &str) { + // Handle "WIP on : " pattern + if let Some(stripped) = input.strip_prefix("WIP on ") + && let Some(colon_pos) = stripped.find(": ") + { + let branch = &stripped[..colon_pos]; + let message = &stripped[colon_pos + 2..]; + if !branch.is_empty() && !message.is_empty() { + return (Some(branch), message); + } + } + + // Handle "On : " pattern + if let Some(stripped) = input.strip_prefix("On ") + && let Some(colon_pos) = stripped.find(": ") + { + let branch = &stripped[..colon_pos]; + let message = &stripped[colon_pos + 2..]; + if !branch.is_empty() && !message.is_empty() { + return (Some(branch), message); + } + } + + // Fallback: treat entire input as message with no branch + (None, input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_stash_index() { + assert_eq!(parse_stash_index("stash@{0}").unwrap(), 0); + assert_eq!(parse_stash_index("stash@{42}").unwrap(), 42); + assert_eq!(parse_stash_index(" stash@{5} ").unwrap(), 5); + + assert!(parse_stash_index("invalid").is_err()); + assert!(parse_stash_index("stash@{not_a_number}").is_err()); + assert!(parse_stash_index("stash@{0").is_err()); + } + + #[test] + fn test_parse_stash_message() { + // WIP format + let (branch, message) = parse_stash_message("WIP on main: working on feature"); + assert_eq!(branch, Some("main")); + assert_eq!(message, "working on feature"); + + // On format + let (branch, message) = parse_stash_message("On feature-branch: some changes"); + assert_eq!(branch, Some("feature-branch")); + assert_eq!(message, "some changes"); + + // No branch format + let (branch, message) = parse_stash_message("just a regular message"); + assert_eq!(branch, None); + assert_eq!(message, "just a regular message"); + + // Edge cases + let (branch, message) = parse_stash_message("WIP on : empty message"); + assert_eq!(branch, None); + assert_eq!(message, "WIP on : empty message"); + + let (branch, message) = parse_stash_message("On branch-name:"); + assert_eq!(branch, None); + assert_eq!(message, "On branch-name:"); + } + + #[test] + fn test_parse_stash_line() { + let line = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: test commit"; + let entry = parse_stash_line(line).unwrap(); + + assert_eq!(entry.index, 0); + assert_eq!(entry.message, "test commit"); + assert_eq!(entry.branch, Some("main".to_string())); + assert_eq!(entry.timestamp, 1234567890); + } + + #[test] + fn test_git_stash_from_str() { + let input = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: first stash\nstash@{1}\u{0000}def456\u{0000}1234567891\u{0000}On feature: second stash"; + let stash = GitStash::from_str(input).unwrap(); + + assert_eq!(stash.entries.len(), 2); + assert_eq!(stash.entries[0].index, 0); + assert_eq!(stash.entries[0].branch, Some("main".to_string())); + assert_eq!(stash.entries[1].index, 1); + assert_eq!(stash.entries[1].branch, Some("feature".to_string())); + } + + #[test] + fn test_git_stash_empty_input() { + let stash = GitStash::from_str("").unwrap(); + assert_eq!(stash.entries.len(), 0); + + let stash = GitStash::from_str(" \n \n ").unwrap(); + assert_eq!(stash.entries.len(), 0); + } +} diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 6158b5179838c2b3bd36fb91f2aa9e2286c52ca1..f3401a0e93990c61df80e0e88e28292c4f2b28e2 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -1,8 +1,10 @@ -use crate::repository::RepoPath; -use anyhow::Result; +use crate::{Oid, repository::RepoPath}; +use anyhow::{Result, anyhow}; +use collections::HashMap; +use gpui::SharedString; use serde::{Deserialize, Serialize}; -use std::{path::Path, str::FromStr, sync::Arc}; -use util::ResultExt; +use std::{str::FromStr, sync::Arc}; +use util::{ResultExt, rel_path::RelPath}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FileStatus { @@ -153,17 +155,11 @@ impl FileStatus { } pub fn is_conflicted(self) -> bool { - match self { - FileStatus::Unmerged { .. } => true, - _ => false, - } + matches!(self, FileStatus::Unmerged { .. }) } pub fn is_ignored(self) -> bool { - match self { - FileStatus::Ignored => true, - _ => false, - } + matches!(self, FileStatus::Ignored) } pub fn has_changes(&self) -> bool { @@ -176,40 +172,35 @@ impl FileStatus { pub fn is_modified(self) -> bool { match self { - FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { - (StatusCode::Modified, _) | (_, StatusCode::Modified) => true, - _ => false, - }, + FileStatus::Tracked(tracked) => matches!( + (tracked.index_status, tracked.worktree_status), + (StatusCode::Modified, _) | (_, StatusCode::Modified) + ), _ => false, } } pub fn is_created(self) -> bool { match self { - FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { - (StatusCode::Added, _) | (_, StatusCode::Added) => true, - _ => false, - }, + FileStatus::Tracked(tracked) => matches!( + (tracked.index_status, tracked.worktree_status), + (StatusCode::Added, _) | (_, StatusCode::Added) + ), FileStatus::Untracked => true, _ => false, } } pub fn is_deleted(self) -> bool { - match self { - FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { - (StatusCode::Deleted, _) | (_, StatusCode::Deleted) => true, - _ => false, - }, - _ => false, - } + let FileStatus::Tracked(tracked) = self else { + return false; + }; + tracked.index_status == StatusCode::Deleted && tracked.worktree_status != StatusCode::Added + || tracked.worktree_status == StatusCode::Deleted } pub fn is_untracked(self) -> bool { - match self { - FileStatus::Untracked => true, - _ => false, - } + matches!(self, FileStatus::Untracked) } pub fn summary(self) -> GitSummary { @@ -393,14 +384,12 @@ impl From for GitSummary { } } -impl sum_tree::Summary for GitSummary { - type Context = (); - - fn zero(_: &Self::Context) -> Self { +impl sum_tree::ContextLessSummary for GitSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, rhs: &Self, _: &Self::Context) { + fn add_summary(&mut self, rhs: &Self) { *self += *rhs; } } @@ -464,11 +453,12 @@ impl FromStr for GitStatus { } let status = entry.as_bytes()[0..2].try_into().unwrap(); let status = FileStatus::from_bytes(status).log_err()?; - let path = RepoPath(Path::new(path).into()); + // git-status outputs `/`-delimited repo paths, even on Windows. + let path = RepoPath(RelPath::unix(path).log_err()?.into()); Some((path, status)) }) .collect::>(); - entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); // When a file exists in HEAD, is deleted in the index, and exists again in the working copy, // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy) // and the other reading `??` (untracked). Merge these two into the equivalent of `DA`. @@ -502,3 +492,128 @@ impl Default for GitStatus { } } } + +pub enum DiffTreeType { + MergeBase { + base: SharedString, + head: SharedString, + }, + Since { + base: SharedString, + head: SharedString, + }, +} + +impl DiffTreeType { + pub fn base(&self) -> &SharedString { + match self { + DiffTreeType::MergeBase { base, .. } => base, + DiffTreeType::Since { base, .. } => base, + } + } + + pub fn head(&self) -> &SharedString { + match self { + DiffTreeType::MergeBase { head, .. } => head, + DiffTreeType::Since { head, .. } => head, + } + } +} + +#[derive(Debug, PartialEq)] +pub struct TreeDiff { + pub entries: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TreeDiffStatus { + Added, + Modified { old: Oid }, + Deleted { old: Oid }, +} + +impl FromStr for TreeDiff { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut fields = s.split('\0'); + let mut parsed = HashMap::default(); + while let Some((status, path)) = fields.next().zip(fields.next()) { + let path = RepoPath(RelPath::unix(path)?.into()); + + let mut fields = status.split(" ").skip(2); + let old_sha = fields + .next() + .ok_or_else(|| anyhow!("expected to find old_sha"))? + .to_owned() + .parse()?; + let _new_sha = fields + .next() + .ok_or_else(|| anyhow!("expected to find new_sha"))?; + let status = fields + .next() + .and_then(|s| { + if s.len() == 1 { + s.as_bytes().first() + } else { + None + } + }) + .ok_or_else(|| anyhow!("expected to find status"))?; + + let result = match StatusCode::from_byte(*status)? { + StatusCode::Modified => TreeDiffStatus::Modified { old: old_sha }, + StatusCode::Added => TreeDiffStatus::Added, + StatusCode::Deleted => TreeDiffStatus::Deleted { old: old_sha }, + _status => continue, + }; + + parsed.insert(path, result); + } + + Ok(Self { entries: parsed }) + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + repository::RepoPath, + status::{TreeDiff, TreeDiffStatus}, + }; + + #[test] + fn test_tree_diff_parsing() { + let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() + + ":100644 000000 bb3e9ed2e97a8c02545bae243264d342c069afb3 0000000000000000000000000000000000000000 D\x00README.md\x00" + + ":100644 100644 42f097005a1f21eb2260fad02ec8c991282beee8 a437d85f63bb8c62bd78f83f40c506631fabf005 M\x00parallel.go\x00"; + + let output: TreeDiff = input.parse().unwrap(); + assert_eq!( + output, + TreeDiff { + entries: [ + ( + RepoPath::new(".zed/settings.json").unwrap(), + TreeDiffStatus::Added, + ), + ( + RepoPath::new("README.md").unwrap(), + TreeDiffStatus::Deleted { + old: "bb3e9ed2e97a8c02545bae243264d342c069afb3".parse().unwrap() + } + ), + ( + RepoPath::new("parallel.go").unwrap(), + TreeDiffStatus::Modified { + old: "42f097005a1f21eb2260fad02ec8c991282beee8".parse().unwrap(), + } + ), + ] + .into_iter() + .collect() + } + ) + } +} diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index cce7ea439ecfb7ea51cbf5bd89dae5e6c6a6f350..2b3e8f235ff6e5f351c1875107443f51838c6da9 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -19,15 +19,14 @@ git.workspace = true gpui.workspace = true http_client.workspace = true regex.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true url.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] indoc.workspace = true serde_json.workspace = true pretty_assertions.workspace = true +git = { workspace = true, features = ["test-support"] } diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index b31412ed4a46b0dc2695ae0229638fad409de13c..1d88c47f2e26fc9ad4e27b1e36351198c4365caf 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -49,13 +49,13 @@ pub fn register_additional_providers( pub fn get_host_from_git_remote_url(remote_url: &str) -> Result { maybe!({ - if let Some(remote_url) = remote_url.strip_prefix("git@") { - if let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') { - return Some(host.to_string()); - } + if let Some(remote_url) = remote_url.strip_prefix("git@") + && let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') + { + return Some(host.to_string()); } - Url::parse(&remote_url) + Url::parse(remote_url) .ok() .and_then(|remote_url| remote_url.host_str().map(|host| host.to_string())) }) diff --git a/crates/git_hosting_providers/src/providers/bitbucket.rs b/crates/git_hosting_providers/src/providers/bitbucket.rs index 26df7b567ae007553657a0075038c0762ce0063e..a6bb83b0f9d6025301db309c4d64ea39ade42076 100644 --- a/crates/git_hosting_providers/src/providers/bitbucket.rs +++ b/crates/git_hosting_providers/src/providers/bitbucket.rs @@ -126,6 +126,7 @@ impl GitHostingProvider for Bitbucket { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -182,11 +183,7 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: None, - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None), ); let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs"; @@ -200,11 +197,7 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)), ); let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7"; @@ -218,11 +211,7 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "f00b4r", - path: "main.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)), ); let expected_url = diff --git a/crates/git_hosting_providers/src/providers/chromium.rs b/crates/git_hosting_providers/src/providers/chromium.rs index b68c629ec7faaf9e37316cd0f7fb4f297b55f502..0826e31b309918fb0967f7b3019b53fe483837b9 100644 --- a/crates/git_hosting_providers/src/providers/chromium.rs +++ b/crates/git_hosting_providers/src/providers/chromium.rs @@ -191,6 +191,7 @@ impl GitHostingProvider for Chromium { #[cfg(test)] mod tests { + use git::repository::repo_path; use indoc::indoc; use pretty_assertions::assert_eq; @@ -218,11 +219,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: None, - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + None, + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h"; @@ -236,11 +237,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: Some(18..18), - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + Some(18..18), + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; @@ -254,11 +255,11 @@ mod tests { owner: Arc::from(""), repo: "chromium/src".into(), }, - BuildPermalinkParams { - sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", - path: "ui/base/cursor/cursor.h", - selection: Some(18..30), - }, + BuildPermalinkParams::new( + "fea5080b182fc92e3be0c01c5dece602fe70b588", + &repo_path("ui/base/cursor/cursor.h"), + Some(18..30), + ), ); let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; @@ -292,7 +293,7 @@ mod tests { assert_eq!( Chromium - .extract_pull_request(&remote, &message) + .extract_pull_request(&remote, message) .unwrap() .url .as_str(), diff --git a/crates/git_hosting_providers/src/providers/codeberg.rs b/crates/git_hosting_providers/src/providers/codeberg.rs index b9f2542d5b00d32b476e20e4925b7805c886d636..4cd7dd2c04aa30973d6409300eadd9fbc980ddc4 100644 --- a/crates/git_hosting_providers/src/providers/codeberg.rs +++ b/crates/git_hosting_providers/src/providers/codeberg.rs @@ -16,25 +16,53 @@ use git::{ #[derive(Debug, Deserialize)] struct CommitDetails { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] commit: Commit, author: Option, } #[derive(Debug, Deserialize)] struct Commit { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] author: Author, } #[derive(Debug, Deserialize)] struct Author { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] name: String, + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] email: String, + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] date: String, } #[derive(Debug, Deserialize)] struct User { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] pub login: String, + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] pub id: u64, pub avatar_url: String, } @@ -176,6 +204,7 @@ impl GitHostingProvider for Codeberg { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -217,11 +246,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs"; @@ -235,11 +264,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7"; @@ -253,11 +282,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48"; diff --git a/crates/git_hosting_providers/src/providers/gitee.rs b/crates/git_hosting_providers/src/providers/gitee.rs index 5090cd0d74d775af490976758f39fdabe062b43b..e2bcb6668240fa43120555f9b3c11a10dd1418d7 100644 --- a/crates/git_hosting_providers/src/providers/gitee.rs +++ b/crates/git_hosting_providers/src/providers/gitee.rs @@ -84,6 +84,7 @@ impl GitHostingProvider for Gitee { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -125,11 +126,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs"; @@ -143,11 +144,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7"; @@ -161,11 +162,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e5fe811d7ad0fc26934edd76f891d20bdc3bb194", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48"; diff --git a/crates/git_hosting_providers/src/providers/github.rs b/crates/git_hosting_providers/src/providers/github.rs index 30f8d058a7c46798209685930518f4b040dbe714..4f5c71830da4e5ce4112812d0737ebc878df7b76 100644 --- a/crates/git_hosting_providers/src/providers/github.rs +++ b/crates/git_hosting_providers/src/providers/github.rs @@ -25,22 +25,38 @@ fn pull_request_number_regex() -> &'static Regex { #[derive(Debug, Deserialize)] struct CommitDetails { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] commit: Commit, author: Option, } #[derive(Debug, Deserialize)] struct Commit { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] author: Author, } #[derive(Debug, Deserialize)] struct Author { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] email: String, } #[derive(Debug, Deserialize)] struct User { + #[expect( + unused, + reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove" + )] pub id: u64, pub avatar_url: String, } @@ -243,6 +259,7 @@ impl GitHostingProvider for Github { #[cfg(test)] mod tests { + use git::repository::repo_path; use indoc::indoc; use pretty_assertions::assert_eq; @@ -384,11 +401,11 @@ mod tests { }; let permalink = Github::public_instance().build_permalink( remote, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -402,11 +419,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", - path: "crates/zed/src/main.rs", - selection: None, - }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; @@ -420,11 +437,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; @@ -438,11 +455,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48"; @@ -474,7 +491,7 @@ mod tests { assert_eq!( github - .extract_pull_request(&remote, &message) + .extract_pull_request(&remote, message) .unwrap() .url .as_str(), @@ -488,6 +505,25 @@ mod tests { See the original PR, this is a fix. "# }; - assert_eq!(github.extract_pull_request(&remote, &message), None); + assert_eq!(github.extract_pull_request(&remote, message), None); + } + + /// Regression test for issue #39875 + #[test] + fn test_git_permalink_url_escaping() { + let permalink = Github::public_instance().build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "nonexistent".into(), + }, + BuildPermalinkParams::new( + "3ef1539900037dd3601be7149b2b39ed6d0ce3db", + &repo_path("app/blog/[slug]/page.tsx"), + Some(7..7), + ), + ); + + let expected_url = "https://github.com/zed-industries/nonexistent/blob/3ef1539900037dd3601be7149b2b39ed6d0ce3db/app/blog/%5Bslug%5D/page.tsx#L8"; + assert_eq!(permalink.to_string(), expected_url.to_string()) } } diff --git a/crates/git_hosting_providers/src/providers/gitlab.rs b/crates/git_hosting_providers/src/providers/gitlab.rs index 969a2ff1d5951400a38946531c1da4527462169a..d18af7cccae058a7b9746f7dfe86beef8d6fda94 100644 --- a/crates/git_hosting_providers/src/providers/gitlab.rs +++ b/crates/git_hosting_providers/src/providers/gitlab.rs @@ -126,6 +126,7 @@ impl GitHostingProvider for Gitlab { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -209,11 +210,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -227,11 +228,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; @@ -245,11 +246,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48"; @@ -266,11 +267,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; @@ -287,11 +288,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", - path: "crates/zed/src/main.rs", - selection: None, - }, + BuildPermalinkParams::new( + "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + &repo_path("crates/zed/src/main.rs"), + None, + ), ); let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; diff --git a/crates/git_hosting_providers/src/providers/sourcehut.rs b/crates/git_hosting_providers/src/providers/sourcehut.rs index c64f72193da4f5affde69b61e27452cb831e9501..55bff551846b5f69bad8ccaeaccf3ad55868303f 100644 --- a/crates/git_hosting_providers/src/providers/sourcehut.rs +++ b/crates/git_hosting_providers/src/providers/sourcehut.rs @@ -89,6 +89,7 @@ impl GitHostingProvider for Sourcehut { #[cfg(test)] mod tests { + use git::repository::repo_path; use pretty_assertions::assert_eq; use super::*; @@ -145,11 +146,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; @@ -163,11 +164,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed.git".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: None, - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; @@ -181,11 +182,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(6..6), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7"; @@ -199,11 +200,11 @@ mod tests { owner: "zed-industries".into(), repo: "zed".into(), }, - BuildPermalinkParams { - sha: "faa6f979be417239b2e070dbbf6392b909224e0b", - path: "crates/editor/src/git/permalink.rs", - selection: Some(23..47), - }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), ); let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; diff --git a/crates/git_hosting_providers/src/settings.rs b/crates/git_hosting_providers/src/settings.rs index 91179fea392bc38cfc2a513bfc391dd3eec6137d..9a1625c8debac5fc83004eae26e6b9673a17290c 100644 --- a/crates/git_hosting_providers/src/settings.rs +++ b/crates/git_hosting_providers/src/settings.rs @@ -1,11 +1,8 @@ use std::sync::Arc; -use anyhow::Result; use git::GitHostingProviderRegistry; use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{GitHostingProviderConfig, GitHostingProviderKind, Settings, SettingsStore}; use url::Url; use util::ResultExt as _; @@ -55,44 +52,20 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) { provider_registry.set_setting_providers(iter); } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum GitHostingProviderKind { - Github, - Gitlab, - Bitbucket, -} - -/// A custom Git hosting provider. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct GitHostingProviderConfig { - /// The type of the provider. - /// - /// Must be one of `github`, `gitlab`, or `bitbucket`. - pub provider: GitHostingProviderKind, - - /// The base URL for the provider (e.g., "https://code.corp.big.com"). - pub base_url: String, - - /// The display name for the provider (e.g., "BigCorp GitHub"). - pub name: String, -} - -#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone)] pub struct GitHostingProviderSettings { - /// The list of custom Git hosting providers. - #[serde(default)] pub git_hosting_providers: Vec, } impl Settings for GitHostingProviderSettings { - const KEY: Option<&'static str> = None; - - type FileContent = Self; - - fn load(sources: settings::SettingsSources, _: &mut App) -> Result { - sources.json_merge() + fn from_settings(content: &settings::SettingsContent) -> Self { + Self { + git_hosting_providers: content + .project + .git_hosting_providers + .clone() + .unwrap() + .into(), + } } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 35f7a603544ae72134a2c6c1b08dcb8a0119b79b..486e43fea94f53e2ad9fd67d88cfe2279afb353c 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -44,11 +44,9 @@ multi_buffer.workspace = true notifications.workspace = true panel.workspace = true picker.workspace = true -postage.workspace = true project.workspace = true schemars.workspace = true serde.workspace = true -serde_derive.workspace = true serde_json.workspace = true settings.workspace = true strum.workspace = true @@ -59,9 +57,9 @@ time_format.workspace = true ui.workspace = true util.workspace = true watch.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true +zeroize.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/git_ui/src/askpass_modal.rs b/crates/git_ui/src/askpass_modal.rs index 149833ad3535bb69ba35e199ece5166e194745a9..bbd507cfc4cce55f02e3c77a4317a6ca69049987 100644 --- a/crates/git_ui/src/askpass_modal.rs +++ b/crates/git_ui/src/askpass_modal.rs @@ -1,18 +1,22 @@ +use askpass::EncryptedPassword; use editor::Editor; use futures::channel::oneshot; use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled}; use ui::{ - ActiveTheme, App, Context, DynamicSpacing, Headline, HeadlineSize, Icon, IconName, IconSize, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, StyledExt, - StyledTypography, Window, div, h_flex, v_flex, + ActiveTheme, AnyElement, App, Button, Clickable, Color, Context, DynamicSpacing, Headline, + HeadlineSize, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, + LabelSize, ParentElement, Render, SharedString, StyledExt, StyledTypography, Window, div, + h_flex, v_flex, }; +use util::maybe; use workspace::ModalView; +use zeroize::Zeroize; pub(crate) struct AskPassModal { operation: SharedString, prompt: SharedString, editor: Entity, - tx: Option>, + tx: Option>, } impl EventEmitter for AskPassModal {} @@ -27,13 +31,13 @@ impl AskPassModal { pub fn new( operation: SharedString, prompt: SharedString, - tx: oneshot::Sender, + tx: oneshot::Sender, window: &mut Window, cx: &mut Context, ) -> Self { let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - if prompt.contains("yes/no") { + if prompt.contains("yes/no") || prompt.contains("Username") { editor.set_masked(false, cx); } else { editor.set_masked(true, cx); @@ -52,12 +56,52 @@ impl AskPassModal { cx.emit(DismissEvent); } - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - if let Some(tx) = self.tx.take() { - tx.send(self.editor.read(cx).text(cx)).ok(); - } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + maybe!({ + let tx = self.tx.take()?; + let mut text = self.editor.update(cx, |this, cx| { + let text = this.text(cx); + this.clear(window, cx); + text + }); + let pw = askpass::EncryptedPassword::try_from(text.as_ref()).ok()?; + text.zeroize(); + tx.send(pw).ok(); + Some(()) + }); + cx.emit(DismissEvent); } + + fn render_hint(&mut self, cx: &mut Context) -> Option { + let color = cx.theme().status().info_background; + if (self.prompt.contains("Password") || self.prompt.contains("Username")) + && self.prompt.contains("github.com") + { + return Some( + div() + .p_2() + .bg(color) + .border_t_1() + .border_color(cx.theme().status().info_border) + .child( + h_flex().gap_2() + .child( + Icon::new(IconName::Github).size(IconSize::Small) + ) + .child( + Label::new("You may need to configure git for Github.") + .size(LabelSize::Small), + ) + .child(Button::new("learn-more", "Learn more").color(Color::Accent).label_size(LabelSize::Small).on_click(|_, _, cx| { + cx.open_url("https://docs.github.com/en/get-started/git-basics/set-up-git#authenticating-with-github-from-git") + })), + ) + .into_any_element(), + ); + } + None + } } impl Render for AskPassModal { @@ -68,9 +112,9 @@ impl Render for AskPassModal { .on_action(cx.listener(Self::confirm)) .elevation_2(cx) .size_full() - .font_buffer(cx) .child( h_flex() + .font_buffer(cx) .px(DynamicSpacing::Base12.rems(cx)) .pt(DynamicSpacing::Base08.rems(cx)) .pb(DynamicSpacing::Base04.rems(cx)) @@ -86,6 +130,7 @@ impl Render for AskPassModal { ) .child( div() + .font_buffer(cx) .text_buffer(cx) .py_2() .px_3() @@ -97,5 +142,6 @@ impl Render for AskPassModal { .child(self.prompt.clone()) .child(self.editor.clone()), ) + .children(self.render_hint(cx)) } } diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index f910de7bbe461ea8edf7addda4acad1b712a6f60..6059bc9e83b63e710815891165fe6e530a0efa1a 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -1,5 +1,5 @@ use crate::{ - commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, + commit_tooltip::{CommitAvatar, CommitTooltip}, commit_view::CommitView, }; use editor::{BlameRenderer, Editor, hover_markdown_style}; @@ -8,8 +8,8 @@ use git::{ repository::CommitSummary, }; use gpui::{ - ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, WeakEntity, - prelude::*, + ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, + TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use markdown::{Markdown, MarkdownElement}; use project::{git_store::Repository, project_settings::ProjectSettings}; @@ -17,7 +17,7 @@ use settings::Settings as _; use theme::ThemeSettings; use time::OffsetDateTime; use time_format::format_local_timestamp; -use ui::{ContextMenu, Divider, IconButtonShape, prelude::*}; +use ui::{ContextMenu, Divider, prelude::*, tooltip_container}; use workspace::Workspace; const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20; @@ -39,6 +39,7 @@ impl BlameRenderer for GitBlameRenderer { editor: Entity, ix: usize, sha_color: Hsla, + window: &mut Window, cx: &mut App, ) -> Option { let relative_timestamp = blame_entry_relative_timestamp(&blame_entry); @@ -46,71 +47,79 @@ impl BlameRenderer for GitBlameRenderer { let author_name = blame_entry.author.as_deref().unwrap_or(""); let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); + let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar { + CommitAvatar::new( + &blame_entry.sha.to_string().into(), + details.as_ref().and_then(|it| it.remote.as_ref()), + ) + .render(window, cx) + } else { + None + }; Some( - h_flex() - .w_full() - .justify_between() - .font_family(style.font().family) - .line_height(style.line_height) - .id(("blame", ix)) - .text_color(cx.theme().status().hint) - .pr_2() - .gap_2() + div() + .mr_2() .child( h_flex() - .items_center() + .id(("blame", ix)) + .w_full() .gap_2() - .child(div().text_color(sha_color).child(short_commit_id)) - .child(name), - ) - .child(relative_timestamp) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .cursor_pointer() - .on_mouse_down(MouseButton::Right, { - let blame_entry = blame_entry.clone(); - let details = details.clone(); - move |event, window, cx| { - deploy_blame_entry_context_menu( - &blame_entry, - details.as_ref(), - editor.clone(), - event.position, - window, - cx, - ); - } - }) - .on_click({ - let blame_entry = blame_entry.clone(); - let repository = repository.clone(); - let workspace = workspace.clone(); - move |_, window, cx| { - CommitView::open( - CommitSummary { - sha: blame_entry.sha.to_string().into(), - subject: blame_entry.summary.clone().unwrap_or_default().into(), - commit_timestamp: blame_entry.committer_time.unwrap_or_default(), - has_parent: true, - }, - repository.downgrade(), - workspace.clone(), - window, - cx, + .justify_between() + .font_family(style.font().family) + .line_height(style.line_height) + .text_color(cx.theme().status().hint) + .child( + h_flex() + .gap_2() + .child(div().text_color(sha_color).child(short_commit_id)) + .children(avatar) + .child(name), ) - } - }) - .hoverable_tooltip(move |_window, cx| { - cx.new(|cx| { - CommitTooltip::blame_entry( - &blame_entry, - details.clone(), - repository.clone(), - workspace.clone(), - cx, - ) - }) - .into() - }) + .child(relative_timestamp) + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .cursor_pointer() + .on_mouse_down(MouseButton::Right, { + let blame_entry = blame_entry.clone(); + let details = details.clone(); + move |event, window, cx| { + deploy_blame_entry_context_menu( + &blame_entry, + details.as_ref(), + editor.clone(), + event.position, + window, + cx, + ); + } + }) + .on_click({ + let blame_entry = blame_entry.clone(); + let repository = repository.clone(); + let workspace = workspace.clone(); + move |_, window, cx| { + CommitView::open( + blame_entry.sha.to_string(), + repository.downgrade(), + workspace.clone(), + None, + window, + cx, + ) + } + }) + .hoverable_tooltip(move |_window, cx| { + cx.new(|cx| { + CommitTooltip::blame_entry( + &blame_entry, + details.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + }) + .into() + }), + ) .into_any(), ) } @@ -125,7 +134,8 @@ impl BlameRenderer for GitBlameRenderer { let author = blame_entry.author.as_deref().unwrap_or_default(); let summary_enabled = ProjectSettings::get_global(cx) .git - .show_inline_commit_summary(); + .inline_blame + .show_commit_summary; let text = match blame_entry.summary.as_ref() { Some(summary) if summary_enabled => { @@ -164,200 +174,180 @@ impl BlameRenderer for GitBlameRenderer { .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok()) .unwrap_or(OffsetDateTime::now_utc()); - let commit_details = CommitDetails { - sha: blame.sha.to_string().into(), - commit_time, - author_name: blame - .author - .clone() - .unwrap_or("".to_string()) - .into(), - author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(), - message: details, - }; - - let avatar = CommitAvatar::new(&commit_details).render(window, cx); + let sha = blame.sha.to_string().into(); + let author: SharedString = blame + .author + .clone() + .unwrap_or("".to_string()) + .into(); + let author_email = blame.author_mail.as_deref().unwrap_or_default(); + let avatar = CommitAvatar::new(&sha, details.as_ref().and_then(|it| it.remote.as_ref())) + .render(window, cx); - let author = commit_details.author_name.clone(); - let author_email = commit_details.author_email.clone(); - - let short_commit_id = commit_details - .sha - .get(0..8) + let short_commit_id = sha + .get(..8) .map(|sha| sha.to_string().into()) - .unwrap_or_else(|| commit_details.sha.clone()); - let full_sha = commit_details.sha.to_string().clone(); + .unwrap_or_else(|| sha.clone()); let absolute_timestamp = format_local_timestamp( - commit_details.commit_time, + commit_time, OffsetDateTime::now_utc(), time_format::TimestampFormat::MediumAbsolute, ); + let link_color = cx.theme().colors().text_accent; let markdown_style = { let mut style = hover_markdown_style(window, cx); if let Some(code_block) = &style.code_block.text { style.base_text_style.refine(code_block); } + style.link.refine(&TextStyleRefinement { + color: Some(link_color), + underline: Some(UnderlineStyle { + color: Some(link_color.opacity(0.4)), + thickness: px(1.0), + ..Default::default() + }), + ..Default::default() + }); style }; - let message = commit_details - .message + let message = details .as_ref() .map(|_| MarkdownElement::new(markdown.clone(), markdown_style).into_any()) .unwrap_or("".into_any()); - let pull_request = commit_details - .message + let pull_request = details .as_ref() .and_then(|details| details.pull_request.clone()); let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4); let commit_summary = CommitSummary { - sha: commit_details.sha.clone(), - subject: commit_details - .message + sha: sha.clone(), + subject: details .as_ref() - .map_or(Default::default(), |message| { - message - .message - .split('\n') - .next() - .unwrap() - .trim_end() - .to_string() - .into() - }), - commit_timestamp: commit_details.commit_time.unix_timestamp(), + .and_then(|details| { + Some( + details + .message + .split('\n') + .next()? + .trim_end() + .to_string() + .into(), + ) + }) + .unwrap_or_default(), + commit_timestamp: commit_time.unix_timestamp(), + author_name: author.clone(), has_parent: false, }; - let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); - - // padding to avoid tooltip appearing right below the mouse cursor - // TODO: use tooltip_container here Some( - div() - .pl_2() - .pt_2p5() - .child( - v_flex() - .elevation_2(cx) - .font(ui_font) - .text_ui(cx) - .text_color(cx.theme().colors().text) - .py_1() - .px_2() - .map(|el| { - el.occlude() - .on_mouse_move(|_, _, cx| cx.stop_propagation()) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child( - v_flex() - .w(gpui::rems(30.)) - .gap_4() - .child( - h_flex() - .pb_1p5() - .gap_x_2() - .overflow_x_hidden() - .flex_wrap() - .children(avatar) - .child(author) - .when(!author_email.is_empty(), |this| { - this.child( - div() - .text_color( - cx.theme().colors().text_muted, - ) - .child(author_email), - ) - }) - .border_b_1() - .border_color(cx.theme().colors().border_variant), - ) - .child( + tooltip_container(cx, |this, cx| { + this.occlude() + .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + v_flex() + .w(gpui::rems(30.)) + .child( + h_flex() + .pb_1() + .gap_2() + .overflow_x_hidden() + .flex_wrap() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .children(avatar) + .child(author) + .when(!author_email.is_empty(), |this| { + this.child( div() - .id("inline-blame-commit-message") - .child(message) - .max_h(message_max_height) - .overflow_y_scroll() - .track_scroll(&scroll_handle), - ) - .child( - h_flex() .text_color(cx.theme().colors().text_muted) - .w_full() - .justify_between() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(absolute_timestamp) - .child( - h_flex() - .gap_1p5() - .when_some(pull_request, |this, pr| { - this.child( - Button::new( - "pull-request-button", - format!("#{}", pr.number), - ) - .color(Color::Muted) - .icon(IconName::PullRequest) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .style(ButtonStyle::Subtle) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.open_url(pr.url.as_str()) - }), - ) - }) - .child(Divider::vertical()) - .child( - Button::new( - "commit-sha-button", - short_commit_id.clone(), - ) - .style(ButtonStyle::Subtle) - .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(move |_, window, cx| { - CommitView::open( - commit_summary.clone(), - repository.downgrade(), - workspace.clone(), - window, - cx, - ); - cx.stop_propagation(); - }), + .child(author_email.to_owned()), + ) + }), + ) + .child( + div() + .id("inline-blame-commit-message") + .track_scroll(&scroll_handle) + .py_1p5() + .max_h(message_max_height) + .overflow_y_scroll() + .child(message), + ) + .child( + h_flex() + .text_color(cx.theme().colors().text_muted) + .w_full() + .justify_between() + .pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(absolute_timestamp) + .child( + h_flex() + .gap_1() + .when_some(pull_request, |this, pr| { + this.child( + Button::new( + "pull-request-button", + format!("#{}", pr.number), + ) + .color(Color::Muted) + .icon(IconName::PullRequest) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.open_url(pr.url.as_str()) + }), + ) + .child(Divider::vertical()) + }) + .child( + Button::new( + "commit-sha-button", + short_commit_id.clone(), + ) + .color(Color::Muted) + .icon(IconName::FileGit) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + CommitView::open( + commit_summary.sha.clone().into(), + repository.downgrade(), + workspace.clone(), + None, + window, + cx, + ); + cx.stop_propagation(); + }), + ) + .child( + IconButton::new("copy-sha-button", IconName::Copy) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.write_to_clipboard( + ClipboardItem::new_string( + sha.to_string(), + ), ) - .child( - IconButton::new( - "copy-sha-button", - IconName::Copy, - ) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.write_to_clipboard( - ClipboardItem::new_string( - full_sha.clone(), - ), - ) - }), - ), - ), - ), - ) - }), - ) - .into_any_element(), + }), + ), + ), + ), + ) + }) + .into_any_element(), ) } @@ -370,14 +360,10 @@ impl BlameRenderer for GitBlameRenderer { cx: &mut App, ) { CommitView::open( - CommitSummary { - sha: blame_entry.sha.to_string().into(), - subject: blame_entry.summary.clone().unwrap_or_default().into(), - commit_timestamp: blame_entry.committer_time.unwrap_or_default(), - has_parent: true, - }, + blame_entry.sha.to_string(), repository.downgrade(), - workspace.clone(), + workspace, + None, window, cx, ) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 6bb84db834ff0c04ff733d00b888aa28f0d70bd6..662e1cc1d712757eb2f31b11a0d6340576c29317 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -10,6 +10,8 @@ use gpui::{ }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::Repository; +use project::project_settings::ProjectSettings; +use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; use time_format::format_local_timestamp; @@ -48,7 +50,7 @@ pub fn open( window: &mut Window, cx: &mut Context, ) { - let repository = workspace.project().read(cx).active_repository(cx).clone(); + let repository = workspace.project().read(cx).active_repository(cx); let style = BranchListStyle::Modal; workspace.toggle_modal(window, cx, |window, cx| { BranchList::new(repository, style, rems(34.), window, cx) @@ -122,29 +124,32 @@ impl BranchList { all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name)); all_branches.sort_by_key(|branch| { - branch - .most_recent_commit - .as_ref() - .map(|commit| 0 - commit.commit_timestamp) + ( + !branch.is_head, // Current branch (is_head=true) comes first + branch + .most_recent_commit + .as_ref() + .map(|commit| 0 - commit.commit_timestamp), + ) }); all_branches }) .await; - this.update_in(cx, |this, window, cx| { + let _ = this.update_in(cx, |this, window, cx| { this.picker.update(cx, |picker, cx| { picker.delegate.default_branch = default_branch; picker.delegate.all_branches = Some(all_branches); picker.refresh(window, cx); }) - })?; + }); anyhow::Ok(()) }) .detach_and_log_err(cx); - let delegate = BranchListDelegate::new(repository.clone(), style); + let delegate = BranchListDelegate::new(repository, style); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); let _subscription = cx.subscribe(&picker, |_, _, _, cx| { @@ -341,7 +346,6 @@ impl PickerDelegate for BranchListDelegate { }; picker .update(cx, |picker, _| { - #[allow(clippy::nonminimal_bool)] if !query.is_empty() && !matches .first() @@ -406,37 +410,20 @@ impl PickerDelegate for BranchListDelegate { return; } - cx.spawn_in(window, { - let branch = entry.branch.clone(); - async move |picker, cx| { - let branch_change_task = picker.update(cx, |this, cx| { - let repo = this - .delegate - .repo - .as_ref() - .context("No active repository")? - .clone(); - - let mut cx = cx.to_async(); - - anyhow::Ok(async move { - repo.update(&mut cx, |repo, _| { - repo.change_branch(branch.name().to_string()) - })? - .await? - }) - })??; - - branch_change_task.await?; + let Some(repo) = self.repo.clone() else { + return; + }; - picker.update(cx, |_, cx| { - cx.emit(DismissEvent); + let branch = entry.branch.clone(); + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? + .await??; - anyhow::Ok(()) - }) - } + anyhow::Ok(()) }) .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None); + + cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { @@ -450,9 +437,9 @@ impl PickerDelegate for BranchListDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let entry = &self.matches[ix]; + let entry = &self.matches.get(ix)?; - let (commit_time, subject) = entry + let (commit_time, author_name, subject) = entry .branch .most_recent_commit .as_ref() @@ -465,9 +452,10 @@ impl PickerDelegate for BranchListDelegate { OffsetDateTime::now_utc(), time_format::TimestampFormat::Relative, ); - (Some(formatted_time), Some(subject)) + let author = commit.author_name.clone(); + (Some(formatted_time), Some(author), Some(subject)) }) - .unwrap_or_else(|| (None, None)); + .unwrap_or_else(|| (None, None, None)); let icon = if let Some(default_branch) = self.default_branch.clone() && entry.is_new @@ -478,11 +466,10 @@ impl PickerDelegate for BranchListDelegate { this.delegate.set_selected_index(ix, window, cx); this.delegate.confirm(true, window, cx); })) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action( format!("Create branch based off default: {default_branch}"), &menu::SecondaryConfirm, - window, cx, ) }), @@ -506,8 +493,12 @@ impl PickerDelegate for BranchListDelegate { ) .into_any_element() } else { - HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) - .truncate() + h_flex() + .max_w_48() + .child( + HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) + .truncate(), + ) .into_any_element() }; @@ -516,6 +507,14 @@ impl PickerDelegate for BranchListDelegate { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) + .tooltip({ + let branch_name = entry.branch.name().to_string(); + if entry.is_new { + Tooltip::text(format!("Create branch \"{}\"", branch_name)) + } else { + Tooltip::text(branch_name) + } + }) .child( v_flex() .w_full() @@ -548,7 +547,18 @@ impl PickerDelegate for BranchListDelegate { "based off the current branch".to_string() } } else { - subject.unwrap_or("no commits found".into()).to_string() + let show_author_name = ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; + + subject.map_or("no commits found".into(), |subject| { + if show_author_name && author_name.is_some() { + format!("{} • {}", author_name.unwrap(), subject) + } else { + subject.to_string() + } + }) }; Label::new(message) .size(LabelSize::Small) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 5e7430ebc693458e6df9a41513138ae993b9097c..45b1563dca0ceed5ed2ac488026fe94084050780 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -35,12 +35,13 @@ impl ModalContainerProperties { // Calculate width based on character width let mut modal_width = 460.0; - let style = window.text_style().clone(); + let style = window.text_style(); let font_id = window.text_system().resolve_font(&style.font()); let font_size = style.font_size.to_pixels(window.rem_size()); if let Ok(em_width) = window.text_system().em_width(font_id, font_size) { - modal_width = preferred_char_width as f32 * em_width.0 + (container_padding * 2.0); + modal_width = + f32::from(preferred_char_width as f32 * em_width + px(container_padding * 2.0)); } Self { @@ -135,11 +136,10 @@ impl CommitModal { .as_ref() .and_then(|repo| repo.read(cx).head_commit.as_ref()) .is_some() + && !git_panel.amend_pending() { - if !git_panel.amend_pending() { - git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); - } + git_panel.set_amend_pending(true, cx); + git_panel.load_last_commit_message_if_empty(cx); } } ForceMode::Commit => { @@ -180,7 +180,7 @@ impl CommitModal { let commit_editor = git_panel.update(cx, |git_panel, cx| { git_panel.set_modal_open(true, cx); - let buffer = git_panel.commit_message_buffer(cx).clone(); + let buffer = git_panel.commit_message_buffer(cx); let panel_editor = git_panel.commit_editor.clone(); let project = git_panel.project.clone(); @@ -195,12 +195,12 @@ impl CommitModal { let commit_message = commit_editor.read(cx).text(cx); - if let Some(suggested_commit_message) = suggested_commit_message { - if commit_message.is_empty() { - commit_editor.update(cx, |editor, cx| { - editor.set_placeholder_text(suggested_commit_message, cx); - }); - } + if let Some(suggested_commit_message) = suggested_commit_message + && commit_message.is_empty() + { + commit_editor.update(cx, |editor, cx| { + editor.set_placeholder_text(&suggested_commit_message, window, cx); + }); } let focus_handle = commit_editor.focus_handle(cx); @@ -286,7 +286,7 @@ impl CommitModal { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu .when_some(keybinding_target.clone(), |el, keybinding_target| { - el.context(keybinding_target.clone()) + el.context(keybinding_target) }) .when(has_previous_commit, |this| { this.toggleable_entry( @@ -327,7 +327,7 @@ impl CommitModal { .anchor(Corner::TopRight) } - pub fn render_footer(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn render_footer(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let ( can_commit, tooltip, @@ -369,10 +369,6 @@ impl CommitModal { .icon_color(Color::Placeholder) .color(Color::Muted) .icon_position(IconPosition::Start) - .tooltip(Tooltip::for_action_title( - "Switch Branch", - &zed_actions::git::Branch, - )) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); })) @@ -392,15 +388,9 @@ impl CommitModal { }); let focus_handle = self.focus_handle(cx); - let close_kb_hint = - if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) { - Some( - KeybindingHint::new(close_kb, cx.theme().colors().editor_background) - .suffix("Cancel"), - ) - } else { - None - }; + let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| { + KeybindingHint::new(close_kb, cx.theme().colors().editor_background).suffix("Cancel") + }); h_flex() .group("commit_editor_footer") @@ -433,7 +423,7 @@ impl CommitModal { .flex_none() .px_1() .gap_4() - .children(close_kb_hint) + .child(close_kb_hint) .child(SplitButton::new( ui::ButtonLike::new_rounded_left(ElementId::Name( format!("split-button-left-{}", commit_label).into(), @@ -462,18 +452,21 @@ impl CommitModal { .disabled(!can_commit) .tooltip({ let focus_handle = focus_handle.clone(); - move |window, cx| { + move |_window, cx| { if can_commit { Tooltip::with_meta_in( tooltip, - Some(&git::Commit), + Some(if is_amend_pending { + &git::Amend + } else { + &git::Commit + }), format!( "git commit{}{}", if is_amend_pending { " --amend" } else { "" }, if is_signoff_enabled { " --signoff" } else { "" } ), &focus_handle.clone(), - window, cx, ) } else { @@ -483,7 +476,7 @@ impl CommitModal { }), self.render_git_commit_menu( ElementId::Name(format!("split-button-right-{}", commit_label).into()), - Some(focus_handle.clone()), + Some(focus_handle), ) .into_any_element(), )), diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 00ab911610c92dff4094452c55662447c4291259..97224840debcc4cfd8dcc74a56d448ef0d2826c1 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -28,25 +28,33 @@ pub struct CommitDetails { } pub struct CommitAvatar<'a> { - commit: &'a CommitDetails, + sha: &'a SharedString, + remote: Option<&'a GitRemote>, } impl<'a> CommitAvatar<'a> { - pub fn new(details: &'a CommitDetails) -> Self { - Self { commit: details } + pub fn new(sha: &'a SharedString, remote: Option<&'a GitRemote>) -> Self { + Self { sha, remote } + } + + pub fn from_commit_details(details: &'a CommitDetails) -> Self { + Self { + sha: &details.sha, + remote: details + .message + .as_ref() + .and_then(|details| details.remote.as_ref()), + } } } impl<'a> CommitAvatar<'a> { pub fn render(&'a self, window: &mut Window, cx: &mut App) -> Option> { let remote = self - .commit - .message - .as_ref() - .and_then(|details| details.remote.clone()) + .remote .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote, self.commit.sha.clone()); + let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha.clone()); let element = match window.use_asset::(&avatar_url, cx) { // Loading or no avatar found @@ -169,7 +177,7 @@ impl CommitTooltip { impl Render for CommitTooltip { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = CommitAvatar::new(&self.commit).render(window, cx); + let avatar = CommitAvatar::from_commit_details(&self.commit).render(window, cx); let author = self.commit.author_name.clone(); @@ -181,7 +189,7 @@ impl Render for CommitTooltip { .get(0..8) .map(|sha| sha.to_string().into()) .unwrap_or_else(|| self.commit.sha.clone()); - let full_sha = self.commit.sha.to_string().clone(); + let full_sha = self.commit.sha.to_string(); let absolute_timestamp = format_local_timestamp( self.commit.commit_time, OffsetDateTime::now_utc(), @@ -229,10 +237,11 @@ impl Render for CommitTooltip { .into() }), commit_timestamp: self.commit.commit_time.unix_timestamp(), + author_name: self.commit.author_name.clone(), has_parent: false, }; - tooltip_container(window, cx, move |this, _, cx| { + tooltip_container(cx, move |this, cx| { this.occlude() .on_mouse_move(|_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) @@ -309,9 +318,10 @@ impl Render for CommitTooltip { .on_click( move |_, window, cx| { CommitView::open( - commit_summary.clone(), + commit_summary.sha.to_string(), repo.downgrade(), workspace.clone(), + None, window, cx, ); diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index c8c237fe90f12f2ac4ead04e0f2f0b4955f8bc1c..0a0c4c18e1f528a9ebaad9a8d9862982632dd04f 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,35 +1,60 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects}; -use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath}; +use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines}; +use git::repository::{CommitDetails, CommitDiff, RepoPath}; use gpui::{ - AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window, + Action, AnyElement, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, Context, + Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PromptLevel, Render, Task, + WeakEntity, Window, actions, }; use language::{ Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, - Point, Rope, TextBuffer, + Point, ReplicaId, Rope, TextBuffer, }; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; use std::{ any::{Any, TypeId}, - ffi::OsStr, fmt::Write as _, - path::{Path, PathBuf}, + path::PathBuf, sync::Arc, }; -use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString}; -use util::{ResultExt, truncate_and_trailoff}; +use ui::{ + Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, Tooltip, prelude::*, +}; +use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::{ - Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, + Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{BreadcrumbText, ItemEvent, TabContentParams}, + notifications::NotifyTaskExt, + pane::SaveIntent, searchable::SearchableItemHandle, }; +use crate::git_panel::GitPanel; + +actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + register_workspace_action(workspace, |toolbar, _: &ApplyCurrentStash, window, cx| { + toolbar.apply_stash(window, cx); + }); + register_workspace_action(workspace, |toolbar, _: &DropCurrentStash, window, cx| { + toolbar.remove_stash(window, cx); + }); + register_workspace_action(workspace, |toolbar, _: &PopCurrentStash, window, cx| { + toolbar.pop_stash(window, cx); + }); + }) + .detach(); +} + pub struct CommitView { commit: CommitDetails, editor: Entity, + stash: Option, multibuffer: Entity, } @@ -40,26 +65,27 @@ struct GitBlob { } struct CommitMetadataFile { - title: Arc, + title: Arc, worktree_id: WorktreeId, } -const COMMIT_METADATA_NAMESPACE: u32 = 0; -const FILE_NAMESPACE: u32 = 1; +const COMMIT_METADATA_SORT_PREFIX: u64 = 0; +const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { pub fn open( - commit: CommitSummary, + commit_sha: String, repo: WeakEntity, workspace: WeakEntity, + stash: Option, window: &mut Window, cx: &mut App, ) { let commit_diff = repo - .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string())) + .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone())) .ok(); let commit_details = repo - .update(cx, |repo, _| repo.show(commit.sha.to_string())) + .update(cx, |repo, _| repo.show(commit_sha.clone())) .ok(); window @@ -78,6 +104,7 @@ impl CommitView { commit_diff, repo, project.clone(), + stash, window, cx, ) @@ -88,11 +115,10 @@ impl CommitView { let ix = pane.items().position(|item| { let commit_view = item.downcast::(); commit_view - .map_or(false, |view| view.read(cx).commit.sha == commit.sha) + .is_some_and(|view| view.read(cx).commit.sha == commit_sha) }); if let Some(ix) = ix { pane.activate_item(ix, true, true, window, cx); - return; } else { pane.add_item(Box::new(commit_view), true, true, None, window, cx); } @@ -108,6 +134,7 @@ impl CommitView { commit_diff: CommitDiff, repository: Entity, project: Entity, + stash: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -129,23 +156,28 @@ impl CommitView { let mut metadata_buffer_id = None; if let Some(worktree_id) = first_worktree_id { + let title = if let Some(stash) = stash { + format!("stash@{{{}}}", stash) + } else { + format!("commit {}", commit.sha) + }; let file = Arc::new(CommitMetadataFile { - title: PathBuf::from(format!("commit {}", commit.sha)).into(), + title: RelPath::unix(&title).unwrap().into(), worktree_id, }); let buffer = cx.new(|cx| { let buffer = TextBuffer::new_normalized( - 0, + ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), LineEnding::default(), - format_commit(&commit).into(), + format_commit(&commit, stash.is_some()).into(), ); metadata_buffer_id = Some(buffer.remote_id()); Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite) }); multibuffer.update(cx, |multibuffer, cx| { multibuffer.set_excerpts_for_path( - PathKey::namespaced(COMMIT_METADATA_NAMESPACE, file.title.clone()), + PathKey::with_sort_prefix(COMMIT_METADATA_SORT_PREFIX, file.title.clone()), buffer.clone(), vec![Point::zero()..buffer.read(cx).max_point()], 0, @@ -160,7 +192,7 @@ impl CommitView { }); } - cx.spawn(async move |this, mut cx| { + cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); let new_text = file.new_text.unwrap_or_default(); @@ -179,9 +211,9 @@ impl CommitView { worktree_id, }) as Arc; - let buffer = build_buffer(new_text, file, &language_registry, &mut cx).await?; + let buffer = build_buffer(new_text, file, &language_registry, cx).await?; let buffer_diff = - build_buffer_diff(old_text, &buffer, &language_registry, &mut cx).await?; + build_buffer_diff(old_text, &buffer, &language_registry, cx).await?; this.update(cx, |this, cx| { this.multibuffer.update(cx, |multibuffer, cx| { @@ -193,10 +225,10 @@ impl CommitView { .collect::>(); let path = snapshot.file().unwrap().path().clone(); let _is_newly_added = multibuffer.set_excerpts_for_path( - PathKey::namespaced(FILE_NAMESPACE, path), + PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), buffer, diff_hunk_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); multibuffer.add_diff(buffer_diff, cx); @@ -211,6 +243,7 @@ impl CommitView { commit, editor, multibuffer, + stash, } } } @@ -228,15 +261,19 @@ impl language::File for GitBlob { } } - fn path(&self) -> &Arc { + fn path_style(&self, _: &App) -> PathStyle { + PathStyle::Posix + } + + fn path(&self) -> &Arc { &self.path.0 } fn full_path(&self, _: &App) -> PathBuf { - self.path.to_path_buf() + self.path.as_std_path().to_path_buf() } - fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr { + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { self.path.file_name().unwrap() } @@ -262,15 +299,19 @@ impl language::File for CommitMetadataFile { DiskState::New } - fn path(&self) -> &Arc { + fn path_style(&self, _: &App) -> PathStyle { + PathStyle::Posix + } + + fn path(&self) -> &Arc { &self.title } fn full_path(&self, _: &App) -> PathBuf { - self.title.as_ref().into() + PathBuf::from(self.title.as_unix_str().to_owned()) } - fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr { + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { self.title.file_name().unwrap() } @@ -308,7 +349,7 @@ async fn build_buffer( }; let buffer = cx.new(|cx| { let buffer = TextBuffer::new_normalized( - 0, + ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), line_ending, text, @@ -361,9 +402,13 @@ async fn build_buffer_diff( }) } -fn format_commit(commit: &CommitDetails) -> String { +fn format_commit(commit: &CommitDetails, is_stash: bool) -> String { let mut result = String::new(); - writeln!(&mut result, "commit {}", commit.sha).unwrap(); + if is_stash { + writeln!(&mut result, "stash commit {}", commit.sha).unwrap(); + } else { + writeln!(&mut result, "commit {}", commit.sha).unwrap(); + } writeln!( &mut result, "Author: {} <{}>", @@ -444,10 +489,6 @@ impl Item for CommitView { .update(cx, |editor, cx| editor.deactivated(window, cx)); } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn act_as_type<'a>( &'a self, type_id: TypeId, @@ -514,10 +555,320 @@ impl Item for CommitView { editor.added_to_workspace(workspace, window, cx) }); } + + fn can_split(&self) -> bool { + true + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task>> + where + Self: Sized, + { + Task::ready(Some(cx.new(|cx| { + let editor = cx.new(|cx| { + self.editor + .update(cx, |editor, cx| editor.clone(window, cx)) + }); + let multibuffer = editor.read(cx).buffer().clone(); + Self { + editor, + multibuffer, + commit: self.commit.clone(), + stash: self.stash, + } + }))) + } } impl Render for CommitView { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.editor.clone() + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_stash = self.stash.is_some(); + div() + .key_context(if is_stash { "StashDiff" } else { "CommitDiff" }) + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(self.editor.clone()) + } +} + +pub struct CommitViewToolbar { + commit_view: Option>, + workspace: WeakEntity, +} + +impl CommitViewToolbar { + pub fn new(workspace: &Workspace, _: &mut Context) -> Self { + Self { + commit_view: None, + workspace: workspace.weak_handle(), + } + } + + fn commit_view(&self, _: &App) -> Option> { + self.commit_view.as_ref()?.upgrade() + } + + async fn close_commit_view( + commit_view: Entity, + workspace: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> anyhow::Result<()> { + workspace + .update_in(cx, |workspace, window, cx| { + let active_pane = workspace.active_pane(); + let commit_view_id = commit_view.entity_id(); + active_pane.update(cx, |pane, cx| { + pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx) + }) + })? + .await?; + anyhow::Ok(()) + } + + fn apply_stash(&mut self, window: &mut Window, cx: &mut Context) { + self.stash_action( + "Apply", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, not applying")); + } + Ok(repo.stash_apply(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn pop_stash(&mut self, window: &mut Window, cx: &mut Context) { + self.stash_action( + "Pop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, pop aborted")); + } + Ok(repo.stash_pop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await?, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn remove_stash(&mut self, window: &mut Window, cx: &mut Context) { + self.stash_action( + "Drop", + window, + cx, + async move |repository, sha, stash, commit_view, workspace, cx| { + let result = repository.update(cx, |repo, cx| { + if !stash_matches_index(&sha, stash, repo) { + return Err(anyhow::anyhow!("Stash has changed, drop aborted")); + } + Ok(repo.stash_drop(Some(stash), cx)) + })?; + + match result { + Ok(task) => task.await??, + Err(err) => { + Self::close_commit_view(commit_view, workspace, cx).await?; + return Err(err); + } + }; + Self::close_commit_view(commit_view, workspace, cx).await?; + anyhow::Ok(()) + }, + ); + } + + fn stash_action( + &mut self, + str_action: &str, + window: &mut Window, + cx: &mut Context, + callback: AsyncFn, + ) where + AsyncFn: AsyncFnOnce( + Entity, + &SharedString, + usize, + Entity, + WeakEntity, + &mut AsyncWindowContext, + ) -> anyhow::Result<()> + + 'static, + { + let Some(commit_view) = self.commit_view(cx) else { + return; + }; + let Some(stash) = commit_view.read(cx).stash else { + return; + }; + let sha = commit_view.read(cx).commit.sha.clone(); + let answer = window.prompt( + PromptLevel::Info, + &format!("{} stash@{{{}}}?", str_action, stash), + None, + &[str_action, "Cancel"], + cx, + ); + + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |_, cx| { + if answer.await != Ok(0) { + return anyhow::Ok(()); + } + let repo = workspace.update(cx, |workspace, cx| { + workspace + .panel::(cx) + .and_then(|p| p.read(cx).active_repository.clone()) + })?; + + let Some(repo) = repo else { + return Ok(()); + }; + callback(repo, &sha, stash, commit_view, workspace, cx).await?; + anyhow::Ok(()) + }) + .detach_and_notify_err(window, cx); + } +} + +impl EventEmitter for CommitViewToolbar {} + +impl ToolbarItemView for CommitViewToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) + && entity.read(cx).stash.is_some() + { + self.commit_view = Some(entity.downgrade()); + return ToolbarItemLocation::PrimaryRight; + } + ToolbarItemLocation::Hidden + } + + fn pane_focus_update( + &mut self, + _pane_focused: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for CommitViewToolbar { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(commit_view) = self.commit_view(cx) else { + return div(); + }; + + let is_stash = commit_view.read(cx).stash.is_some(); + if !is_stash { + return div(); + } + + let focus_handle = commit_view.focus_handle(cx); + + h_group_xl().my_neg_1().py_1().items_center().child( + h_group_sm() + .child( + Button::new("apply-stash", "Apply") + .tooltip(Tooltip::for_action_title_in( + "Apply current stash", + &ApplyCurrentStash, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.apply_stash(window, cx))), + ) + .child( + Button::new("pop-stash", "Pop") + .tooltip(Tooltip::for_action_title_in( + "Pop current stash", + &PopCurrentStash, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.pop_stash(window, cx))), + ) + .child( + Button::new("remove-stash", "Remove") + .icon(IconName::Trash) + .tooltip(Tooltip::for_action_title_in( + "Remove current stash", + &DropCurrentStash, + &focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| this.remove_stash(window, cx))), + ), + ) + } +} + +fn register_workspace_action( + workspace: &mut Workspace, + callback: fn(&mut CommitViewToolbar, &A, &mut Window, &mut Context), +) { + workspace.register_action(move |workspace, action: &A, window, cx| { + if workspace.has_active_modal(window, cx) { + cx.propagate(); + return; + } + + workspace.active_pane().update(cx, |pane, cx| { + pane.toolbar().update(cx, move |workspace, cx| { + if let Some(toolbar) = workspace.item_of_type::() { + toolbar.update(cx, move |toolbar, cx| { + callback(toolbar, action, window, cx); + cx.notify(); + }); + } + }); + }) + }); +} + +fn stash_matches_index(sha: &str, index: usize, repo: &mut Repository) -> bool { + match repo + .cached_stash() + .entries + .iter() + .find(|entry| entry.index == index) + { + Some(entry) => entry.oid.to_string() == sha, + None => false, } } diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 6482ebb9f8fa6b7fa5688a7263968319427ac2a4..91cc3ce76b3f10aa310185b566b6c6086580b69c 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -55,7 +55,7 @@ pub fn register_editor(editor: &mut Editor, buffer: Entity, cx: &mu buffers: Default::default(), }); - let buffers = buffer.read(cx).all_buffers().clone(); + let buffers = buffer.read(cx).all_buffers(); for buffer in buffers { buffer_added(editor, buffer, cx); } @@ -129,7 +129,7 @@ fn buffer_added(editor: &mut Editor, buffer: Entity, cx: &mut Context, -) { +) -> Option<()> { log::debug!("update conflict highlighting for {conflict:?}"); - let outer_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.range.start) - .unwrap(); - let outer_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.range.end) - .unwrap(); - let our_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.ours.start) - .unwrap(); - let our_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.ours.end) - .unwrap(); - let their_start = buffer - .anchor_in_excerpt(excerpt_id, conflict.theirs.start) - .unwrap(); - let their_end = buffer - .anchor_in_excerpt(excerpt_id, conflict.theirs.end) - .unwrap(); + let outer = buffer.anchor_range_in_excerpt(excerpt_id, conflict.range.clone())?; + let ours = buffer.anchor_range_in_excerpt(excerpt_id, conflict.ours.clone())?; + let theirs = buffer.anchor_range_in_excerpt(excerpt_id, conflict.theirs.clone())?; let ours_background = cx.theme().colors().version_control_conflict_marker_ours; let theirs_background = cx.theme().colors().version_control_conflict_marker_theirs; @@ -352,32 +333,29 @@ fn update_conflict_highlighting( }; editor.insert_gutter_highlight::( - outer_start..their_end, + outer.start..theirs.end, |cx| cx.theme().colors().editor_background, cx, ); // Prevent diff hunk highlighting within the entire conflict region. - editor.highlight_rows::(outer_start..outer_end, theirs_background, options, cx); - editor.highlight_rows::(our_start..our_end, ours_background, options, cx); + editor.highlight_rows::(outer.clone(), theirs_background, options, cx); + editor.highlight_rows::(ours.clone(), ours_background, options, cx); editor.highlight_rows::( - outer_start..our_start, + outer.start..ours.start, ours_background, options, cx, ); - editor.highlight_rows::( - their_start..their_end, - theirs_background, - options, - cx, - ); + editor.highlight_rows::(theirs.clone(), theirs_background, options, cx); editor.highlight_rows::( - their_end..outer_end, + theirs.end..outer.end, theirs_background, options, cx, ); + + Some(()) } fn render_conflict_buttons( @@ -437,7 +415,6 @@ fn render_conflict_buttons( Button::new("both", "Use Both") .label_size(LabelSize::Small) .on_click({ - let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); let theirs = conflict.theirs.clone(); @@ -489,20 +466,16 @@ pub(crate) fn resolve_conflict( }) .ok()?; let &(_, block_id) = &state.block_ids[ix]; - let start = snapshot - .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start) - .unwrap(); - let end = snapshot - .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end) - .unwrap(); - - editor.remove_gutter_highlights::(vec![start..end], cx); - - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); - editor.remove_highlighted_rows::(vec![start..end], cx); + let range = + snapshot.anchor_range_in_excerpt(excerpt_id, resolved_conflict.range)?; + + editor.remove_gutter_highlights::(vec![range.clone()], cx); + + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range.clone()], cx); + editor.remove_highlighted_rows::(vec![range], cx); editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); Some((workspace, project, multibuffer, buffer)) }) diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 2f8a744ed893761f6491f23a31e19bfb55a4db62..387bda808708cf38beded2fe17edd92466885672 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -123,7 +123,7 @@ impl FileDiffView { old_buffer, new_buffer, _recalculate_diff_task: cx.spawn(async move |this, cx| { - while let Ok(_) = buffer_changes_rx.recv().await { + while buffer_changes_rx.recv().await.is_ok() { loop { let mut timer = cx .background_executor() @@ -241,7 +241,7 @@ impl Item for FileDiffView { buffer .read(cx) .file() - .map(|file| file.full_path(cx).compact().to_string_lossy().to_string()) + .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned()) .unwrap_or_else(|| "untitled".into()) }; let old_path = path(&self.old_buffer); @@ -263,10 +263,6 @@ impl Item for FileDiffView { .update(cx, |editor, cx| editor.deactivated(window, cx)); } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn act_as_type<'a>( &'a self, type_id: TypeId, @@ -364,7 +360,7 @@ mod tests { use editor::test::editor_test_context::assert_state_with_diff; use gpui::TestAppContext; use project::{FakeFs, Fs, Project}; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use std::path::PathBuf; use unindent::unindent; use util::path; @@ -378,7 +374,7 @@ mod tests { Project::init_settings(cx); workspace::init_settings(cx); editor::init_settings(cx); - theme::ThemeSettings::register(cx) + theme::init(theme::LoadThemes::JustBase, cx); }); } @@ -398,7 +394,7 @@ mod tests { let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, mut cx) = + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let diff_view = workspace @@ -417,7 +413,7 @@ mod tests { // Verify initial diff assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()), - &mut cx, + cx, &unindent( " - old line 1 @@ -452,7 +448,7 @@ mod tests { cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE); assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()), - &mut cx, + cx, &unindent( " - old line 1 @@ -487,7 +483,7 @@ mod tests { cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE); assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()), - &mut cx, + cx, &unindent( " ˇnew line 1 diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 70987dd2128e380c23c64289272e06c24b9b338b..9ff8602a18fd1a7eec5804deecee5c21921c6eee 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2,7 +2,6 @@ use crate::askpass_modal::AskPassModal; use crate::commit_modal::CommitModal; use crate::commit_tooltip::CommitTooltip; use crate::commit_view::CommitView; -use crate::git_panel_settings::StatusStyle; use crate::project_diff::{self, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::{branch_picker, picker_prompt, render_remote_button}; @@ -13,10 +12,7 @@ use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; use db::kvp::KEY_VALUE_STORE; -use editor::{ - Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, - scroll::ScrollbarAutoHide, -}; +use editor::{Editor, EditorElement, EditorMode, MultiBuffer}; use futures::StreamExt as _; use git::blame::ParsedCommitMessage; use git::repository::{ @@ -24,24 +20,23 @@ use git::repository::{ PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, get_git_committer, }; +use git::stash::GitStash; use git::status::StageStatus; use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ - ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashPop, TrashUntrackedFiles, - UnstageAll, + ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, + TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, - ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point, - PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle, - WeakEntity, actions, anchored, deferred, percentage, uniform_list, + Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, + MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, + UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, }; use itertools::Itertools; use language::{Buffer, File}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, Role, + ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, }; use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use multi_buffer::ExcerptInfo; @@ -51,21 +46,22 @@ use panel::{ panel_icon_button, }; use project::{ - DisableAiSettings, Fs, Project, ProjectPath, + Fs, Project, ProjectPath, git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId}, }; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; use std::ops::Range; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::{collections::HashSet, sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar, - ScrollbarState, SplitButton, Tooltip, prelude::*, + Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, + PopoverMenu, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*, }; +use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, maybe}; use workspace::SERIALIZATION_THROTTLE_TIME; @@ -91,6 +87,8 @@ actions!( FocusChanges, /// Toggles automatic co-author suggestions. ToggleFillCoAuthors, + /// Toggles sorting entries by path vs status. + ToggleSortByPath, ] ); @@ -103,7 +101,7 @@ fn prompt( where T: IntoEnumIterator + VariantNames + 'static, { - let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx); + let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx); cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap())) } @@ -119,6 +117,8 @@ struct GitMenuState { has_staged_changes: bool, has_unstaged_changes: bool, has_new_changes: bool, + sort_by_path: bool, + has_stash_items: bool, } fn git_panel_context_menu( @@ -146,7 +146,8 @@ fn git_panel_context_menu( "Stash All", StashAll.boxed_clone(), ) - .action("Stash Pop", StashPop.boxed_clone()) + .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone()) + .action("View Stash", zed_actions::git::ViewStash.boxed_clone()) .separator() .action("Open Diff", project_diff::Diff.boxed_clone()) .separator() @@ -160,6 +161,16 @@ fn git_panel_context_menu( "Trash Untracked Files", TrashUntrackedFiles.boxed_clone(), ) + .separator() + .entry( + if state.sort_by_path { + "Sort by Status" + } else { + "Sort by Path" + }, + Some(Box::new(ToggleSortByPath)), + move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + ) }) } @@ -241,23 +252,22 @@ impl GitListEntry { #[derive(Debug, PartialEq, Eq, Clone)] pub struct GitStatusEntry { pub(crate) repo_path: RepoPath, - pub(crate) abs_path: PathBuf, pub(crate) status: FileStatus, pub(crate) staging: StageStatus, } impl GitStatusEntry { - fn display_name(&self) -> String { + fn display_name(&self, path_style: PathStyle) -> String { self.repo_path .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| self.repo_path.to_string_lossy().into_owned()) + .map(|name| name.to_owned()) + .unwrap_or_else(|| self.repo_path.display(path_style).to_string()) } - fn parent_dir(&self) -> Option { + fn parent_dir(&self, path_style: PathStyle) -> Option { self.repo_path .parent() - .map(|parent| parent.to_string_lossy().into_owned()) + .map(|parent| parent.display(path_style).to_string()) } } @@ -276,61 +286,6 @@ struct PendingOperation { op_id: usize, } -// computed state related to how to render scrollbars -// one per axis -// on render we just read this off the panel -// we update it when -// - settings change -// - on focus in, on focus out, on hover, etc. -#[derive(Debug)] -struct ScrollbarProperties { - axis: Axis, - show_scrollbar: bool, - show_track: bool, - auto_hide: bool, - hide_task: Option>, - state: ScrollbarState, -} - -impl ScrollbarProperties { - // Shows the scrollbar and cancels any pending hide task - fn show(&mut self, cx: &mut Context) { - if !self.auto_hide { - return; - } - self.show_scrollbar = true; - self.hide_task.take(); - cx.notify(); - } - - fn hide(&mut self, window: &mut Window, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - - if !self.auto_hide { - return; - } - - let axis = self.axis; - self.hide_task = Some(cx.spawn_in(window, async move |panel, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - - if let Some(panel) = panel.upgrade() { - panel - .update(cx, |panel, cx| { - match axis { - Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false, - Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false, - } - cx.notify(); - }) - .log_err(); - } - })); - } -} - pub struct GitPanel { pub(crate) active_repository: Option>, pub(crate) commit_editor: Entity, @@ -343,14 +298,13 @@ pub struct GitPanel { single_tracked_entry: Option, focus_handle: FocusHandle, fs: Arc, - horizontal_scrollbar: ScrollbarProperties, - vertical_scrollbar: ScrollbarProperties, new_count: usize, entry_count: usize, new_staged_count: usize, pending: Vec, pending_commit: Option>, amend_pending: bool, + original_commit_message: Option, signoff_enabled: bool, pending_serialization: Task<()>, pub(crate) project: Entity, @@ -369,6 +323,7 @@ pub struct GitPanel { local_committer: Option, local_committer_task: Option>, bulk_staging: Option, + stash_entries: GitStash, _settings_subscription: Subscription, } @@ -388,14 +343,11 @@ pub(crate) fn commit_message_editor( window: &mut Window, cx: &mut Context, ) -> Editor { - project.update(cx, |this, cx| { - this.mark_buffer_as_non_searchable(commit_message_buffer.read(cx).remote_id(), cx); - }); let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx)); let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 }; let mut commit_editor = Editor::new( EditorMode::AutoHeight { - min_lines: 1, + min_lines: max_lines, max_lines: Some(max_lines), }, buffer, @@ -410,7 +362,7 @@ pub(crate) fn commit_message_editor( commit_editor.set_show_wrap_guides(false, cx); commit_editor.set_show_indent_guides(false, cx); let placeholder = placeholder.unwrap_or("Enter commit message".into()); - commit_editor.set_placeholder_text(placeholder, cx); + commit_editor.set_placeholder_text(&placeholder, window, cx); commit_editor } @@ -426,19 +378,16 @@ impl GitPanel { let git_store = project.read(cx).git_store().clone(); let active_repository = project.read(cx).active_repository(cx); - let git_panel = cx.new(|cx| { + cx.new(|cx| { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, window, Self::focus_in).detach(); - cx.on_focus_out(&focus_handle, window, |this, _, window, cx| { - this.hide_scrollbars(window, cx); - }) - .detach(); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - cx.observe_global::(move |this, cx| { + cx.observe_global_in::(window, move |this, window, cx| { let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; if is_sort_by_path != was_sort_by_path { - this.update_visible_entries(cx); + this.entries.clear(); + this.update_visible_entries(window, cx); } was_sort_by_path = is_sort_by_path }) @@ -457,33 +406,11 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); - let vertical_scrollbar = ScrollbarProperties { - axis: Axis::Vertical, - state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()), - show_scrollbar: false, - show_track: false, - auto_hide: false, - hide_task: None, - }; - - let horizontal_scrollbar = ScrollbarProperties { - axis: Axis::Horizontal, - state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()), - show_scrollbar: false, - show_track: false, - auto_hide: false, - hide_task: None, - }; - - let mut assistant_enabled = AgentSettings::get_global(cx).enabled; - let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + let mut was_ai_enabled = AgentSettings::get_global(cx).enabled(cx); let _settings_subscription = cx.observe_global::(move |_, cx| { - let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; - if assistant_enabled != AgentSettings::get_global(cx).enabled - || was_ai_disabled != is_ai_disabled - { - assistant_enabled = AgentSettings::get_global(cx).enabled; - was_ai_disabled = is_ai_disabled; + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + if was_ai_enabled != is_ai_enabled { + was_ai_enabled = is_ai_enabled; cx.notify(); } }); @@ -498,13 +425,20 @@ impl GitPanel { } GitStoreEvent::RepositoryUpdated( _, - RepositoryEvent::Updated { full_scan, .. }, + RepositoryEvent::StatusesChanged { full_scan: true } + | RepositoryEvent::BranchChanged + | RepositoryEvent::MergeHeadsChanged, true, ) => { - this.schedule_update(*full_scan, window, cx); + this.schedule_update(true, window, cx); } - - GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => { + GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::StatusesChanged { full_scan: false }, + true, + ) + | GitStoreEvent::RepositoryAdded + | GitStoreEvent::RepositoryRemoved(_) => { this.schedule_update(false, window, cx); } GitStoreEvent::IndexWriteError(error) => { @@ -535,6 +469,7 @@ impl GitPanel { pending: Vec::new(), pending_commit: None, amend_pending: false, + original_commit_message: None, signoff_enabled: false, pending_serialization: Task::ready(()), single_staged_entry: None, @@ -555,111 +490,28 @@ impl GitPanel { workspace: workspace.weak_handle(), modal_open: false, entry_count: 0, - horizontal_scrollbar, - vertical_scrollbar, bulk_staging: None, + stash_entries: Default::default(), _settings_subscription, }; this.schedule_update(false, window, cx); this - }); - - git_panel - } - - fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context) { - self.horizontal_scrollbar.hide(window, cx); - self.vertical_scrollbar.hide(window, cx); - } - - fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context) { - // TODO: This PR should have defined Editor's `scrollbar.axis` - // as an Option, not a ScrollbarAxes as it would allow you to - // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`. - // - // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis` - // so we can show each axis based on the settings. - // - // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495 - - let show_setting = GitPanelSettings::get_global(cx) - .scrollbar - .show - .unwrap_or(EditorSettings::get_global(cx).scrollbar.show); - - let scroll_handle = self.scroll_handle.0.borrow(); - - let autohide = |show: ShowScrollbar, cx: &mut Context| match show { - ShowScrollbar::Auto => true, - ShowScrollbar::System => cx - .try_global::() - .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), - ShowScrollbar::Always => false, - ShowScrollbar::Never => false, - }; - - let longest_item_width = scroll_handle.last_item_size.and_then(|size| { - (size.contents.width > size.item.width).then_some(size.contents.width) - }); - - // is there an item long enough that we should show a horizontal scrollbar? - let item_wider_than_container = if let Some(longest_item_width) = longest_item_width { - longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0) - } else { - true - }; - - let show_horizontal = match (show_setting, item_wider_than_container) { - (_, false) => false, - (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true, - (ShowScrollbar::Never, true) => false, - }; - - let show_vertical = match show_setting { - ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true, - ShowScrollbar::Never => false, - }; - - let show_horizontal_track = - show_horizontal && matches!(show_setting, ShowScrollbar::Always); - - // TODO: we probably should hide the scroll track when the list doesn't need to scroll - let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always); - - self.vertical_scrollbar = ScrollbarProperties { - axis: self.vertical_scrollbar.axis, - state: self.vertical_scrollbar.state.clone(), - show_scrollbar: show_vertical, - show_track: show_vertical_track, - auto_hide: autohide(show_setting, cx), - hide_task: None, - }; - - self.horizontal_scrollbar = ScrollbarProperties { - axis: self.horizontal_scrollbar.axis, - state: self.horizontal_scrollbar.state.clone(), - show_scrollbar: show_horizontal, - show_track: show_horizontal_track, - auto_hide: autohide(show_setting, cx), - hide_task: None, - }; - - cx.notify(); + }) } pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option { if GitPanelSettings::get_global(cx).sort_by_path { return self .entries - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path)) + .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) .ok(); } if self.conflicted_count > 0 { let conflicted_start = 1; if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path)) + .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) { return Some(conflicted_start + ix); } @@ -671,7 +523,7 @@ impl GitPanel { 0 } + 1; if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path)) + .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) { return Some(tracked_start + ix); } @@ -687,7 +539,7 @@ impl GitPanel { 0 } + 1; if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path)) + .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) { return Some(untracked_start + ix); } @@ -775,7 +627,7 @@ impl GitPanel { if window .focused(cx) - .map_or(false, |focused| self.focus_handle == focused) + .is_some_and(|focused| self.focus_handle == focused) { dispatch_context.add("menu"); dispatch_context.add("ChangesList"); @@ -894,9 +746,7 @@ impl GitPanel { let have_entries = self .active_repository .as_ref() - .map_or(false, |active_repository| { - active_repository.read(cx).status_summary().count > 0 - }); + .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0); if have_entries && self.selected_entry.is_none() { self.selected_entry = Some(1); self.scroll_to_selected_entry(cx); @@ -926,19 +776,17 @@ impl GitPanel { let workspace = self.workspace.upgrade()?; let git_repo = self.active_repository.as_ref()?; - if let Some(project_diff) = workspace.read(cx).active_item_as::(cx) { - if let Some(project_path) = project_diff.read(cx).active_path(cx) { - if Some(&entry.repo_path) - == git_repo - .read(cx) - .project_path_to_repo_path(&project_path, cx) - .as_ref() - { - project_diff.focus_handle(cx).focus(window); - project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx)); - return None; - } - } + if let Some(project_diff) = workspace.read(cx).active_item_as::(cx) + && let Some(project_path) = project_diff.read(cx).active_path(cx) + && Some(&entry.repo_path) + == git_repo + .read(cx) + .project_path_to_repo_path(&project_path, cx) + .as_ref() + { + project_diff.focus_handle(cx).focus(window); + project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx)); + return None; }; self.workspace @@ -986,6 +834,7 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { + let path_style = self.project.read(cx).path_style(cx); maybe!({ let list_entry = self.entries.get(self.selected_entry?)?.clone(); let entry = list_entry.status_entry()?.to_owned(); @@ -1001,8 +850,7 @@ impl GitPanel { entry .repo_path .file_name() - .unwrap_or(entry.repo_path.as_os_str()) - .to_string_lossy() + .unwrap_or(entry.repo_path.display(path_style).as_ref()), ), None, &["Restore", "Cancel"], @@ -1029,6 +877,77 @@ impl GitPanel { }); } + fn add_to_gitignore( + &mut self, + _: &git::AddToGitignore, + _window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let list_entry = self.entries.get(self.selected_entry?)?.clone(); + let entry = list_entry.status_entry()?.to_owned(); + + if !entry.status.is_created() { + return Some(()); + } + + let project = self.project.downgrade(); + let repo_path = entry.repo_path; + let active_repository = self.active_repository.as_ref()?.downgrade(); + + cx.spawn(async move |_, cx| { + let file_path_str = repo_path.0.display(PathStyle::Posix); + + let repo_root = active_repository.read_with(cx, |repository, _| { + repository.snapshot().work_directory_abs_path + })?; + + let gitignore_abs_path = repo_root.join(".gitignore"); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(gitignore_abs_path, cx) + })? + .await?; + + let mut should_save = false; + buffer.update(cx, |buffer, cx| { + let existing_content = buffer.text(); + + if existing_content + .lines() + .any(|line| line.trim() == file_path_str) + { + return; + } + + let insert_position = existing_content.len(); + let new_entry = if existing_content.is_empty() { + format!("{}\n", file_path_str) + } else if existing_content.ends_with('\n') { + format!("{}\n", file_path_str) + } else { + format!("\n{}\n", file_path_str) + }; + + buffer.edit([(insert_position..insert_position, new_entry)], None, cx); + should_save = true; + })?; + + if should_save { + project + .update(cx, |project, cx| project.save_buffer(buffer, cx))? + .await?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn revert_entry( &mut self, entry: &GitStatusEntry, @@ -1045,10 +964,10 @@ impl GitPanel { if entry.status.staging().has_staged() { self.change_file_stage(false, vec![entry.clone()], cx); } - let filename = path.path.file_name()?.to_string_lossy(); + let filename = path.path.file_name()?.to_string(); if !entry.status.is_created() { - self.perform_checkout(vec![entry.clone()], cx); + self.perform_checkout(vec![entry.clone()], window, cx); } else { let prompt = prompt(&format!("Trash {}?", filename), None, window, cx); cx.spawn_in(window, async move |_, cx| { @@ -1077,7 +996,12 @@ impl GitPanel { }); } - fn perform_checkout(&mut self, entries: Vec, cx: &mut Context) { + fn perform_checkout( + &mut self, + entries: Vec, + window: &mut Window, + cx: &mut Context, + ) { let workspace = self.workspace.clone(); let Some(active_repository) = self.active_repository.clone() else { return; @@ -1090,7 +1014,7 @@ impl GitPanel { entries: entries.clone(), finished: false, }); - self.update_visible_entries(cx); + self.update_visible_entries(window, cx); let task = cx.spawn(async move |_, cx| { let tasks: Vec<_> = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { @@ -1137,16 +1061,16 @@ impl GitPanel { Ok(()) }); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { let result = task.await; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { for pending in this.pending.iter_mut() { if pending.op_id == op_id { pending.finished = true; if result.is_err() { pending.target_status = TargetStatus::Unchanged; - this.update_visible_entries(cx); + this.update_visible_entries(window, cx); } break; } @@ -1183,7 +1107,7 @@ impl GitPanel { let mut details = entries .iter() .filter_map(|entry| entry.repo_path.0.file_name()) - .map(|filename| filename.to_string_lossy()) + .map(|filename| filename.to_string()) .take(5) .join("\n"); if entries.len() > 5 { @@ -1202,16 +1126,13 @@ impl GitPanel { window, cx, ); - cx.spawn(async move |this, cx| match prompt.await { - Ok(RestoreCancel::RestoreTrackedFiles) => { - this.update(cx, |this, cx| { - this.perform_checkout(entries, cx); + cx.spawn_in(window, async move |this, cx| { + if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await { + this.update_in(cx, |this, window, cx| { + this.perform_checkout(entries, window, cx); }) .ok(); } - _ => { - return; - } }) .detach(); } @@ -1242,7 +1163,7 @@ impl GitPanel { .repo_path .0 .file_name() - .map(|f| f.to_string_lossy()) + .map(|f| f.to_string()) .unwrap_or_default() }) .take(5) @@ -1341,10 +1262,10 @@ impl GitPanel { .iter() .filter_map(|entry| entry.status_entry()) .filter(|status_entry| { - section.contains(&status_entry, repository) + section.contains(status_entry, repository) && status_entry.staging.as_bool() != Some(goal_staged_state) }) - .map(|status_entry| status_entry.clone()) + .cloned() .collect::>(); (goal_staged_state, entries) @@ -1431,7 +1352,7 @@ impl GitPanel { cx.spawn({ async move |this, cx| { let stash_task = active_repository - .update(cx, |repo, cx| repo.stash_pop(cx))? + .update(cx, |repo, cx| repo.stash_pop(None, cx))? .await; this.update(cx, |this, cx| { stash_task @@ -1446,6 +1367,29 @@ impl GitPanel { .detach(); } + pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.active_repository.clone() else { + return; + }; + + cx.spawn({ + async move |this, cx| { + let stash_task = active_repository + .update(cx, |repo, cx| repo.stash_apply(None, cx))? + .await; + this.update(cx, |this, cx| { + stash_task + .map_err(|e| { + this.show_error_toast("stash apply", e, cx); + }) + .ok(); + cx.notify(); + }) + } + }) + .detach(); + } + pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context) { let Some(active_repository) = self.active_repository.clone() else { return; @@ -1476,7 +1420,6 @@ impl GitPanel { .read(cx) .as_singleton() .unwrap() - .clone() } fn toggle_staged_for_selected( @@ -1561,7 +1504,6 @@ impl GitPanel { self.load_last_commit_message_if_empty(cx); } else { telemetry::event!("Git Amended", source = "Git Panel"); - self.set_amend_pending(false, cx); self.commit_changes( CommitOptions { amend: true, @@ -1642,13 +1584,12 @@ impl GitPanel { fn has_commit_message(&self, cx: &mut Context) -> bool { let text = self.commit_editor.read(cx).text(cx); if !text.trim().is_empty() { - return true; + true } else if text.is_empty() { - return self - .suggest_commit_message(cx) - .is_some_and(|text| !text.trim().is_empty()); + self.suggest_commit_message(cx) + .is_some_and(|text| !text.trim().is_empty()) } else { - return false; + false } } @@ -1704,7 +1645,7 @@ impl GitPanel { .map(|status_entry| status_entry.repo_path.clone()) .collect::>(); - if changed_files.is_empty() { + if changed_files.is_empty() && !options.amend { error_spawn("No changes to commit", window, cx); return; } @@ -1727,6 +1668,7 @@ impl GitPanel { Ok(()) => { this.commit_editor .update(cx, |editor, cx| editor.clear(window, cx)); + this.original_commit_message = None; } Err(e) => this.show_error_toast("commit", e, cx), } @@ -1735,9 +1677,12 @@ impl GitPanel { }); self.pending_commit = Some(task); + if options.amend { + self.set_amend_pending(false, cx); + } } - fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { let Some(repo) = self.active_repository.clone() else { return; }; @@ -1833,7 +1778,9 @@ impl GitPanel { let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry { Some(staged_entry) - } else if let Some(single_tracked_entry) = &self.single_tracked_entry { + } else if self.total_staged_count() == 0 + && let Some(single_tracked_entry) = &self.single_tracked_entry + { Some(single_tracked_entry) } else { None @@ -1853,7 +1800,7 @@ impl GitPanel { .repo_path .file_name() .unwrap_or_default() - .to_string_lossy(); + .to_string(); Some(format!("{} {}", action_text, file_name)) } @@ -1869,13 +1816,14 @@ impl GitPanel { /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { - if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai { + if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { return; } - let model = match current_language_model(cx) { - Some(value) => value, - None => return, + let Some(ConfiguredModel { provider, model }) = + LanguageModelRegistry::read_global(cx).commit_message_model() + else { + return; }; let Some(repo) = self.active_repository.as_ref() else { @@ -1900,6 +1848,16 @@ impl GitPanel { this.generate_commit_message_task.take(); }); + if let Some(task) = cx.update(|cx| { + if !provider.is_authenticated(cx) { + Some(provider.authenticate(cx)) + } else { + None + } + })? { + task.await.log_err(); + }; + let mut diff_text = match diff.await { Ok(result) => match result { Ok(text) => text, @@ -1950,7 +1908,7 @@ impl GitPanel { thinking_allowed: false, }; - let stream = model.stream_completion_text(request, &cx); + let stream = model.stream_completion_text(request, cx); match stream.await { Ok(mut messages) => { if !text_empty { @@ -2086,6 +2044,7 @@ impl GitPanel { files: false, directories: true, multiple: false, + prompt: Some("Select as Repository Destination".into()), }); let workspace = self.workspace.clone(); @@ -2093,11 +2052,7 @@ impl GitPanel { cx.spawn_in(window, async move |this, cx| { let mut paths = path.await.ok()?.ok()??; let mut path = paths.pop()?; - let repo_name = repo - .split(std::path::MAIN_SEPARATOR_STR) - .last()? - .strip_suffix(".git")? - .to_owned(); + let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned(); let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?; @@ -2183,7 +2138,7 @@ impl GitPanel { let worktree = if worktrees.len() == 1 { Task::ready(Some(worktrees.first().unwrap().clone())) - } else if worktrees.len() == 0 { + } else if worktrees.is_empty() { let result = window.prompt( PromptLevel::Warning, "Unable to initialize a git repository", @@ -2209,7 +2164,7 @@ impl GitPanel { .to_string() .into() } else { - worktree_abs_path.to_string_lossy().to_string().into() + worktree_abs_path.to_string_lossy().into_owned().into() } }) .collect_vec(); @@ -2511,10 +2466,11 @@ impl GitPanel { new_co_authors.push((name.clone(), email.clone())) } } - if !project.is_local() && !project.is_read_only(cx) { - if let Some(local_committer) = self.local_committer(room, cx) { - new_co_authors.push(local_committer); - } + if !project.is_local() + && !project.is_read_only(cx) + && let Some(local_committer) = self.local_committer(room, cx) + { + new_co_authors.push(local_committer); } new_co_authors } @@ -2541,6 +2497,25 @@ impl GitPanel { cx.notify(); } + fn toggle_sort_by_path( + &mut self, + _: &ToggleSortByPath, + _: &mut Window, + cx: &mut Context, + ) { + let current_setting = GitPanelSettings::get_global(cx).sort_by_path; + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().sort_by_path = + Some(!current_setting); + }); + }); + } + } + fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -2605,8 +2580,7 @@ impl GitPanel { if clear_pending { git_panel.clear_pending(); } - git_panel.update_visible_entries(cx); - git_panel.update_scrollbar_properties(window, cx); + git_panel.update_visible_entries(window, cx); }) .ok(); } @@ -2658,7 +2632,8 @@ impl GitPanel { self.pending.retain(|v| !v.finished) } - fn update_visible_entries(&mut self, cx: &mut Context) { + fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context) { + let path_style = self.project.read(cx).path_style(cx); let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() @@ -2692,6 +2667,8 @@ impl GitPanel { let repo = repo.read(cx); + self.stash_entries = repo.cached_stash(); + for entry in repo.cached_status() { let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); let is_new = entry.status.is_created(); @@ -2708,10 +2685,8 @@ impl GitPanel { continue; } - let abs_path = repo.work_directory_abs_path.join(&entry.repo_path.0); let entry = GitStatusEntry { repo_path: entry.repo_path.clone(), - abs_path, status: entry.status, staging, }; @@ -2722,8 +2697,8 @@ impl GitPanel { } let width_estimate = Self::item_width_estimate( - entry.parent_dir().map(|s| s.len()).unwrap_or(0), - entry.display_name().len(), + entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), + entry.display_name(path_style).len(), ); match max_width_item.as_mut() { @@ -2753,35 +2728,34 @@ impl GitPanel { for pending in self.pending.iter() { if pending.target_status == TargetStatus::Staged { pending_staged_count += pending.entries.len(); - last_pending_staged = pending.entries.iter().next().cloned(); + last_pending_staged = pending.entries.first().cloned(); } - if let Some(single_staged) = &single_staged_entry { - if pending + if let Some(single_staged) = &single_staged_entry + && pending .entries .iter() .any(|entry| entry.repo_path == single_staged.repo_path) - { - pending_status_for_single_staged = Some(pending.target_status); - } + { + pending_status_for_single_staged = Some(pending.target_status); } } - if conflict_entries.len() == 0 && staged_count == 1 && pending_staged_count == 0 { + if conflict_entries.is_empty() && staged_count == 1 && pending_staged_count == 0 { match pending_status_for_single_staged { Some(TargetStatus::Staged) | None => { self.single_staged_entry = single_staged_entry; } _ => {} } - } else if conflict_entries.len() == 0 && pending_staged_count == 1 { + } else if conflict_entries.is_empty() && pending_staged_count == 1 { self.single_staged_entry = last_pending_staged; } - if conflict_entries.len() == 0 && changed_entries.len() == 1 { + if conflict_entries.is_empty() && changed_entries.len() == 1 { self.single_tracked_entry = changed_entries.first().cloned(); } - if conflict_entries.len() > 0 { + if !conflict_entries.is_empty() { self.entries.push(GitListEntry::Header(GitHeaderEntry { header: Section::Conflict, })); @@ -2789,7 +2763,7 @@ impl GitPanel { .extend(conflict_entries.into_iter().map(GitListEntry::Status)); } - if changed_entries.len() > 0 { + if !changed_entries.is_empty() { if !sort_by_path { self.entries.push(GitListEntry::Header(GitHeaderEntry { header: Section::Tracked, @@ -2798,7 +2772,7 @@ impl GitPanel { self.entries .extend(changed_entries.into_iter().map(GitListEntry::Status)); } - if new_entries.len() > 0 { + if !new_entries.is_empty() { self.entries.push(GitListEntry::Header(GitHeaderEntry { header: Section::New, })); @@ -2834,7 +2808,7 @@ impl GitPanel { let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into()); self.commit_editor.update(cx, |editor, cx| { - editor.set_placeholder_text(Arc::from(placeholder_text), cx) + editor.set_placeholder_text(&placeholder_text, window, cx) }); cx.notify(); @@ -2937,8 +2911,7 @@ impl GitPanel { .matches(git::repository::REMOTE_CANCELLED_BY_USER) .next() .is_some() - { - return; // Hide the cancelled by user message + { // Hide the cancelled by user message } else { workspace.update(cx, |workspace, cx| { let workspace_weak = cx.weak_entity(); @@ -2992,9 +2965,7 @@ impl GitPanel { let status_toast = StatusToast::new(message, cx, move |this, _cx| { use remote_output::SuccessStyle::*; match style { - Toast { .. } => { - this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) - } + Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)), ToastWithLog { output } => this .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action("View Log", move |window, cx| { @@ -3064,6 +3035,7 @@ impl GitPanel { let has_staged_changes = self.has_staged_changes(); let has_unstaged_changes = self.has_unstaged_changes(); let has_new_changes = self.new_count > 0; + let has_stash_items = self.stash_entries.entries.len() > 0; PopoverMenu::new(id.into()) .trigger( @@ -3079,6 +3051,8 @@ impl GitPanel { has_staged_changes, has_unstaged_changes, has_new_changes, + sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + has_stash_items, }, window, cx, @@ -3091,42 +3065,45 @@ impl GitPanel { &self, cx: &Context, ) -> Option { - current_language_model(cx).is_some().then(|| { - if self.generate_commit_message_task.is_some() { - return h_flex() + if !agent_settings::AgentSettings::get_global(cx).enabled(cx) + || LanguageModelRegistry::read_global(cx) + .commit_message_model() + .is_none() + { + return None; + } + + if self.generate_commit_message_task.is_some() { + return Some( + h_flex() .gap_1() .child( Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage(delta))) - }, - ), + .with_rotate_animation(2), ) .child( Label::new("Generating Commit...") .size(LabelSize::Small) .color(Color::Muted), ) - .into_any_element(); - } + .into_any_element(), + ); + } - let can_commit = self.can_commit(); - let editor_focus_handle = self.commit_editor.focus_handle(cx); + let can_commit = self.can_commit(); + let editor_focus_handle = self.commit_editor.focus_handle(cx); + Some( IconButton::new("generate-commit-message", IconName::AiEdit) .shape(ui::IconButtonShape::Square) .icon_color(Color::Muted) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { if can_commit { Tooltip::for_action_in( "Generate Commit Message", &git::GenerateCommitMessage, &editor_focus_handle, - window, cx, ) } else { @@ -3137,8 +3114,8 @@ impl GitPanel { .on_click(cx.listener(move |this, _event, _window, cx| { this.generate_commit_message(cx); })) - .into_any_element() - }) + .into_any_element(), + ) } pub(crate) fn render_co_authors(&self, cx: &Context) -> Option { @@ -3215,7 +3192,7 @@ impl GitPanel { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu .when_some(keybinding_target.clone(), |el, keybinding_target| { - el.context(keybinding_target.clone()) + el.context(keybinding_target) }) .when(has_previous_commit, |this| { this.toggleable_entry( @@ -3251,7 +3228,7 @@ impl GitPanel { pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { if self.has_unstaged_conflicts() { (false, "You must resolve conflicts before committing") - } else if !self.has_staged_changes() && !self.has_tracked_changes() { + } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending { (false, "No changes to commit") } else if self.pending_commit.is_some() { (false, "Commit in progress") @@ -3268,15 +3245,15 @@ impl GitPanel { if self.amend_pending { if self.has_staged_changes() { "Amend" - } else { + } else if self.has_tracked_changes() { "Amend Tracked" - } - } else { - if self.has_staged_changes() { - "Commit" } else { - "Commit Tracked" + "Amend" } + } else if self.has_staged_changes() { + "Commit" + } else { + "Commit Tracked" } } @@ -3397,7 +3374,7 @@ impl GitPanel { let enable_coauthors = self.render_co_authors(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx); - let expand_tooltip_focus_handle = editor_focus_handle.clone(); + let expand_tooltip_focus_handle = editor_focus_handle; let branch = active_repository.read(cx).branch.clone(); let head_commit = active_repository.read(cx).head_commit.clone(); @@ -3426,7 +3403,7 @@ impl GitPanel { display_name, branch, head_commit, - Some(git_panel.clone()), + Some(git_panel), )) .child( panel_editor_container(window, cx) @@ -3488,12 +3465,11 @@ impl GitPanel { panel_icon_button("expand-commit-editor", IconName::Maximize) .icon_size(IconSize::Small) .size(ui::ButtonSize::Default) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::for_action_in( "Open Commit Modal", &git::ExpandCommitEditor, &expand_tooltip_focus_handle, - window, cx, ) }) @@ -3543,7 +3519,6 @@ impl GitPanel { telemetry::event!("Git Committed", source = "Git Panel"); git_panel .update(cx, |git_panel, cx| { - git_panel.set_amend_pending(false, cx); git_panel.commit_changes( CommitOptions { amend, signoff }, window, @@ -3556,18 +3531,17 @@ impl GitPanel { .disabled(!can_commit || self.modal_open) .tooltip({ let handle = commit_tooltip_focus_handle.clone(); - move |window, cx| { + move |_window, cx| { if can_commit { Tooltip::with_meta_in( tooltip, - Some(&git::Commit), + Some(if amend { &git::Amend } else { &git::Commit }), format!( "git commit{}{}", if amend { " --amend" } else { "" }, if signoff { " --signoff" } else { "" } ), &handle.clone(), - window, cx, ) } else { @@ -3577,7 +3551,7 @@ impl GitPanel { }), self.render_git_commit_menu( ElementId::Name(format!("split-button-right-{}", title).into()), - Some(commit_tooltip_focus_handle.clone()), + Some(commit_tooltip_focus_handle), cx, ) .into_any_element(), @@ -3629,7 +3603,7 @@ impl GitPanel { div() .flex_grow() .overflow_hidden() - .max_w(relative(0.85)) + .line_clamp(1) .child( Label::new(commit.subject.clone()) .size(LabelSize::Small) @@ -3641,9 +3615,10 @@ impl GitPanel { let repo = active_repository.downgrade(); move |_, window, cx| { CommitView::open( - commit.clone(), + commit.sha.to_string(), repo.clone(), - workspace.clone().clone(), + workspace.clone(), + None, window, cx, ); @@ -3669,7 +3644,7 @@ impl GitPanel { panel_icon_button("undo", IconName::Undo) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { Tooltip::with_meta( "Uncommit", Some(&git::Uncommit), @@ -3678,7 +3653,6 @@ impl GitPanel { } else { "git reset HEAD^" }, - window, cx, ) }) @@ -3723,110 +3697,6 @@ impl GitPanel { ) } - fn render_vertical_scrollbar( - &self, - show_horizontal_scrollbar_container: bool, - cx: &mut Context, - ) -> impl IntoElement { - div() - .id("git-panel-vertical-scroll") - .occlude() - .flex_none() - .h_full() - .cursor_default() - .absolute() - .right_0() - .top_0() - .bottom_0() - .w(px(12.)) - .when(show_horizontal_scrollbar_container, |this| { - this.pb_neg_3p5() - }) - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - if !this.vertical_scrollbar.state.is_dragging() - && !this.focus_handle.contains_focused(window, cx) - { - this.vertical_scrollbar.hide(window, cx); - cx.notify(); - } - - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .children(Scrollbar::vertical( - // percentage as f32..end_offset as f32, - self.vertical_scrollbar.state.clone(), - )) - } - - /// Renders the horizontal scrollbar. - /// - /// The right offset is used to determine how far to the right the - /// scrollbar should extend to, useful for ensuring it doesn't collide - /// with the vertical scrollbar when visible. - fn render_horizontal_scrollbar( - &self, - right_offset: Pixels, - cx: &mut Context, - ) -> impl IntoElement { - div() - .id("git-panel-horizontal-scroll") - .occlude() - .flex_none() - .w_full() - .cursor_default() - .absolute() - .bottom_neg_px() - .left_0() - .right_0() - .pr(right_offset) - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, window, cx| { - if !this.horizontal_scrollbar.state.is_dragging() - && !this.focus_handle.contains_focused(window, cx) - { - this.horizontal_scrollbar.hide(window, cx); - cx.notify(); - } - - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .children(Scrollbar::horizontal( - // percentage as f32..end_offset as f32, - self.horizontal_scrollbar.state.clone(), - )) - } - fn render_buffer_header_controls( &self, entity: &Entity, @@ -3835,7 +3705,7 @@ impl GitPanel { cx: &App, ) -> Option { let repo = self.active_repository.as_ref()?.read(cx); - let project_path = (file.worktree_id(cx), file.path()).into(); + let project_path = (file.worktree_id(cx), file.path().clone()).into(); let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; let ix = self.entry_by_path(&repo_path, cx)?; let entry = self.entries.get(ix)?; @@ -3874,33 +3744,16 @@ impl GitPanel { fn render_entries( &self, has_write_access: bool, - _: &Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let entry_count = self.entries.len(); - let scroll_track_size = px(16.); - - let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar { - // magic number - px(3.) - } else { - px(0.) - }; - v_flex() .flex_1() .size_full() .overflow_hidden() .relative() - // Show a border on the top and bottom of the container when - // the vertical scrollbar container is visible so we don't have a - // floating left border in the panel. - .when(self.vertical_scrollbar.show_track, |this| { - this.border_t_1() - .border_b_1() - .border_color(cx.theme().colors().border) - }) .child( h_flex() .flex_1() @@ -3941,15 +3794,6 @@ impl GitPanel { items }), ) - .when( - !self.horizontal_scrollbar.show_track - && self.horizontal_scrollbar.show_scrollbar, - |this| { - // when not showing the horizontal scrollbar track, make sure we don't - // obscure the last entry - this.pb(scroll_track_size) - }, - ) .size_full() .flex_grow() .with_sizing_behavior(ListSizingBehavior::Auto) @@ -3965,72 +3809,17 @@ impl GitPanel { this.deploy_panel_context_menu(event.position, window, cx) }), ) - .when(self.vertical_scrollbar.show_track, |this| { - this.child( - v_flex() - .h_full() - .flex_none() - .w(scroll_track_size) - .bg(cx.theme().colors().panel_background) - .child( - div() - .size_full() - .flex_1() - .border_l_1() - .border_color(cx.theme().colors().border), - ), - ) - }) - .when(self.vertical_scrollbar.show_scrollbar, |this| { - this.child( - self.render_vertical_scrollbar( - self.horizontal_scrollbar.show_track, - cx, + .custom_scrollbars( + Scrollbars::for_settings::() + .tracked_scroll_handle(self.scroll_handle.clone()) + .with_track_along( + ScrollAxes::Horizontal, + cx.theme().colors().panel_background, ), - ) - }), + window, + cx, + ), ) - .when(self.horizontal_scrollbar.show_track, |this| { - this.child( - h_flex() - .w_full() - .h(scroll_track_size) - .flex_none() - .relative() - .child( - div() - .w_full() - .flex_1() - // for some reason the horizontal scrollbar is 1px - // taller than the vertical scrollbar?? - .h(scroll_track_size - px(1.)) - .bg(cx.theme().colors().panel_background) - .border_t_1() - .border_color(cx.theme().colors().border), - ) - .when(self.vertical_scrollbar.show_track, |this| { - this.child( - div() - .flex_none() - // -1px prevents a missing pixel between the two container borders - .w(scroll_track_size - px(1.)) - .h_full(), - ) - .child( - // HACK: Fill the missing 1px 🥲 - div() - .absolute() - .right(scroll_track_size - px(1.)) - .bottom(scroll_track_size - px(1.)) - .size_px() - .bg(cx.theme().colors().border), - ) - }), - ) - }) - .when(self.horizontal_scrollbar.show_scrollbar, |this| { - this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx)) - }) } fn entry_label(&self, label: impl Into, color: Color) -> Label { @@ -4103,10 +3892,17 @@ impl GitPanel { "Restore File" }; let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { - context_menu + let mut context_menu = context_menu .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) - .action(restore_title, git::RestoreFile::default().boxed_clone()) + .action(restore_title, git::RestoreFile::default().boxed_clone()); + + if entry.status.is_created() { + context_menu = + context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()); + } + + context_menu .separator() .action("Open Diff", Confirm.boxed_clone()) .action("Open File", SecondaryConfirm.boxed_clone()) @@ -4128,6 +3924,8 @@ impl GitPanel { has_staged_changes: self.has_staged_changes(), has_unstaged_changes: self.has_unstaged_changes(), has_new_changes: self.new_count > 0, + sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + has_stash_items: self.stash_entries.entries.len() > 0, }, window, cx, @@ -4167,7 +3965,8 @@ impl GitPanel { window: &Window, cx: &Context, ) -> AnyElement { - let display_name = entry.display_name(); + let path_style = self.project.read(cx).path_style(cx); + let display_name = entry.display_name(path_style); let selected = self.selected_entry == Some(ix); let marked = self.marked_entries.contains(&ix); @@ -4324,13 +4123,13 @@ impl GitPanel { .ok(); } }) - .tooltip(move |window, cx| { + .tooltip(move |_window, cx| { let is_staged = entry_staging.is_fully_staged(); let action = if is_staged { "Unstage" } else { "Stage" }; let tooltip_name = action.to_string(); - Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx) + Tooltip::for_action(tooltip_name, &ToggleStaged, cx) }), ), ) @@ -4340,18 +4139,21 @@ impl GitPanel { .items_center() .flex_1() // .overflow_hidden() - .when_some(entry.parent_dir(), |this, parent| { + .when_some(entry.parent_dir(path_style), |this, parent| { if !parent.is_empty() { this.child( - self.entry_label(format!("{}/", parent), path_color) - .when(status.is_deleted(), |this| this.strikethrough()), + self.entry_label( + format!("{parent}{}", path_style.separator()), + path_color, + ) + .when(status.is_deleted(), |this| this.strikethrough()), ) } else { this } }) .child( - self.entry_label(display_name.clone(), label_color) + self.entry_label(display_name, label_color) .when(status.is_deleted(), |this| this.strikethrough()), ), ) @@ -4367,6 +4169,22 @@ impl GitPanel { } pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { + if value && !self.amend_pending { + let current_message = self.commit_message_buffer(cx).read(cx).text(); + self.original_commit_message = if current_message.trim().is_empty() { + None + } else { + Some(current_message) + }; + } else if !value && self.amend_pending { + let message = self.original_commit_message.take().unwrap_or_default(); + self.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, message)], None, cx); + }); + } + self.amend_pending = value; self.serialize(cx); cx.notify(); @@ -4472,24 +4290,10 @@ impl GitPanel { } } -fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { - let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled - && !DisableAiSettings::get_global(cx).disable_ai; - - is_enabled - .then(|| { - let ConfiguredModel { provider, model } = - LanguageModelRegistry::read_global(cx).commit_message_model()?; - - provider.is_authenticated(cx).then(|| model) - }) - .flatten() -} - impl Render for GitPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let project = self.project.read(cx); - let has_entries = self.entries.len() > 0; + let has_entries = !self.entries.is_empty(); let room = self .workspace .upgrade() @@ -4497,7 +4301,7 @@ impl Render for GitPanel { let has_write_access = self.has_write_access(cx); - let has_co_authors = room.map_or(false, |room| { + let has_co_authors = room.is_some_and(|room| { self.load_local_committer(cx); let room = room.read(cx); room.remote_participants() @@ -4521,6 +4325,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::unstage_selected)) .on_action(cx.listener(Self::restore_tracked_files)) .on_action(cx.listener(Self::revert_selected)) + .on_action(cx.listener(Self::add_to_gitignore)) .on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::generate_commit_message_action)) .on_action(cx.listener(Self::stash_all)) @@ -4539,15 +4344,7 @@ impl Render for GitPanel { .when(has_write_access && has_co_authors, |git_panel| { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) - .on_hover(cx.listener(move |this, hovered, window, cx| { - if *hovered { - this.horizontal_scrollbar.show(cx); - this.vertical_scrollbar.show(cx); - cx.notify(); - } else if !this.focus_handle.contains_focused(window, cx) { - this.hide_scrollbars(window, cx); - } - })) + .on_action(cx.listener(Self::toggle_sort_by_path)) .size_full() .overflow_hidden() .bg(cx.theme().colors().panel_background) @@ -4617,7 +4414,7 @@ impl editor::Addon for GitPanelAddon { git_panel .read(cx) - .render_buffer_header_controls(&git_panel, &file, window, cx) + .render_buffer_header_controls(&git_panel, file, window, cx) } } @@ -4626,6 +4423,10 @@ impl Panel for GitPanel { "GitPanel" } + fn panel_key() -> &'static str { + GIT_PANEL_KEY + } + fn position(&self, _: &Window, cx: &App) -> DockPosition { GitPanelSettings::get_global(cx).dock } @@ -4635,11 +4436,9 @@ impl Panel for GitPanel { } fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| settings.dock = Some(position), - ); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.git_panel.get_or_insert_default().dock = Some(position.into()) + }); } fn size(&self, _: &Window, cx: &App) -> Pixels { @@ -4700,7 +4499,7 @@ impl GitPanelMessageTooltip { author_email: details.author_email.clone(), commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, message: Some(ParsedCommitMessage { - message: details.message.clone(), + message: details.message, ..Default::default() }), }; @@ -4813,12 +4612,10 @@ impl RenderOnce for PanelRepoFooter { // ideally, show the whole branch and repo names but // when we can't, use a budget to allocate space between the two - let (repo_display_len, branch_display_len) = if branch_actual_len + repo_actual_len - <= LABEL_CHARACTER_BUDGET - { - (repo_actual_len, branch_actual_len) - } else { - if branch_actual_len <= MAX_BRANCH_LEN { + let (repo_display_len, branch_display_len) = + if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET { + (repo_actual_len, branch_actual_len) + } else if branch_actual_len <= MAX_BRANCH_LEN { let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN); (repo_space, branch_actual_len) } else if repo_actual_len <= MAX_REPO_LEN { @@ -4826,8 +4623,7 @@ impl RenderOnce for PanelRepoFooter { (repo_actual_len, branch_space) } else { (MAX_REPO_LEN, MAX_BRANCH_LEN) - } - }; + }; let truncated_repo_name = if repo_actual_len <= repo_display_len { active_repo_name.to_string() @@ -4836,20 +4632,19 @@ impl RenderOnce for PanelRepoFooter { }; let truncated_branch_name = if branch_actual_len <= branch_display_len { - branch_name.to_string() + branch_name } else { util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len) }; let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name) - .style(ButtonStyle::Transparent) .size(ButtonSize::None) .label_size(LabelSize::Small) .color(Color::Muted); let repo_selector = PopoverMenu::new("repository-switcher") .menu({ - let project = project.clone(); + let project = project; move |window, cx| { let project = project.clone()?; Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx))) @@ -4863,14 +4658,9 @@ impl RenderOnce for PanelRepoFooter { .into_any_element(); let branch_selector_button = Button::new("branch-selector", truncated_branch_name) - .style(ButtonStyle::Transparent) .size(ButtonSize::None) .label_size(LabelSize::Small) .truncate(true) - .tooltip(Tooltip::for_action_title( - "Switch Branch", - &zed_actions::git::Switch, - )) .on_click(|_, window, cx| { window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx); }); @@ -4888,34 +4678,31 @@ impl RenderOnce for PanelRepoFooter { }); h_flex() + .h(px(36.)) .w_full() .px_2() - .h(px(36.)) - .items_center() .justify_between() .gap_1() .child( h_flex() .flex_1() .overflow_hidden() - .items_center() + .gap_px() .child( - div().child( - Icon::new(IconName::GitBranchAlt) - .size(IconSize::Small) - .color(if single_repo { - Color::Disabled - } else { - Color::Muted - }), - ), + Icon::new(IconName::GitBranchAlt) + .size(IconSize::Small) + .color(if single_repo { + Color::Disabled + } else { + Color::Muted + }), ) .child(repo_selector) .when(show_separator, |this| { this.child( div() - .text_color(cx.theme().colors().text_muted) .text_sm() + .text_color(cx.theme().colors().icon_muted.opacity(0.5)) .child("/"), ) }) @@ -4979,6 +4766,7 @@ impl Component for PanelRepoFooter { sha: "abc123".into(), subject: "Modify stuff".into(), commit_timestamp: 1710932954, + author_name: "John Doe".into(), has_parent: true, }), } @@ -4996,6 +4784,7 @@ impl Component for PanelRepoFooter { sha: "abc123".into(), subject: "Modify stuff".into(), commit_timestamp: 1710932954, + author_name: "John Doe".into(), has_parent: true, }), } @@ -5020,10 +4809,7 @@ impl Component for PanelRepoFooter { div() .w(example_width) .overflow_hidden() - .child(PanelRepoFooter::new_preview( - active_repository(1).clone(), - None, - )) + .child(PanelRepoFooter::new_preview(active_repository(1), None)) .into_any_element(), ), single_example( @@ -5032,7 +4818,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(2).clone(), + active_repository(2), Some(branch(unknown_upstream)), )) .into_any_element(), @@ -5043,7 +4829,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(3).clone(), + active_repository(3), Some(branch(no_remote_upstream)), )) .into_any_element(), @@ -5054,7 +4840,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(4).clone(), + active_repository(4), Some(branch(not_ahead_or_behind_upstream)), )) .into_any_element(), @@ -5065,7 +4851,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(5).clone(), + active_repository(5), Some(branch(behind_upstream)), )) .into_any_element(), @@ -5076,7 +4862,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(6).clone(), + active_repository(6), Some(branch(ahead_of_upstream)), )) .into_any_element(), @@ -5087,7 +4873,7 @@ impl Component for PanelRepoFooter { .w(example_width) .overflow_hidden() .child(PanelRepoFooter::new_preview( - active_repository(7).clone(), + active_repository(7), Some(branch(ahead_and_behind_upstream)), )) .into_any_element(), @@ -5185,13 +4971,17 @@ impl Component for PanelRepoFooter { #[cfg(test)] mod tests { - use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode}; - use gpui::{TestAppContext, VisualTestContext}; + use git::{ + repository::repo_path, + status::{StatusCode, UnmergedStatus, UnmergedStatusCode}, + }; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, WorktreeSettings}; use serde_json::json; use settings::SettingsStore; use theme::LoadThemes; use util::path; + use util::rel_path::rel_path; use super::*; @@ -5237,14 +5027,8 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/zed/.git")), &[ - ( - Path::new("crates/gpui/gpui.rs"), - StatusCode::Modified.worktree(), - ), - ( - Path::new("crates/util/util.rs"), - StatusCode::Modified.worktree(), - ), + ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()), + ("crates/util/util.rs", StatusCode::Modified.worktree()), ], ); @@ -5258,7 +5042,7 @@ mod tests { project .read(cx) .worktrees(cx) - .nth(0) + .next() .unwrap() .read(cx) .as_local() @@ -5285,14 +5069,12 @@ mod tests { header: Section::Tracked }), GitListEntry::Status(GitStatusEntry { - abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(), - repo_path: "crates/gpui/gpui.rs".into(), + repo_path: repo_path("crates/gpui/gpui.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, }), GitListEntry::Status(GitStatusEntry { - abs_path: path!("/root/zed/crates/util/util.rs").into(), - repo_path: "crates/util/util.rs".into(), + repo_path: repo_path("crates/util/util.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, },), @@ -5312,14 +5094,12 @@ mod tests { header: Section::Tracked }), GitListEntry::Status(GitStatusEntry { - abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(), - repo_path: "crates/gpui/gpui.rs".into(), + repo_path: repo_path("crates/gpui/gpui.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, }), GitListEntry::Status(GitStatusEntry { - abs_path: path!("/root/zed/crates/util/util.rs").into(), - repo_path: "crates/util/util.rs".into(), + repo_path: repo_path("crates/util/util.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, },), @@ -5357,14 +5137,14 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/project/.git")), &[ - (Path::new("src/main.rs"), StatusCode::Modified.worktree()), - (Path::new("src/lib.rs"), StatusCode::Modified.worktree()), - (Path::new("tests/test.rs"), StatusCode::Modified.worktree()), - (Path::new("new_file.txt"), FileStatus::Untracked), - (Path::new("another_new.rs"), FileStatus::Untracked), - (Path::new("src/utils.rs"), FileStatus::Untracked), + ("src/main.rs", StatusCode::Modified.worktree()), + ("src/lib.rs", StatusCode::Modified.worktree()), + ("tests/test.rs", StatusCode::Modified.worktree()), + ("new_file.txt", FileStatus::Untracked), + ("another_new.rs", FileStatus::Untracked), + ("src/utils.rs", FileStatus::Untracked), ( - Path::new("conflict.txt"), + "conflict.txt", UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, @@ -5383,7 +5163,7 @@ mod tests { project .read(cx) .worktrees(cx) - .nth(0) + .next() .unwrap() .read(cx) .as_local() @@ -5434,7 +5214,7 @@ mod tests { project .read(cx) .worktrees(cx) - .nth(0) + .next() .unwrap() .read(cx) .as_local() @@ -5483,7 +5263,7 @@ mod tests { project .read(cx) .worktrees(cx) - .nth(0) + .next() .unwrap() .read(cx) .as_local() @@ -5518,4 +5298,386 @@ mod tests { ], ); } + + #[gpui::test] + async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) { + use GitListEntry::*; + + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn hello() {}", + "utils.rs": "pub fn util() {}" + }, + "tests": { + "test.rs": "fn test() {}" + }, + "new_file.txt": "new content", + "another_new.rs": "// new file", + "conflict.txt": "conflicted content" + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[ + ("src/main.rs", StatusCode::Modified.worktree()), + ("src/lib.rs", StatusCode::Modified.worktree()), + ("tests/test.rs", StatusCode::Modified.worktree()), + ("new_file.txt", FileStatus::Untracked), + ("another_new.rs", FileStatus::Untracked), + ("src/utils.rs", FileStatus::Untracked), + ( + "conflict.txt", + UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + ), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Header(GitHeaderEntry { header: Section::Conflict }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Header(GitHeaderEntry { header: Section::Tracked }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Header(GitHeaderEntry { header: Section::New }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }), + ], + ); + + assert_entry_paths( + &entries, + &[ + None, + Some("conflict.txt"), + None, + Some("src/lib.rs"), + Some("src/main.rs"), + Some("tests/test.rs"), + None, + Some("another_new.rs"), + Some("new_file.txt"), + Some("src/utils.rs"), + ], + ); + + let second_status_entry = entries[3].clone(); + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&second_status_entry, window, cx); + }); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + }) + }); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.selected_entry = Some(7); + panel.stage_range(&git::StageRange, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }), + ], + ); + + assert_entry_paths( + &entries, + &[ + Some("another_new.rs"), + Some("conflict.txt"), + Some("new_file.txt"), + Some("src/lib.rs"), + Some("src/main.rs"), + Some("src/utils.rs"), + Some("tests/test.rs"), + ], + ); + + let third_status_entry = entries[4].clone(); + panel.update_in(cx, |panel, window, cx| { + panel.toggle_staged_for_entry(&third_status_entry, window, cx); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.selected_entry = Some(9); + panel.stage_range(&git::StageRange, window, cx); + }); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let entries = panel.read_with(cx, |panel, _| panel.entries.clone()); + #[rustfmt::skip] + pretty_assertions::assert_matches!( + entries.as_slice(), + &[ + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }), + Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }), + Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }), + ], + ); + + assert_entry_paths( + &entries, + &[ + Some("another_new.rs"), + Some("conflict.txt"), + Some("new_file.txt"), + Some("src/lib.rs"), + Some("src/main.rs"), + Some("src/utils.rs"), + Some("tests/test.rs"), + ], + ); + } + + #[gpui::test] + async fn test_amend_commit_message_handling(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + // Test: User has commit message, enables amend (saves message), then disables (restores message) + panel.update(cx, |panel, cx| { + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, "Initial commit message")], None, cx); + }); + + panel.set_amend_pending(true, cx); + assert!(panel.original_commit_message.is_some()); + + panel.set_amend_pending(false, cx); + let current_message = panel.commit_message_buffer(cx).read(cx).text(); + assert_eq!(current_message, "Initial commit message"); + assert!(panel.original_commit_message.is_none()); + }); + + // Test: User has empty commit message, enables amend, then disables (clears message) + panel.update(cx, |panel, cx| { + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, "")], None, cx); + }); + + panel.set_amend_pending(true, cx); + assert!(panel.original_commit_message.is_none()); + + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + let start = buffer.anchor_before(0); + let end = buffer.anchor_after(buffer.len()); + buffer.edit([(start..end, "Previous commit message")], None, cx); + }); + + panel.set_amend_pending(false, cx); + let current_message = panel.commit_message_buffer(cx).read(cx).text(); + assert_eq!(current_message, ""); + }); + } + + #[gpui::test] + async fn test_open_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + "untracked": "\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + // Enable the `sort_by_path` setting and wait for entries to be updated, + // as there should no longer be separators between Tracked and Untracked + // files. + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + }) + }); + }); + + cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }) + .await; + + // Confirm that `Open Diff` still works for the untracked file, updating + // the Project Diff's active path. + panel.update_in(cx, |panel, window, cx| { + panel.selected_entry = Some(1); + panel.open_diff(&Confirm, window, cx); + }); + cx.run_until_parked(); + + let _ = workspace.update(cx, |workspace, _window, cx| { + let active_path = workspace + .item_of_type::(cx) + .expect("ProjectDiff should exist") + .read(cx) + .active_path(cx) + .expect("active_path should exist"); + + assert_eq!(active_path.path, rel_path("untracked").into_arc()); + }); + } + + fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) { + assert_eq!(entries.len(), expected_paths.len()); + for (entry, expected_path) in entries.iter().zip(expected_paths) { + assert_eq!( + entry.status_entry().map(|status| status + .repo_path + .0 + .as_std_path() + .to_string_lossy() + .to_string()), + expected_path.map(|s| s.to_string()) + ); + } + } } diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index b6891c7d256794b5b457669a20b17e6e41e4fd23..83259b228b59c5bb063473cc4a04710a0520808c 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -1,83 +1,20 @@ -use editor::ShowScrollbar; +use editor::EditorSettings; use gpui::Pixels; use schemars::JsonSchema; -use serde_derive::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use serde::{Deserialize, Serialize}; +use settings::{Settings, StatusStyle}; +use ui::{ + px, + scrollbars::{ScrollbarVisibility, ShowScrollbar}, +}; use workspace::dock::DockPosition; -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct ScrollbarSettingsContent { - /// When to show the scrollbar in the git panel. - /// - /// Default: inherits editor scrollbar settings - pub show: Option>, -} - #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct ScrollbarSettings { pub show: Option, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -// Style of the git status indicator in the panel. -// -// Default: icon -pub enum StatusStyleContent { - Icon, - LabelColor, -} - -#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum StatusStyle { - #[default] - Icon, - LabelColor, -} - -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct GitPanelSettingsContent { - /// Whether to show the panel button in the status bar. - /// - /// Default: true - pub button: Option, - /// Where to dock the panel. - /// - /// Default: left - pub dock: Option, - /// Default width of the panel in pixels. - /// - /// Default: 360 - pub default_width: Option, - /// How entry statuses are displayed. - /// - /// Default: icon - pub status_style: Option, - /// How and when the scrollbar should be displayed. - /// - /// Default: inherits editor scrollbar settings - pub scrollbar: Option, - - /// What the default branch name should be when - /// `init.defaultBranch` is not set in git - /// - /// Default: main - pub fallback_branch_name: Option, - - /// Whether to sort entries in the panel by path - /// or by status (the default). - /// - /// Default: false - pub sort_by_path: Option, - - /// Whether to collapse untracked files in the diff panel. - /// - /// Default: false - pub collapse_untracked_diff: Option, -} - -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct GitPanelSettings { pub button: bool, pub dock: DockPosition, @@ -89,20 +26,36 @@ pub struct GitPanelSettings { pub collapse_untracked_diff: bool, } -impl Settings for GitPanelSettings { - const KEY: Option<&'static str> = Some("git_panel"); - - type FileContent = GitPanelSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() +impl ScrollbarVisibility for GitPanelSettings { + fn visibility(&self, cx: &ui::App) -> ShowScrollbar { + // TODO: This PR should have defined Editor's `scrollbar.axis` + // as an Option, not a ScrollbarAxes as it would allow you to + // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`. + // + // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis` + // so we can show each axis based on the settings. + // + // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495 + self.scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } +} - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) { - vscode.bool_setting("git.enabled", &mut current.button); - vscode.string_setting("git.defaultBranchName", &mut current.fallback_branch_name); +impl Settings for GitPanelSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let git_panel = content.git_panel.clone().unwrap(); + Self { + button: git_panel.button.unwrap(), + dock: git_panel.dock.unwrap().into(), + default_width: px(git_panel.default_width.unwrap()), + status_style: git_panel.status_style.unwrap(), + scrollbar: ScrollbarSettings { + show: git_panel.scrollbar.unwrap().show.map(Into::into), + }, + fallback_branch_name: git_panel.fallback_branch_name.unwrap(), + sort_by_path: git_panel.sort_by_path.unwrap(), + collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), + } } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 3b4196b8ec3191e5e993552c9b97a200bf711a34..919cdf154d438e8ee5b38422032aa150edc5dd34 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -4,20 +4,28 @@ use ::settings::Settings; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; +use ui::{ + Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled, + StyledExt, div, h_flex, rems, v_flex, +}; + mod blame_ui; + use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use git_panel_settings::GitPanelSettings; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, - actions, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, + Window, actions, }; +use menu::{Cancel, Confirm}; use onboarding::GitOnboardingModal; +use project::git_store::Repository; use project_diff::ProjectDiff; use ui::prelude::*; -use workspace::{ModalView, Workspace}; +use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; use zed_actions; use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; @@ -26,7 +34,7 @@ mod askpass_modal; pub mod branch_picker; mod commit_modal; pub mod commit_tooltip; -mod commit_view; +pub mod commit_view; mod conflict_view; pub mod file_diff_view; pub mod git_panel; @@ -36,6 +44,7 @@ pub mod picker_prompt; pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; +pub mod stash_picker; pub mod text_diff_view; actions!( @@ -50,6 +59,7 @@ pub fn init(cx: &mut App) { GitPanelSettings::register(cx); editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); + commit_view::init(cx); cx.observe_new(|editor: &mut Editor, _, cx| { conflict_view::register_editor(editor, editor.buffer().clone(), cx); @@ -62,6 +72,7 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); branch_picker::register(workspace); + stash_picker::register(workspace); let project = workspace.project().read(cx); if project.is_read_only(cx) { @@ -133,6 +144,14 @@ pub fn init(cx: &mut App) { panel.stash_pop(action, window, cx); }); }); + workspace.register_action(|workspace, action: &git::StashApply, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stash_apply(action, window, cx); + }); + }); workspace.register_action(|workspace, action: &git::StageAll, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; @@ -149,6 +168,14 @@ pub fn init(cx: &mut App) { panel.unstage_all(action, window, cx); }); }); + workspace.register_action(|workspace, _: &git::Uncommit, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.uncommit(window, cx); + }) + }); CommandPaletteFilter::update_global(cx, |filter, _cx| { filter.hide_action_types(&[ zed_actions::OpenGitIntegrationOnboarding.type_id(), @@ -184,6 +211,9 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| { open_modified_files(workspace, window, cx); }); + workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| { + rename_current_branch(workspace, window, cx); + }); workspace.register_action( |workspace, action: &DiffClipboardWithSelectionData, window, cx| { if let Some(task) = TextDiffView::open(action, workspace, window, cx) { @@ -227,6 +257,122 @@ pub fn git_status_icon(status: FileStatus) -> impl IntoElement { GitStatusIcon::new(status) } +struct RenameBranchModal { + current_branch: SharedString, + editor: Entity, + repo: Entity, +} + +impl RenameBranchModal { + fn new( + current_branch: String, + repo: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(current_branch.clone(), window, cx); + editor + }); + Self { + current_branch: current_branch.into(), + editor, + repo, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let new_name = self.editor.read(cx).text(cx); + if new_name.is_empty() || new_name == self.current_branch.as_ref() { + cx.emit(DismissEvent); + return; + } + + let repo = self.repo.clone(); + let current_branch = self.current_branch.to_string(); + cx.spawn(async move |_, cx| { + match repo + .update(cx, |repo, _| { + repo.rename_branch(current_branch, new_name.clone()) + })? + .await + { + Ok(Ok(_)) => Ok(()), + Ok(Err(error)) => Err(error), + Err(_) => Err(anyhow::anyhow!("Operation was canceled")), + } + }) + .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None); + cx.emit(DismissEvent); + } +} + +impl EventEmitter for RenameBranchModal {} +impl ModalView for RenameBranchModal {} +impl Focusable for RenameBranchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for RenameBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("RenameBranchModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .w_full() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child( + Headline::new(format!("Rename Branch ({})", self.current_branch)) + .size(HeadlineSize::XSmall), + ), + ) + .child(div().px_3().pb_3().w_full().child(self.editor.clone())) + } +} + +fn rename_current_branch( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let current_branch: Option = panel.update(cx, |panel, cx| { + let repo = panel.active_repository.as_ref()?; + let repo = repo.read(cx); + repo.branch.as_ref().map(|branch| branch.name().to_string()) + }); + + let Some(current_branch_name) = current_branch else { + return; + }; + + let repo = panel.read(cx).active_repository.clone(); + let Some(repo) = repo else { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + RenameBranchModal::new(current_branch_name, repo, window, cx) + }); +} + fn render_remote_button( id: impl Into, branch: &Branch, @@ -245,12 +391,12 @@ fn render_remote_button( } (0, 0) => None, (ahead, 0) => Some(remote_button::render_push_button( - keybinding_target.clone(), + keybinding_target, id, ahead, )), (ahead, behind) => Some(remote_button::render_pull_button( - keybinding_target.clone(), + keybinding_target, id, ahead, behind, @@ -289,13 +435,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Fetch), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Fetch updates from remote", &git::Fetch, "git fetch", keybinding_target.clone(), - window, cx, ) }, @@ -317,13 +462,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Push committed changes to remote", &git::Push, "git push", keybinding_target.clone(), - window, cx, ) }, @@ -346,13 +490,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Pull), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Pull", &git::Pull, "git pull", keybinding_target.clone(), - window, cx, ) }, @@ -373,13 +516,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Publish branch to remote", &git::Push, "git push --set-upstream", keybinding_target.clone(), - window, cx, ) }, @@ -400,13 +542,12 @@ mod remote_button { move |_, window, cx| { window.dispatch_action(Box::new(git::Push), cx); }, - move |window, cx| { + move |_window, cx| { git_action_tooltip( "Re-publish branch to remote", &git::Push, "git push --set-upstream", keybinding_target.clone(), - window, cx, ) }, @@ -418,23 +559,15 @@ mod remote_button { action: &dyn Action, command: impl Into, focus_handle: Option, - window: &mut Window, cx: &mut App, ) -> AnyView { let label = label.into(); let command = command.into(); if let Some(handle) = focus_handle { - Tooltip::with_meta_in( - label.clone(), - Some(action), - command.clone(), - &handle, - window, - cx, - ) + Tooltip::with_meta_in(label, Some(action), command, &handle, cx) } else { - Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx) + Tooltip::with_meta(label, Some(action), command, cx) } } @@ -457,7 +590,7 @@ mod remote_button { Some(ContextMenu::build(window, cx, |context_menu, _, _| { context_menu .when_some(keybinding_target.clone(), |el, keybinding_target| { - el.context(keybinding_target.clone()) + el.context(keybinding_target) }) .action("Fetch", git::Fetch.boxed_clone()) .action("Fetch From", git::FetchFrom.boxed_clone()) @@ -507,7 +640,6 @@ mod remote_button { this.child( h_flex() .ml_neg_0p5() - .mr_1() .when(behind_count > 0, |this| { this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall)) .child(count(behind_count)) @@ -522,7 +654,6 @@ mod remote_button { this.child( h_flex() .ml_neg_0p5() - .mr_1() .child(Icon::new(left_icon).size(IconSize::XSmall)), ) }) @@ -636,7 +767,7 @@ impl GitCloneModal { pub fn show(panel: Entity, window: &mut Window, cx: &mut Context) -> Self { let repo_input = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Enter repository URL…", cx); + editor.set_placeholder_text("Enter repository URL…", window, cx); editor }); let focus_handle = repo_input.focus_handle(cx); diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 4077e0f3623e0925a87824e252a77755c78721ea..6161c62af571f3a90c3110d63cc26ea3a7e032ae 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -152,7 +152,7 @@ impl PickerDelegate for PickerPromptDelegate { .all_options .iter() .enumerate() - .map(|(ix, option)| StringMatchCandidate::new(ix, &option)) + .map(|(ix, option)| StringMatchCandidate::new(ix, option)) .collect::>() }); let Some(candidates) = candidates.log_err() else { @@ -216,7 +216,7 @@ impl PickerDelegate for PickerPromptDelegate { _window: &mut Window, _cx: &mut Context>, ) -> Option { - let hit = &self.matches[ix]; + let hit = &self.matches.get(ix)?; let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length); Some( @@ -228,7 +228,7 @@ impl PickerDelegate for PickerPromptDelegate { let highlights: Vec<_> = hit .positions .iter() - .filter(|index| index < &&self.max_match_length) + .filter(|&&index| index < self.max_match_length) .copied() .collect(); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index d6a4e27286af1bb38dcd1acc488bce9da1813a42..5c49ca286eb901a9e97281f27dcaef5c993d73b1 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -4,18 +4,18 @@ use crate::{ git_panel_settings::GitPanelSettings, remote_button::{render_publish_button, render_push_button}, }; -use anyhow::Result; +use anyhow::{Context as _, Result, anyhow}; use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use editor::{ - Editor, EditorEvent, SelectionEffects, + Addon, Editor, EditorEvent, SelectionEffects, actions::{GoToHunk, GoToPreviousHunk}, + multibuffer_context_lines, scroll::Autoscroll, }; -use futures::StreamExt; use git::{ Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, - repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::FileStatus, }; use gpui::{ @@ -26,18 +26,23 @@ use language::{Anchor, Buffer, Capability, OffsetRangeExt}; use multi_buffer::{MultiBuffer, PathKey}; use project::{ Project, ProjectPath, - git_store::{GitStore, GitStoreEvent, RepositoryEvent}, + git_store::{ + Repository, + branch_diff::{self, BranchDiffEvent, DiffBase}, + }, }; use settings::{Settings, SettingsStore}; use std::any::{Any, TypeId}; use std::ops::Range; +use std::sync::Arc; use theme::ActiveTheme; use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider}; -use util::ResultExt as _; +use util::{ResultExt as _, rel_path::RelPath}; use workspace::{ CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams}, + notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; @@ -47,38 +52,34 @@ actions!( /// Shows the diff between the working directory and the index. Diff, /// Adds files to the git staging area. - Add + Add, + /// Shows the diff between the working directory and your default + /// branch (typically main or master). + BranchDiff ] ); pub struct ProjectDiff { project: Entity, multibuffer: Entity, + branch_diff: Entity, editor: Entity, - git_store: Entity, + buffer_diff_subscriptions: HashMap, (Entity, Subscription)>, workspace: WeakEntity, focus_handle: FocusHandle, - update_needed: postage::watch::Sender<()>, pending_scroll: Option, _task: Task>, _subscription: Subscription, } -#[derive(Debug)] -struct DiffBuffer { - path_key: PathKey, - buffer: Entity, - diff: Entity, - file_status: FileStatus, -} - -const CONFLICT_NAMESPACE: u32 = 1; -const TRACKED_NAMESPACE: u32 = 2; -const NEW_NAMESPACE: u32 = 3; +const CONFLICT_SORT_PREFIX: u64 = 1; +const TRACKED_SORT_PREFIX: u64 = 2; +const NEW_SORT_PREFIX: u64 = 3; impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); + workspace.register_action(Self::deploy_branch_diff); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -94,6 +95,40 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } + fn deploy_branch_diff( + workspace: &mut Workspace, + _: &BranchDiff, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!("Git Branch Diff Opened"); + let project = workspace.project().clone(); + + let existing = workspace + .items_of_type::(cx) + .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } + let workspace = cx.entity(); + window + .spawn(cx, async move |cx| { + let this = cx + .update(|window, cx| { + Self::new_with_default_branch(project, workspace.clone(), window, cx) + })? + .await?; + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(this), None, true, window, cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_notify_err(window, cx); + } + pub fn deploy_at( workspace: &mut Workspace, entry: Option, @@ -108,7 +143,10 @@ impl ProjectDiff { "Action" } ); - let project_diff = if let Some(existing) = workspace.item_of_type::(cx) { + let existing = workspace + .items_of_type::(cx) + .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let project_diff = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); existing } else { @@ -137,11 +175,54 @@ impl ProjectDiff { }) } + fn new_with_default_branch( + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + let Some(repo) = project.read(cx).git_store().read(cx).active_repository() else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + let main_branch = repo.update(cx, |repo, _| repo.default_branch()); + window.spawn(cx, async move |cx| { + let main_branch = main_branch + .await?? + .context("Could not determine default branch")?; + + let branch_diff = cx.new_window_entity(|window, cx| { + branch_diff::BranchDiff::new( + DiffBase::Merge { + base_ref: main_branch, + }, + project.clone(), + window, + cx, + ) + })?; + cx.new_window_entity(|window, cx| { + Self::new_impl(branch_diff, project, workspace, window, cx) + }) + }) + } + fn new( project: Entity, workspace: Entity, window: &mut Window, cx: &mut Context, + ) -> Self { + let branch_diff = + cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + Self::new_impl(branch_diff, project, workspace, window, cx) + } + + fn new_impl( + branch_diff: Entity, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); @@ -151,9 +232,25 @@ impl ProjectDiff { Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); diff_display_editor.disable_diagnostics(cx); diff_display_editor.set_expand_all_diff_hunks(cx); - diff_display_editor.register_addon(GitPanelAddon { - workspace: workspace.downgrade(), - }); + + match branch_diff.read(cx).diff_base() { + DiffBase::Head => { + diff_display_editor.register_addon(GitPanelAddon { + workspace: workspace.downgrade(), + }); + } + DiffBase::Merge { .. } => { + diff_display_editor.register_addon(BranchDiffAddon { + branch_diff: branch_diff.clone(), + }); + diff_display_editor.start_temporary_diff_override(); + diff_display_editor.set_render_diff_hunk_controls( + Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), + cx, + ); + // + } + } diff_display_editor }); window.defer(cx, { @@ -170,79 +267,76 @@ impl ProjectDiff { cx.subscribe_in(&editor, window, Self::handle_editor_event) .detach(); - let git_store = project.read(cx).git_store().clone(); - let git_store_subscription = cx.subscribe_in( - &git_store, + let branch_diff_subscription = cx.subscribe_in( + &branch_diff, window, - move |this, _git_store, event, _window, _cx| match event { - GitStoreEvent::ActiveRepositoryChanged(_) - | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::Updated { .. }, true) - | GitStoreEvent::ConflictsUpdated => { - *this.update_needed.borrow_mut() = (); + move |this, _git_store, event, window, cx| match event { + BranchDiffEvent::FileListChanged => { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) } - _ => {} }, ); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let mut was_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; - cx.observe_global::(move |this, cx| { + cx.observe_global_in::(window, move |this, window, cx| { let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let is_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; if is_sort_by_path != was_sort_by_path || is_collapse_untracked_diff != was_collapse_untracked_diff { - *this.update_needed.borrow_mut() = (); + this._task = { + window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + } } was_sort_by_path = is_sort_by_path; was_collapse_untracked_diff = is_collapse_untracked_diff; }) .detach(); - let (mut send, recv) = postage::watch::channel::<()>(); - let worker = window.spawn(cx, { + let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::handle_status_updates(this, recv, cx).await + async |cx| Self::refresh(this, cx).await }); - // Kick off a refresh immediately - *send.borrow_mut() = (); Self { project, - git_store: git_store.clone(), workspace: workspace.downgrade(), + branch_diff, focus_handle, editor, multibuffer, + buffer_diff_subscriptions: Default::default(), pending_scroll: None, - update_needed: send, - _task: worker, - _subscription: git_store_subscription, + _task: task, + _subscription: branch_diff_subscription, } } + pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase { + self.branch_diff.read(cx).diff_base() + } + pub fn move_to_entry( &mut self, entry: GitStatusEntry, window: &mut Window, cx: &mut Context, ) { - let Some(git_repo) = self.git_store.read(cx).active_repository() else { + let Some(git_repo) = self.branch_diff.read(cx).repo() else { return; }; let repo = git_repo.read(cx); - - let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) { - CONFLICT_NAMESPACE - } else if entry.status.is_created() { - NEW_NAMESPACE - } else { - TRACKED_NAMESPACE - }; - - let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone()); + let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx); + let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0); self.move_to_path(path_key, window, cx) } @@ -280,7 +374,7 @@ impl ProjectDiff { fn button_states(&self, cx: &App) -> ButtonStates { let editor = self.editor.read(cx); let snapshot = self.multibuffer.read(cx).snapshot(cx); - let prev_next = snapshot.diff_hunks().skip(1).next().is_some(); + let prev_next = snapshot.diff_hunks().nth(1).is_some(); let mut selection = true; let mut ranges = editor @@ -329,14 +423,14 @@ impl ProjectDiff { }) .ok(); - return ButtonStates { + ButtonStates { stage: has_unstaged_hunks, unstage: has_staged_hunks, prev_next, selection, stage_all, unstage_all, - }; + } } fn handle_editor_event( @@ -346,100 +440,49 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - match event { - EditorEvent::SelectionsChanged { local: true } => { - let Some(project_path) = self.active_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); - } - _ => {} + if let EditorEvent::SelectionsChanged { local: true } = event { + let Some(project_path) = self.active_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); } - if editor.focus_handle(cx).contains_focused(window, cx) { - if self.multibuffer.read(cx).is_empty() { - self.focus_handle.focus(window) - } + if editor.focus_handle(cx).contains_focused(window, cx) + && self.multibuffer.read(cx).is_empty() + { + self.focus_handle.focus(window) } } - fn load_buffers(&mut self, cx: &mut Context) -> Vec>> { - let Some(repo) = self.git_store.read(cx).active_repository() else { - self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - }); - return vec![]; - }; - - let mut previous_paths = self.multibuffer.read(cx).paths().collect::>(); - - let mut result = vec![]; - repo.update(cx, |repo, cx| { - for entry in repo.cached_status() { - if !entry.status.has_changes() { - continue; - } - let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path, cx) - else { - continue; - }; - let namespace = if GitPanelSettings::get_global(cx).sort_by_path { - TRACKED_NAMESPACE - } else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) { - CONFLICT_NAMESPACE - } else if entry.status.is_created() { - NEW_NAMESPACE - } else { - TRACKED_NAMESPACE - }; - let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone()); - - previous_paths.remove(&path_key); - let load_buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); - - let project = self.project.clone(); - result.push(cx.spawn(async move |_, cx| { - let buffer = load_buffer.await?; - let changes = project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await?; - Ok(DiffBuffer { - path_key, - buffer, - diff: changes, - file_status: entry.status, - }) - })); - } - }); - self.multibuffer.update(cx, |multibuffer, cx| { - for path in previous_paths { - multibuffer.remove_excerpts_for_path(path, cx); - } - }); - result - } - fn register_buffer( &mut self, - diff_buffer: DiffBuffer, + path_key: PathKey, + file_status: FileStatus, + buffer: Entity, + diff: Entity, window: &mut Window, cx: &mut Context, ) { - let path_key = diff_buffer.path_key; - let buffer = diff_buffer.buffer; - let diff = diff_buffer.diff; + if self.branch_diff.read(cx).diff_base().is_merge_base() { + self.multibuffer.update(cx, |multibuffer, cx| { + multibuffer.add_diff(diff.clone(), cx); + }); + } + let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { + this._task = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::refresh(this, cx).await + }) + }); + self.buffer_diff_subscriptions + .insert(path_key.path.clone(), (diff.clone(), subscription)); let conflict_addon = self .editor @@ -451,16 +494,17 @@ impl ProjectDiff { let diff = diff.read(cx); let diff_hunk_ranges = diff .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.clone()); + .map(|diff_hunk| diff_hunk.buffer_range); let conflicts = conflict_addon .conflict_set(snapshot.remote_id()) - .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts.clone()) + .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) .unwrap_or_default(); let conflicts = conflicts.iter().map(|conflict| conflict.range.clone()); - let excerpt_ranges = merge_anchor_ranges(diff_hunk_ranges, conflicts, &snapshot) - .map(|range| range.to_point(&snapshot)) - .collect::>(); + let excerpt_ranges = + merge_anchor_ranges(diff_hunk_ranges.into_iter(), conflicts, &snapshot) + .map(|range| range.to_point(&snapshot)) + .collect::>(); let (was_empty, is_excerpt_newly_added) = self.multibuffer.update(cx, |multibuffer, cx| { let was_empty = multibuffer.is_empty(); @@ -468,7 +512,7 @@ impl ProjectDiff { path_key.clone(), buffer, excerpt_ranges, - editor::DEFAULT_MULTIBUFFER_CONTEXT, + multibuffer_context_lines(cx), cx, ); (was_empty, is_newly_added) @@ -482,8 +526,8 @@ impl ProjectDiff { }); } if is_excerpt_newly_added - && (diff_buffer.file_status.is_deleted() - || (diff_buffer.file_status.is_untracked() + && (file_status.is_deleted() + || (file_status.is_untracked() && GitPanelSettings::get_global(cx).collapse_untracked_diff)) { editor.fold_buffer(snapshot.text.remote_id(), cx) @@ -508,40 +552,77 @@ impl ProjectDiff { } } - pub async fn handle_status_updates( - this: WeakEntity, - mut recv: postage::watch::Receiver<()>, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - while let Some(_) = recv.next().await { - let buffers_to_load = this.update(cx, |this, cx| this.load_buffers(cx))?; - for buffer_to_load in buffers_to_load { - if let Some(buffer) = buffer_to_load.await.log_err() { - cx.update(|window, cx| { - this.update(cx, |this, cx| this.register_buffer(buffer, window, cx)) - .ok(); - })?; + pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + let mut path_keys = Vec::new(); + let buffers_to_load = this.update(cx, |this, cx| { + let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { + let load_buffers = branch_diff.load_buffers(cx); + (branch_diff.repo().cloned(), load_buffers) + }); + let mut previous_paths = this.multibuffer.read(cx).paths().collect::>(); + + if let Some(repo) = repo { + let repo = repo.read(cx); + + path_keys = Vec::with_capacity(buffers_to_load.len()); + for entry in buffers_to_load.iter() { + let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx); + let path_key = + PathKey::with_sort_prefix(sort_prefix, entry.repo_path.0.clone()); + previous_paths.remove(&path_key); + path_keys.push(path_key) } } - this.update(cx, |this, cx| { - this.pending_scroll.take(); - cx.notify(); - })?; + + this.multibuffer.update(cx, |multibuffer, cx| { + for path in previous_paths { + this.buffer_diff_subscriptions.remove(&path.path); + multibuffer.remove_excerpts_for_path(path, cx); + } + }); + buffers_to_load + })?; + + for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) { + if let Some((buffer, diff)) = entry.load.await.log_err() { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx) + }) + .ok(); + })?; + } } + this.update(cx, |this, cx| { + this.pending_scroll.take(); + cx.notify(); + })?; Ok(()) } #[cfg(any(test, feature = "test-support"))] - pub fn excerpt_paths(&self, cx: &App) -> Vec { + pub fn excerpt_paths(&self, cx: &App) -> Vec> { self.multibuffer .read(cx) .excerpt_paths() - .map(|key| key.path().to_string_lossy().to_string()) + .map(|key| key.path.clone()) .collect() } } +fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { + if GitPanelSettings::get_global(cx).sort_by_path { + TRACKED_SORT_PREFIX + } else if repo.had_conflict_on_last_merge_head_change(repo_path) { + CONFLICT_SORT_PREFIX + } else if status.is_created() { + NEW_SORT_PREFIX + } else { + TRACKED_SORT_PREFIX + } +} + impl EventEmitter for ProjectDiff {} impl Focusable for ProjectDiff { @@ -584,8 +665,8 @@ impl Item for ProjectDiff { Some("Project Diff".into()) } - fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement { - Label::new("Uncommitted Changes") + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(0, cx)) .color(if params.selected { Color::Default } else { @@ -594,8 +675,11 @@ impl Item for ProjectDiff { .into_any_element() } - fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString { - "Uncommitted Changes".into() + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + match self.branch_diff.read(cx).diff_base() { + DiffBase::Head => "Uncommitted Changes".into(), + DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), + } } fn telemetry_event_text(&self) -> Option<&'static str> { @@ -614,10 +698,6 @@ impl Item for ProjectDiff { self.editor.for_each_project_item(cx, f) } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn set_nav_history( &mut self, nav_history: ItemNavHistory, @@ -629,17 +709,25 @@ impl Item for ProjectDiff { }); } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - let workspace = self.workspace.upgrade()?; - Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx))) + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + Task::ready(Some(cx.new(|cx| { + ProjectDiff::new(self.project.clone(), workspace, window, cx) + }))) } fn is_dirty(&self, cx: &App) -> bool { @@ -719,7 +807,7 @@ impl Item for ProjectDiff { } impl Render for ProjectDiff { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); div() @@ -740,7 +828,7 @@ impl Render for ProjectDiff { } else { None }; - let keybinding_focus_handle = self.focus_handle(cx).clone(); + let keybinding_focus_handle = self.focus_handle(cx); el.child( v_flex() .gap_1() @@ -764,7 +852,6 @@ impl Render for ProjectDiff { .key_binding(KeyBinding::for_action_in( &CloseActiveItem::default(), &keybinding_focus_handle, - window, cx, )) .on_click(move |_, window, cx| { @@ -797,30 +884,47 @@ impl SerializableItem for ProjectDiff { } fn deserialize( - _project: Entity, + project: Entity, workspace: WeakEntity, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { - workspace.update_in(cx, |workspace, window, cx| { - let workspace_handle = cx.entity(); - cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)) - }) + let diff_base = persistence::PROJECT_DIFF_DB.get_diff_base(item_id, workspace_id)?; + + let diff = cx.update(|window, cx| { + let branch_diff = cx + .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); + let workspace = workspace.upgrade().context("workspace gone")?; + anyhow::Ok( + cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), + ) + })??; + + Ok(diff) }) } fn serialize( &mut self, - _workspace: &mut Workspace, - _item_id: workspace::ItemId, + workspace: &mut Workspace, + item_id: workspace::ItemId, _closing: bool, _window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) -> Option>> { - None + let workspace_id = workspace.database_id()?; + let diff_base = self.diff_base(cx).clone(); + + Some(cx.background_spawn({ + async move { + persistence::PROJECT_DIFF_DB + .save_diff_base(item_id, workspace_id, diff_base.clone()) + .await + } + })) } fn should_serialize(&self, _: &Self::Event) -> bool { @@ -828,6 +932,80 @@ impl SerializableItem for ProjectDiff { } } +mod persistence { + + use anyhow::Context as _; + use db::{ + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + use project::git_store::branch_diff::DiffBase; + use workspace::{ItemId, WorkspaceDb, WorkspaceId}; + + pub struct ProjectDiffDb(ThreadSafeConnection); + + impl Domain for ProjectDiffDb { + const NAME: &str = stringify!(ProjectDiffDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE project_diffs( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + + diff_base TEXT, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; + } + + db::static_connection!(PROJECT_DIFF_DB, ProjectDiffDb, [WorkspaceDb]); + + impl ProjectDiffDb { + pub async fn save_diff_base( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + diff_base: DiffBase, + ) -> anyhow::Result<()> { + self.write(move |connection| { + let sql_stmt = sql!( + INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?) + ); + let diff_base_str = serde_json::to_string(&diff_base)?; + let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?; + query((item_id, workspace_id, diff_base_str)).context(format!( + "exec_bound failed to execute or parse for: {}", + sql_stmt + )) + }) + .await + } + + pub fn get_diff_base( + &self, + item_id: ItemId, + workspace_id: WorkspaceId, + ) -> anyhow::Result { + let sql_stmt = + sql!(SELECT diff_base FROM project_diffs WHERE item_id = ?AND workspace_id = ?); + let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?( + (item_id, workspace_id), + ) + .context(::std::format!( + "Error in get_diff_base, select_row_bound failed to execute or parse for: {}", + sql_stmt + ))?; + let Some(diff_base_str) = diff_base_str else { + return Ok(DiffBase::Head); + }; + serde_json::from_str(&diff_base_str).context("deserializing diff base") + } + } +} + pub struct ProjectDiffToolbar { project_diff: Option>, workspace: WeakEntity, @@ -892,6 +1070,7 @@ impl ToolbarItemView for ProjectDiffToolbar { ) -> ToolbarItemLocation { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) + .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight @@ -956,6 +1135,11 @@ impl Render for ProjectDiffToolbar { &StageAndNext, &focus_handle, )) + .disabled( + !button_states.prev_next + && !button_states.stage_all + && !button_states.unstage_all, + ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&StageAndNext, window, cx) })), @@ -967,6 +1151,11 @@ impl Render for ProjectDiffToolbar { &UnstageAndNext, &focus_handle, )) + .disabled( + !button_states.prev_next + && !button_states.stage_all + && !button_states.unstage_all, + ) .on_click(cx.listener(|this, _, window, cx| { this.dispatch_action(&UnstageAndNext, window, cx) })), @@ -1073,8 +1262,7 @@ pub struct ProjectDiffEmptyState { impl RenderOnce for ProjectDiffEmptyState { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool { - match self.current_branch { - Some(Branch { + matches!(self.current_branch, Some(Branch { upstream: Some(Upstream { tracking: @@ -1084,9 +1272,7 @@ impl RenderOnce for ProjectDiffEmptyState { .. }), .. - }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0) => true, - _ => false, - } + }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0)) }; let change_count = |current_branch: &Branch| -> (usize, usize) { @@ -1173,7 +1359,7 @@ impl RenderOnce for ProjectDiffEmptyState { .child(Label::new("No Changes").color(Color::Muted)) } else { this.when_some(self.current_branch.as_ref(), |this, branch| { - this.child(has_branch_container(&branch)) + this.child(has_branch_container(branch)) }) } }), @@ -1225,6 +1411,7 @@ mod preview { sha: "abc123".into(), subject: "Modify stuff".into(), commit_timestamp: 1710932954, + author_name: "John Doe".into(), has_parent: true, }), } @@ -1332,14 +1519,14 @@ fn merge_anchor_ranges<'a>( loop { if let Some(left_range) = left .peek() - .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le()) + .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) .cloned() { left.next(); next_range.end = left_range.end; } else if let Some(right_range) = right .peek() - .filter(|range| range.start.cmp(&next_range.end, &snapshot).is_le()) + .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) .cloned() { right.next(); @@ -1353,19 +1540,42 @@ fn merge_anchor_ranges<'a>( }) } -#[cfg(not(target_os = "windows"))] +struct BranchDiffAddon { + branch_diff: Entity, +} + +impl Addon for BranchDiffAddon { + fn to_any(&self) -> &dyn std::any::Any { + self + } + + fn override_status_for_buffer_id( + &self, + buffer_id: language::BufferId, + cx: &App, + ) -> Option { + self.branch_diff + .read(cx) + .status_for_buffer_id(buffer_id, cx) + } +} + #[cfg(test)] mod tests { + use collections::HashMap; use db::indoc; use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; - use git::status::{UnmergedStatus, UnmergedStatusCode}; + use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::TestAppContext; use project::FakeFs; use serde_json::json; use settings::SettingsStore; use std::path::Path; use unindent::Unindent as _; - use util::path; + use util::{ + path, + rel_path::{RelPath, rel_path}, + }; use super::*; @@ -1410,12 +1620,12 @@ mod tests { fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[("foo.txt".into(), "foo\n".into())], + &[("foo.txt", "foo\n".into())], "deadbeef", ); fs.set_index_for_repo( path!("/project/.git").as_ref(), - &[("foo.txt".into(), "foo\n".into())], + &[("foo.txt", "foo\n".into())], ); cx.run_until_parked(); @@ -1465,16 +1675,13 @@ mod tests { fs.set_head_and_index_for_repo( path!("/project/.git").as_ref(), - &[ - ("bar".into(), "bar\n".into()), - ("foo".into(), "foo\n".into()), - ], + &[("bar", "bar\n".into()), ("foo", "foo\n".into())], ); cx.run_until_parked(); let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::namespaced(TRACKED_NAMESPACE, Path::new("foo").into()), + PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()), window, cx, ); @@ -1495,7 +1702,7 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::namespaced(TRACKED_NAMESPACE, Path::new("bar").into()), + PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()), window, cx, ); @@ -1547,7 +1754,7 @@ mod tests { fs.set_head_for_repo( path!("/project/.git").as_ref(), - &[("foo".into(), "original\n".into())], + &[("foo", "original\n".into())], "deadbeef", ); cx.run_until_parked(); @@ -1567,7 +1774,7 @@ mod tests { let prev_buffer_hunks = cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { let snapshot = buffer_editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot; + let snapshot = &snapshot.buffer_snapshot(); let prev_buffer_hunks = buffer_editor .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot) .collect::>(); @@ -1580,7 +1787,7 @@ mod tests { let new_buffer_hunks = cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| { let snapshot = buffer_editor.snapshot(window, cx); - let snapshot = &snapshot.buffer_snapshot; + let snapshot = &snapshot.buffer_snapshot(); buffer_editor .diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot) .collect::>() @@ -1623,8 +1830,8 @@ mod tests { cx, &" - original - + different - ˇ" + + ˇdifferent + " .unindent(), ); } @@ -1640,7 +1847,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/a", + path!("/a"), json!({ ".git": {}, "a.txt": "created\n", @@ -1650,16 +1857,16 @@ mod tests { ) .await; - fs.set_git_content_for_repo( - Path::new("/a/.git"), + fs.set_head_and_index_for_repo( + Path::new(path!("/a/.git")), &[ - ("b.txt".into(), "before\n".to_string(), None), - ("c.txt".into(), "unchanged\n".to_string(), None), - ("d.txt".into(), "deleted\n".to_string(), None), + ("b.txt", "before\n".to_string()), + ("c.txt", "unchanged\n".to_string()), + ("d.txt", "deleted\n".to_string()), ], ); - let project = Project::test(fs, [Path::new("/a")], cx).await; + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); @@ -1760,7 +1967,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/a", + path!("/a"), json!({ ".git": {}, "main.rs": buffer_contents, @@ -1768,12 +1975,12 @@ mod tests { ) .await; - fs.set_git_content_for_repo( - Path::new("/a/.git"), - &[("main.rs".into(), git_contents.to_owned(), None)], + fs.set_head_and_index_for_repo( + Path::new(path!("/a/.git")), + &[("main.rs", git_contents.to_owned())], ); - let project = Project::test(fs, [Path::new("/a")], cx).await; + let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); @@ -1820,7 +2027,7 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/project/.git")), &[( - Path::new("foo"), + "foo", UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, @@ -1879,4 +2086,282 @@ mod tests { let contents = String::from_utf8(contents).unwrap(); assert_eq!(contents, "ours\n"); } + + #[gpui::test] + async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "foo.txt": " + one + two + three + four + five + six + seven + eight + nine + ten + ELEVEN + twelve + ".unindent() + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let diff = cx.new_window_entity(|window, cx| { + ProjectDiff::new(project.clone(), workspace, window, cx) + }); + cx.run_until_parked(); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "foo.txt", + " + one + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + " + .unindent(), + )], + ); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, _| diff.editor.clone()); + + assert_state_with_diff( + &editor, + cx, + &" + ˇnine + ten + - eleven + + ELEVEN + twelve + " + .unindent(), + ); + + // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/foo.txt"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit_via_marked_text( + &" + one + «TWO» + three + four + five + six + seven + eight + nine + ten + ELEVEN + twelve + " + .unindent(), + None, + cx, + ); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + + assert_state_with_diff( + &editor, + cx, + &" + one + - two + + TWO + three + four + five + ˇnine + ten + - eleven + + ELEVEN + twelve + " + .unindent(), + ); + } + + #[gpui::test] + async fn test_branch_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "C", + "b.txt": "new", + "c.txt": "in-merge-base-and-work-tree", + "d.txt": "created-in-head", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let diff = cx + .update(|window, cx| { + ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("a.txt", "B".into()), ("d.txt", "created-in-head".into())], + "sha", + ); + // fs.set_index_for_repo(dot_git, index_state); + fs.set_merge_base_content_for_repo( + Path::new(path!("/project/.git")), + &[ + ("a.txt", "A".into()), + ("c.txt", "in-merge-base-and-work-tree".into()), + ], + ); + cx.run_until_parked(); + + let editor = diff.read_with(cx, |diff, _| diff.editor.clone()); + + assert_state_with_diff( + &editor, + cx, + &" + - A + + ˇC + + new + + created-in-head" + .unindent(), + ); + + let statuses: HashMap, Option> = + editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .all_buffers() + .iter() + .map(|buffer| { + ( + buffer.read(cx).file().unwrap().path().clone(), + editor.status_for_buffer_id(buffer.read(cx).remote_id(), cx), + ) + }) + .collect() + }); + + assert_eq!( + statuses, + HashMap::from_iter([ + ( + rel_path("a.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Modified, + worktree_status: git::status::StatusCode::Modified + })) + ), + (rel_path("b.txt").into_arc(), Some(FileStatus::Untracked)), + ( + rel_path("d.txt").into_arc(), + Some(FileStatus::Tracked(TrackedStatus { + index_status: git::status::StatusCode::Added, + worktree_status: git::status::StatusCode::Added + })) + ) + ]) + ); + } + + #[gpui::test] + async fn test_update_on_uncommit(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "README.md": "# My cool project\n".to_owned() + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("README.md", "# My cool project\n".to_owned())], + ); + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.run_until_parked(); + + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(project_diff::Diff.boxed_clone(), cx); + }); + cx.run_until_parked(); + let item = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + cx.focus(&item); + let editor = item.read_with(cx, |item, _| item.editor.clone()); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[( + "README.md", + "# My cool project\nDetails to come.\n".to_owned(), + )], + ); + cx.run_until_parked(); + + let mut cx = EditorTestContext::for_editor_in(editor, cx).await; + + cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n"); + } } diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index db080ab0b4974dfc3ef83ffb3a0ec71481c683bc..5e60bebc4279df4bbf90a685ccffa957803253f7 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -1,6 +1,6 @@ use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; use itertools::Itertools; -use picker::{Picker, PickerDelegate}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Project, git_store::Repository}; use std::sync::Arc; use ui::{ListItem, ListItemSpacing, prelude::*}; @@ -36,11 +36,11 @@ impl RepositorySelector { ) -> Self { let git_store = project_handle.read(cx).git_store().clone(); let repository_entries = git_store.update(cx, |git_store, _cx| { - git_store - .repositories() - .values() - .cloned() - .collect::>() + let mut repos: Vec<_> = git_store.repositories().values().cloned().collect(); + + repos.sort_by_key(|a| a.read(_cx).display_name()); + + repos }); let filtered_repositories = repository_entries.clone(); @@ -59,7 +59,7 @@ impl RepositorySelector { }; let picker = cx.new(|cx| { - Picker::nonsearchable_uniform_list(delegate, window, cx) + Picker::uniform_list(delegate, window, cx) .widest_item(widest_item_ix) .max_height(Some(rems(20.).into())) }); @@ -158,6 +158,10 @@ impl PickerDelegate for RepositorySelectorDelegate { "Select a repository...".into() } + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::End + } + fn update_matches( &mut self, query: String, @@ -166,25 +170,31 @@ impl PickerDelegate for RepositorySelectorDelegate { ) -> Task<()> { let all_repositories = self.repository_entries.clone(); + let repo_names: Vec<(Entity, String)> = all_repositories + .iter() + .map(|repo| (repo.clone(), repo.read(cx).display_name().to_lowercase())) + .collect(); + cx.spawn_in(window, async move |this, cx| { let filtered_repositories = cx .background_spawn(async move { if query.is_empty() { all_repositories } else { - all_repositories + let query_lower = query.to_lowercase(); + repo_names .into_iter() - .filter(|_repo_info| { - // TODO: Implement repository filtering logic - true - }) + .filter(|(_, display_name)| display_name.contains(&query_lower)) + .map(|(repo, _)| repo) .collect() } }) .await; this.update_in(cx, |this, window, cx| { - this.delegate.filtered_repositories = filtered_repositories; + let mut sorted_repositories = filtered_repositories; + sorted_repositories.sort_by_key(|a| a.read(cx).display_name()); + this.delegate.filtered_repositories = sorted_repositories; this.delegate.set_selected_index(0, window, cx); cx.notify(); }) diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs new file mode 100644 index 0000000000000000000000000000000000000000..58f17d7a3bb087ff058878f7889d6d83bc1727a6 --- /dev/null +++ b/crates/git_ui/src/stash_picker.rs @@ -0,0 +1,574 @@ +use fuzzy::StringMatchCandidate; + +use chrono; +use git::stash::StashEntry; +use gpui::{ + Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, + SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, svg, +}; +use picker::{Picker, PickerDelegate}; +use project::git_store::{Repository, RepositoryEvent}; +use std::sync::Arc; +use time::{OffsetDateTime, UtcOffset}; +use time_format; +use ui::{ + ButtonLike, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, +}; +use util::ResultExt; +use workspace::notifications::DetachAndPromptErr; +use workspace::{ModalView, Workspace}; + +use crate::commit_view::CommitView; +use crate::stash_picker; + +actions!( + stash_picker, + [ + /// Drop the selected stash entry. + DropStashItem, + /// Show the diff view of the selected stash entry. + ShowStashItem, + ] +); + +pub fn register(workspace: &mut Workspace) { + workspace.register_action(open); +} + +pub fn open( + workspace: &mut Workspace, + _: &zed_actions::git::ViewStash, + window: &mut Window, + cx: &mut Context, +) { + let repository = workspace.project().read(cx).active_repository(cx); + let weak_workspace = workspace.weak_handle(); + workspace.toggle_modal(window, cx, |window, cx| { + StashList::new(repository, weak_workspace, rems(34.), window, cx) + }) +} + +pub struct StashList { + width: Rems, + pub picker: Entity>, + picker_focus_handle: FocusHandle, + _subscriptions: Vec, +} + +impl StashList { + fn new( + repository: Option>, + workspace: WeakEntity, + width: Rems, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut _subscriptions = Vec::new(); + let stash_request = repository + .clone() + .map(|repository| repository.read_with(cx, |repo, _| repo.cached_stash())); + + if let Some(repo) = repository.clone() { + _subscriptions.push( + cx.subscribe_in(&repo, window, |this, _, event, window, cx| { + if matches!(event, RepositoryEvent::StashEntriesChanged) { + let stash_entries = this.picker.read_with(cx, |picker, cx| { + picker + .delegate + .repo + .clone() + .map(|repo| repo.read(cx).cached_stash().entries.to_vec()) + }); + this.picker.update(cx, |this, cx| { + this.delegate.all_stash_entries = stash_entries; + this.refresh(window, cx); + }); + } + }), + ) + } + + cx.spawn_in(window, async move |this, cx| { + let stash_entries = stash_request + .map(|git_stash| git_stash.entries.to_vec()) + .unwrap_or_default(); + + this.update_in(cx, |this, window, cx| { + this.picker.update(cx, |picker, cx| { + picker.delegate.all_stash_entries = Some(stash_entries); + picker.refresh(window, cx); + }) + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + let delegate = StashListDelegate::new(repository, workspace, window, cx); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + _subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + })); + + Self { + picker, + picker_focus_handle, + width, + _subscriptions, + } + } + + fn handle_drop_stash( + &mut self, + _: &DropStashItem, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .drop_stash_at(picker.delegate.selected_index(), window, cx); + }); + cx.notify(); + } + + fn handle_show_stash( + &mut self, + _: &ShowStashItem, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .show_stash_at(picker.delegate.selected_index(), window, cx); + }); + cx.notify(); + } + + fn handle_modifiers_changed( + &mut self, + ev: &ModifiersChangedEvent, + _: &mut Window, + cx: &mut Context, + ) { + self.picker + .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) + } +} + +impl ModalView for StashList {} +impl EventEmitter for StashList {} +impl Focusable for StashList { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.picker_focus_handle.clone() + } +} + +impl Render for StashList { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("StashList") + .w(self.width) + .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .on_action(cx.listener(Self::handle_drop_stash)) + .on_action(cx.listener(Self::handle_show_stash)) + .child(self.picker.clone()) + } +} + +#[derive(Debug, Clone)] +struct StashEntryMatch { + entry: StashEntry, + positions: Vec, + formatted_timestamp: String, +} + +pub struct StashListDelegate { + matches: Vec, + all_stash_entries: Option>, + repo: Option>, + workspace: WeakEntity, + selected_index: usize, + last_query: String, + modifiers: Modifiers, + focus_handle: FocusHandle, + timezone: UtcOffset, +} + +impl StashListDelegate { + fn new( + repo: Option>, + workspace: WeakEntity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let timezone = + UtcOffset::from_whole_seconds(chrono::Local::now().offset().local_minus_utc()) + .unwrap_or(UtcOffset::UTC); + + Self { + matches: vec![], + repo, + workspace, + all_stash_entries: None, + selected_index: 0, + last_query: Default::default(), + modifiers: Default::default(), + focus_handle: cx.focus_handle(), + timezone, + } + } + + fn format_message(ix: usize, message: &String) -> String { + format!("#{}: {}", ix, message) + } + + fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = + OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc()); + time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ) + } + + fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry_match) = self.matches.get(ix) else { + return; + }; + let stash_index = entry_match.entry.index; + let Some(repo) = self.repo.clone() else { + return; + }; + + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))? + .await??; + Ok(()) + }) + .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| { + Some(e.to_string()) + }); + } + + fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry_match) = self.matches.get(ix) else { + return; + }; + let stash_sha = entry_match.entry.oid.to_string(); + let stash_index = entry_match.entry.index; + let Some(repo) = self.repo.clone() else { + return; + }; + CommitView::open( + stash_sha, + repo.downgrade(), + self.workspace.clone(), + Some(stash_index), + window, + cx, + ); + } + + fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context>) { + let Some(repo) = self.repo.clone() else { + return; + }; + + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))? + .await?; + Ok(()) + }) + .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } + + fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context>) { + let Some(repo) = self.repo.clone() else { + return; + }; + + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))? + .await?; + Ok(()) + }) + .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } +} + +impl PickerDelegate for StashListDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a stash…".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let Some(all_stash_entries) = self.all_stash_entries.clone() else { + return Task::ready(()); + }; + + let timezone = self.timezone; + + cx.spawn_in(window, async move |picker, cx| { + let matches: Vec = if query.is_empty() { + all_stash_entries + .into_iter() + .map(|entry| { + let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone); + + StashEntryMatch { + entry, + positions: Vec::new(), + formatted_timestamp, + } + }) + .collect() + } else { + let candidates = all_stash_entries + .iter() + .enumerate() + .map(|(ix, entry)| { + StringMatchCandidate::new( + ix, + &Self::format_message(entry.index, &entry.message), + ) + }) + .collect::>(); + fuzzy::match_strings( + &candidates, + &query, + false, + true, + 10000, + &Default::default(), + cx.background_executor().clone(), + ) + .await + .into_iter() + .map(|candidate| { + let entry = all_stash_entries[candidate.candidate_id].clone(); + let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone); + + StashEntryMatch { + entry, + positions: candidate.positions, + formatted_timestamp, + } + }) + .collect() + }; + + picker + .update(cx, |picker, _| { + let delegate = &mut picker.delegate; + delegate.matches = matches; + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + core::cmp::min(delegate.selected_index, delegate.matches.len() - 1); + } + delegate.last_query = query; + }) + .log_err(); + }) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(entry_match) = self.matches.get(self.selected_index()) else { + return; + }; + let stash_index = entry_match.entry.index; + if secondary { + self.pop_stash(stash_index, window, cx); + } else { + self.apply_stash(stash_index, window, cx); + } + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let entry_match = &self.matches[ix]; + + let stash_message = + Self::format_message(entry_match.entry.index, &entry_match.entry.message); + let positions = entry_match.positions.clone(); + let stash_label = HighlightedLabel::new(stash_message, positions) + .truncate() + .into_any_element(); + + let branch_name = entry_match.entry.branch.clone().unwrap_or_default(); + let branch_label = h_flex() + .gap_1p5() + .w_full() + .child( + h_flex() + .gap_0p5() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new(branch_name) + .truncate() + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + Label::new("•") + .alpha(0.5) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new(entry_match.formatted_timestamp.clone()) + .color(Color::Muted) + .size(LabelSize::Small), + ); + + let show_button = div() + .group("show-button-hover") + .child( + ButtonLike::new("show-button") + .child( + svg() + .size(IconSize::Medium.rems()) + .flex_none() + .path(IconName::Eye.path()) + .text_color(Color::Default.color(cx)) + .group_hover("show-button-hover", |this| { + this.text_color(Color::Accent.color(cx)) + }) + .hover(|this| this.text_color(Color::Accent.color(cx))), + ) + .tooltip(Tooltip::for_action_title("Show Stash", &ShowStashItem)) + .on_click(cx.listener(move |picker, _, window, cx| { + cx.stop_propagation(); + picker.delegate.show_stash_at(ix, window, cx); + })), + ) + .into_any_element(); + + Some( + ListItem::new(SharedString::from(format!("stash-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .end_slot(show_button) + .child( + v_flex() + .w_full() + .overflow_hidden() + .child(stash_label) + .child(branch_label.into_element()), + ) + .tooltip(Tooltip::text(format!( + "stash@{{{}}}", + entry_match.entry.index + ))), + ) + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some("No stashes found".into()) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_0p5() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("apply-stash", "Apply") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("pop-stash", "Pop") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("drop-stash", "Drop") + .key_binding( + KeyBinding::for_action_in( + &stash_picker::DropStashItem, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx) + }), + ) + .into_any(), + ) + } +} diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 005c1e18b40727f42df81437c7038f4e5a7ef905..fd8cd3597377a6de78b3153ccc430afe81b1127e 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -48,8 +48,8 @@ impl TextDiffView { let selection_data = source_editor.update(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); - let source_buffer = multibuffer.as_singleton()?.clone(); - let selections = editor.selections.all::(cx); + let source_buffer = multibuffer.as_singleton()?; + let selections = editor.selections.all::(&editor.display_snapshot(cx)); let buffer_snapshot = source_buffer.read(cx); let first_selection = selections.first()?; let max_point = buffer_snapshot.max_point(); @@ -193,7 +193,7 @@ impl TextDiffView { .and_then(|b| { b.read(cx) .file() - .map(|f| f.full_path(cx).compact().to_string_lossy().to_string()) + .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned()) }) .unwrap_or("untitled".into()); @@ -207,7 +207,7 @@ impl TextDiffView { path: Some(format!("Clipboard ↔ {selection_location_path}").into()), buffer_changes_tx, _recalculate_diff_task: cx.spawn(async move |_, cx| { - while let Ok(_) = buffer_changes_rx.recv().await { + while buffer_changes_rx.recv().await.is_ok() { loop { let mut timer = cx .background_executor() @@ -259,7 +259,7 @@ async fn update_diff_buffer( let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot())?; let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let base_text = base_buffer_snapshot.text().to_string(); + let base_text = base_buffer_snapshot.text(); let diff_snapshot = cx .update(|cx| { @@ -324,10 +324,6 @@ impl Item for TextDiffView { .update(cx, |editor, cx| editor.deactivated(window, cx)); } - fn is_singleton(&self, _: &App) -> bool { - false - } - fn act_as_type<'a>( &'a self, type_id: TypeId, @@ -416,7 +412,7 @@ impl Item for TextDiffView { pub fn selection_location_text(editor: &Editor, cx: &App) -> Option { let buffer = editor.buffer().read(cx); let buffer_snapshot = buffer.snapshot(cx); - let first_selection = editor.selections.disjoint.first()?; + let first_selection = editor.selections.disjoint_anchors().first()?; let selection_start = first_selection.start.to_point(&buffer_snapshot); let selection_end = first_selection.end.to_point(&buffer_snapshot); @@ -454,7 +450,7 @@ mod tests { use gpui::{TestAppContext, VisualContext}; use project::{FakeFs, Project}; use serde_json::json; - use settings::{Settings, SettingsStore}; + use settings::SettingsStore; use unindent::unindent; use util::{path, test::marked_text_ranges}; @@ -466,7 +462,7 @@ mod tests { Project::init_settings(cx); workspace::init_settings(cx); editor::init_settings(cx); - theme::ThemeSettings::register(cx) + theme::init(theme::LoadThemes::JustBase, cx); }); } @@ -686,7 +682,7 @@ mod tests { let project = Project::test(fs, [project_root.as_ref()], cx).await; - let (workspace, mut cx) = + let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let buffer = project @@ -725,7 +721,7 @@ mod tests { assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()), - &mut cx, + cx, expected_diff, ); diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 57438910fbef39f2b0308a3f4706d5e01df0f832..0260cd2d122f83f2c11505be9e6e8a84f69f8569 100644 --- a/crates/go_to_line/Cargo.toml +++ b/crates/go_to_line/Cargo.toml @@ -13,12 +13,10 @@ path = "src/go_to_line.rs" doctest = false [dependencies] -anyhow.workspace = true editor.workspace = true gpui.workspace = true language.workspace = true menu.workspace = true -schemars.workspace = true serde.workspace = true settings.workspace = true text.workspace = true @@ -26,7 +24,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index af92621378bdd1635af147d845ab809fe3326828..2a67ff67479021353d7231939726a13b948bf4b7 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -1,8 +1,6 @@ -use editor::{Editor, EditorSettings, MultiBufferSnapshot}; -use gpui::{App, Entity, FocusHandle, Focusable, Subscription, Task, WeakEntity}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use editor::{Editor, EditorEvent, MultiBufferSnapshot}; +use gpui::{App, Entity, FocusHandle, Focusable, Styled, Subscription, Task, WeakEntity}; +use settings::Settings; use std::{fmt::Write, num::NonZeroU32, time::Duration}; use text::{Point, Selection}; use ui::{ @@ -10,7 +8,7 @@ use ui::{ Render, Tooltip, Window, div, }; use util::paths::FILE_ROW_COLUMN_DELIMITER; -use workspace::{StatusItemView, Workspace, item::ItemHandle}; +use workspace::{StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; #[derive(Copy, Clone, Debug, Default, PartialOrd, PartialEq)] pub(crate) struct SelectionStats { @@ -83,7 +81,7 @@ impl CursorPosition { fn update_position( &mut self, - editor: Entity, + editor: &Entity, debounce: Option, window: &mut Window, cx: &mut Context, @@ -95,10 +93,8 @@ impl CursorPosition { .ok() .unwrap_or(true); - if !is_singleton { - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } + if !is_singleton && let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; } editor @@ -108,20 +104,21 @@ impl CursorPosition { cursor_position.selected_count.selections = editor.selections.count(); match editor.mode() { editor::EditorMode::AutoHeight { .. } - | editor::EditorMode::SingleLine { .. } + | editor::EditorMode::SingleLine | editor::EditorMode::Minimap { .. } => { cursor_position.position = None; cursor_position.context = None; } editor::EditorMode::Full { .. } => { let mut last_selection = None::>; - let snapshot = editor.buffer().read(cx).snapshot(cx); - if snapshot.excerpts().count() > 0 { - for selection in editor.selections.all_adjusted(cx) { + let snapshot = editor.display_snapshot(cx); + if snapshot.buffer_snapshot().excerpts().count() > 0 { + for selection in editor.selections.all_adjusted(&snapshot) { let selection_summary = snapshot + .buffer_snapshot() .text_summary_for_range::( - selection.start..selection.end, - ); + selection.start..selection.end, + ); cursor_position.selected_count.characters += selection_summary.chars; if selection.end != selection.start { @@ -131,15 +128,19 @@ impl CursorPosition { cursor_position.selected_count.lines += 1; } } - if last_selection.as_ref().map_or(true, |last_selection| { + if last_selection.as_ref().is_none_or(|last_selection| { selection.id > last_selection.id }) { last_selection = Some(selection); } } } - cursor_position.position = last_selection - .map(|s| UserCaretPosition::at_selection_end(&s, &snapshot)); + cursor_position.position = last_selection.map(|s| { + UserCaretPosition::at_selection_end( + &s, + snapshot.buffer_snapshot(), + ) + }); cursor_position.context = Some(editor.focus_handle(cx)); } } @@ -209,11 +210,8 @@ impl CursorPosition { impl Render for CursorPosition { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if !EditorSettings::get_global(cx) - .status_bar - .cursor_position_button - { - return div(); + if !StatusBarSettings::get_global(cx).cursor_position_button { + return div().hidden(); } div().when_some(self.position, |el, position| { @@ -234,29 +232,25 @@ impl Render for CursorPosition { if let Some(editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) + && let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx) { - if let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx) - { - workspace.toggle_modal(window, cx, |window, cx| { - crate::GoToLine::new(editor, buffer, window, cx) - }) - } + workspace.toggle_modal(window, cx, |window, cx| { + crate::GoToLine::new(editor, buffer, window, cx) + }) } }); } })) - .tooltip(move |window, cx| match context.as_ref() { + .tooltip(move |_window, cx| match context.as_ref() { Some(context) => Tooltip::for_action_in( "Go to Line/Column", &editor::actions::ToggleGoToLine, context, - window, cx, ), None => Tooltip::for_action( "Go to Line/Column", &editor::actions::ToggleGoToLine, - window, cx, ), }), @@ -275,19 +269,21 @@ impl StatusItemView for CursorPosition { cx: &mut Context, ) { if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { - self._observe_active_editor = - Some( - cx.observe_in(&editor, window, |cursor_position, editor, window, cx| { - Self::update_position( - cursor_position, - editor, - Some(UPDATE_DEBOUNCE), - window, - cx, - ) - }), - ); - self.update_position(editor, None, window, cx); + self._observe_active_editor = Some(cx.subscribe_in( + &editor, + window, + |cursor_position, editor, event, window, cx| match event { + EditorEvent::SelectionsChanged { .. } => Self::update_position( + cursor_position, + editor, + Some(UPDATE_DEBOUNCE), + window, + cx, + ), + _ => {} + }, + )); + self.update_position(&editor, None, window, cx); } else { self.position = None; self._observe_active_editor = None; @@ -297,35 +293,23 @@ impl StatusItemView for CursorPosition { } } -#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum LineIndicatorFormat { +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LineIndicatorFormat { Short, - #[default] Long, } -#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)] -#[serde(transparent)] -pub(crate) struct LineIndicatorFormatContent(LineIndicatorFormat); +impl From for LineIndicatorFormat { + fn from(format: settings::LineIndicatorFormat) -> Self { + match format { + settings::LineIndicatorFormat::Short => LineIndicatorFormat::Short, + settings::LineIndicatorFormat::Long => LineIndicatorFormat::Long, + } + } +} impl Settings for LineIndicatorFormat { - const KEY: Option<&'static str> = Some("line_indicator_format"); - - type FileContent = Option; - - fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { - let format = [ - sources.release_channel, - sources.operating_system, - sources.user, - ] - .into_iter() - .find_map(|value| value.copied().flatten()) - .unwrap_or(sources.default.ok_or_else(Self::missing_default)?); - - Ok(format.0) + fn from_settings(content: &settings::SettingsContent) -> Self { + content.line_indicator_format.unwrap().into() } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 1ac933e316bcde24384139c851a8bedb63388611..b9654ab14e1826c6d90c92878bbc4b55d1ef2959 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -3,7 +3,8 @@ pub mod cursor_position; use cursor_position::{LineIndicatorFormat, UserCaretPosition}; use editor::{ Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint, - actions::Tab, scroll::Autoscroll, + actions::Tab, + scroll::{Autoscroll, ScrollOffset}, }; use gpui::{ App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled, @@ -15,7 +16,7 @@ use text::{Bias, Point}; use theme::ActiveTheme; use ui::prelude::*; use util::paths::FILE_ROW_COLUMN_DELIMITER; -use workspace::ModalView; +use workspace::{DismissDecision, ModalView}; pub fn init(cx: &mut App) { LineIndicatorFormat::register(cx); @@ -26,11 +27,20 @@ pub struct GoToLine { line_editor: Entity, active_editor: Entity, current_text: SharedString, - prev_scroll_position: Option>, + prev_scroll_position: Option>, _subscriptions: Vec, } -impl ModalView for GoToLine {} +impl ModalView for GoToLine { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> DismissDecision { + self.prev_scroll_position.take(); + DismissDecision::Dismiss(true) + } +} impl Focusable for GoToLine { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -73,7 +83,9 @@ impl GoToLine { ) -> Self { let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| { let user_caret = UserCaretPosition::at_selection_end( - &editor.selections.last::(cx), + &editor + .selections + .last::(&editor.display_snapshot(cx)), &editor.buffer().read(cx).snapshot(cx), ); @@ -103,17 +115,20 @@ impl GoToLine { return; }; editor.update(cx, |editor, cx| { - if let Some(placeholder_text) = editor.placeholder_text() { - if editor.text(cx).is_empty() { - let placeholder_text = placeholder_text.to_string(); - editor.set_text(placeholder_text, window, cx); - } + if let Some(placeholder_text) = editor.placeholder_text(cx) + && editor.text(cx).is_empty() + { + editor.set_text(placeholder_text, window, cx); } }); } }) .detach(); - editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx); + editor.set_placeholder_text( + &format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), + window, + cx, + ); editor }); let line_editor_change = cx.subscribe_in(&line_editor, window, Self::on_line_editor_event); @@ -157,7 +172,7 @@ impl GoToLine { self.prev_scroll_position.take(); cx.emit(DismissEvent) } - editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx), + editor::EditorEvent::BufferEdited => self.highlight_current_line(cx), _ => {} } } @@ -308,7 +323,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use std::{num::NonZeroU32, sync::Arc, time::Duration}; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{AppState, Workspace}; #[gpui::test] @@ -353,7 +368,7 @@ mod tests { .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "a.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) }) .await .unwrap() @@ -457,7 +472,7 @@ mod tests { .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "a.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) }) .await .unwrap() @@ -542,7 +557,7 @@ mod tests { .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "a.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) }) .await .unwrap() @@ -620,7 +635,7 @@ mod tests { .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "a.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) }) .await .unwrap() @@ -691,11 +706,11 @@ mod tests { let go_to_line_view = open_go_to_line_view(workspace, cx); go_to_line_view.update(cx, |go_to_line_view, cx| { assert_eq!( - go_to_line_view - .line_editor - .read(cx) - .placeholder_text() - .expect("No placeholder text"), + go_to_line_view.line_editor.update(cx, |line_editor, cx| { + line_editor + .placeholder_text(cx) + .expect("No placeholder text") + }), format!( "{}:{}", expected_placeholder.line, expected_placeholder.character @@ -712,7 +727,7 @@ mod tests { ) -> Entity { cx.dispatch_action(editor::actions::ToggleGoToLine); workspace.update(cx, |workspace, cx| { - workspace.active_modal::(cx).unwrap().clone() + workspace.active_modal::(cx).unwrap() }) } @@ -735,7 +750,7 @@ mod tests { let selections = editor.update(cx, |editor, cx| { editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.start..s.end) .collect::>() @@ -763,4 +778,171 @@ mod tests { state }) } + + #[gpui::test] + async fn test_scroll_position_on_outside_click(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + drop(go_to_line_view); + workspace.update_in(cx, |workspace, window, cx| { + workspace.hide_modal(window, cx); + }); + cx.run_until_parked(); + + let scroll_position_after_auto_dismiss = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_auto_dismiss, scroll_position_after_input, + "Dismissing via outside click should maintain new scroll position" + ); + } + + #[gpui::test] + async fn test_scroll_position_on_cancel(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + cx.dispatch_action(menu::Cancel); + drop(go_to_line_view); + cx.run_until_parked(); + + let scroll_position_after_cancel = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_cancel, scroll_position_after_input, + "Cancel should maintain new scroll position" + ); + } + + #[gpui::test] + async fn test_scroll_position_on_confirm(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let file_content = (0..100) + .map(|i| format!("struct Line{};", i)) + .collect::>() + .join("\n"); + fs.insert_tree(path!("/dir"), json!({"a.rs": file_content})) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let go_to_line_view = open_go_to_line_view(&workspace, cx); + + let scroll_position_before_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + cx.simulate_input("47"); + let scroll_position_after_input = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_ne!(scroll_position_before_input, scroll_position_after_input); + + cx.dispatch_action(menu::Confirm); + drop(go_to_line_view); + cx.run_until_parked(); + + let scroll_position_after_confirm = + editor.update(cx, |editor, cx| editor.scroll_position(cx)); + assert_eq!( + scroll_position_after_confirm, scroll_position_after_input, + "Confirm should maintain new scroll position" + ); + } } diff --git a/crates/google_ai/Cargo.toml b/crates/google_ai/Cargo.toml index bc9213ca8f81d8f1bccfd84cbffe95aa61923cd8..81e05e4836529e9b73b58b72683a7e72a4d5c984 100644 --- a/crates/google_ai/Cargo.toml +++ b/crates/google_ai/Cargo.toml @@ -21,5 +21,5 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true +settings.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index dfa51d024c46c2acc15744cd366c2fd723d59046..9b7e5ec8d1c42fc846d131cfd063de5bba8287ae 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -4,6 +4,7 @@ use anyhow::{Result, anyhow, bail}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +pub use settings::ModelMode as GoogleModelMode; pub const API_URL: &str = "https://generativelanguage.googleapis.com"; @@ -13,6 +14,7 @@ pub async fn stream_generate_content( api_key: &str, mut request: GenerateContentRequest, ) -> Result>> { + let api_key = api_key.trim(); validate_generate_content_request(&request)?; // The `model` field is emptied as it is provided as a path parameter. @@ -106,10 +108,9 @@ pub fn validate_generate_content_request(request: &GenerateContentRequest) -> Re .contents .iter() .find(|content| content.role == Role::User) + && user_content.parts.is_empty() { - if user_content.parts.is_empty() { - bail!("User content must contain at least one part"); - } + bail!("User content must contain at least one part"); } Ok(()) @@ -267,7 +268,7 @@ pub struct CitationMetadata { pub struct PromptFeedback { #[serde(skip_serializing_if = "Option::is_none")] pub block_reason: Option, - pub safety_ratings: Vec, + pub safety_ratings: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub block_reason_message: Option, } @@ -295,16 +296,6 @@ pub struct ThinkingConfig { pub thinking_budget: u32, } -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub enum GoogleModelMode { - #[default] - Default, - Thinking { - budget_tokens: Option, - }, -} - #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GenerationConfig { @@ -478,10 +469,10 @@ impl<'de> Deserialize<'de> for ModelName { model_id: id.to_string(), }) } else { - return Err(serde::de::Error::custom(format!( + Err(serde::de::Error::custom(format!( "Expected model name to begin with {}, got: {}", MODEL_NAME_PREFIX, string - ))); + ))) } } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 6be8c5fd1f29e535ddcbd855dbafaa49cdbda591..3bec72b2f2726d6373449f6c6828943d7c086909 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -1,24 +1,29 @@ [package] name = "gpui" -version = "0.1.0" +version = "0.2.2" edition.workspace = true authors = ["Nathan Sobo "] description = "Zed's GPU-accelerated UI framework" repository = "https://github.com/zed-industries/zed" -publish.workspace = true +publish = true license = "Apache-2.0" +homepage = "https://gpui.rs" +readme = "README.md" +keywords = ["desktop", "gui", "immediate"] +categories = ["gui"] + [lints] workspace = true [features] -default = ["http_client", "font-kit", "wayland", "x11", "windows-manifest"] +default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", "rand", "util/test-support", - "http_client?/test-support", + "http_client/test-support", "wayland", "x11", ] @@ -34,11 +39,12 @@ macos-blade = [ "objc2-metal", ] wayland = [ + "bitflags", "blade-graphics", "blade-macros", "blade-util", "bytemuck", - "ashpd", + "ashpd/wayland", "cosmic-text", "font-kit", "calloop-wayland-source", @@ -47,6 +53,7 @@ wayland = [ "wayland-cursor", "wayland-protocols", "wayland-protocols-plasma", + "wayland-protocols-wlr", "filedescriptor", "xkbcommon", "open", @@ -80,7 +87,8 @@ doctest = false [dependencies] anyhow.workspace = true async-task = "4.7" -backtrace = { version = "0.3", optional = true } +backtrace = { workspace = true, optional = true } +bitflags = { workspace = true, optional = true } blade-graphics = { workspace = true, optional = true } blade-macros = { workspace = true, optional = true } blade-util = { workspace = true, optional = true } @@ -91,7 +99,7 @@ derive_more.workspace = true etagere = "0.2" futures.workspace = true gpui_macros.workspace = true -http_client = { optional = true, workspace = true } +http_client.workspace = true image.workspace = true inventory.workspace = true itertools.workspace = true @@ -110,15 +118,16 @@ resvg = { version = "0.45.0", default-features = false, features = [ "memmap-fonts", ] } usvg = { version = "0.45.0", default-features = false } +util_macros.workspace = true schemars.workspace = true seahash = "4.1" semantic_version.workspace = true serde.workspace = true -serde_derive.workspace = true serde_json.workspace = true -slotmap = "1.0.6" +slotmap.workspace = true smallvec.workspace = true smol.workspace = true +stacksafe.workspace = true strum.workspace = true sum_tree.workspace = true taffy = "=0.9.0" @@ -127,18 +136,20 @@ util.workspace = true uuid.workspace = true waker-fn = "1.2.0" lyon = "1.0" -workspace-hack.workspace = true libc.workspace = true +pin-project = "1.1.10" [target.'cfg(target_os = "macos")'.dependencies] block = "0.1" cocoa.workspace = true +cocoa-foundation.workspace = true core-foundation.workspace = true core-foundation-sys.workspace = true core-graphics = "0.24" core-video.workspace = true core-text = "21" -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "5474cfad4b719a72ec8ed2cb7327b2b01fd10568", optional = true } +# WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", optional = true } foreign-types = "0.5" log.workspace = true media.workspace = true @@ -157,7 +168,7 @@ scap = { workspace = true, optional = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] # Always used flume = "0.11" -oo7 = { version = "0.4.0", default-features = false, features = [ +oo7 = { version = "0.5.0", default-features = false, features = [ "async-std", "native_crypto", ] } @@ -169,7 +180,8 @@ blade-macros = { workspace = true, optional = true } blade-util = { workspace = true, optional = true } bytemuck = { version = "1", optional = true } cosmic-text = { version = "0.14.0", optional = true } -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "5474cfad4b719a72ec8ed2cb7327b2b01fd10568", features = [ +# WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", features = [ "source-fontconfig-dlopen", ], optional = true } @@ -193,6 +205,9 @@ wayland-protocols = { version = "0.31.2", features = [ wayland-protocols-plasma = { version = "0.2.0", features = [ "client", ], optional = true } +wayland-protocols-wlr = { version = "0.3.9", features = [ + "client", +], optional = true } # X11 as-raw-xcb-connection = { version = "1", optional = true } @@ -209,10 +224,11 @@ xkbcommon = { version = "0.8.0", features = [ "wayland", "x11", ], optional = true } -xim = { git = "https://github.com/zed-industries/xim-rs", rev = "c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" , features = [ +# WARNING: If you change this, you must also publish a new version of zed-xim to crates.io +xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881b815a2b6cdfd6687988e84f8447d8" , features = [ "x11rb-xcb", "x11rb-client", -], optional = true } +], package = "zed-xim", version = "0.4.0-zed", optional = true } x11-clipboard = { version = "0.9.3", optional = true } [target.'cfg(target_os = "windows")'.dependencies] @@ -224,14 +240,15 @@ windows-numerics = "0.2" windows-registry = "0.5" [dev-dependencies] -backtrace = "0.3" +backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true http_client = { workspace = true, features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } +pretty_assertions.workspace = true rand.workspace = true -unicode-segmentation.workspace = true reqwest_client = { workspace = true, features = ["test-support"] } +unicode-segmentation.workspace = true util = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "windows")'.build-dependencies] diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 9faab7b6801873f531f8138e375cdad73fc23dc4..2c411f76cd4782904f5e704c446a6f0e76f7d9ab 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -5,12 +5,14 @@ for Rust, designed to support a wide variety of applications. ## Getting Started -GPUI is still in active development as we work on the Zed code editor and isn't yet on crates.io. You'll also need to use the latest version of stable Rust and be on macOS or Linux. Add the following to your `Cargo.toml`: +GPUI is still in active development as we work on the Zed code editor, and is still pre-1.0. There will often be breaking changes between versions. You'll also need to use the latest version of stable Rust and be on macOS or Linux. Add the following to your `Cargo.toml`: ```toml -gpui = { git = "https://github.com/zed-industries/zed" } +gpui = { version = "*" } ``` + - [Ownership and data flow](src/_ownership_and_data_flow.rs) + Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. ### Dependencies @@ -23,7 +25,7 @@ On macOS, GPUI uses Metal for rendering. In order to use Metal, you need to do t - Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account. -> Ensure you launch XCode after installing, and install the macOS components, which is the default option. +> Ensure you launch Xcode after installing, and install the macOS components, which is the default option. - Install [Xcode command line tools](https://developer.apple.com/xcode/resources/) diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 93a1c15c41dd173a35ffc0adf06af6c449809890..83aea8a17911aa3d8f63938d3cccdd00dd0935c3 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] #![cfg_attr(any(not(target_os = "macos"), feature = "macos-blade"), allow(unused))] //TODO: consider generating shader code for WGSL @@ -48,7 +49,7 @@ fn check_wgsl_shaders() { // All clear } Err(e) => { - eprintln!("WGSL shader compilation failed:\n{}", e); + println!("cargo::error=WGSL shader compilation failed:\n{}", e); process::exit(1); } } @@ -219,8 +220,8 @@ mod macos { .unwrap(); if !output.status.success() { - eprintln!( - "metal shader compilation failed:\n{}", + println!( + "cargo::error=metal shader compilation failed:\n{}", String::from_utf8_lossy(&output.stderr) ); process::exit(1); @@ -235,8 +236,8 @@ mod macos { .unwrap(); if !output.status.success() { - eprintln!( - "metallib compilation failed:\n{}", + println!( + "cargo::error=metallib compilation failed:\n{}", String::from_utf8_lossy(&output.stderr) ); process::exit(1); @@ -327,10 +328,10 @@ mod windows { /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler. fn find_fxc_compiler() -> String { // Check environment variable - if let Ok(path) = std::env::var("GPUI_FXC_PATH") { - if Path::new(&path).exists() { - return path; - } + if let Ok(path) = std::env::var("GPUI_FXC_PATH") + && Path::new(&path).exists() + { + return path; } // Try to find in PATH @@ -338,11 +339,10 @@ mod windows { if let Ok(output) = std::process::Command::new("where.exe") .arg("fxc.exe") .output() + && output.status.success() { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout); - return path.trim().to_string(); - } + let path = String::from_utf8_lossy(&output.stdout); + return path.trim().to_string(); } // Check the default path @@ -374,7 +374,7 @@ mod windows { shader_path, "vs_4_1", ); - generate_rust_binding(&const_name, &output_file, &rust_binding_path); + generate_rust_binding(&const_name, &output_file, rust_binding_path); // Compile fragment shader let output_file = format!("{}/{}_ps.h", out_dir, module); @@ -387,7 +387,7 @@ mod windows { shader_path, "ps_4_1", ); - generate_rust_binding(&const_name, &output_file, &rust_binding_path); + generate_rust_binding(&const_name, &output_file, rust_binding_path); } fn compile_shader_impl( @@ -418,15 +418,15 @@ mod windows { if result.status.success() { return; } - eprintln!( - "Shader compilation failed for {}:\n{}", + println!( + "cargo::error=Shader compilation failed for {}:\n{}", entry_point, String::from_utf8_lossy(&result.stderr) ); process::exit(1); } Err(e) => { - eprintln!("Failed to run fxc for {}: {}", entry_point, e); + println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); process::exit(1); } } diff --git a/crates/gpui/examples/animation.rs b/crates/gpui/examples/animation.rs index 6bfe75dd0a51fa5807efc6cd579751460aa8b187..16d6e1b269975f61316fa35880d5d3924790fed1 100644 --- a/crates/gpui/examples/animation.rs +++ b/crates/gpui/examples/animation.rs @@ -3,8 +3,8 @@ use std::time::Duration; use anyhow::Result; use gpui::{ Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, SharedString, - Transformation, Window, WindowBounds, WindowOptions, black, bounce, div, ease_in_out, - percentage, prelude::*, px, rgb, size, svg, + Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, percentage, + prelude::*, px, size, svg, }; struct Assets {} @@ -21,7 +21,7 @@ impl AssetSource for Assets { Ok(std::fs::read_dir(path)? .filter_map(|entry| { Some(SharedString::from( - entry.ok()?.path().to_string_lossy().to_string(), + entry.ok()?.path().to_string_lossy().into_owned(), )) }) .collect::>()) @@ -37,37 +37,66 @@ struct AnimationExample {} impl Render for AnimationExample { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().flex().flex_col().size_full().justify_around().child( - div().flex().flex_row().w_full().justify_around().child( + div() + .flex() + .flex_col() + .size_full() + .bg(gpui::white()) + .text_color(gpui::black()) + .justify_around() + .child( div() .flex() - .bg(rgb(0x2e7d32)) - .size(px(300.0)) - .justify_center() - .items_center() - .shadow_lg() - .text_xl() - .text_color(black()) - .child("hello") + .flex_col() + .size_full() + .justify_around() .child( - svg() - .size_8() - .path(ARROW_CIRCLE_SVG) - .text_color(black()) - .with_animation( - "image_circle", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(bounce(ease_in_out)), - |svg, delta| { - svg.with_transformation(Transformation::rotate(percentage( - delta, - ))) - }, + div() + .id("content") + .flex() + .flex_col() + .h(px(150.)) + .overflow_y_scroll() + .w_full() + .flex_1() + .justify_center() + .items_center() + .text_xl() + .gap_4() + .child("Hello Animation") + .child( + svg() + .size_20() + .overflow_hidden() + .path(ARROW_CIRCLE_SVG) + .text_color(gpui::black()) + .with_animation( + "image_circle", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(bounce(ease_in_out)), + |svg, delta| { + svg.with_transformation(Transformation::rotate( + percentage(delta), + )) + }, + ), ), + ) + .child( + div() + .flex() + .h(px(64.)) + .w_full() + .p_2() + .justify_center() + .items_center() + .border_t_1() + .border_color(gpui::black().opacity(0.1)) + .bg(gpui::black().opacity(0.05)) + .child("Other Panel"), ), - ), - ) + ) } } diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs index 5e82b08839de5f3b98ec3267b22a3bb8586fa02c..56c9625ed3039b872cf4fcc70e84719ce903e268 100644 --- a/crates/gpui/examples/data_table.rs +++ b/crates/gpui/examples/data_table.rs @@ -38,58 +38,58 @@ pub struct Quote { impl Quote { pub fn random() -> Self { use rand::Rng; - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); // simulate a base price in a realistic range - let prev_close = rng.gen_range(100.0..200.0); - let change = rng.gen_range(-5.0..5.0); + let prev_close = rng.random_range(100.0..200.0); + let change = rng.random_range(-5.0..5.0); let last_done = prev_close + change; - let open = prev_close + rng.gen_range(-3.0..3.0); - let high = (prev_close + rng.gen_range::(0.0..10.0)).max(open); - let low = (prev_close - rng.gen_range::(0.0..10.0)).min(open); - let timestamp = Duration::from_secs(rng.gen_range(0..86400)); - let volume = rng.gen_range(1_000_000..100_000_000); + let open = prev_close + rng.random_range(-3.0..3.0); + let high = (prev_close + rng.random_range::(0.0..10.0)).max(open); + let low = (prev_close - rng.random_range::(0.0..10.0)).min(open); + let timestamp = Duration::from_secs(rng.random_range(0..86400)); + let volume = rng.random_range(1_000_000..100_000_000); let turnover = last_done * volume as f64; let symbol = { let mut ticker = String::new(); - if rng.gen_bool(0.5) { + if rng.random_bool(0.5) { ticker.push_str(&format!( "{:03}.{}", - rng.gen_range(100..1000), - rng.gen_range(0..10) + rng.random_range(100..1000), + rng.random_range(0..10) )); } else { ticker.push_str(&format!( "{}{}", - rng.gen_range('A'..='Z'), - rng.gen_range('A'..='Z') + rng.random_range('A'..='Z'), + rng.random_range('A'..='Z') )); } - ticker.push_str(&format!(".{}", rng.gen_range('A'..='Z'))); + ticker.push_str(&format!(".{}", rng.random_range('A'..='Z'))); ticker }; let name = format!( "{} {} - #{}", symbol, - rng.gen_range(1..100), - rng.gen_range(10000..100000) + rng.random_range(1..100), + rng.random_range(10000..100000) ); - let ttm = rng.gen_range(0.0..10.0); - let market_cap = rng.gen_range(1_000_000.0..10_000_000.0); - let float_cap = market_cap + rng.gen_range(1_000.0..10_000.0); - let shares = rng.gen_range(100.0..1000.0); + let ttm = rng.random_range(0.0..10.0); + let market_cap = rng.random_range(1_000_000.0..10_000_000.0); + let float_cap = market_cap + rng.random_range(1_000.0..10_000.0); + let shares = rng.random_range(100.0..1000.0); let pb = market_cap / shares; let pe = market_cap / shares; let eps = market_cap / shares; - let dividend = rng.gen_range(0.0..10.0); - let dividend_yield = rng.gen_range(0.0..10.0); - let dividend_per_share = rng.gen_range(0.0..10.0); + let dividend = rng.random_range(0.0..10.0); + let dividend_yield = rng.random_range(0.0..10.0); + let dividend_per_share = rng.random_range(0.0..10.0); let dividend_date = SharedString::new(format!( "{}-{}-{}", - rng.gen_range(2000..2023), - rng.gen_range(1..12), - rng.gen_range(1..28) + rng.random_range(2000..2023), + rng.random_range(1..12), + rng.random_range(1..28) )); - let dividend_payment = rng.gen_range(0.0..10.0); + let dividend_payment = rng.random_range(0.0..10.0); Self { name: name.into(), @@ -238,7 +238,7 @@ impl RenderOnce for TableRow { .flex_row() .border_b_1() .border_color(rgb(0xE0E0E0)) - .bg(if self.ix % 2 == 0 { + .bg(if self.ix.is_multiple_of(2) { rgb(0xFFFFFF) } else { rgb(0xFAFAFA) @@ -374,7 +374,6 @@ impl DataTable { impl Render for DataTable { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() - .font_family(".SystemUIFont") .bg(gpui::white()) .text_sm() .size_full() diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs new file mode 100644 index 0000000000000000000000000000000000000000..737317cabadb7d3358c9c0497b52d4c2ff2e1028 --- /dev/null +++ b/crates/gpui/examples/focus_visible.rs @@ -0,0 +1,214 @@ +use gpui::{ + App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, + Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, +}; + +actions!(example, [Tab, TabPrev, Quit]); + +struct Example { + focus_handle: FocusHandle, + items: Vec<(FocusHandle, &'static str)>, + message: SharedString, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let items = vec![ + ( + cx.focus_handle().tab_index(1).tab_stop(true), + "Button with .focus() - always shows border when focused", + ), + ( + cx.focus_handle().tab_index(2).tab_stop(true), + "Button with .focus_visible() - only shows border with keyboard", + ), + ( + cx.focus_handle().tab_index(3).tab_stop(true), + "Button with both .focus() and .focus_visible()", + ), + ]; + + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); + + Self { + focus_handle, + items, + message: SharedString::from( + "Try clicking vs tabbing! Click shows no border, Tab shows border.", + ), + } + } + + fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { + window.focus_next(); + self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { + window.focus_prev(); + self.message = + SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); + } + + fn on_quit(&mut self, _: &Quit, _window: &mut Window, cx: &mut Context) { + cx.quit(); + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn button_base(id: impl Into, label: &'static str) -> Stateful
{ + div() + .id(id) + .h_16() + .w_full() + .flex() + .justify_center() + .items_center() + .bg(gpui::rgb(0x2563eb)) + .text_color(gpui::white()) + .rounded_md() + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(0x1d4ed8))) + .child(label) + } + + div() + .id("app") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) + .on_action(cx.listener(Self::on_quit)) + .size_full() + .flex() + .flex_col() + .p_8() + .gap_6() + .bg(gpui::rgb(0xf3f4f6)) + .child( + div() + .text_2xl() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x111827)) + .child("CSS focus-visible Demo"), + ) + .child( + div() + .p_4() + .rounded_md() + .bg(gpui::rgb(0xdbeafe)) + .text_color(gpui::rgb(0x1e3a8a)) + .child(self.message.clone()), + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("1. Regular .focus() - always visible:"), + ) + .child( + button_base("button1", self.items[0].1) + .track_focus(&self.items[0].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 1 - focus border is visible!".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child("2. New .focus_visible() - only keyboard:"), + ) + .child( + button_base("button2", self.items[1].1) + .track_focus(&self.items[1].0) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 2 - no border! Try Tab instead.".into(); + cx.notify(); + })), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::BOLD) + .text_color(gpui::rgb(0x374151)) + .child( + "3. Both .focus() (yellow) and .focus_visible() (green):", + ), + ) + .child( + button_base("button3", self.items[2].1) + .track_focus(&self.items[2].0) + .focus(|style| { + style.border_4().border_color(gpui::rgb(0xfbbf24)) + }) + .focus_visible(|style| { + style.border_4().border_color(gpui::rgb(0x10b981)) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.message = + "Clicked button 3 - yellow border. Tab shows green!" + .into(); + cx.notify(); + })), + ), + ), + ) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| Example::new(window, cx)), + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/gradient.rs b/crates/gpui/examples/gradient.rs index 4a84d2319d1d0b3d432d35dd2b66e168d733cffd..30fb3090a30d4f6c70e968d637dbf98b73559529 100644 --- a/crates/gpui/examples/gradient.rs +++ b/crates/gpui/examples/gradient.rs @@ -20,7 +20,6 @@ impl Render for GradientViewer { let color_space = self.color_space; div() - .font_family(".SystemUIFont") .bg(gpui::white()) .size_full() .p_4() diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index bd1708e8c453656b2b7047b428f3dc63409eddec..34a510f76db396a91a225dffe21fcec986a62e20 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -75,65 +75,71 @@ impl Render for ImageShowcase { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .id("main") + .bg(gpui::white()) .overflow_y_scroll() .p_5() .size_full() - .flex() - .flex_col() - .justify_center() - .items_center() - .gap_8() - .bg(rgb(0xffffff)) .child( div() .flex() - .flex_row() + .flex_col() .justify_center() .items_center() .gap_8() - .child(ImageContainer::new( - "Image loaded from a local file", - self.local_resource.clone(), - )) - .child(ImageContainer::new( - "Image loaded from a remote resource", - self.remote_resource.clone(), + .child(img( + "https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg", )) - .child(ImageContainer::new( - "Image loaded from an asset", - self.asset_resource.clone(), - )), - ) - .child( - div() - .flex() - .flex_row() - .gap_8() .child( div() - .flex_col() - .child("Auto Width") - .child(img("https://picsum.photos/800/400").h(px(180.))), + .flex() + .flex_row() + .justify_center() + .items_center() + .gap_8() + .child(ImageContainer::new( + "Image loaded from a local file", + self.local_resource.clone(), + )) + .child(ImageContainer::new( + "Image loaded from a remote resource", + self.remote_resource.clone(), + )) + .child(ImageContainer::new( + "Image loaded from an asset", + self.asset_resource.clone(), + )), + ) + .child( + div() + .flex() + .flex_row() + .gap_8() + .child( + div() + .flex_col() + .child("Auto Width") + .child(img("https://picsum.photos/800/400").h(px(180.))), + ) + .child( + div() + .flex_col() + .child("Auto Height") + .child(img("https://picsum.photos/800/400").w(px(180.))), + ), ) .child( div() + .flex() .flex_col() - .child("Auto Height") - .child(img("https://picsum.photos/800/400").w(px(180.))), + .justify_center() + .items_center() + .w_full() + .border_1() + .border_color(rgb(0xC0C0C0)) + .child("image with max width 100%") + .child(img("https://picsum.photos/800/400").max_w_full()), ), ) - .child( - div() - .flex() - .flex_col() - .justify_center() - .items_center() - .w_full() - .border_1() - .border_color(rgb(0xC0C0C0)) - .child("image with max width 100%") - .child(img("https://picsum.photos/800/400").max_w_full()), - ) } } diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index e7abb196c75ef2cd4a9376b10c253e54a89374e5..1fa7a8678f4794b50d245a02e210ea0c2d423ca3 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -47,7 +47,6 @@ impl Render for ImageGallery { div() .image_cache(self.image_cache.clone()) .id("main") - .font_family(".SystemUIFont") .text_color(gpui::black()) .bg(rgb(0xE9E9E9)) .overflow_y_scroll() @@ -102,7 +101,6 @@ impl Render for ImageGallery { .child(image_cache(simple_lru_cache("lru-cache", IMAGES_IN_GALLERY)).child( div() .id("main") - .font_family(".SystemUIFont") .bg(rgb(0xE9E9E9)) .text_color(gpui::black()) .overflow_y_scroll() diff --git a/crates/gpui/examples/image_loading.rs b/crates/gpui/examples/image_loading.rs index 2c4d6e9437191405129e52a3af17a6ac8bcc883d..399bd2634f9bfb363d3ff9614150f2082e824eca 100644 --- a/crates/gpui/examples/image_loading.rs +++ b/crates/gpui/examples/image_loading.rs @@ -2,9 +2,9 @@ use std::{path::Path, sync::Arc, time::Duration}; use gpui::{ Animation, AnimationExt, App, Application, Asset, AssetLogger, AssetSource, Bounds, Context, - Hsla, ImageAssetLoader, ImageCacheError, ImgResourceLoader, LOADING_DELAY, Length, Pixels, - RenderImage, Resource, SharedString, Window, WindowBounds, WindowOptions, black, div, img, - prelude::*, pulsating_between, px, red, size, + Hsla, ImageAssetLoader, ImageCacheError, ImgResourceLoader, LOADING_DELAY, Length, RenderImage, + Resource, SharedString, Window, WindowBounds, WindowOptions, black, div, img, prelude::*, + pulsating_between, px, red, size, }; struct Assets {} @@ -21,7 +21,7 @@ impl AssetSource for Assets { Ok(std::fs::read_dir(path)? .filter_map(|entry| { Some(SharedString::from( - entry.ok()?.path().to_string_lossy().to_string(), + entry.ok()?.path().to_string_lossy().into_owned(), )) }) .collect::>()) @@ -105,7 +105,7 @@ impl Render for ImageLoadingExample { div() .flex() .bg(gpui::white()) - .size(Length::Definite(Pixels(300.0).into())) + .size(Length::Definite(px(300.0).into())) .justify_center() .items_center() .child({ @@ -199,7 +199,7 @@ fn main() { let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( None, - size(px(300.), Pixels(300.)), + size(px(300.), px(300.)), cx, ))), ..Default::default() diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index b0f560e38d4896f889a30b5315a265c83065d068..37115feaa551a787562e7299c9d44bcc97b5fca3 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -137,14 +137,14 @@ impl TextInput { fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { if !self.selected_range.is_empty() { cx.write_to_clipboard(ClipboardItem::new_string( - (&self.content[self.selected_range.clone()]).to_string(), + self.content[self.selected_range.clone()].to_string(), )); } } fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { if !self.selected_range.is_empty() { cx.write_to_clipboard(ClipboardItem::new_string( - (&self.content[self.selected_range.clone()]).to_string(), + self.content[self.selected_range.clone()].to_string(), )); self.replace_text_in_range(None, "", window, cx) } @@ -446,7 +446,7 @@ impl Element for TextElement { let (display_text, text_color) = if content.is_empty() { (input.placeholder.clone(), hsla(0., 0., 0., 0.2)) } else { - (content.clone(), style.color) + (content, style.color) }; let run = TextRun { @@ -474,7 +474,7 @@ impl Element for TextElement { }, TextRun { len: display_text.len() - marked_range.end, - ..run.clone() + ..run }, ] .into_iter() @@ -549,10 +549,10 @@ impl Element for TextElement { line.paint(bounds.origin, window.line_height(), window, cx) .unwrap(); - if focus_handle.is_focused(window) { - if let Some(cursor) = prepaint.cursor.take() { - window.paint_quad(cursor); - } + if focus_handle.is_focused(window) + && let Some(cursor) = prepaint.cursor.take() + { + window.paint_quad(cursor); } self.input.update(cx, |input, _cx| { diff --git a/crates/gpui/examples/layer_shell.rs b/crates/gpui/examples/layer_shell.rs new file mode 100644 index 0000000000000000000000000000000000000000..51577b1b26491b8416a7df17ee310fd50dade8a3 --- /dev/null +++ b/crates/gpui/examples/layer_shell.rs @@ -0,0 +1,87 @@ +fn main() { + #[cfg(all(target_os = "linux", feature = "wayland"))] + example::main(); + + #[cfg(not(all(target_os = "linux", feature = "wayland")))] + panic!("This example requires the `wayland` feature and a linux system."); +} + +#[cfg(all(target_os = "linux", feature = "wayland"))] +mod example { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use gpui::{ + App, Application, Bounds, Context, FontWeight, Size, Window, WindowBackgroundAppearance, + WindowBounds, WindowKind, WindowOptions, div, layer_shell::*, point, prelude::*, px, rems, + rgba, white, + }; + + struct LayerShellExample; + + impl LayerShellExample { + fn new(cx: &mut Context) -> Self { + cx.spawn(async move |this, cx| { + loop { + let _ = this.update(cx, |_, cx| cx.notify()); + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + } + }) + .detach(); + + LayerShellExample + } + } + + impl Render for LayerShellExample { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let hours = (now / 3600) % 24; + let minutes = (now / 60) % 60; + let seconds = now % 60; + + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_size(rems(4.5)) + .font_weight(FontWeight::EXTRA_BOLD) + .text_color(white()) + .bg(rgba(0x0000044)) + .rounded_xl() + .child(format!("{:02}:{:02}:{:02}", hours, minutes, seconds)) + } + } + + pub fn main() { + Application::new().run(|cx: &mut App| { + cx.open_window( + WindowOptions { + titlebar: None, + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: Size::new(px(500.), px(200.)), + })), + app_id: Some("gpui-layer-shell-example".to_string()), + window_background: WindowBackgroundAppearance::Transparent, + kind: WindowKind::LayerShell(LayerShellOptions { + namespace: "gpui".to_string(), + anchor: Anchor::LEFT | Anchor::RIGHT | Anchor::BOTTOM, + margin: Some((px(0.), px(0.), px(40.), px(0.))), + keyboard_interactivity: KeyboardInteractivity::None, + ..Default::default() + }), + ..Default::default() + }, + |_, cx| cx.new(LayerShellExample::new), + ) + .unwrap(); + }); + } +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 634df29a4c0be14ac3efd97b39f6db70d95e383a..b6c01fc3cf3866d29e184ccf975e6796e7d07df7 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -1,10 +1,9 @@ -use std::{fs, path::PathBuf, time::Duration}; +use std::{fs, path::PathBuf}; use anyhow::Result; use gpui::{ App, Application, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task, - Timer, Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, - svg, + Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg, }; struct Assets { @@ -37,6 +36,7 @@ impl AssetSource for Assets { struct HelloWorld { _task: Option>, opacity: f32, + animating: bool, } impl HelloWorld { @@ -44,39 +44,29 @@ impl HelloWorld { Self { _task: None, opacity: 0.5, + animating: false, } } - fn change_opacity(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + fn start_animation(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { self.opacity = 0.0; + self.animating = true; cx.notify(); - - self._task = Some(cx.spawn_in(window, async move |view, cx| { - loop { - Timer::after(Duration::from_secs_f32(0.05)).await; - let mut stop = false; - let _ = cx.update(|_, cx| { - view.update(cx, |view, cx| { - if view.opacity >= 1.0 { - stop = true; - return; - } - - view.opacity += 0.1; - cx.notify(); - }) - }); - - if stop { - break; - } - } - })); } } impl Render for HelloWorld { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.animating { + self.opacity += 0.005; + if self.opacity >= 1.0 { + self.animating = false; + self.opacity = 1.0; + } else { + window.request_animation_frame(); + } + } + div() .flex() .flex_row() @@ -96,7 +86,7 @@ impl Render for HelloWorld { .child( div() .id("panel") - .on_click(cx.listener(Self::change_opacity)) + .on_click(cx.listener(Self::start_animation)) .absolute() .top_8() .left_8() @@ -150,7 +140,15 @@ impl Render for HelloWorld { .text_2xl() .size_8(), ) - .child("🎊✈️🎉🎈🎁🎂") + .child( + div() + .flex() + .children(["🎊", "✈️", "🎉", "🎈", "🎁", "🎂"].map(|emoji| { + div() + .child(emoji.to_string()) + .hover(|style| style.opacity(0.5)) + })), + ) .child(img("image/black-cat-typing.gif").size_12()), ), ) diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 668aed23772d32a84a81cc0648d6b60dd05e21cf..e7055cbdbbd781523edbc851d143bf56a551728f 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -328,7 +328,6 @@ impl Render for PaintingViewer { let dashed = self.dashed; div() - .font_family(".SystemUIFont") .bg(gpui::white()) .size_full() .p_4() diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index 1166bb279541c80eb8686b59c85724b4068895ed..3cb95897b668eeb142d6b84f13af83b1ad3ff5f4 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -132,11 +132,11 @@ impl RenderOnce for Specimen { let mut line_height = global_style.line_height; if let Some(style_override) = style_override { - font_size = style_override.font_size.to_pixels(rem_size).0; + font_size = style_override.font_size.to_pixels(rem_size).into(); line_height = match style_override.line_height { DefiniteLength::Absolute(absolute_len) => match absolute_len { - AbsoluteLength::Rems(absolute_len) => absolute_len.to_pixels(rem_size).0, - AbsoluteLength::Pixels(absolute_len) => absolute_len.0, + AbsoluteLength::Rems(absolute_len) => absolute_len.to_pixels(rem_size).into(), + AbsoluteLength::Pixels(absolute_len) => absolute_len.into(), }, DefiniteLength::Fraction(value) => value, }; @@ -155,7 +155,7 @@ impl RenderOnce for Specimen { .text_size(px(font_size * scale)) .line_height(relative(line_height)) .p(px(10.0)) - .child(self.string.clone()) + .child(self.string) } } diff --git a/crates/gpui/examples/text_layout.rs b/crates/gpui/examples/text_layout.rs index c4cbcd4e5edc142dde58a1dd5d9b61a1daee0c3a..8929955ba824c36c90951ece2cf9ba710259ddac 100644 --- a/crates/gpui/examples/text_layout.rs +++ b/crates/gpui/examples/text_layout.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, - size, + App, Application, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds, + WindowOptions, div, prelude::*, px, size, }; struct HelloWorld {} @@ -71,6 +71,12 @@ impl Render for HelloWorld { .child("100%"), ), ) + .child(div().flex().gap_2().justify_between().child( + StyledText::new("ABCD").with_highlights([ + (0..1, FontWeight::EXTRA_BOLD.into()), + (2..3, FontStyle::Italic.into()), + ]), + )) } } diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index 4c6e5e2ac89bac4f805aa5ed45733035a3f0fb7e..18372ea9e137cc3cfb11f3df59ce698660ad06be 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/crates/gpui/examples/text_wrapper.rs @@ -7,7 +7,11 @@ struct HelloWorld {} impl Render for HelloWorld { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let text = "The longest word 你好世界这段是中文,こんにちはこの段落は日本語です in any of the major English language dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that refers to a lung disease contracted from the inhalation of very fine silica particles, specifically from a volcano; medically, it is the same as silicosis."; + let text = "The longest word 你好世界这段是中文,こんにちはこの段落は日本語です in any of the major \ + English language dictionaries is pneumonoultramicroscopicsilicovolcanoconiosis, a word that \ + refers to a lung disease contracted from the inhalation of very fine silica particles, \ + a url https://github.com/zed-industries/zed/pull/35724?query=foo&bar=2, \ + specifically from a volcano; medically, it is the same as silicosis."; div() .id("page") .size_full() diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 30f3697b223d6d85a9db573eb3659e9689af60a5..4445f24e4ec0f2809109964fd34610cad1299e90 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -152,6 +152,36 @@ impl Render for WindowDemo { ) .unwrap(); })) + .child(button("Unresizable", move |_, cx| { + cx.open_window( + WindowOptions { + is_resizable: false, + window_bounds: Some(window_bounds), + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Unminimizable", move |_, cx| { + cx.open_window( + WindowOptions { + is_minimizable: false, + window_bounds: Some(window_bounds), + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + }) + }, + ) + .unwrap(); + })) .child(button("Hide Application", |window, cx| { cx.hide(); diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 0f0bb8ac288d7117867df9b12a104e4272903378..ca6cd535d67aa8b2e700b2d0bc632056e928e0e7 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -62,6 +62,8 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, + ..Default::default() } } diff --git a/crates/gpui/resources/windows/gpui.manifest.xml b/crates/gpui/resources/windows/gpui.manifest.xml index 5a69b434865166dc5f85a9558d28bea6cd646ffe..c3a99d23ff9e60e3604fe0aa8a203345e9c355be 100644 --- a/crates/gpui/resources/windows/gpui.manifest.xml +++ b/crates/gpui/resources/windows/gpui.manifest.xml @@ -1,16 +1,32 @@ - - - - true + + + + + + + + + + + + + + + + + true/pm PerMonitorV2 - - + + - + version='6.0.0.0' + processorArchitecture='*' + publicKeyToken='6595b64144ccf1df' + /> diff --git a/crates/gpui/src/_ownership_and_data_flow.rs b/crates/gpui/src/_ownership_and_data_flow.rs new file mode 100644 index 0000000000000000000000000000000000000000..9bb8bf66956a101cc8d9db170e34512628a6b0be --- /dev/null +++ b/crates/gpui/src/_ownership_and_data_flow.rs @@ -0,0 +1,140 @@ +//! In GPUI, every model or view in the application is actually owned by a single top-level object called the `App`. When a new entity or view is created (referred to collectively as _entities_), the application is given ownership of their state to enable their participation in a variety of app services and interaction with other entities. +//! +//! To illustrate, consider the trivial app below. We start the app by calling `run` with a callback, which is passed a reference to the `App` that owns all the state for the application. This `App` is our gateway to all application-level services, such as opening windows, presenting dialogs, etc. It also has an `insert_entity` method, which is called below to create an entity and give ownership of it to the application. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Entity}; +//! # struct Counter { +//! # count: usize, +//! # } +//! Application::new().run(|cx: &mut App| { +//! let _counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! // ... +//! }); +//! ``` +//! +//! The call to `new_entity` returns an _entity handle_, which carries a type parameter based on the type of object it references. By itself, this `Entity` handle doesn't provide access to the entity's state. It's merely an inert identifier plus a compile-time type tag, and it maintains a reference counted pointer to the underlying `Counter` object that is owned by the app. +//! +//! Much like an `Rc` from the Rust standard library, this reference count is incremented when the handle is cloned and decremented when it is dropped to enable shared ownership over the underlying model, but unlike an `Rc` it only provides access to the model's state when a reference to an `App` is available. The handle doesn't truly _own_ the state, but it can be used to access the state from its true owner, the `App`. Stripping away some of the setup code for brevity: +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Context, Entity}; +//! # struct Counter { +//! # count: usize, +//! # } +//! Application::new().run(|cx: &mut App| { +//! let counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! // Call `update` to access the model's state. +//! counter.update(cx, |counter: &mut Counter, _cx: &mut Context| { +//! counter.count += 1; +//! }); +//! }); +//! ``` +//! +//! To update the counter, we call `update` on the handle, passing the context reference and a callback. The callback is yielded a mutable reference to the counter, which can be used to manipulate state. +//! +//! The callback is also provided a second `Context` reference. This reference is similar to the `App` reference provided to the `run` callback. A `Context` is actually a wrapper around the `App`, including some additional data to indicate which particular entity it is tied to; in this case the counter. +//! +//! In addition to the application-level services provided by `App`, a `Context` provides access to entity-level services. For example, it can be used it to inform observers of this entity that its state has changed. Let's add that to our example, by calling `cx.notify()`. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Entity}; +//! # struct Counter { +//! # count: usize, +//! # } +//! Application::new().run(|cx: &mut App| { +//! let counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! counter.update(cx, |counter, cx| { +//! counter.count += 1; +//! cx.notify(); // Notify observers +//! }); +//! }); +//! ``` +//! +//! Next, these notifications need to be observed and reacted to. Before updating the counter, we'll construct a second counter that observes it. Whenever the first counter changes, twice its count is assigned to the second counter. Note how `observe` is called on the `Context` belonging to our second counter to arrange for it to be notified whenever the first counter notifies. The call to `observe` returns a `Subscription`, which is `detach`ed to preserve this behavior for as long as both counters exist. We could also store this subscription and drop it at a time of our choosing to cancel this behavior. +//! +//! The `observe` callback is passed a mutable reference to the observer and a _handle_ to the observed counter, whose state we access with the `read` method. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Entity, prelude::*}; +//! # struct Counter { +//! # count: usize, +//! # } +//! Application::new().run(|cx: &mut App| { +//! let first_counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! +//! let second_counter = cx.new(|cx: &mut Context| { +//! // Note we can set up the callback before the Counter is even created! +//! cx.observe( +//! &first_counter, +//! |second: &mut Counter, first: Entity, cx| { +//! second.count = first.read(cx).count * 2; +//! }, +//! ) +//! .detach(); +//! +//! Counter { count: 0 } +//! }); +//! +//! first_counter.update(cx, |counter, cx| { +//! counter.count += 1; +//! cx.notify(); +//! }); +//! +//! assert_eq!(second_counter.read(cx).count, 2); +//! }); +//! ``` +//! +//! After updating the first counter, it can be noted that the observing counter's state is maintained according to our subscription. +//! +//! In addition to `observe` and `notify`, which indicate that an entity's state has changed, GPUI also offers `subscribe` and `emit`, which enables entities to emit typed events. To opt into this system, the emitting object must implement the `EventEmitter` trait. +//! +//! Let's introduce a new event type called `CounterChangeEvent`, then indicate that `Counter` can emit this type of event: +//! +//! ```no_run +//! use gpui::EventEmitter; +//! # struct Counter { +//! # count: usize, +//! # } +//! struct CounterChangeEvent { +//! increment: usize, +//! } +//! +//! impl EventEmitter for Counter {} +//! ``` +//! +//! Next, the example should be updated, replacing the observation with a subscription. Whenever the counter is incremented, a `Change` event is emitted to indicate the magnitude of the increase. +//! +//! ```no_run +//! # use gpui::{App, AppContext, Application, Context, Entity, EventEmitter}; +//! # struct Counter { +//! # count: usize, +//! # } +//! # struct CounterChangeEvent { +//! # increment: usize, +//! # } +//! # impl EventEmitter for Counter {} +//! Application::new().run(|cx: &mut App| { +//! let first_counter: Entity = cx.new(|_cx| Counter { count: 0 }); +//! +//! let second_counter = cx.new(|cx: &mut Context| { +//! // Note we can set up the callback before the Counter is even created! +//! cx.subscribe(&first_counter, |second: &mut Counter, _first: Entity, event, _cx| { +//! second.count += event.increment * 2; +//! }) +//! .detach(); +//! +//! Counter { +//! count: first_counter.read(cx).count * 2, +//! } +//! }); +//! +//! first_counter.update(cx, |first, cx| { +//! first.count += 2; +//! cx.emit(CounterChangeEvent { increment: 2 }); +//! cx.notify(); +//! }); +//! +//! assert_eq!(second_counter.read(cx).count, 4); +//! }); +//! ``` diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index b179076cd5f0da826ca0d5da5e2a5a41cbb5e806..38e94aa356dcb7ea1f0f988d1407471bfc0d3a84 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -13,6 +13,7 @@ use std::{ /// For example: /// /// ``` +/// use gpui::actions; /// actions!(editor, [MoveUp, MoveDown, MoveLeft, MoveRight, Newline]); /// ``` /// @@ -45,6 +46,7 @@ macro_rules! actions { /// struct action for each listed action name in the given namespace. /// /// ``` +/// use gpui::actions; /// actions!(editor, [MoveUp, MoveDown, MoveLeft, MoveRight, Newline]); /// ``` /// @@ -55,6 +57,7 @@ macro_rules! actions { /// More complex data types can also be actions, by using the derive macro for `Action`: /// /// ``` +/// use gpui::Action; /// #[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, Action)] /// #[action(namespace = editor)] /// pub struct SelectNext { @@ -73,18 +76,18 @@ macro_rules! actions { /// - `name = "ActionName"` overrides the action's name. This must not contain `::`. /// /// - `no_json` causes the `build` method to always error and `action_json_schema` to return `None`, -/// and allows actions not implement `serde::Serialize` and `schemars::JsonSchema`. +/// and allows actions not implement `serde::Serialize` and `schemars::JsonSchema`. /// /// - `no_register` skips registering the action. This is useful for implementing the `Action` trait -/// while not supporting invocation by name or JSON deserialization. +/// while not supporting invocation by name or JSON deserialization. /// /// - `deprecated_aliases = ["editor::SomeAction"]` specifies deprecated old names for the action. -/// These action names should *not* correspond to any actions that are registered. These old names -/// can then still be used to refer to invoke this action. In Zed, the keymap JSON schema will -/// accept these old names and provide warnings. +/// These action names should *not* correspond to any actions that are registered. These old names +/// can then still be used to refer to invoke this action. In Zed, the keymap JSON schema will +/// accept these old names and provide warnings. /// /// - `deprecated = "Message about why this action is deprecation"` specifies a deprecation message. -/// In Zed, the keymap JSON schema will cause this to be displayed as a warning. +/// In Zed, the keymap JSON schema will cause this to be displayed as a warning. /// /// # Manual Implementation /// @@ -93,14 +96,22 @@ macro_rules! actions { /// `main`. /// /// ``` -/// #[derive(gpui::private::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone)] +/// use gpui::{SharedString, register_action}; +/// #[derive(Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)] /// pub struct Paste { /// pub content: SharedString, /// } /// /// impl gpui::Action for Paste { -/// ///... +/// # fn boxed_clone(&self) -> Box { unimplemented!()} +/// # fn partial_eq(&self, other: &dyn gpui::Action) -> bool { unimplemented!() } +/// # fn name(&self) -> &'static str { "Paste" } +/// # fn name_for_type() -> &'static str { "Paste" } +/// # fn build(value: serde_json::Value) -> anyhow::Result> { +/// # unimplemented!() +/// # } /// } +/// /// register_action!(Paste); /// ``` pub trait Action: Any + Send { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index e1df6d0be4d7122b6ac8108e4a59774bcfc3d016..d4bd7798187a5b7a358106965d9e41fd85efeffe 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -7,7 +7,7 @@ use std::{ path::{Path, PathBuf}, rc::{Rc, Weak}, sync::{Arc, atomic::Ordering::SeqCst}, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{Context as _, Result, anyhow}; @@ -17,6 +17,7 @@ use futures::{ channel::oneshot, future::{LocalBoxFuture, Shared}, }; +use itertools::Itertools; use parking_lot::RwLock; use slotmap::SlotMap; @@ -37,10 +38,10 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptButton, PromptHandle, - PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, - SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, - WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, + PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, + Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, + TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -237,6 +238,300 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +#[doc(hidden)] +#[derive(Clone, PartialEq, Eq)] +pub struct SystemWindowTab { + pub id: WindowId, + pub title: SharedString, + pub handle: AnyWindowHandle, + pub last_active_at: Instant, +} + +impl SystemWindowTab { + /// Create a new instance of the window tab. + pub fn new(title: SharedString, handle: AnyWindowHandle) -> Self { + Self { + id: handle.id, + title, + handle, + last_active_at: Instant::now(), + } + } +} + +/// A controller for managing window tabs. +#[derive(Default)] +pub struct SystemWindowTabController { + visible: Option, + tab_groups: FxHashMap>, +} + +impl Global for SystemWindowTabController {} + +impl SystemWindowTabController { + /// Create a new instance of the window tab controller. + pub fn new() -> Self { + Self { + visible: None, + tab_groups: FxHashMap::default(), + } + } + + /// Initialize the global window tab controller. + pub fn init(cx: &mut App) { + cx.set_global(SystemWindowTabController::new()); + } + + /// Get all tab groups. + pub fn tab_groups(&self) -> &FxHashMap> { + &self.tab_groups + } + + /// Get the next tab group window handle. + pub fn get_next_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let next_idx = (idx + 1) % group_ids.len(); + + controller + .tab_groups + .get(group_ids[next_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get the previous tab group window handle. + pub fn get_prev_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> { + let controller = cx.global::(); + let current_group = controller + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); + + let current_group = current_group?; + let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); + let idx = group_ids.iter().position(|g| *g == current_group)?; + let prev_idx = if idx == 0 { + group_ids.len() - 1 + } else { + idx - 1 + }; + + controller + .tab_groups + .get(group_ids[prev_idx]) + .and_then(|tabs| { + tabs.iter() + .max_by_key(|tab| tab.last_active_at) + .or_else(|| tabs.first()) + .map(|tab| &tab.handle) + }) + } + + /// Get all tabs in the same window. + pub fn tabs(&self, id: WindowId) -> Option<&Vec> { + let tab_group = self + .tab_groups + .iter() + .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?; + + self.tab_groups.get(&tab_group) + } + + /// Initialize the visibility of the system window tab controller. + pub fn init_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + if controller.visible.is_none() { + controller.visible = Some(visible); + } + } + + /// Get the visibility of the system window tab controller. + pub fn is_visible(&self) -> bool { + self.visible.unwrap_or(false) + } + + /// Set the visibility of the system window tab controller. + pub fn set_visible(cx: &mut App, visible: bool) { + let mut controller = cx.global_mut::(); + controller.visible = Some(visible); + } + + /// Update the last active of a window. + pub fn update_last_active(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.last_active_at = Instant::now(); + } + } + } + } + + /// Update the position of a tab within its group. + pub fn update_tab_position(cx: &mut App, id: WindowId, ix: usize) { + let mut controller = cx.global_mut::(); + for (_, windows) in controller.tab_groups.iter_mut() { + if let Some(current_pos) = windows.iter().position(|tab| tab.id == id) { + if ix < windows.len() && current_pos != ix { + let window_tab = windows.remove(current_pos); + windows.insert(ix, window_tab); + } + break; + } + } + } + + /// Update the title of a tab. + pub fn update_tab_title(cx: &mut App, id: WindowId, title: SharedString) { + let controller = cx.global::(); + let tab = controller + .tab_groups + .values() + .flat_map(|windows| windows.iter()) + .find(|tab| tab.id == id); + + if tab.map_or(true, |t| t.title == title) { + return; + } + + let mut controller = cx.global_mut::(); + for windows in controller.tab_groups.values_mut() { + for tab in windows.iter_mut() { + if tab.id == id { + tab.title = title; + return; + } + } + } + } + + /// Insert a tab into a tab group. + pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { + let mut controller = cx.global_mut::(); + let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + return; + }; + + let mut expected_tab_ids: Vec<_> = tabs + .iter() + .filter(|tab| tab.id != id) + .map(|tab| tab.id) + .sorted() + .collect(); + + let mut tab_group_id = None; + for (group_id, group_tabs) in &controller.tab_groups { + let tab_ids: Vec<_> = group_tabs.iter().map(|tab| tab.id).sorted().collect(); + if tab_ids == expected_tab_ids { + tab_group_id = Some(*group_id); + break; + } + } + + if let Some(tab_group_id) = tab_group_id { + if let Some(tabs) = controller.tab_groups.get_mut(&tab_group_id) { + tabs.push(tab); + } + } else { + let new_group_id = controller.tab_groups.len(); + controller.tab_groups.insert(new_group_id, tabs); + } + } + + /// Remove a tab from a tab group. + pub fn remove_tab(cx: &mut App, id: WindowId) -> Option { + let mut controller = cx.global_mut::(); + let mut removed_tab = None; + + controller.tab_groups.retain(|_, tabs| { + if let Some(pos) = tabs.iter().position(|tab| tab.id == id) { + removed_tab = Some(tabs.remove(pos)); + } + !tabs.is_empty() + }); + + removed_tab + } + + /// Move a tab to a new tab group. + pub fn move_tab_to_new_window(cx: &mut App, id: WindowId) { + let mut removed_tab = Self::remove_tab(cx, id); + let mut controller = cx.global_mut::(); + + if let Some(tab) = removed_tab { + let new_group_id = controller.tab_groups.keys().max().map_or(0, |k| k + 1); + controller.tab_groups.insert(new_group_id, vec![tab]); + } + } + + /// Merge all tab groups into a single group. + pub fn merge_all_windows(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(initial_tabs) = controller.tabs(id) else { + return; + }; + + let mut all_tabs = initial_tabs.clone(); + for tabs in controller.tab_groups.values() { + all_tabs.extend( + tabs.iter() + .filter(|tab| !initial_tabs.contains(tab)) + .cloned(), + ); + } + + controller.tab_groups.clear(); + controller.tab_groups.insert(0, all_tabs); + } + + /// Selects the next tab in the tab group in the trailing direction. + pub fn select_next_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let next_index = (current_index + 1) % tabs.len(); + + let _ = &tabs[next_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } + + /// Selects the previous tab in the tab group in the leading direction. + pub fn select_previous_tab(cx: &mut App, id: WindowId) { + let mut controller = cx.global_mut::(); + let Some(tabs) = controller.tabs(id) else { + return; + }; + + let current_index = tabs.iter().position(|tab| tab.id == id).unwrap(); + let previous_index = if current_index == 0 { + tabs.len() - 1 + } else { + current_index - 1 + }; + + let _ = &tabs[previous_index].handle.update(cx, |_, window, _| { + window.activate_window(); + }); + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. @@ -258,11 +553,12 @@ pub struct App { pub(crate) entities: EntityMap, pub(crate) window_update_stack: Vec, pub(crate) new_entity_observers: SubscriberSet, - pub(crate) windows: SlotMap>, + pub(crate) windows: SlotMap>>, pub(crate) window_handles: FxHashMap, pub(crate) focus_handles: Arc, pub(crate) keymap: Rc>, pub(crate) keyboard_layout: Box, + pub(crate) keyboard_mapper: Rc, pub(crate) global_action_listeners: FxHashMap>>, pending_effects: VecDeque, @@ -312,6 +608,7 @@ impl App { let text_system = Arc::new(TextSystem::new(platform.text_system())); let entities = EntityMap::new(); let keyboard_layout = platform.keyboard_layout(); + let keyboard_mapper = platform.keyboard_mapper(); let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(App { @@ -337,6 +634,7 @@ impl App { focus_handles: Arc::new(RwLock::new(SlotMap::with_key())), keymap: Rc::new(RefCell::new(Keymap::default())), keyboard_layout, + keyboard_mapper, global_action_listeners: FxHashMap::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), @@ -368,7 +666,8 @@ impl App { }), }); - init_app_menus(platform.as_ref(), &mut app.borrow_mut()); + init_app_menus(platform.as_ref(), &app.borrow()); + SystemWindowTabController::init(&mut app.borrow_mut()); platform.on_keyboard_layout_change(Box::new({ let app = Rc::downgrade(&app); @@ -376,6 +675,7 @@ impl App { if let Some(app) = app.upgrade() { let cx = &mut app.borrow_mut(); cx.keyboard_layout = cx.platform.keyboard_layout(); + cx.keyboard_mapper = cx.platform.keyboard_mapper(); cx.keyboard_layout_observers .clone() .retain(&(), move |callback| (callback)(cx)); @@ -424,6 +724,11 @@ impl App { self.keyboard_layout.as_ref() } + /// Get the current keyboard mapper. + pub fn keyboard_mapper(&self) -> &Rc { + &self.keyboard_mapper + } + /// Invokes a handler when the current keyboard layout changes pub fn on_keyboard_layout_change(&self, mut callback: F) -> Subscription where @@ -650,8 +955,16 @@ impl App { cx.window_update_stack.pop(); window.root.replace(root_view.into()); window.defer(cx, |window: &mut Window, cx| window.appearance_changed(cx)); + + // allow a window to draw at least once before returning + // this didn't cause any issues on non windows platforms as it seems we always won the race to on_request_frame + // on windows we quite frequently lose the race and return a window that has never rendered, which leads to a crash + // where DispatchTree::root_node_id asserts on empty nodes + let clear = window.draw(cx); + clear.clear(); + cx.window_handles.insert(id, window.handle); - cx.windows.get_mut(id).unwrap().replace(window); + cx.windows.get_mut(id).unwrap().replace(Box::new(window)); Ok(handle) } Err(e) => { @@ -926,7 +1239,7 @@ impl App { .windows .values() .filter_map(|window| { - let window = window.as_ref()?; + let window = window.as_deref()?; window.invalidator.is_dirty().then_some(window.handle) }) .collect::>() @@ -1007,7 +1320,7 @@ impl App { fn apply_refresh_effect(&mut self) { for window in self.windows.values_mut() { - if let Some(window) = window.as_mut() { + if let Some(window) = window.as_deref_mut() { window.refreshing = true; window.invalidator.set_dirty(true); } @@ -1050,12 +1363,7 @@ impl App { F: FnOnce(AnyView, &mut Window, &mut App) -> T, { self.update(|cx| { - let mut window = cx - .windows - .get_mut(id) - .context("window not found")? - .take() - .context("window not found")?; + let mut window = cx.windows.get_mut(id)?.take()?; let root_view = window.root.clone().unwrap(); @@ -1072,15 +1380,14 @@ impl App { true }); } else { - cx.windows - .get_mut(id) - .context("window not found")? - .replace(window); + cx.windows.get_mut(id)?.replace(window); } - Ok(result) + Some(result) }) + .context("window not found") } + /// Creates an `AsyncApp`, which can be cloned and has a static lifetime /// so it can be held across `await` points. pub fn to_async(&self) -> AsyncApp { @@ -1310,7 +1617,7 @@ impl App { T: 'static, { let window_handle = window.handle; - self.observe_release(&handle, move |entity, cx| { + self.observe_release(handle, move |entity, cx| { let _ = window_handle.update(cx, |_, window, cx| on_release(entity, window, cx)); }) } @@ -1332,7 +1639,7 @@ impl App { } inner( - &mut self.keystroke_observers, + &self.keystroke_observers, Box::new(move |event, window, cx| { f(event, window, cx); true @@ -1358,7 +1665,7 @@ impl App { } inner( - &mut self.keystroke_interceptors, + &self.keystroke_interceptors, Box::new(move |event, window, cx| { f(event, window, cx); true @@ -1516,12 +1823,11 @@ impl App { /// the bindings in the element tree, and any global action listeners. pub fn is_action_available(&mut self, action: &dyn Action) -> bool { let mut action_available = false; - if let Some(window) = self.active_window() { - if let Ok(window_action_available) = + if let Some(window) = self.active_window() + && let Ok(window_action_available) = window.update(self, |_, window, cx| window.is_action_available(action, cx)) - { - action_available = window_action_available; - } + { + action_available = window_action_available; } action_available @@ -1606,27 +1912,26 @@ impl App { .insert(action.as_any().type_id(), global_listeners); } - if self.propagate_event { - if let Some(mut global_listeners) = self + if self.propagate_event + && let Some(mut global_listeners) = self .global_action_listeners .remove(&action.as_any().type_id()) - { - for listener in global_listeners.iter().rev() { - listener(action.as_any(), DispatchPhase::Bubble, self); - if !self.propagate_event { - break; - } + { + for listener in global_listeners.iter().rev() { + listener(action.as_any(), DispatchPhase::Bubble, self); + if !self.propagate_event { + break; } + } - global_listeners.extend( - self.global_action_listeners - .remove(&action.as_any().type_id()) - .unwrap_or_default(), - ); - + global_listeners.extend( self.global_action_listeners - .insert(action.as_any().type_id(), global_listeners); - } + .remove(&action.as_any().type_id()) + .unwrap_or_default(), + ); + + self.global_action_listeners + .insert(action.as_any().type_id(), global_listeners); } } @@ -1709,8 +2014,8 @@ impl App { .unwrap_or_else(|| { is_first = true; let future = A::load(source.clone(), self); - let task = self.background_executor().spawn(future).shared(); - task + + self.background_executor().spawn(future).shared() }); self.loading_assets.insert(asset_id, Box::new(task.clone())); @@ -1894,7 +2199,7 @@ impl AppContext for App { .windows .get(window.id) .context("window not found")? - .as_ref() + .as_deref() .expect("attempted to read a window that is already on the stack"); let root_view = window.root.clone().unwrap(); @@ -1917,7 +2222,7 @@ impl AppContext for App { G: Global, { let mut g = self.global::(); - callback(&g, self) + callback(g, self) } } @@ -2007,7 +2312,7 @@ pub struct AnyDrag { } /// Contains state associated with a tooltip. You'll only need this struct if you're implementing -/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip]. +/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip](crate::Interactivity::tooltip). #[derive(Clone)] pub struct AnyTooltip { /// The view used to display the tooltip @@ -2093,6 +2398,20 @@ impl<'a, T: 'static> std::borrow::BorrowMut for GpuiBorrow<'a, T> { } } +impl<'a, T: 'static> std::ops::Deref for GpuiBorrow<'a, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.inner.as_ref().unwrap() + } +} + +impl<'a, T: 'static> std::ops::DerefMut for GpuiBorrow<'a, T> { + fn deref_mut(&mut self) -> &mut T { + self.inner.as_mut().unwrap() + } +} + impl<'a, T> Drop for GpuiBorrow<'a, T> { fn drop(&mut self) { let lease = self.inner.take().unwrap(); diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index d9d21c024461cab68d62d685a40b61c9c74d46dd..381541d4b11377b988dd30e03155855c7ba25aed 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -176,7 +176,7 @@ impl AsyncApp { lock.open_window(options, build_root_view) } - /// Schedule a future to be polled in the background. + /// Schedule a future to be polled in the foreground. #[track_caller] pub fn spawn(&self, f: AsyncFn) -> Task where @@ -218,7 +218,24 @@ impl AsyncApp { Some(read(app.try_global()?, &app)) } - /// A convenience method for [App::update_global] + /// Reads the global state of the specified type, passing it to the given callback. + /// A default value is assigned if a global of this type has not yet been assigned. + /// + /// # Errors + /// If the app has ben dropped this returns an error. + pub fn try_read_default_global( + &self, + read: impl FnOnce(&G, &App) -> R, + ) -> Result { + let app = self.app.upgrade().context("app was released")?; + let mut app = app.borrow_mut(); + app.update(|cx| { + cx.default_global::(); + }); + Ok(read(app.try_global().context("app was released")?, &app)) + } + + /// A convenience method for [`App::update_global`](BorrowAppContext::update_global) /// for updating the global state of the specified type. pub fn update_global( &self, @@ -293,7 +310,7 @@ impl AsyncWindowContext { .update(self, |_, window, cx| read(cx.global(), window, cx)) } - /// A convenience method for [`App::update_global`]. + /// A convenience method for [`App::update_global`](BorrowAppContext::update_global). /// for updating the global state of the specified type. pub fn update_global( &mut self, @@ -465,7 +482,7 @@ impl VisualContext for AsyncWindowContext { V: Focusable, { self.window.update(self, |_, window, cx| { - view.read(cx).focus_handle(cx).clone().focus(window); + view.read(cx).focus_handle(cx).focus(window); }) } } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 68c41592b3872addef6381d9f5e8a3f611611bd0..41d6cac82b7c179040d61ddfd22b003c143a5fb9 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -4,12 +4,12 @@ use crate::{ Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, }; use anyhow::Result; -use derive_more::{Deref, DerefMut}; use futures::FutureExt; use std::{ any::{Any, TypeId}, borrow::{Borrow, BorrowMut}, future::Future, + ops, sync::Arc, }; use util::Deferred; @@ -17,14 +17,25 @@ use util::Deferred; use super::{App, AsyncWindowContext, Entity, KeystrokeEvent}; /// The app context, with specialized behavior for the given entity. -#[derive(Deref, DerefMut)] pub struct Context<'a, T> { - #[deref] - #[deref_mut] app: &'a mut App, entity_state: WeakEntity, } +impl<'a, T> ops::Deref for Context<'a, T> { + type Target = App; + + fn deref(&self) -> &Self::Target { + self.app + } +} + +impl<'a, T> ops::DerefMut for Context<'a, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.app + } +} + impl<'a, T: 'static> Context<'a, T> { pub(crate) fn new_context(app: &'a mut App, entity_state: WeakEntity) -> Self { Self { app, entity_state } @@ -69,6 +80,20 @@ impl<'a, T: 'static> Context<'a, T> { }) } + /// Observe changes to ourselves + pub fn observe_self( + &mut self, + mut on_event: impl FnMut(&mut T, &mut Context) + 'static, + ) -> Subscription + where + T: 'static, + { + let this = self.entity(); + self.app.observe(&this, move |this, cx| { + this.update(cx, |this, cx| on_event(this, cx)) + }) + } + /// Subscribe to an event type from another entity pub fn subscribe( &mut self, @@ -472,7 +497,7 @@ impl<'a, T: 'static> Context<'a, T> { let view = self.weak_entity(); inner( - &mut self.keystroke_observers, + &self.keystroke_observers, Box::new(move |event, window, cx| { if let Some(view) = view.upgrade() { view.update(cx, |view, cx| f(view, event, window, cx)); @@ -610,16 +635,16 @@ impl<'a, T: 'static> Context<'a, T> { let (subscription, activate) = window.new_focus_listener(Box::new(move |event, window, cx| { view.update(cx, |view, cx| { - if let Some(blurred_id) = event.previous_focus_path.last().copied() { - if event.is_focus_out(focus_id) { - let event = FocusOutEvent { - blurred: WeakFocusHandle { - id: blurred_id, - handles: Arc::downgrade(&cx.focus_handles), - }, - }; - listener(view, event, window, cx) - } + if let Some(blurred_id) = event.previous_focus_path.last().copied() + && event.is_focus_out(focus_id) + { + let event = FocusOutEvent { + blurred: WeakFocusHandle { + id: blurred_id, + handles: Arc::downgrade(&cx.focus_handles), + }, + }; + listener(view, event, window, cx) } }) .is_ok() diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index fccb417caa70c7526a0f15a307d74caeabcdab77..bea98cb06a5f80fc8141a52bc47f48e8734b40c9 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -231,14 +231,15 @@ impl AnyEntity { Self { entity_id: id, entity_type, - entity_map: entity_map.clone(), #[cfg(any(test, feature = "leak-detection"))] handle_id: entity_map + .clone() .upgrade() .unwrap() .write() .leak_detector .handle_created(id), + entity_map, } } @@ -377,11 +378,9 @@ pub struct Entity { #[deref] #[deref_mut] pub(crate) any_entity: AnyEntity, - pub(crate) entity_type: PhantomData, + pub(crate) entity_type: PhantomData T>, } -unsafe impl Send for Entity {} -unsafe impl Sync for Entity {} impl Sealed for Entity {} impl Entity { @@ -656,21 +655,18 @@ pub struct WeakEntity { #[deref] #[deref_mut] any_entity: AnyWeakEntity, - entity_type: PhantomData, + entity_type: PhantomData T>, } impl std::fmt::Debug for WeakEntity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct(&type_name::()) + f.debug_struct(type_name::()) .field("entity_id", &self.any_entity.entity_id) .field("entity_type", &type_name::()) .finish() } } -unsafe impl Send for WeakEntity {} -unsafe impl Sync for WeakEntity {} - impl Clone for WeakEntity { fn clone(&self) -> Self { Self { @@ -786,7 +782,7 @@ impl PartialOrd for WeakEntity { #[cfg(any(test, feature = "leak-detection"))] static LEAK_BACKTRACE: std::sync::LazyLock = - std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").map_or(false, |b| !b.is_empty())); + std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").is_ok_and(|b| !b.is_empty())); #[cfg(any(test, feature = "leak-detection"))] #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index a96c24432a8851cb8b7dbadb3f7e794971dfca0b..d974823396d9f0d546a6b035f47b569145eb021b 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -134,7 +134,7 @@ impl TestAppContext { app: App::new_app(platform.clone(), asset_source, http_client), background_executor, foreground_executor, - dispatcher: dispatcher.clone(), + dispatcher, test_platform: platform, text_system, fn_name, @@ -144,7 +144,7 @@ impl TestAppContext { /// Create a single TestAppContext, for non-multi-client tests pub fn single() -> Self { - let dispatcher = TestDispatcher::new(StdRng::from_entropy()); + let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); Self::build(dispatcher, None) } @@ -192,6 +192,7 @@ impl TestAppContext { &self.foreground_executor } + #[expect(clippy::wrong_self_convention)] fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut cx = self.app.borrow_mut(); cx.new(build_entity) @@ -219,7 +220,7 @@ impl TestAppContext { let mut cx = self.app.borrow_mut(); // Some tests rely on the window size matching the bounds of the test display - let bounds = Bounds::maximized(None, &mut cx); + let bounds = Bounds::maximized(None, &cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), @@ -233,7 +234,7 @@ impl TestAppContext { /// Adds a new window with no content. pub fn add_empty_window(&mut self) -> &mut VisualTestContext { let mut cx = self.app.borrow_mut(); - let bounds = Bounds::maximized(None, &mut cx); + let bounds = Bounds::maximized(None, &cx); let window = cx .open_window( WindowOptions { @@ -244,7 +245,7 @@ impl TestAppContext { ) .unwrap(); drop(cx); - let cx = VisualTestContext::from_window(*window.deref(), self).as_mut(); + let cx = VisualTestContext::from_window(*window.deref(), self).into_mut(); cx.run_until_parked(); cx } @@ -261,7 +262,7 @@ impl TestAppContext { V: 'static + Render, { let mut cx = self.app.borrow_mut(); - let bounds = Bounds::maximized(None, &mut cx); + let bounds = Bounds::maximized(None, &cx); let window = cx .open_window( WindowOptions { @@ -273,7 +274,7 @@ impl TestAppContext { .unwrap(); drop(cx); let view = window.root(self).unwrap(); - let cx = VisualTestContext::from_window(*window.deref(), self).as_mut(); + let cx = VisualTestContext::from_window(*window.deref(), self).into_mut(); cx.run_until_parked(); // it might be nice to try and cleanup these at the end of each test. @@ -338,7 +339,7 @@ impl TestAppContext { /// Returns all windows open in the test. pub fn windows(&self) -> Vec { - self.app.borrow().windows().clone() + self.app.borrow().windows() } /// Run the given task on the main thread. @@ -454,7 +455,7 @@ impl TestAppContext { .windows .get_mut(window.id) .unwrap() - .as_mut() + .as_deref_mut() .unwrap() .platform_window .as_test() @@ -618,7 +619,7 @@ impl Entity { } }), cx.subscribe(self, { - let mut tx = tx.clone(); + let mut tx = tx; move |_, _: &Evt, _| { tx.blocking_send(()).ok(); } @@ -835,7 +836,7 @@ impl VisualTestContext { }) } - /// Simulate an event from the platform, e.g. a SrollWheelEvent + /// Simulate an event from the platform, e.g. a ScrollWheelEvent /// Make sure you've called [VisualTestContext::draw] first! pub fn simulate_event(&mut self, event: E) { self.test_window(self.window) @@ -882,12 +883,14 @@ impl VisualTestContext { /// Get an &mut VisualTestContext (which is mostly what you need to pass to other methods). /// This method internally retains the VisualTestContext until the end of the test. - pub fn as_mut(self) -> &'static mut Self { + pub fn into_mut(self) -> &'static mut Self { let ptr = Box::into_raw(Box::new(self)); // safety: on_quit will be called after the test has finished. // the executor will ensure that all tasks related to the test have stopped. // so there is no way for cx to be accessed after on_quit is called. - let cx = Box::leak(unsafe { Box::from_raw(ptr) }); + // todo: This is unsound under stacked borrows (also tree borrows probably?) + // the mutable reference invalidates `ptr` which is later used in the closure + let cx = unsafe { &mut *ptr }; cx.on_quit(move || unsafe { drop(Box::from_raw(ptr)); }); @@ -1025,7 +1028,7 @@ impl VisualContext for VisualTestContext { fn focus(&mut self, view: &Entity) -> Self::Result<()> { self.window .update(&mut self.cx, |_, window, cx| { - view.read(cx).focus_handle(cx).clone().focus(window) + view.read(cx).focus_handle(cx).focus(window) }) .unwrap() } diff --git a/crates/gpui/src/arena.rs b/crates/gpui/src/arena.rs index ee72d0e96425816220094f4cbff86315153afb74..9898c8056ab0240abd32ee34992dbe96f8ebab57 100644 --- a/crates/gpui/src/arena.rs +++ b/crates/gpui/src/arena.rs @@ -1,8 +1,9 @@ use std::{ alloc::{self, handle_alloc_error}, cell::Cell, + num::NonZeroUsize, ops::{Deref, DerefMut}, - ptr, + ptr::{self, NonNull}, rc::Rc, }; @@ -14,9 +15,7 @@ struct ArenaElement { impl Drop for ArenaElement { #[inline(always)] fn drop(&mut self) { - unsafe { - (self.drop)(self.value); - } + unsafe { (self.drop)(self.value) }; } } @@ -30,42 +29,38 @@ impl Drop for Chunk { fn drop(&mut self) { unsafe { let chunk_size = self.end.offset_from_unsigned(self.start); - // this never fails as it succeeded during allocation - let layout = alloc::Layout::from_size_align(chunk_size, 1).unwrap(); + // SAFETY: This succeeded during allocation. + let layout = alloc::Layout::from_size_align_unchecked(chunk_size, 1); alloc::dealloc(self.start, layout); } } } impl Chunk { - fn new(chunk_size: usize) -> Self { - unsafe { - // this only fails if chunk_size is unreasonably huge - let layout = alloc::Layout::from_size_align(chunk_size, 1).unwrap(); - let start = alloc::alloc(layout); - if start.is_null() { - handle_alloc_error(layout); - } - let end = start.add(chunk_size); - Self { - start, - end, - offset: start, - } + fn new(chunk_size: NonZeroUsize) -> Self { + // this only fails if chunk_size is unreasonably huge + let layout = alloc::Layout::from_size_align(chunk_size.get(), 1).unwrap(); + let start = unsafe { alloc::alloc(layout) }; + if start.is_null() { + handle_alloc_error(layout); + } + let end = unsafe { start.add(chunk_size.get()) }; + Self { + start, + end, + offset: start, } } - fn allocate(&mut self, layout: alloc::Layout) -> Option<*mut u8> { - unsafe { - let aligned = self.offset.add(self.offset.align_offset(layout.align())); - let next = aligned.add(layout.size()); + fn allocate(&mut self, layout: alloc::Layout) -> Option> { + let aligned = unsafe { self.offset.add(self.offset.align_offset(layout.align())) }; + let next = unsafe { aligned.add(layout.size()) }; - if next <= self.end { - self.offset = next; - Some(aligned) - } else { - None - } + if next <= self.end { + self.offset = next; + NonNull::new(aligned) + } else { + None } } @@ -79,7 +74,7 @@ pub struct Arena { elements: Vec, valid: Rc>, current_chunk_index: usize, - chunk_size: usize, + chunk_size: NonZeroUsize, } impl Drop for Arena { @@ -90,7 +85,7 @@ impl Drop for Arena { impl Arena { pub fn new(chunk_size: usize) -> Self { - assert!(chunk_size > 0); + let chunk_size = NonZeroUsize::try_from(chunk_size).unwrap(); Self { chunks: vec![Chunk::new(chunk_size)], elements: Vec::new(), @@ -101,7 +96,7 @@ impl Arena { } pub fn capacity(&self) -> usize { - self.chunks.len() * self.chunk_size + self.chunks.len() * self.chunk_size.get() } pub fn clear(&mut self) { @@ -121,54 +116,48 @@ impl Arena { where F: FnOnce() -> T, { - unsafe { - ptr::write(ptr, f()); - } + unsafe { ptr::write(ptr, f()) }; } unsafe fn drop(ptr: *mut u8) { - unsafe { - std::ptr::drop_in_place(ptr.cast::()); - } + unsafe { std::ptr::drop_in_place(ptr.cast::()) }; } - unsafe { - let layout = alloc::Layout::new::(); - let mut current_chunk = &mut self.chunks[self.current_chunk_index]; - let ptr = if let Some(ptr) = current_chunk.allocate(layout) { - ptr + let layout = alloc::Layout::new::(); + let mut current_chunk = &mut self.chunks[self.current_chunk_index]; + let ptr = if let Some(ptr) = current_chunk.allocate(layout) { + ptr.as_ptr() + } else { + self.current_chunk_index += 1; + if self.current_chunk_index >= self.chunks.len() { + self.chunks.push(Chunk::new(self.chunk_size)); + assert_eq!(self.current_chunk_index, self.chunks.len() - 1); + log::trace!( + "increased element arena capacity to {}kb", + self.capacity() / 1024, + ); + } + current_chunk = &mut self.chunks[self.current_chunk_index]; + if let Some(ptr) = current_chunk.allocate(layout) { + ptr.as_ptr() } else { - self.current_chunk_index += 1; - if self.current_chunk_index >= self.chunks.len() { - self.chunks.push(Chunk::new(self.chunk_size)); - assert_eq!(self.current_chunk_index, self.chunks.len() - 1); - log::info!( - "increased element arena capacity to {}kb", - self.capacity() / 1024, - ); - } - current_chunk = &mut self.chunks[self.current_chunk_index]; - if let Some(ptr) = current_chunk.allocate(layout) { - ptr - } else { - panic!( - "Arena chunk_size of {} is too small to allocate {} bytes", - self.chunk_size, - layout.size() - ); - } - }; - - inner_writer(ptr.cast(), f); - self.elements.push(ArenaElement { - value: ptr, - drop: drop::, - }); - - ArenaBox { - ptr: ptr.cast(), - valid: self.valid.clone(), + panic!( + "Arena chunk_size of {} is too small to allocate {} bytes", + self.chunk_size, + layout.size() + ); } + }; + + unsafe { inner_writer(ptr.cast(), f) }; + self.elements.push(ArenaElement { + value: ptr, + drop: drop::, + }); + + ArenaBox { + ptr: ptr.cast(), + valid: self.valid.clone(), } } } diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index 70a07c11e9239c048f9eaede8cae31a79acf779c..8930b58f8d4fc0423b7d6f41755189a03d8b8b84 100644 --- a/crates/gpui/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -1,4 +1,4 @@ -use crate::{DevicePixels, Result, SharedString, Size, size}; +use crate::{DevicePixels, Pixels, Result, SharedString, Size, size}; use smallvec::SmallVec; use image::{Delay, Frame}; @@ -42,6 +42,8 @@ pub(crate) struct RenderImageParams { pub struct RenderImage { /// The ID associated with this image pub id: ImageId, + /// The scale factor of this image on render. + pub(crate) scale_factor: f32, data: SmallVec<[Frame; 1]>, } @@ -60,6 +62,7 @@ impl RenderImage { Self { id: ImageId(NEXT_ID.fetch_add(1, SeqCst)), + scale_factor: 1.0, data: data.into(), } } @@ -77,6 +80,12 @@ impl RenderImage { size(width.into(), height.into()) } + /// Get the size of this image, in pixels for display, adjusted for the scale factor. + pub(crate) fn render_size(&self, frame_index: usize) -> Size { + self.size(frame_index) + .map(|v| (v.0 as f32 / self.scale_factor).into()) + } + /// Get the delay of this frame from the previous pub fn delay(&self, frame_index: usize) -> Delay { self.data[frame_index].delay() diff --git a/crates/gpui/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index 03f83b95035489bd86201c4d64c15f5a12ed50ea..d621609bf7334801059513e03dfd11b4036ea816 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -34,15 +34,14 @@ where pub fn insert(&mut self, new_bounds: Bounds) -> u32 { // If the tree is empty, make the root the new leaf. - if self.root.is_none() { + let Some(mut index) = self.root else { let new_node = self.push_leaf(new_bounds, 1); self.root = Some(new_node); return 1; - } + }; // Search for the best place to add the new leaf based on heuristics. let mut max_intersecting_ordering = 0; - let mut index = self.root.unwrap(); while let Node::Internal { left, right, @@ -309,12 +308,12 @@ mod tests { let mut expected_quads: Vec<(Bounds, u32)> = Vec::new(); // Insert a random number of random AABBs into the tree. - let num_bounds = rng.gen_range(1..=max_bounds); + let num_bounds = rng.random_range(1..=max_bounds); for _ in 0..num_bounds { - let min_x: f32 = rng.gen_range(-100.0..100.0); - let min_y: f32 = rng.gen_range(-100.0..100.0); - let width: f32 = rng.gen_range(0.0..50.0); - let height: f32 = rng.gen_range(0.0..50.0); + let min_x: f32 = rng.random_range(-100.0..100.0); + let min_y: f32 = rng.random_range(-100.0..100.0); + let width: f32 = rng.random_range(0.0..50.0); + let height: f32 = rng.random_range(0.0..50.0); let bounds = Bounds { origin: Point { x: min_x, y: min_y }, size: Size { width, height }, diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 639c84c10144310b14a94c2a22b84957b8b09524..3af5731bb57dad81a522d93a77c0aec871ae47cb 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -151,9 +151,9 @@ impl From for Rgba { }; Rgba { - r, - g, - b, + r: r.clamp(0., 1.), + g: g.clamp(0., 1.), + b: b.clamp(0., 1.), a: color.a, } } @@ -362,7 +362,7 @@ pub const fn transparent_black() -> Hsla { } } -/// Transparent black in [`Hsla`] +/// Transparent white in [`Hsla`] pub const fn transparent_white() -> Hsla { Hsla { h: 0., @@ -473,6 +473,11 @@ impl Hsla { self.a == 0.0 } + /// Returns true if the HSLA color is fully opaque, false otherwise. + pub fn is_opaque(&self) -> bool { + self.a == 1.0 + } + /// Blends `other` on top of `self` based on `other`'s alpha value. The resulting color is a combination of `self`'s and `other`'s colors. /// /// If `other`'s alpha value is 1.0 or greater, `other` color is fully opaque, thus `other` is returned as the output color. @@ -532,9 +537,10 @@ impl Hsla { /// /// Example: /// ``` - /// let color = hlsa(0.7, 1.0, 0.5, 0.7); // A saturated blue + /// use gpui::hsla; + /// let color = hsla(0.7, 1.0, 0.5, 0.7); // A saturated blue /// let faded_color = color.opacity(0.16); - /// assert_eq!(faded_color.a, 0.112); + /// assert!((faded_color.a - 0.112).abs() < 1e-6); /// ``` /// /// This will return a blue color with around ~10% opacity, @@ -563,6 +569,7 @@ impl Hsla { /// /// Example: /// ``` + /// use gpui::hsla; /// let color = hsla(0.7, 1.0, 0.5, 0.7); // A saturated blue /// let faded_color = color.alpha(0.25); /// assert_eq!(faded_color.a, 0.25); @@ -905,9 +912,9 @@ mod tests { assert_eq!(background.solid, color); assert_eq!(background.opacity(0.5).solid, color.opacity(0.5)); - assert_eq!(background.is_transparent(), false); + assert!(!background.is_transparent()); background.solid = hsla(0.0, 0.0, 0.0, 0.0); - assert_eq!(background.is_transparent(), true); + assert!(background.is_transparent()); } #[test] @@ -921,7 +928,7 @@ mod tests { assert_eq!(background.opacity(0.5).colors[0], from.opacity(0.5)); assert_eq!(background.opacity(0.5).colors[1], to.opacity(0.5)); - assert_eq!(background.is_transparent(), false); - assert_eq!(background.opacity(0.0).is_transparent(), true); + assert!(!background.is_transparent()); + assert!(background.opacity(0.0).is_transparent()); } } diff --git a/crates/gpui/src/colors.rs b/crates/gpui/src/colors.rs index 5e14c1238addbb02b0c6a02942aae05b703583ea..ef11ef57fdb363dae3f910db2e540e3de02fe453 100644 --- a/crates/gpui/src/colors.rs +++ b/crates/gpui/src/colors.rs @@ -88,9 +88,9 @@ impl Deref for GlobalColors { impl Global for GlobalColors {} -/// Implement this trait to allow global [Color] access via `cx.default_colors()`. +/// Implement this trait to allow global [Colors] access via `cx.default_colors()`. pub trait DefaultColors { - /// Returns the default [`gpui::Colors`] + /// Returns the default [`Colors`] fn default_colors(&self) -> &Arc; } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index e5f49c7be141a3620e52599bcc2b151acc1f7319..2c695486c5d09103f69fb211076aec6629a29f1b 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -14,13 +14,13 @@ //! tree and any callbacks they have registered with GPUI are dropped and the process repeats. //! //! But some state is too simple and voluminous to store in every view that needs it, e.g. -//! whether a hover has been started or not. For this, GPUI provides the [`Element::State`], associated type. +//! whether a hover has been started or not. For this, GPUI provides the [`Element::PrepaintState`], associated type. //! //! # Implementing your own elements //! //! Elements are intended to be the low level, imperative API to GPUI. They are responsible for upholding, //! or breaking, GPUI's features as they deem necessary. As an example, most GPUI elements are expected -//! to stay in the bounds that their parent element gives them. But with [`WindowContext::break_content_mask`], +//! to stay in the bounds that their parent element gives them. But with [`Window::with_content_mask`], //! you can ignore this restriction and paint anywhere inside of the window's bounds. This is useful for overlays //! and popups and anything else that shows up 'on top' of other elements. //! With great power, comes great responsibility. @@ -37,11 +37,11 @@ use crate::{ util::FluentBuilder, }; use derive_more::{Deref, DerefMut}; -pub(crate) use smallvec::SmallVec; use std::{ any::{Any, type_name}, fmt::{self, Debug, Display}, mem, panic, + sync::Arc, }; /// Implemented by types that participate in laying out and painting the contents of a window. @@ -272,8 +272,8 @@ impl IntoElement for Component { } /// A globally unique identifier for an element, used to track state across frames. -#[derive(Deref, DerefMut, Default, Debug, Eq, PartialEq, Hash)] -pub struct GlobalElementId(pub(crate) SmallVec<[ElementId; 32]>); +#[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] +pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); impl Display for GlobalElementId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -353,7 +353,7 @@ impl Drawable { ElementDrawPhase::Start => { let global_id = self.element.id().map(|element_id| { window.element_id_stack.push(element_id); - GlobalElementId(window.element_id_stack.clone()) + GlobalElementId(Arc::from(&*window.element_id_stack)) }); let inspector_id; @@ -361,7 +361,7 @@ impl Drawable { { inspector_id = self.element.source_location().map(|source| { let path = crate::InspectorElementPath { - global_id: GlobalElementId(window.element_id_stack.clone()), + global_id: GlobalElementId(Arc::from(&*window.element_id_stack)), source_location: source, }; window.build_inspector_element_id(path) @@ -412,7 +412,7 @@ impl Drawable { } => { if let Some(element_id) = self.element.id() { window.element_id_stack.push(element_id); - debug_assert_eq!(global_id.as_ref().unwrap().0, window.element_id_stack); + debug_assert_eq!(&*global_id.as_ref().unwrap().0, &*window.element_id_stack); } let bounds = window.layout_bounds(layout_id); @@ -461,7 +461,7 @@ impl Drawable { } => { if let Some(element_id) = self.element.id() { window.element_id_stack.push(element_id); - debug_assert_eq!(global_id.as_ref().unwrap().0, window.element_id_stack); + debug_assert_eq!(&*global_id.as_ref().unwrap().0, &*window.element_id_stack); } window.next_frame.dispatch_tree.set_active_node(node_id); @@ -603,10 +603,8 @@ impl AnyElement { self.0.prepaint(window, cx); - if !focus_assigned { - if let Some(focus_id) = window.next_frame.focus { - return FocusHandle::for_id(focus_id, &cx.focus_handles); - } + if !focus_assigned && let Some(focus_id) = window.next_frame.focus { + return FocusHandle::for_id(focus_id, &cx.focus_handles); } None @@ -743,7 +741,17 @@ impl Element for Empty { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), None, cx), ()) + ( + window.request_layout( + Style { + display: crate::Display::None, + ..Default::default() + }, + None, + cx, + ), + (), + ) } fn prepaint( diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index 11dd19e260c20e49b87e05137771be73a3f816ea..e72fb00456d14dec74ffc56e040511c189af1d18 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -87,7 +87,7 @@ pub trait AnimationExt { } } -impl AnimationExt for E {} +impl AnimationExt for E {} /// A GPUI element that applies an animation to another element pub struct AnimationElement { diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c581dc9f1098e3131113f22b5694574043832f09..3af666a411d33279a590d1a43fbf39f779c27652 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -16,17 +16,19 @@ //! constructed by combining these two systems into an all-in-one element. use crate::{ - Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, - Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, - HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, - KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, - MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, ParentElement, Pixels, - Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, - TooltipId, Visibility, Window, WindowControlArea, point, px, size, + AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, + DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, + Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, + KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, + ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, + size, }; use collections::HashMap; use refineable::Refineable; use smallvec::SmallVec; +use stacksafe::{StackSafe, stacksafe}; use std::{ any::{Any, TypeId}, cell::RefCell, @@ -285,21 +287,20 @@ impl Interactivity { { self.mouse_move_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { - if phase == DispatchPhase::Capture { - if let Some(drag) = &cx.active_drag { - if drag.value.as_ref().type_id() == TypeId::of::() { - (listener)( - &DragMoveEvent { - event: event.clone(), - bounds: hitbox.bounds, - drag: PhantomData, - dragged_item: Arc::clone(&drag.value), - }, - window, - cx, - ); - } - } + if phase == DispatchPhase::Capture + && let Some(drag) = &cx.active_drag + && drag.value.as_ref().type_id() == TypeId::of::() + { + (listener)( + &DragMoveEvent { + event: event.clone(), + bounds: hitbox.bounds, + drag: PhantomData, + dragged_item: Arc::clone(&drag.value), + }, + window, + cx, + ); } })); } @@ -533,7 +534,7 @@ impl Interactivity { } /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. - /// The imperative API equivalent to [`InteractiveElement::tooltip`] + /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`] pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) where Self: Sized, @@ -550,7 +551,7 @@ impl Interactivity { /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into - /// the tooltip. The imperative API equivalent to [`InteractiveElement::hoverable_tooltip`] + /// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`] pub fn hoverable_tooltip( &mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, @@ -618,10 +619,37 @@ pub trait InteractiveElement: Sized { self } - /// Set index of the tab stop order. + /// Set whether this element is a tab stop. + /// + /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation. + /// Useful for container elements: focus the container, then call `window.focus_next()` to focus + /// the first tab stop inside it while having the container element itself be unreachable via the keyboard. + /// Should only be used with `tab_index`. + fn tab_stop(mut self, tab_stop: bool) -> Self { + self.interactivity().tab_stop = tab_stop; + self + } + + /// Set index of the tab stop order, and set this node as a tab stop. + /// This will default the element to being a tab stop. See [`Self::tab_stop`] for more information. + /// This should only be used in conjunction with `tab_group` + /// in order to not interfere with the tab index of other elements. fn tab_index(mut self, index: isize) -> Self { self.interactivity().focusable = true; self.interactivity().tab_index = Some(index); + self.interactivity().tab_stop = true; + self + } + + /// Designate this div as a "tab group". Tab groups have their own location in the tab-index order, + /// but for children of the tab group, the tab index is reset to 0. This can be useful for swapping + /// the order of tab stops within the group, without having to renumber all the tab stops in the whole + /// application. + fn tab_group(mut self) -> Self { + self.interactivity().tab_group = true; + if self.interactivity().tab_index.is_none() { + self.interactivity().tab_index = Some(0); + } self } @@ -731,7 +759,7 @@ pub trait InteractiveElement: Sized { #[cfg(any(test, feature = "test-support"))] /// Set a key that can be used to look up this element's bounds - /// in the [`VisualTestContext::debug_bounds`] map + /// in the [`crate::VisualTestContext::debug_bounds`] map /// This is a noop in release builds fn debug_selector(mut self, f: impl FnOnce() -> String) -> Self { self.interactivity().debug_selector = Some(f()); @@ -740,7 +768,7 @@ pub trait InteractiveElement: Sized { #[cfg(not(any(test, feature = "test-support")))] /// Set a key that can be used to look up this element's bounds - /// in the [`VisualTestContext::debug_bounds`] map + /// in the [`crate::VisualTestContext::debug_bounds`] map /// This is a noop in release builds #[inline] fn debug_selector(self, _: impl FnOnce() -> String) -> Self { @@ -1061,6 +1089,18 @@ pub trait InteractiveElement: Sized { self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default()))); self } + + /// Set the given styles to be applied when this element is focused via keyboard navigation. + /// This is similar to CSS's `:focus-visible` pseudo-class - it only applies when the element + /// is focused AND the user is navigating via keyboard (not mouse clicks). + /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`]. + fn focus_visible(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self + where + Self: Sized, + { + self.interactivity().focus_visible_style = Some(Box::new(f(StyleRefinement::default()))); + self + } } /// A trait for elements that want to use the standard GPUI interactivity features @@ -1091,6 +1131,15 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Set the space to be reserved for rendering the scrollbar. + /// + /// This will only affect the layout of the element when overflow for this element is set to + /// `Overflow::Scroll`. + fn scrollbar_width(mut self, width: impl Into) -> Self { + self.interactivity().base_style.scrollbar_width = Some(width.into()); + self + } + /// Track the scroll state of this element with the given handle. fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { self.interactivity().tracked_scroll_handle = Some(scroll_handle.clone()); @@ -1142,7 +1191,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// On drag initiation, this callback will be used to create a new view to render the dragged value for a /// drag and drop operation. This API should also be used as the equivalent of 'on drag start' with - /// the [`Self::on_drag_move`] API. + /// the [`InteractiveElement::on_drag_move`] API. /// The callback also has access to the offset of triggering click from the origin of parent element. /// The fluent API equivalent to [`Interactivity::on_drag`] /// @@ -1250,7 +1299,7 @@ pub fn div() -> Div { /// A [`Div`] element, the all-in-one element for building complex UIs in GPUI pub struct Div { interactivity: Interactivity, - children: SmallVec<[AnyElement; 2]>, + children: SmallVec<[StackSafe; 2]>, prepaint_listener: Option>, &mut Window, &mut App) + 'static>>, image_cache: Option>, } @@ -1311,7 +1360,8 @@ impl InteractiveElement for Div { impl ParentElement for Div { fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements) + self.children + .extend(elements.into_iter().map(StackSafe::new)) } } @@ -1327,6 +1377,7 @@ impl Element for Div { self.interactivity.source_location() } + #[stacksafe] fn request_layout( &mut self, global_id: Option<&GlobalElementId>, @@ -1362,6 +1413,7 @@ impl Element for Div { (layout_id, DivFrameState { child_layout_ids }) } + #[stacksafe] fn prepaint( &mut self, global_id: Option<&GlobalElementId>, @@ -1408,6 +1460,10 @@ impl Element for Div { (child_max - child_min).into() }; + if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() { + scroll_handle.scroll_to_active_item(); + } + self.interactivity.prepaint( global_id, inspector_id, @@ -1415,7 +1471,12 @@ impl Element for Div { content_size, window, cx, - |_style, scroll_offset, hitbox, window, cx| { + |style, scroll_offset, hitbox, window, cx| { + // skip children + if style.display == Display::None { + return hitbox; + } + window.with_element_offset(scroll_offset, |window| { for child in &mut self.children { child.prepaint(window, cx); @@ -1431,6 +1492,7 @@ impl Element for Div { ) } + #[stacksafe] fn paint( &mut self, global_id: Option<&GlobalElementId>, @@ -1454,7 +1516,12 @@ impl Element for Div { hitbox.as_ref(), window, cx, - |_style, window, cx| { + |style, window, cx| { + // skip children + if style.display == Display::None { + return; + } + for child in &mut self.children { child.paint(window, cx); } @@ -1497,6 +1564,7 @@ pub struct Interactivity { pub base_style: Box, pub(crate) focus_style: Option>, pub(crate) in_focus_style: Option>, + pub(crate) focus_visible_style: Option>, pub(crate) hover_style: Option>, pub(crate) group_hover_style: Option, pub(crate) active_style: Option>, @@ -1524,6 +1592,8 @@ pub struct Interactivity { pub(crate) window_control: Option, pub(crate) hitbox_behavior: HitboxBehavior, pub(crate) tab_index: Option, + pub(crate) tab_group: bool, + pub(crate) tab_stop: bool, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1565,15 +1635,14 @@ impl Interactivity { let mut element_state = element_state.map(|element_state| element_state.unwrap_or_default()); - if let Some(element_state) = element_state.as_ref() { - if cx.has_active_drag() { - if let Some(pending_mouse_down) = element_state.pending_mouse_down.as_ref() - { - *pending_mouse_down.borrow_mut() = None; - } - if let Some(clicked_state) = element_state.clicked_state.as_ref() { - *clicked_state.borrow_mut() = ElementClickedState::default(); - } + if let Some(element_state) = element_state.as_ref() + && cx.has_active_drag() + { + if let Some(pending_mouse_down) = element_state.pending_mouse_down.as_ref() { + *pending_mouse_down.borrow_mut() = None; + } + if let Some(clicked_state) = element_state.clicked_state.as_ref() { + *clicked_state.borrow_mut() = ElementClickedState::default(); } } @@ -1581,35 +1650,35 @@ impl Interactivity { // If there's an explicit focus handle we're tracking, use that. Otherwise // create a new handle and store it in the element state, which lives for as // as frames contain an element with this id. - if self.focusable && self.tracked_focus_handle.is_none() { - if let Some(element_state) = element_state.as_mut() { - let mut handle = element_state - .focus_handle - .get_or_insert_with(|| cx.focus_handle()) - .clone() - .tab_stop(false); - - if let Some(index) = self.tab_index { - handle = handle.tab_index(index).tab_stop(true); - } - - self.tracked_focus_handle = Some(handle); + if self.focusable + && self.tracked_focus_handle.is_none() + && let Some(element_state) = element_state.as_mut() + { + let mut handle = element_state + .focus_handle + .get_or_insert_with(|| cx.focus_handle()) + .clone() + .tab_stop(self.tab_stop); + + if let Some(index) = self.tab_index { + handle = handle.tab_index(index); } + + self.tracked_focus_handle = Some(handle); } if let Some(scroll_handle) = self.tracked_scroll_handle.as_ref() { self.scroll_offset = Some(scroll_handle.0.borrow().offset.clone()); - } else if self.base_style.overflow.x == Some(Overflow::Scroll) - || self.base_style.overflow.y == Some(Overflow::Scroll) + } else if (self.base_style.overflow.x == Some(Overflow::Scroll) + || self.base_style.overflow.y == Some(Overflow::Scroll)) + && let Some(element_state) = element_state.as_mut() { - if let Some(element_state) = element_state.as_mut() { - self.scroll_offset = Some( - element_state - .scroll_offset - .get_or_insert_with(Rc::default) - .clone(), - ); - } + self.scroll_offset = Some( + element_state + .scroll_offset + .get_or_insert_with(Rc::default) + .clone(), + ); } let mut style = @@ -1829,8 +1898,12 @@ impl Interactivity { return ((), element_state); } + let mut tab_group = None; + if self.tab_group { + tab_group = self.tab_index; + } if let Some(focus_handle) = &self.tracked_focus_handle { - window.next_frame.tab_handles.insert(focus_handle); + window.next_frame.tab_stops.insert(focus_handle); } window.with_element_opacity(style.opacity, |window| { @@ -1839,55 +1912,59 @@ impl Interactivity { window.with_content_mask( style.overflow_mask(bounds, window.rem_size()), |window| { - if let Some(hitbox) = hitbox { - #[cfg(debug_assertions)] - self.paint_debug_info( - global_id, hitbox, &style, window, cx, - ); - - if let Some(drag) = cx.active_drag.as_ref() { - if let Some(mouse_cursor) = drag.cursor_style { - window.set_window_cursor_style(mouse_cursor); + window.with_tab_group(tab_group, |window| { + if let Some(hitbox) = hitbox { + #[cfg(debug_assertions)] + self.paint_debug_info( + global_id, hitbox, &style, window, cx, + ); + + if let Some(drag) = cx.active_drag.as_ref() { + if let Some(mouse_cursor) = drag.cursor_style { + window.set_window_cursor_style(mouse_cursor); + } + } else { + if let Some(mouse_cursor) = style.mouse_cursor { + window.set_cursor_style(mouse_cursor, hitbox); + } } - } else { - if let Some(mouse_cursor) = style.mouse_cursor { - window.set_cursor_style(mouse_cursor, hitbox); + + if let Some(group) = self.group.clone() { + GroupHitboxes::push(group, hitbox.id, cx); } - } - if let Some(group) = self.group.clone() { - GroupHitboxes::push(group, hitbox.id, cx); - } + if let Some(area) = self.window_control { + window.insert_window_control_hitbox( + area, + hitbox.clone(), + ); + } - if let Some(area) = self.window_control { - window - .insert_window_control_hitbox(area, hitbox.clone()); + self.paint_mouse_listeners( + hitbox, + element_state.as_mut(), + window, + cx, + ); + self.paint_scroll_listener(hitbox, &style, window, cx); } - self.paint_mouse_listeners( - hitbox, - element_state.as_mut(), - window, - cx, - ); - self.paint_scroll_listener(hitbox, &style, window, cx); - } - - self.paint_keyboard_listeners(window, cx); - f(&style, window, cx); + self.paint_keyboard_listeners(window, cx); + f(&style, window, cx); - if let Some(_hitbox) = hitbox { - #[cfg(any(feature = "inspector", debug_assertions))] - window.insert_inspector_hitbox( - _hitbox.id, - _inspector_id, - cx, - ); + if let Some(_hitbox) = hitbox { + #[cfg(any(feature = "inspector", debug_assertions))] + window.insert_inspector_hitbox( + _hitbox.id, + _inspector_id, + cx, + ); - if let Some(group) = self.group.as_ref() { - GroupHitboxes::pop(group, cx); + if let Some(group) = self.group.as_ref() { + GroupHitboxes::pop(group, cx); + } } - } + }) }, ); }); @@ -2101,26 +2178,27 @@ impl Interactivity { let hitbox = hitbox.clone(); window.on_mouse_event({ move |_: &MouseUpEvent, phase, window, cx| { - if let Some(drag) = &cx.active_drag { - if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { - let drag_state_type = drag.value.as_ref().type_id(); - for (drop_state_type, listener) in &drop_listeners { - if *drop_state_type == drag_state_type { - let drag = cx - .active_drag - .take() - .expect("checked for type drag state type above"); - - let mut can_drop = true; - if let Some(predicate) = &can_drop_predicate { - can_drop = predicate(drag.value.as_ref(), window, cx); - } + if let Some(drag) = &cx.active_drag + && phase == DispatchPhase::Bubble + && hitbox.is_hovered(window) + { + let drag_state_type = drag.value.as_ref().type_id(); + for (drop_state_type, listener) in &drop_listeners { + if *drop_state_type == drag_state_type { + let drag = cx + .active_drag + .take() + .expect("checked for type drag state type above"); + + let mut can_drop = true; + if let Some(predicate) = &can_drop_predicate { + can_drop = predicate(drag.value.as_ref(), window, cx); + } - if can_drop { - listener(drag.value.as_ref(), window, cx); - window.refresh(); - cx.stop_propagation(); - } + if can_drop { + listener(drag.value.as_ref(), window, cx); + window.refresh(); + cx.stop_propagation(); } } } @@ -2164,31 +2242,24 @@ impl Interactivity { } let mut pending_mouse_down = pending_mouse_down.borrow_mut(); - if let Some(mouse_down) = pending_mouse_down.clone() { - if !cx.has_active_drag() - && (event.position - mouse_down.position).magnitude() - > DRAG_THRESHOLD - { - if let Some((drag_value, drag_listener)) = drag_listener.take() { - *clicked_state.borrow_mut() = ElementClickedState::default(); - let cursor_offset = event.position - hitbox.origin; - let drag = (drag_listener)( - drag_value.as_ref(), - cursor_offset, - window, - cx, - ); - cx.active_drag = Some(AnyDrag { - view: drag, - value: drag_value, - cursor_offset, - cursor_style: drag_cursor_style, - }); - pending_mouse_down.take(); - window.refresh(); - cx.stop_propagation(); - } - } + if let Some(mouse_down) = pending_mouse_down.clone() + && !cx.has_active_drag() + && (event.position - mouse_down.position).magnitude() > DRAG_THRESHOLD + && let Some((drag_value, drag_listener)) = drag_listener.take() + { + *clicked_state.borrow_mut() = ElementClickedState::default(); + let cursor_offset = event.position - hitbox.origin; + let drag = + (drag_listener)(drag_value.as_ref(), cursor_offset, window, cx); + cx.active_drag = Some(AnyDrag { + view: drag, + value: drag_value, + cursor_offset, + cursor_style: drag_cursor_style, + }); + pending_mouse_down.take(); + window.refresh(); + cx.stop_propagation(); } } }); @@ -2352,7 +2423,7 @@ impl Interactivity { window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _cx| { if phase == DispatchPhase::Bubble && !window.default_prevented() { let group_hovered = active_group_hitbox - .map_or(false, |group_hitbox_id| group_hitbox_id.is_hovered(window)); + .is_some_and(|group_hitbox_id| group_hitbox_id.is_hovered(window)); let element_hovered = hitbox.is_hovered(window); if group_hovered || element_hovered { *active_state.borrow_mut() = ElementClickedState { @@ -2498,33 +2569,39 @@ impl Interactivity { style.refine(&self.base_style); if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { - if let Some(in_focus_style) = self.in_focus_style.as_ref() { - if focus_handle.within_focused(window, cx) { - style.refine(in_focus_style); - } + if let Some(in_focus_style) = self.in_focus_style.as_ref() + && focus_handle.within_focused(window, cx) + { + style.refine(in_focus_style); } - if let Some(focus_style) = self.focus_style.as_ref() { - if focus_handle.is_focused(window) { - style.refine(focus_style); - } + if let Some(focus_style) = self.focus_style.as_ref() + && focus_handle.is_focused(window) + { + style.refine(focus_style); + } + + if let Some(focus_visible_style) = self.focus_visible_style.as_ref() + && focus_handle.is_focused(window) + && window.last_input_was_keyboard() + { + style.refine(focus_visible_style); } } if let Some(hitbox) = hitbox { if !cx.has_active_drag() { - if let Some(group_hover) = self.group_hover_style.as_ref() { - if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { - if group_hitbox_id.is_hovered(window) { - style.refine(&group_hover.style); - } - } + if let Some(group_hover) = self.group_hover_style.as_ref() + && let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) + && group_hitbox_id.is_hovered(window) + { + style.refine(&group_hover.style); } - if let Some(hover_style) = self.hover_style.as_ref() { - if hitbox.is_hovered(window) { - style.refine(hover_style); - } + if let Some(hover_style) = self.hover_style.as_ref() + && hitbox.is_hovered(window) + { + style.refine(hover_style); } } @@ -2538,12 +2615,10 @@ impl Interactivity { for (state_type, group_drag_style) in &self.group_drag_over_styles { if let Some(group_hitbox_id) = GroupHitboxes::get(&group_drag_style.group, cx) + && *state_type == drag.value.as_ref().type_id() + && group_hitbox_id.is_hovered(window) { - if *state_type == drag.value.as_ref().type_id() - && group_hitbox_id.is_hovered(window) - { - style.refine(&group_drag_style.style); - } + style.refine(&group_drag_style.style); } } @@ -2565,16 +2640,16 @@ impl Interactivity { .clicked_state .get_or_insert_with(Default::default) .borrow(); - if clicked_state.group { - if let Some(group) = self.group_active_style.as_ref() { - style.refine(&group.style) - } + if clicked_state.group + && let Some(group) = self.group_active_style.as_ref() + { + style.refine(&group.style) } - if let Some(active_style) = self.active_style.as_ref() { - if clicked_state.element { - style.refine(active_style) - } + if let Some(active_style) = self.active_style.as_ref() + && clicked_state.element + { + style.refine(active_style) } } @@ -2696,7 +2771,7 @@ pub(crate) fn register_tooltip_mouse_handlers( window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); move |_: &MouseDownEvent, _phase, window: &mut Window, _cx| { - if !tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(window)) { + if !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)) { clear_active_tooltip_if_not_hoverable(&active_tooltip, window); } } @@ -2705,7 +2780,7 @@ pub(crate) fn register_tooltip_mouse_handlers( window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); move |_: &ScrollWheelEvent, _phase, window: &mut Window, _cx| { - if !tooltip_id.map_or(false, |tooltip_id| tooltip_id.is_hovered(window)) { + if !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window)) { clear_active_tooltip_if_not_hoverable(&active_tooltip, window); } } @@ -2861,7 +2936,7 @@ fn handle_tooltip_check_visible_and_update( match action { Action::None => {} - Action::Hide => clear_active_tooltip(&active_tooltip, window), + Action::Hide => clear_active_tooltip(active_tooltip, window), Action::ScheduleHide(tooltip) => { let delayed_hide_task = window.spawn(cx, { let active_tooltip = active_tooltip.clone(); @@ -3031,8 +3106,7 @@ where } /// Represents an element that can be scrolled *to* in its parent element. -/// -/// Contrary to [ScrollHandle::scroll_to_item], an anchored element does not have to be an immediate child of the parent. +/// Contrary to [ScrollHandle::scroll_to_active_item], an anchored element does not have to be an immediate child of the parent. #[derive(Clone)] pub struct ScrollAnchor { handle: ScrollHandle, @@ -3067,6 +3141,20 @@ struct ScrollHandleState { child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, + active_item: Option, +} + +#[derive(Default, Debug, Clone, Copy)] +struct ScrollActiveItem { + index: usize, + strategy: ScrollStrategy, +} + +#[derive(Default, Debug, Clone, Copy)] +enum ScrollStrategy { + #[default] + FirstVisible, + Top, } /// A handle to the scrollable aspects of an element. @@ -3116,6 +3204,25 @@ impl ScrollHandle { } } + /// Get the bottom child that's scrolled into view. + pub fn bottom_item(&self) -> usize { + let state = self.0.borrow(); + let bottom = state.bounds.bottom() - state.offset.borrow().y; + + match state.child_bounds.binary_search_by(|bounds| { + if bottom < bounds.top() { + Ordering::Greater + } else if bottom > bounds.bottom() { + Ordering::Less + } else { + Ordering::Equal + } + }) { + Ok(ix) => ix, + Err(ix) => ix.min(state.child_bounds.len().saturating_sub(1)), + } + } + /// Return the bounds into which this child is painted pub fn bounds(&self) -> Bounds { self.0.borrow().bounds @@ -3126,32 +3233,66 @@ impl ScrollHandle { self.0.borrow().child_bounds.get(ix).cloned() } - /// scroll_to_item scrolls the minimal amount to ensure that the child is - /// fully visible + /// Update [ScrollHandleState]'s active item for scrolling to in prepaint pub fn scroll_to_item(&self, ix: usize) { - let state = self.0.borrow(); + let mut state = self.0.borrow_mut(); + state.active_item = Some(ScrollActiveItem { + index: ix, + strategy: ScrollStrategy::default(), + }); + } + + /// Update [ScrollHandleState]'s active item for scrolling to in prepaint + /// This scrolls the minimal amount to ensure that the child is the first visible element + pub fn scroll_to_top_of_item(&self, ix: usize) { + let mut state = self.0.borrow_mut(); + state.active_item = Some(ScrollActiveItem { + index: ix, + strategy: ScrollStrategy::Top, + }); + } - let Some(bounds) = state.child_bounds.get(ix) else { + /// Scrolls the minimal amount to either ensure that the child is + /// fully visible or the top element of the view depends on the + /// scroll strategy + fn scroll_to_active_item(&self) { + let mut state = self.0.borrow_mut(); + + let Some(active_item) = state.active_item else { return; }; - let mut scroll_offset = state.offset.borrow_mut(); - - if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { - scroll_offset.y = state.bounds.top() - bounds.top(); - } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { - scroll_offset.y = state.bounds.bottom() - bounds.bottom(); - } - } + let active_item = match state.child_bounds.get(active_item.index) { + Some(bounds) => { + let mut scroll_offset = state.offset.borrow_mut(); + + match active_item.strategy { + ScrollStrategy::FirstVisible => { + if state.overflow.y == Overflow::Scroll { + if bounds.top() + scroll_offset.y < state.bounds.top() { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { + scroll_offset.y = state.bounds.bottom() - bounds.bottom(); + } + } + } + ScrollStrategy::Top => { + scroll_offset.y = state.bounds.top() - bounds.top(); + } + } - if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { - scroll_offset.x = state.bounds.left() - bounds.left(); - } else if bounds.right() + scroll_offset.x > state.bounds.right() { - scroll_offset.x = state.bounds.right() - bounds.right(); + if state.overflow.x == Overflow::Scroll { + if bounds.left() + scroll_offset.x < state.bounds.left() { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.right() + scroll_offset.x > state.bounds.right() { + scroll_offset.x = state.bounds.right() - bounds.right(); + } + } + None } - } + None => Some(active_item), + }; + state.active_item = active_item; } /// Scrolls to the bottom. @@ -3183,6 +3324,21 @@ impl ScrollHandle { } } + /// Get the logical scroll bottom, based on a child index and a pixel offset. + pub fn logical_scroll_bottom(&self) -> (usize, Pixels) { + let ix = self.bottom_item(); + let state = self.0.borrow(); + + if let Some(child_bounds) = state.child_bounds.get(ix) { + ( + ix, + child_bounds.bottom() + state.offset.borrow().y - state.bounds.bottom(), + ) + } else { + (ix, px(0.)) + } + } + /// Get the count of children for scrollable item. pub fn children_count(&self) -> usize { self.0.borrow().child_bounds.len() diff --git a/crates/gpui/src/elements/image_cache.rs b/crates/gpui/src/elements/image_cache.rs index e7bdeaf9eb4d26913718a9b235cee4fcb0ca85ff..ee1436134a30f70e7015ab1c86f60733e60e9164 100644 --- a/crates/gpui/src/elements/image_cache.rs +++ b/crates/gpui/src/elements/image_cache.rs @@ -64,7 +64,7 @@ mod any_image_cache { cx: &mut App, ) -> Option, ImageCacheError>> { let image_cache = image_cache.clone().downcast::().unwrap(); - return image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx)); + image_cache.update(cx, |image_cache, cx| image_cache.load(resource, window, cx)) } } @@ -297,10 +297,10 @@ impl RetainAllImageCache { /// Remove the image from the cache by the given source. pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) { let hash = hash(source); - if let Some(mut item) = self.0.remove(&hash) { - if let Some(Ok(image)) = item.get() { - cx.drop_image(image, Some(window)); - } + if let Some(mut item) = self.0.remove(&hash) + && let Some(Ok(image)) = item.get() + { + cx.drop_image(image, Some(window)); } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 993b319b697ece386ad8af6d6164c1b85bf3a1c7..fcba6a6a4e5b3d82262129bc9f7d9bdc72c88da9 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -1,15 +1,14 @@ use crate::{ - AbsoluteLength, AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, - Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, - InteractiveElement, Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, - RenderImage, Resource, SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, - Styled, SvgSize, Task, Window, px, swap_rgba_pa_to_bgra, + AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId, + Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement, + Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource, + SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px, }; use anyhow::{Context as _, Result}; use futures::{AsyncReadExt, Future}; use image::{ - AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba, + AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; use smallvec::SmallVec; @@ -160,13 +159,15 @@ pub trait StyledImage: Sized { self } - /// Set the object fit for the image. + /// Set a fallback function that will be invoked to render an error view should + /// the image fail to load. fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self { self.image_style().fallback = Some(Box::new(fallback)); self } - /// Set the object fit for the image. + /// Set a fallback function that will be invoked to render a view while the image + /// is still being loaded. fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self { self.image_style().loading = Some(Box::new(loading)); self @@ -332,33 +333,34 @@ impl Element for Img { state.started_loading = None; } - let image_size = data.size(frame_index); - style.aspect_ratio = - Some(image_size.width.0 as f32 / image_size.height.0 as f32); + let image_size = data.render_size(frame_index); + style.aspect_ratio = Some(image_size.width / image_size.height); if let Length::Auto = style.size.width { style.size.width = match style.size.height { - Length::Definite(DefiniteLength::Absolute( - AbsoluteLength::Pixels(height), - )) => Length::Definite( - px(image_size.width.0 as f32 * height.0 - / image_size.height.0 as f32) - .into(), - ), - _ => Length::Definite(px(image_size.width.0 as f32).into()), + Length::Definite(DefiniteLength::Absolute(abs_length)) => { + let height_px = abs_length.to_pixels(window.rem_size()); + Length::Definite( + px(image_size.width.0 * height_px.0 + / image_size.height.0) + .into(), + ) + } + _ => Length::Definite(image_size.width.into()), }; } if let Length::Auto = style.size.height { style.size.height = match style.size.width { - Length::Definite(DefiniteLength::Absolute( - AbsoluteLength::Pixels(width), - )) => Length::Definite( - px(image_size.height.0 as f32 * width.0 - / image_size.width.0 as f32) - .into(), - ), - _ => Length::Definite(px(image_size.height.0 as f32).into()), + Length::Definite(DefiniteLength::Absolute(abs_length)) => { + let width_px = abs_length.to_pixels(window.rem_size()); + Length::Definite( + px(image_size.height.0 * width_px.0 + / image_size.width.0) + .into(), + ) + } + _ => Length::Definite(image_size.height.into()), }; } @@ -379,13 +381,12 @@ impl Element for Img { None => { if let Some(state) = &mut state { if let Some((started_loading, _)) = state.started_loading { - if started_loading.elapsed() > LOADING_DELAY { - if let Some(loading) = self.style.loading.as_ref() { - let mut element = loading(); - replacement_id = - Some(element.request_layout(window, cx)); - layout_state.replacement = Some(element); - } + if started_loading.elapsed() > LOADING_DELAY + && let Some(loading) = self.style.loading.as_ref() + { + let mut element = loading(); + replacement_id = Some(element.request_layout(window, cx)); + layout_state.replacement = Some(element); } } else { let current_view = window.current_view(); @@ -476,7 +477,7 @@ impl Element for Img { .paint_image( new_bounds, corner_radii, - data.clone(), + data, layout_state.frame_index, self.style.grayscale, ) @@ -631,7 +632,7 @@ impl Asset for ImageAssetLoader { } }; - let data = if let Ok(format) = image::guess_format(&bytes) { + if let Ok(format) = image::guess_format(&bytes) { let data = match format { ImageFormat::Gif => { let decoder = GifDecoder::new(Cursor::new(&bytes))?; @@ -689,23 +690,12 @@ impl Asset for ImageAssetLoader { } }; - RenderImage::new(data) + Ok(Arc::new(RenderImage::new(data))) } else { - let pixmap = - // TODO: Can we make svgs always rescale? - svg_renderer.render_pixmap(&bytes, SvgSize::ScaleFactor(SMOOTH_SVG_SCALE_FACTOR))?; - - let mut buffer = - ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap(); - - for pixel in buffer.chunks_exact_mut(4) { - swap_rgba_pa_to_bgra(pixel); - } - - RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1)) - }; - - Ok(Arc::new(data)) + svg_renderer + .render_single_frame(&bytes, 1.0, true) + .map_err(Into::into) + } } } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 39f38bdc69d6a5d4c9ce8c7c349707e906124cca..78566208c89a7d6bf73804f611b45aa70e4933ec 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -5,7 +5,7 @@ //! In order to minimize re-renders, this element's state is stored intrusively //! on your own views, so that your code can coordinate directly with the list element's cached state. //! -//! If all of your elements are the same height, see [`UniformList`] for a simpler API +//! If all of your elements are the same height, see [`crate::UniformList`] for a simpler API use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId, @@ -70,6 +70,7 @@ struct StateInner { #[allow(clippy::type_complexity)] scroll_handler: Option>, scrollbar_drag_start_height: Option, + measuring_behavior: ListMeasuringBehavior, } /// Whether the list is scrolling from top to bottom or bottom to top. @@ -103,6 +104,26 @@ pub enum ListSizingBehavior { Auto, } +/// The measuring behavior to apply during layout. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ListMeasuringBehavior { + /// Measure all items in the list. + /// Note: This can be expensive for the first frame in a large list. + Measure(bool), + /// Only measure visible items + #[default] + Visible, +} + +impl ListMeasuringBehavior { + fn reset(&mut self) { + match self { + ListMeasuringBehavior::Measure(has_measured) => *has_measured = false, + ListMeasuringBehavior::Visible => {} + } + } +} + /// The horizontal sizing behavior to apply during layout. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ListHorizontalSizingBehavior { @@ -203,11 +224,20 @@ impl ListState { scroll_handler: None, reset: false, scrollbar_drag_start_height: None, + measuring_behavior: ListMeasuringBehavior::default(), }))); this.splice(0..0, item_count); this } + /// Set the list to measure all items in the list in the first layout phase. + /// + /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements. + pub fn measure_all(self) -> Self { + self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -215,6 +245,7 @@ impl ListState { let old_count = { let state = &mut *self.0.borrow_mut(); state.reset = true; + state.measuring_behavior.reset(); state.logical_scroll_top = None; state.scrollbar_drag_start_height = None; state.items.summary().count @@ -235,7 +266,7 @@ impl ListState { } /// Register with the list state that the items in `old_range` have been replaced - /// by new items. As opposed to [`splice`], this method allows an iterator of optional focus handles + /// by new items. As opposed to [`Self::splice`], this method allows an iterator of optional focus handles /// to be supplied to properly integrate with items in the list that can be focused. If a focused item /// is scrolled out of view, the list will continue to render it to allow keyboard interaction. pub fn splice_focusable( @@ -245,7 +276,7 @@ impl ListState { ) { let state = &mut *self.0.borrow_mut(); - let mut old_items = state.items.cursor::(&()); + let mut old_items = state.items.cursor::(()); let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right); old_items.seek_forward(&Count(old_range.end), Bias::Right); @@ -255,9 +286,9 @@ impl ListState { spliced_count += 1; ListItem::Unmeasured { focus_handle } }), - &(), + (), ); - new_items.append(old_items.suffix(), &()); + new_items.append(old_items.suffix(), ()); drop(old_items); state.items = new_items; @@ -296,7 +327,7 @@ impl ListState { let current_offset = self.logical_scroll_top(); let state = &mut *self.0.borrow_mut(); - let mut cursor = state.items.cursor::(&()); + let mut cursor = state.items.cursor::(()); cursor.seek(&Count(current_offset.item_ix), Bias::Right); let start_pixel_offset = cursor.start().height + current_offset.offset_in_item; @@ -339,7 +370,7 @@ impl ListState { scroll_top.item_ix = ix; scroll_top.offset_in_item = px(0.); } else { - let mut cursor = state.items.cursor::(&()); + let mut cursor = state.items.cursor::(()); cursor.seek(&Count(ix + 1), Bias::Right); let bottom = cursor.start().height + padding.top; let goal_top = px(0.).max(bottom - height + padding.bottom); @@ -368,7 +399,7 @@ impl ListState { return None; } - let mut cursor = state.items.cursor::>(&()); + let mut cursor = state.items.cursor::>(()); cursor.seek(&Count(scroll_top.item_ix), Bias::Right); let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item; @@ -426,7 +457,7 @@ impl ListState { let state = &self.0.borrow(); let logical_scroll_top = state.logical_scroll_top(); - let mut cursor = state.items.cursor::(&()); + let mut cursor = state.items.cursor::(()); let summary: ListItemSummary = cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right); let content_height = state.items.summary().height; @@ -446,7 +477,7 @@ impl ListState { impl StateInner { fn visible_range(&self, height: Pixels, scroll_top: &ListOffset) -> Range { - let mut cursor = self.items.cursor::(&()); + let mut cursor = self.items.cursor::(()); cursor.seek(&Count(scroll_top.item_ix), Bias::Right); let start_y = cursor.start().height + scroll_top.offset_in_item; cursor.seek_forward(&Height(start_y + height), Bias::Left); @@ -478,10 +509,11 @@ impl StateInner { if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { - let mut cursor = self.items.cursor::(&()); - cursor.seek(&Height(new_scroll_top), Bias::Right); - let item_ix = cursor.start().count; - let offset_in_item = new_scroll_top - cursor.start().height; + let (start, ..) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); + let item_ix = start.count; + let offset_in_item = new_scroll_top - start.height; self.logical_scroll_top = Some(ListOffset { item_ix, offset_in_item, @@ -519,9 +551,54 @@ impl StateInner { } fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels { - let mut cursor = self.items.cursor::(&()); - cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right); - cursor.start().height + logical_scroll_top.offset_in_item + let (start, ..) = self.items.find::( + (), + &Count(logical_scroll_top.item_ix), + Bias::Right, + ); + start.height + logical_scroll_top.offset_in_item + } + + fn layout_all_items( + &mut self, + available_width: Pixels, + render_item: &mut RenderItemFn, + window: &mut Window, + cx: &mut App, + ) { + match &mut self.measuring_behavior { + ListMeasuringBehavior::Visible => { + return; + } + ListMeasuringBehavior::Measure(has_measured) => { + if *has_measured { + return; + } + *has_measured = true; + } + } + + let mut cursor = self.items.cursor::(()); + let available_item_space = size( + AvailableSpace::Definite(available_width), + AvailableSpace::MinContent, + ); + + let mut measured_items = Vec::default(); + + for (ix, item) in cursor.enumerate() { + let size = item.size().unwrap_or_else(|| { + let mut element = render_item(ix, window, cx); + element.layout_as_root(available_item_space, window, cx) + }); + + measured_items.push(ListItem::Measured { + size, + focus_handle: item.focus_handle(), + }); + } + + self.items = SumTree::from_iter(measured_items, ()); } fn layout_items( @@ -548,7 +625,7 @@ impl StateInner { AvailableSpace::MinContent, ); - let mut cursor = old_items.cursor::(&()); + let mut cursor = old_items.cursor::(()); // Render items after the scroll top, including those in the trailing overdraw cursor.seek(&Count(scroll_top.item_ix), Bias::Right); @@ -663,11 +740,11 @@ impl StateInner { } let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len()); - let mut cursor = old_items.cursor::(&()); + let mut cursor = old_items.cursor::(()); let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right); - new_items.extend(measured_items, &()); + new_items.extend(measured_items, ()); cursor.seek(&Count(measured_range.end), Bias::Right); - new_items.append(cursor.suffix(), &()); + new_items.append(cursor.suffix(), ()); self.items = new_items; // If none of the visible items are focused, check if an off-screen item is focused @@ -676,7 +753,7 @@ impl StateInner { if !rendered_focused_item { let mut cursor = self .items - .filter::<_, Count>(&(), |summary| summary.has_focus_handles); + .filter::<_, Count>((), |summary| summary.has_focus_handles); cursor.next(); while let Some(item) = cursor.item() { if item.contains_focused(window, cx) { @@ -711,6 +788,13 @@ impl StateInner { cx: &mut App, ) -> Result { window.transact(|window| { + match self.measuring_behavior { + ListMeasuringBehavior::Measure(has_measured) if !has_measured => { + self.layout_all_items(bounds.size.width, render_item, window, cx); + } + _ => {} + } + let mut layout_response = self.layout_items( Some(bounds.size.width), bounds.size.height, @@ -732,46 +816,44 @@ impl StateInner { item.element.prepaint_at(item_origin, window, cx); }); - if let Some(autoscroll_bounds) = window.take_autoscroll() { - if autoscroll { - if autoscroll_bounds.top() < bounds.top() { - return Err(ListOffset { - item_ix: item.index, - offset_in_item: autoscroll_bounds.top() - item_origin.y, - }); - } else if autoscroll_bounds.bottom() > bounds.bottom() { - let mut cursor = self.items.cursor::(&()); - cursor.seek(&Count(item.index), Bias::Right); - let mut height = bounds.size.height - padding.top - padding.bottom; - - // Account for the height of the element down until the autoscroll bottom. - height -= autoscroll_bounds.bottom() - item_origin.y; - - // Keep decreasing the scroll top until we fill all the available space. - while height > Pixels::ZERO { - cursor.prev(); - let Some(item) = cursor.item() else { break }; - - let size = item.size().unwrap_or_else(|| { - let mut item = render_item(cursor.start().0, window, cx); - let item_available_size = size( - bounds.size.width.into(), - AvailableSpace::MinContent, - ); - item.layout_as_root(item_available_size, window, cx) - }); - height -= size.height; - } - - return Err(ListOffset { - item_ix: cursor.start().0, - offset_in_item: if height < Pixels::ZERO { - -height - } else { - Pixels::ZERO - }, + if let Some(autoscroll_bounds) = window.take_autoscroll() + && autoscroll + { + if autoscroll_bounds.top() < bounds.top() { + return Err(ListOffset { + item_ix: item.index, + offset_in_item: autoscroll_bounds.top() - item_origin.y, + }); + } else if autoscroll_bounds.bottom() > bounds.bottom() { + let mut cursor = self.items.cursor::(()); + cursor.seek(&Count(item.index), Bias::Right); + let mut height = bounds.size.height - padding.top - padding.bottom; + + // Account for the height of the element down until the autoscroll bottom. + height -= autoscroll_bounds.bottom() - item_origin.y; + + // Keep decreasing the scroll top until we fill all the available space. + while height > Pixels::ZERO { + cursor.prev(); + let Some(item) = cursor.item() else { break }; + + let size = item.size().unwrap_or_else(|| { + let mut item = render_item(cursor.start().0, window, cx); + let item_available_size = + size(bounds.size.width.into(), AvailableSpace::MinContent); + item.layout_as_root(item_available_size, window, cx) }); + height -= size.height; } + + return Err(ListOffset { + item_ix: cursor.start().0, + offset_in_item: if height < Pixels::ZERO { + -height + } else { + Pixels::ZERO + }, + }); } } @@ -804,11 +886,12 @@ impl StateInner { if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { - let mut cursor = self.items.cursor::(&()); - cursor.seek(&Height(new_scroll_top), Bias::Right); + let (start, _, _) = + self.items + .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = cursor.start().count; - let offset_in_item = new_scroll_top - cursor.start().height; + let item_ix = start.count; + let offset_in_item = new_scroll_top - start.height; self.logical_scroll_top = Some(ListOffset { item_ix, offset_in_item, @@ -940,14 +1023,15 @@ impl Element for List { let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); // If the width of the list has changed, invalidate all cached item heights - if state.last_layout_bounds.map_or(true, |last_bounds| { - last_bounds.size.width != bounds.size.width - }) { + if state + .last_layout_bounds + .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width) + { let new_items = SumTree::from_iter( state.items.iter().map(|item| ListItem::Unmeasured { focus_handle: item.focus_handle(), }), - &(), + (), ); state.items = new_items; @@ -1028,7 +1112,7 @@ impl Styled for List { impl sum_tree::Item for ListItem { type Summary = ListItemSummary; - fn summary(&self, _: &()) -> Self::Summary { + fn summary(&self, _: ()) -> Self::Summary { match self { ListItem::Unmeasured { focus_handle } => ListItemSummary { count: 1, @@ -1050,14 +1134,12 @@ impl sum_tree::Item for ListItem { } } -impl sum_tree::Summary for ListItemSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for ListItemSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, summary: &Self, _: &()) { + fn add_summary(&mut self, summary: &Self) { self.count += summary.count; self.rendered_count += summary.rendered_count; self.unrendered_count += summary.unrendered_count; @@ -1067,33 +1149,33 @@ impl sum_tree::Summary for ListItemSummary { } impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) { self.0 += summary.count; } } impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) { self.0 += summary.height; } } impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count { - fn cmp(&self, other: &ListItemSummary, _: &()) -> std::cmp::Ordering { + fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering { self.0.partial_cmp(&other.count).unwrap() } } impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height { - fn cmp(&self, other: &ListItemSummary, _: &()) -> std::cmp::Ordering { + fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering { self.0.partial_cmp(&other.height).unwrap() } } diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 014f617e2cfc74755908368f57060aeaeb38aa74..914e8a286510a2ffd833db4c4d3ef85c84db073f 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -8,6 +8,7 @@ use crate::{ use anyhow::Context as _; use smallvec::SmallVec; use std::{ + borrow::Cow, cell::{Cell, RefCell}, mem, ops::Range, @@ -180,8 +181,7 @@ impl StyledText { "Can't use `with_default_highlights` and `with_highlights`" ); let runs = Self::compute_runs(&self.text, default_style, highlights); - self.runs = Some(runs); - self + self.with_runs(runs) } /// Set the styling attributes for the given text, as well as @@ -194,7 +194,15 @@ impl StyledText { self.runs.is_none(), "Can't use `with_highlights` and `with_default_highlights`" ); - self.delayed_highlights = Some(highlights.into_iter().collect::>()); + self.delayed_highlights = Some( + highlights + .into_iter() + .inspect(|(run, _)| { + debug_assert!(self.text.is_char_boundary(run.start)); + debug_assert!(self.text.is_char_boundary(run.end)); + }) + .collect::>(), + ); self } @@ -207,8 +215,10 @@ impl StyledText { let mut ix = 0; for (range, highlight) in highlights { if ix < range.start { + debug_assert!(text.is_char_boundary(range.start)); runs.push(default_style.clone().to_run(range.start - ix)); } + debug_assert!(text.is_char_boundary(range.end)); runs.push( default_style .clone() @@ -225,6 +235,11 @@ impl StyledText { /// Set the text runs for this piece of text. pub fn with_runs(mut self, runs: Vec) -> Self { + let mut text = &**self.text; + for run in &runs { + text = text.get(run.len..).expect("invalid text run"); + } + assert!(text.is_empty(), "invalid text run"); self.runs = Some(runs); self } @@ -320,13 +335,12 @@ impl TextLayout { .line_height .to_pixels(font_size.into(), window.rem_size()); - let mut runs = if let Some(runs) = runs { + let runs = if let Some(runs) = runs { runs } else { vec![text_style.to_run(text.len())] }; - - let layout_id = window.request_measured_layout(Default::default(), { + window.request_measured_layout(Default::default(), { let element_state = self.clone(); move |known_dimensions, available_space, window, cx| { @@ -356,24 +370,23 @@ impl TextLayout { (None, "".into()) }; - if let Some(text_layout) = element_state.0.borrow().as_ref() { - if text_layout.size.is_some() - && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) - { - return text_layout.size.unwrap(); - } + if let Some(text_layout) = element_state.0.borrow().as_ref() + && text_layout.size.is_some() + && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) + { + return text_layout.size.unwrap(); } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let text = if let Some(truncate_width) = truncate_width { + let (text, runs) = if let Some(truncate_width) = truncate_width { line_wrapper.truncate_line( text.clone(), truncate_width, &truncation_suffix, - &mut runs, + &runs, ) } else { - text.clone() + (text.clone(), Cow::Borrowed(&*runs)) }; let len = text.len(); @@ -417,9 +430,7 @@ impl TextLayout { size } - }); - - layout_id + }) } fn prepaint(&self, bounds: Bounds, text: &str) { @@ -763,14 +774,13 @@ impl Element for InteractiveText { let mut interactive_state = interactive_state.unwrap_or_default(); if let Some(click_listener) = self.click_listener.take() { let mouse_position = window.mouse_position(); - if let Ok(ix) = text_layout.index_for_position(mouse_position) { - if self + if let Ok(ix) = text_layout.index_for_position(mouse_position) + && self .clickable_ranges .iter() .any(|range| range.contains(&ix)) - { - window.set_cursor_style(crate::CursorStyle::PointingHand, hitbox) - } + { + window.set_cursor_style(crate::CursorStyle::PointingHand, hitbox) } let text_layout = text_layout.clone(); @@ -803,13 +813,13 @@ impl Element for InteractiveText { } else { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseDownEvent, phase, window, _| { - if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { - if let Ok(mouse_down_index) = + if phase == DispatchPhase::Bubble + && hitbox.is_hovered(window) + && let Ok(mouse_down_index) = text_layout.index_for_position(event.position) - { - mouse_down.set(Some(mouse_down_index)); - window.refresh(); - } + { + mouse_down.set(Some(mouse_down_index)); + window.refresh(); } }); } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index cdf90d4eb8934de99a21c65b6c9efa2a2fdde258..739fa1c5e25eb62378fbe57eea1b62c833780d9d 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -5,10 +5,10 @@ //! elements with uniform height. use crate::{ - AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, - Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, - ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled, - Window, point, size, + AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, + GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, + IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, + StyleRefinement, Styled, Window, point, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -71,7 +71,7 @@ pub struct UniformList { /// Frame state used by the [UniformList]. pub struct UniformListFrameState { items: SmallVec<[AnyElement; 32]>, - decorations: SmallVec<[AnyElement; 1]>, + decorations: SmallVec<[AnyElement; 2]>, } /// A handle for controlling the scroll position of a uniform list. @@ -88,6 +88,10 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Center, + /// Attempt to place the element at the bottom of the list's viewport. + /// May not be possible if there's not enough list items above the item scrolled to: + /// in this case, the element will be placed at the closest possible position. + Bottom, } #[derive(Clone, Copy, Debug)] @@ -99,6 +103,7 @@ pub struct DeferredScrollToItem { pub strategy: ScrollStrategy, /// The offset in number of items pub offset: usize, + pub scroll_strict: bool, } #[derive(Clone, Debug, Default)] @@ -133,25 +138,73 @@ impl UniformListScrollHandle { }))) } - /// Scroll the list to the given item index. + /// Scroll the list so that the given item index is visible. + /// + /// This uses non-strict scrolling: if the item is already fully visible, no scrolling occurs. + /// If the item is out of view, it scrolls the minimum amount to bring it into view according + /// to the strategy. pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) { self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { item_index: ix, strategy, offset: 0, + scroll_strict: false, + }); + } + + /// Scroll the list so that the given item index is at scroll strategy position. + /// + /// This uses strict scrolling: the item will always be scrolled to match the strategy position, + /// even if it's already visible. Use this when you need precise positioning. + pub fn scroll_to_item_strict(&self, ix: usize, strategy: ScrollStrategy) { + self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset: 0, + scroll_strict: true, }); } - /// Scroll the list to the given item index with an offset. + /// Scroll the list to the given item index with an offset in number of items. /// - /// For ScrollStrategy::Top, the item will be placed at the offset position from the top. + /// This uses non-strict scrolling: if the item is already visible within the offset region, + /// no scrolling occurs. /// - /// For ScrollStrategy::Center, the item will be centered between offset and the last visible item. + /// The offset parameter shrinks the effective viewport by the specified number of items + /// from the corresponding edge, then applies the scroll strategy within that reduced viewport: + /// - `ScrollStrategy::Top`: Shrinks from top, positions item at the new top + /// - `ScrollStrategy::Center`: Shrinks from top, centers item in the reduced viewport + /// - `ScrollStrategy::Bottom`: Shrinks from bottom, positions item at the new bottom pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) { self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { item_index: ix, strategy, offset, + scroll_strict: false, + }); + } + + /// Scroll the list so that the given item index is at the exact scroll strategy position with an offset. + /// + /// This uses strict scrolling: the item will always be scrolled to match the strategy position, + /// even if it's already visible. + /// + /// The offset parameter shrinks the effective viewport by the specified number of items + /// from the corresponding edge, then applies the scroll strategy within that reduced viewport: + /// - `ScrollStrategy::Top`: Shrinks from top, positions item at the new top + /// - `ScrollStrategy::Center`: Shrinks from top, centers item in the reduced viewport + /// - `ScrollStrategy::Bottom`: Shrinks from bottom, positions item at the new bottom + pub fn scroll_to_item_strict_with_offset( + &self, + ix: usize, + strategy: ScrollStrategy, + offset: usize, + ) { + self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset, + scroll_strict: true, }); } @@ -198,6 +251,8 @@ impl Element for UniformList { None } + // self.max_found_width = 0.0 + // fn request_layout( &mut self, global_id: Option<&GlobalElementId>, @@ -290,7 +345,7 @@ impl Element for UniformList { }; let content_size = Size { width: content_width, - height: longest_item_size.height * self.item_count + padding.top + padding.bottom, + height: longest_item_size.height * self.item_count, }; let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap(); @@ -311,17 +366,7 @@ impl Element for UniformList { content_size, window, cx, - |style, mut scroll_offset, hitbox, window, cx| { - let border = style.border_widths.to_pixels(window.rem_size()); - let padding = style - .padding - .to_pixels(bounds.size.into(), window.rem_size()); - - let padded_bounds = Bounds::from_corners( - bounds.origin + point(border.left + padding.left, border.top), - bounds.bottom_right() - point(border.right + padding.right, border.bottom), - ); - + |_style, mut scroll_offset, hitbox, window, cx| { let y_flipped = if let Some(scroll_handle) = &self.scroll_handle { let scroll_state = scroll_handle.0.borrow(); scroll_state.y_flipped @@ -330,13 +375,14 @@ impl Element for UniformList { }; if self.item_count > 0 { - let content_height = - item_height * self.item_count + padding.top + padding.bottom; + let content_height = item_height * self.item_count; + let is_scrolled_vertically = !scroll_offset.y.is_zero(); - let min_vertical_scroll_offset = padded_bounds.size.height - content_height; - if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset { - shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset; - scroll_offset.y = min_vertical_scroll_offset; + let max_scroll_offset = padded_bounds.size.height - content_height; + + if is_scrolled_vertically && scroll_offset.y < max_scroll_offset { + shared_scroll_offset.borrow_mut().y = max_scroll_offset; + scroll_offset.y = max_scroll_offset; } let content_width = content_size.width + padding.left + padding.right; @@ -354,38 +400,51 @@ impl Element for UniformList { } let list_height = padded_bounds.size.height; let mut updated_scroll_offset = shared_scroll_offset.borrow_mut(); - let item_top = item_height * ix + padding.top; + let item_top = item_height * ix; let item_bottom = item_top + item_height; let scroll_top = -updated_scroll_offset.y; let offset_pixels = item_height * deferred_scroll.offset; let mut scrolled_to_top = false; - if item_top < scroll_top + padding.top + offset_pixels { + if item_top < scroll_top + offset_pixels { scrolled_to_top = true; - updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels; - } else if item_bottom > scroll_top + list_height - padding.bottom { + // todo: using the padding here is wrong - this only works well for few scenarios + updated_scroll_offset.y = -item_top + padding.top + offset_pixels; + } else if item_bottom > scroll_top + list_height { scrolled_to_top = true; - updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom; + updated_scroll_offset.y = -(item_bottom - list_height); } - match deferred_scroll.strategy { - ScrollStrategy::Top => {} - ScrollStrategy::Center => { - if scrolled_to_top { + if deferred_scroll.scroll_strict + || (scrolled_to_top + && (item_top < scroll_top + offset_pixels + || item_bottom > scroll_top + list_height)) + { + match deferred_scroll.strategy { + ScrollStrategy::Top => { + updated_scroll_offset.y = -(item_top - offset_pixels) + .max(Pixels::ZERO) + .min(content_height - list_height) + .max(Pixels::ZERO); + } + ScrollStrategy::Center => { let item_center = item_top + item_height / 2.0; let viewport_height = list_height - offset_pixels; let viewport_center = offset_pixels + viewport_height / 2.0; let target_scroll_top = item_center - viewport_center; - if item_top < scroll_top + offset_pixels - || item_bottom > scroll_top + list_height - { - updated_scroll_offset.y = -target_scroll_top - .max(Pixels::ZERO) - .min(content_height - list_height) - .max(Pixels::ZERO); - } + updated_scroll_offset.y = -target_scroll_top + .max(Pixels::ZERO) + .min(content_height - list_height) + .max(Pixels::ZERO); + } + ScrollStrategy::Bottom => { + updated_scroll_offset.y = -(item_bottom - list_height + + offset_pixels) + .max(Pixels::ZERO) + .min(content_height - list_height) + .max(Pixels::ZERO); } } } @@ -415,14 +474,9 @@ impl Element for UniformList { window.with_content_mask(Some(content_mask), |window| { for (mut item, ix) in items.into_iter().zip(visible_range.clone()) { let item_origin = padded_bounds.origin - + point( - if can_scroll_horizontally { - scroll_offset.x + padding.left - } else { - scroll_offset.x - }, - item_height * ix + scroll_offset.y + padding.top, - ); + + scroll_offset + + point(Pixels::ZERO, item_height * ix); + let available_width = if can_scroll_horizontally { padded_bounds.size.width + scroll_offset.x.abs() } else { @@ -437,18 +491,8 @@ impl Element for UniformList { frame_state.items.push(item); } - let bounds = Bounds::new( - padded_bounds.origin - + point( - if can_scroll_horizontally { - scroll_offset.x + padding.left - } else { - scroll_offset.x - }, - scroll_offset.y + padding.top, - ), - padded_bounds.size, - ); + let bounds = + Bounds::new(padded_bounds.origin + scroll_offset, padded_bounds.size); for decoration in &self.decorations { let mut decoration = decoration.as_ref().compute( visible_range.clone(), @@ -529,6 +573,31 @@ pub trait UniformListDecoration { ) -> AnyElement; } +impl UniformListDecoration for Entity { + fn compute( + &self, + visible_range: Range, + bounds: Bounds, + scroll_offset: Point, + item_height: Pixels, + item_count: usize, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + self.update(cx, |inner, cx| { + inner.compute( + visible_range, + bounds, + scroll_offset, + item_height, + item_count, + window, + cx, + ) + }) + } +} + impl UniformList { /// Selects a specific list item for measurement. pub fn with_width_from_item(mut self, item_index: Option) -> Self { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 273a3ea503bad26de075a8eb1c6cec01d23f453b..b6d3a407f5dbbab07e0273e668e9b5710824edda 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -2,21 +2,20 @@ use crate::{App, PlatformDispatcher}; use async_task::Runnable; use futures::channel::mpsc; use smol::prelude::*; -use std::mem::ManuallyDrop; -use std::panic::Location; -use std::thread::{self, ThreadId}; use std::{ fmt::Debug, marker::PhantomData, - mem, + mem::{self, ManuallyDrop}, num::NonZeroUsize, + panic::Location, pin::Pin, rc::Rc, sync::{ Arc, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicUsize, Ordering}, }, task::{Context, Poll}, + thread::{self, ThreadId}, time::{Duration, Instant}, }; use util::TryFutureExt; @@ -39,7 +38,7 @@ pub struct BackgroundExecutor { /// This is intentionally `!Send` via the `not_send` marker field. This is because /// `ForegroundExecutor::spawn` does not require `Send` but checks at runtime that the future is /// only polled from the same thread it was spawned from. These checks would fail when spawning -/// foreground tasks from from background threads. +/// foreground tasks from background threads. #[derive(Clone)] pub struct ForegroundExecutor { #[doc(hidden)] @@ -123,7 +122,12 @@ impl TaskLabel { /// Construct a new task label. pub fn new() -> Self { static NEXT_TASK_LABEL: AtomicUsize = AtomicUsize::new(1); - Self(NEXT_TASK_LABEL.fetch_add(1, SeqCst).try_into().unwrap()) + Self( + NEXT_TASK_LABEL + .fetch_add(1, Ordering::SeqCst) + .try_into() + .unwrap(), + ) } } @@ -210,7 +214,8 @@ impl BackgroundExecutor { } let deadline = timeout.map(|timeout| Instant::now() + timeout); - let unparker = self.dispatcher.unparker(); + let parker = parking::Parker::new(); + let unparker = parker.unparker(); let waker = waker_fn(move || { unparker.unpark(); }); @@ -222,10 +227,14 @@ impl BackgroundExecutor { Poll::Pending => { let timeout = deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - if !self.dispatcher.park(timeout) - && deadline.is_some_and(|deadline| deadline < Instant::now()) - { - return Err(future); + if let Some(timeout) = timeout { + if !parker.park_timeout(timeout) + && deadline.is_some_and(|deadline| deadline < Instant::now()) + { + return Err(future); + } + } else { + parker.park(); } } } @@ -242,6 +251,8 @@ impl BackgroundExecutor { ) -> Result + use> { use std::sync::atomic::AtomicBool; + use parking::Parker; + let mut future = Box::pin(future); if timeout == Some(Duration::ZERO) { return Err(future); @@ -255,17 +266,24 @@ impl BackgroundExecutor { } else { usize::MAX }; - let unparker = self.dispatcher.unparker(); + + let parker = Parker::new(); + let unparker = parker.unparker(); + let awoken = Arc::new(AtomicBool::new(false)); let waker = waker_fn({ let awoken = awoken.clone(); + let unparker = unparker.clone(); move || { - awoken.store(true, SeqCst); + awoken.store(true, Ordering::SeqCst); unparker.unpark(); } }); let mut cx = std::task::Context::from_waker(&waker); + let duration = Duration::from_secs(500); + let mut test_should_end_by = Instant::now() + duration; + loop { match future.as_mut().poll(&mut cx) { Poll::Ready(result) => return Ok(result), @@ -276,7 +294,7 @@ impl BackgroundExecutor { max_ticks -= 1; if !dispatcher.tick(background_only) { - if awoken.swap(false, SeqCst) { + if awoken.swap(false, Ordering::SeqCst) { continue; } @@ -297,7 +315,13 @@ impl BackgroundExecutor { "parked with nothing left to run{waiting_message}{backtrace_message}", ) } - self.dispatcher.park(None); + dispatcher.set_unparker(unparker.clone()); + parker.park_timeout( + test_should_end_by.saturating_duration_since(Instant::now()), + ); + if Instant::now() > test_should_end_by { + panic!("test timed out after {duration:?} with allow_parking") + } } } } @@ -391,7 +415,7 @@ impl BackgroundExecutor { } /// in tests, run all tasks that are ready to run. If after doing so - /// the test still has outstanding tasks, this will panic. (See also `allow_parking`) + /// the test still has outstanding tasks, this will panic. (See also [`Self::allow_parking`]) #[cfg(any(test, feature = "test-support"))] pub fn run_until_parked(&self) { self.dispatcher.as_test().unwrap().run_until_parked() @@ -405,7 +429,7 @@ impl BackgroundExecutor { self.dispatcher.as_test().unwrap().allow_parking(); } - /// undoes the effect of [`allow_parking`]. + /// undoes the effect of [`Self::allow_parking`]. #[cfg(any(test, feature = "test-support"))] pub fn forbid_parking(&self) { self.dispatcher.as_test().unwrap().forbid_parking(); @@ -455,7 +479,6 @@ impl ForegroundExecutor { } /// Enqueues the given Task to run on the main thread at some point in the future. - #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task where R: 'static, @@ -480,7 +503,7 @@ impl ForegroundExecutor { /// Variant of `async_task::spawn_local` that includes the source location of the spawn in panics. /// /// Copy-modified from: -/// https://github.com/smol-rs/async-task/blob/ca9dbe1db9c422fd765847fa91306e30a6bb58a9/src/runnable.rs#L405 +/// #[track_caller] fn spawn_local_with_source_location( future: Fut, @@ -513,9 +536,7 @@ where "local task dropped by a thread that didn't spawn it. Task spawned at {}", self.location ); - unsafe { - ManuallyDrop::drop(&mut self.inner); - } + unsafe { ManuallyDrop::drop(&mut self.inner) }; } } diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 2de3e23ff716d179bb4e2b55c80650d2b010c38e..fa6f90b9ac9949ed7b5444e13045aaef6f9c0224 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -102,7 +102,7 @@ pub struct Point { /// # Examples /// /// ``` -/// # use gpui::Point; +/// use gpui::point; /// let p = point(10, 20); /// assert_eq!(p.x, 10); /// assert_eq!(p.y, 20); @@ -122,6 +122,7 @@ impl Point { /// # Examples /// /// ``` + /// use gpui::Point; /// let p = Point::new(10, 20); /// assert_eq!(p.x, 10); /// assert_eq!(p.y, 20); @@ -148,6 +149,7 @@ impl Point { /// let p_float = p.map(|coord| coord as f32); /// assert_eq!(p_float, Point { x: 3.0, y: 4.0 }); /// ``` + #[must_use] pub fn map(&self, f: impl Fn(T) -> U) -> Point { Point { x: f(self.x.clone()), @@ -198,9 +200,9 @@ impl Point { /// /// ``` /// # use gpui::{Point, Pixels, ScaledPixels}; - /// let p = Point { x: Pixels(10.0), y: Pixels(20.0) }; + /// let p = Point { x: Pixels::from(10.0), y: Pixels::from(20.0) }; /// let scaled_p = p.scale(1.5); - /// assert_eq!(scaled_p, Point { x: ScaledPixels(15.0), y: ScaledPixels(30.0) }); + /// assert_eq!(scaled_p, Point { x: ScaledPixels::from(15.0), y: ScaledPixels::from(30.0) }); /// ``` pub fn scale(&self, factor: f32) -> Point { Point { @@ -215,7 +217,7 @@ impl Point { /// /// ``` /// # use gpui::{Pixels, Point}; - /// let p = Point { x: Pixels(3.0), y: Pixels(4.0) }; + /// let p = Point { x: Pixels::from(3.0), y: Pixels::from(4.0) }; /// assert_eq!(p.magnitude(), 5.0); /// ``` pub fn magnitude(&self) -> f64 { @@ -418,7 +420,7 @@ impl Size { /// # Examples /// /// ``` -/// # use gpui::Size; +/// use gpui::size; /// let my_size = size(10, 20); /// assert_eq!(my_size.width, 10); /// assert_eq!(my_size.height, 20); @@ -491,9 +493,9 @@ impl Size { /// /// ``` /// # use gpui::{Size, Pixels, ScaledPixels}; - /// let size = Size { width: Pixels(100.0), height: Pixels(50.0) }; + /// let size = Size { width: Pixels::from(100.0), height: Pixels::from(50.0) }; /// let scaled_size = size.scale(2.0); - /// assert_eq!(scaled_size, Size { width: ScaledPixels(200.0), height: ScaledPixels(100.0) }); + /// assert_eq!(scaled_size, Size { width: ScaledPixels::from(200.0), height: ScaledPixels::from(100.0) }); /// ``` pub fn scale(&self, factor: f32) -> Size { Size { @@ -1025,12 +1027,13 @@ where /// origin: Point { x: 10, y: 10 }, /// size: Size { width: 10, height: 10 }, /// }; - /// bounds.dilate(5); - /// assert_eq!(bounds, Bounds { + /// let expanded_bounds = bounds.dilate(5); + /// assert_eq!(expanded_bounds, Bounds { /// origin: Point { x: 5, y: 5 }, /// size: Size { width: 20, height: 20 }, /// }); /// ``` + #[must_use] pub fn dilate(&self, amount: T) -> Bounds { let double_amount = amount.clone() + amount.clone(); Bounds { @@ -1040,13 +1043,14 @@ where } /// Extends the bounds different amounts in each direction. + #[must_use] pub fn extend(&self, amount: Edges) -> Bounds { Bounds { origin: self.origin.clone() - point(amount.left.clone(), amount.top.clone()), size: self.size.clone() + size( amount.left.clone() + amount.right.clone(), - amount.top.clone() + amount.bottom.clone(), + amount.top.clone() + amount.bottom, ), } } @@ -1159,10 +1163,10 @@ where /// Computes the space available within outer bounds. pub fn space_within(&self, outer: &Self) -> Edges { Edges { - top: self.top().clone() - outer.top().clone(), - right: outer.right().clone() - self.right().clone(), - bottom: outer.bottom().clone() - self.bottom().clone(), - left: self.left().clone() - outer.left().clone(), + top: self.top() - outer.top(), + right: outer.right() - self.right(), + bottom: outer.bottom() - self.bottom(), + left: self.left() - outer.left(), } } } @@ -1359,7 +1363,7 @@ where /// # Examples /// /// ``` - /// # use zed::{Bounds, Corner, Point, Size}; + /// use gpui::{Bounds, Corner, Point, Size}; /// let bounds = Bounds { /// origin: Point { x: 0, y: 0 }, /// size: Size { width: 10, height: 20 }, @@ -1399,7 +1403,7 @@ where /// # Examples /// /// ``` - /// # use gpui::{Point, Bounds}; + /// # use gpui::{Point, Bounds, Size}; /// let bounds = Bounds { /// origin: Point { x: 0, y: 0 }, /// size: Size { width: 10, height: 10 }, @@ -1407,8 +1411,8 @@ where /// let inside_point = Point { x: 5, y: 5 }; /// let outside_point = Point { x: 15, y: 15 }; /// - /// assert!(bounds.contains_point(&inside_point)); - /// assert!(!bounds.contains_point(&outside_point)); + /// assert!(bounds.contains(&inside_point)); + /// assert!(!bounds.contains(&outside_point)); /// ``` pub fn contains(&self, point: &Point) -> bool { point.x >= self.origin.x @@ -1565,6 +1569,7 @@ impl Bounds { /// # Returns /// /// Returns `true` if either the width or the height of the bounds is less than or equal to zero, indicating an empty area. + #[must_use] pub fn is_empty(&self) -> bool { self.size.width <= T::default() || self.size.height <= T::default() } @@ -1621,16 +1626,22 @@ impl Bounds { /// # Examples /// /// ``` - /// # use gpui::{Bounds, Point, Size, Pixels}; + /// # use gpui::{Bounds, Point, Size, Pixels, ScaledPixels, DevicePixels}; /// let bounds = Bounds { - /// origin: Point { x: Pixels(10.0), y: Pixels(20.0) }, - /// size: Size { width: Pixels(30.0), height: Pixels(40.0) }, + /// origin: Point { x: Pixels::from(10.0), y: Pixels::from(20.0) }, + /// size: Size { width: Pixels::from(30.0), height: Pixels::from(40.0) }, /// }; /// let display_scale_factor = 2.0; /// let scaled_bounds = bounds.scale(display_scale_factor); /// assert_eq!(scaled_bounds, Bounds { - /// origin: Point { x: ScaledPixels(20.0), y: ScaledPixels(40.0) }, - /// size: Size { width: ScaledPixels(60.0), height: ScaledPixels(80.0) }, + /// origin: Point { + /// x: ScaledPixels::from(20.0), + /// y: ScaledPixels::from(40.0), + /// }, + /// size: Size { + /// width: ScaledPixels::from(60.0), + /// height: ScaledPixels::from(80.0) + /// }, /// }); /// ``` pub fn scale(&self, factor: f32) -> Bounds { @@ -1641,7 +1652,7 @@ impl Bounds { } /// Convert the bounds from logical pixels to physical pixels - pub fn to_device_pixels(&self, factor: f32) -> Bounds { + pub fn to_device_pixels(self, factor: f32) -> Bounds { Bounds { origin: point( DevicePixels((self.origin.x.0 * factor).round() as i32), @@ -1712,7 +1723,7 @@ where top: self.top.clone() * rhs.top, right: self.right.clone() * rhs.right, bottom: self.bottom.clone() * rhs.bottom, - left: self.left.clone() * rhs.left, + left: self.left * rhs.left, } } } @@ -1847,7 +1858,7 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::Edges; + /// # use gpui::{Edges, Length}; /// let auto_edges = Edges::auto(); /// assert_eq!(auto_edges.top, Length::Auto); /// assert_eq!(auto_edges.right, Length::Auto); @@ -1875,12 +1886,12 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::Edges; - /// let no_edges = Edges::zero(); - /// assert_eq!(no_edges.top, Length::Definite(DefiniteLength::from(Pixels(0.)))); - /// assert_eq!(no_edges.right, Length::Definite(DefiniteLength::from(Pixels(0.)))); - /// assert_eq!(no_edges.bottom, Length::Definite(DefiniteLength::from(Pixels(0.)))); - /// assert_eq!(no_edges.left, Length::Definite(DefiniteLength::from(Pixels(0.)))); + /// # use gpui::{DefiniteLength, Edges, Length, Pixels}; + /// let no_edges = Edges::::zero(); + /// assert_eq!(no_edges.top, Length::Definite(DefiniteLength::from(Pixels::ZERO))); + /// assert_eq!(no_edges.right, Length::Definite(DefiniteLength::from(Pixels::ZERO))); + /// assert_eq!(no_edges.bottom, Length::Definite(DefiniteLength::from(Pixels::ZERO))); + /// assert_eq!(no_edges.left, Length::Definite(DefiniteLength::from(Pixels::ZERO))); /// ``` pub fn zero() -> Self { Self { @@ -1905,8 +1916,8 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::{px, Edges}; - /// let no_edges = Edges::zero(); + /// # use gpui::{px, DefiniteLength, Edges}; + /// let no_edges = Edges::::zero(); /// assert_eq!(no_edges.top, DefiniteLength::from(px(0.))); /// assert_eq!(no_edges.right, DefiniteLength::from(px(0.))); /// assert_eq!(no_edges.bottom, DefiniteLength::from(px(0.))); @@ -1938,7 +1949,7 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::{Edges, DefiniteLength, px, AbsoluteLength, Size}; + /// # use gpui::{Edges, DefiniteLength, px, AbsoluteLength, rems, Size}; /// let edges = Edges { /// top: DefiniteLength::Absolute(AbsoluteLength::Pixels(px(10.0))), /// right: DefiniteLength::Fraction(0.5), @@ -1957,7 +1968,7 @@ impl Edges { /// assert_eq!(edges_in_pixels.bottom, px(32.0)); // 2 rems /// assert_eq!(edges_in_pixels.left, px(50.0)); // 25% of parent width /// ``` - pub fn to_pixels(&self, parent_size: Size, rem_size: Pixels) -> Edges { + pub fn to_pixels(self, parent_size: Size, rem_size: Pixels) -> Edges { Edges { top: self.top.to_pixels(parent_size.height, rem_size), right: self.right.to_pixels(parent_size.width, rem_size), @@ -1980,12 +1991,12 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::Edges; - /// let no_edges = Edges::zero(); - /// assert_eq!(no_edges.top, AbsoluteLength::Pixels(Pixels(0.0))); - /// assert_eq!(no_edges.right, AbsoluteLength::Pixels(Pixels(0.0))); - /// assert_eq!(no_edges.bottom, AbsoluteLength::Pixels(Pixels(0.0))); - /// assert_eq!(no_edges.left, AbsoluteLength::Pixels(Pixels(0.0))); + /// # use gpui::{AbsoluteLength, Edges, Pixels}; + /// let no_edges = Edges::::zero(); + /// assert_eq!(no_edges.top, AbsoluteLength::Pixels(Pixels::ZERO)); + /// assert_eq!(no_edges.right, AbsoluteLength::Pixels(Pixels::ZERO)); + /// assert_eq!(no_edges.bottom, AbsoluteLength::Pixels(Pixels::ZERO)); + /// assert_eq!(no_edges.left, AbsoluteLength::Pixels(Pixels::ZERO)); /// ``` pub fn zero() -> Self { Self { @@ -2012,7 +2023,7 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::{Edges, AbsoluteLength, Pixels, px}; + /// # use gpui::{Edges, AbsoluteLength, Pixels, px, rems}; /// let edges = Edges { /// top: AbsoluteLength::Pixels(px(10.0)), /// right: AbsoluteLength::Rems(rems(1.0)), @@ -2027,7 +2038,7 @@ impl Edges { /// assert_eq!(edges_in_pixels.bottom, px(20.0)); // Already in pixels /// assert_eq!(edges_in_pixels.left, px(32.0)); // 2 rems converted to pixels /// ``` - pub fn to_pixels(&self, rem_size: Pixels) -> Edges { + pub fn to_pixels(self, rem_size: Pixels) -> Edges { Edges { top: self.top.to_pixels(rem_size), right: self.right.to_pixels(rem_size), @@ -2053,18 +2064,18 @@ impl Edges { /// # Examples /// /// ``` - /// # use gpui::{Edges, Pixels}; + /// # use gpui::{Edges, Pixels, ScaledPixels}; /// let edges = Edges { - /// top: Pixels(10.0), - /// right: Pixels(20.0), - /// bottom: Pixels(30.0), - /// left: Pixels(40.0), + /// top: Pixels::from(10.0), + /// right: Pixels::from(20.0), + /// bottom: Pixels::from(30.0), + /// left: Pixels::from(40.0), /// }; /// let scaled_edges = edges.scale(2.0); - /// assert_eq!(scaled_edges.top, ScaledPixels(20.0)); - /// assert_eq!(scaled_edges.right, ScaledPixels(40.0)); - /// assert_eq!(scaled_edges.bottom, ScaledPixels(60.0)); - /// assert_eq!(scaled_edges.left, ScaledPixels(80.0)); + /// assert_eq!(scaled_edges.top, ScaledPixels::from(20.0)); + /// assert_eq!(scaled_edges.right, ScaledPixels::from(40.0)); + /// assert_eq!(scaled_edges.bottom, ScaledPixels::from(60.0)); + /// assert_eq!(scaled_edges.left, ScaledPixels::from(80.0)); /// ``` pub fn scale(&self, factor: f32) -> Edges { Edges { @@ -2104,7 +2115,7 @@ impl From for Edges { } /// Identifies a corner of a 2d box. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Corner { /// The top left corner TopLeft, @@ -2122,9 +2133,10 @@ impl Corner { /// # Examples /// /// ``` - /// # use zed::Corner; + /// # use gpui::Corner; /// assert_eq!(Corner::TopLeft.opposite_corner(), Corner::BottomRight); /// ``` + #[must_use] pub fn opposite_corner(self) -> Self { match self { Corner::TopLeft => Corner::BottomRight, @@ -2139,10 +2151,11 @@ impl Corner { /// # Examples /// /// ``` - /// # use zed::Corner; + /// # use gpui::{Axis, Corner}; /// let result = Corner::TopLeft.other_side_corner_along(Axis::Horizontal); /// assert_eq!(result, Corner::TopRight); /// ``` + #[must_use] pub fn other_side_corner_along(self, axis: Axis) -> Self { match axis { Axis::Vertical => match self { @@ -2224,7 +2237,7 @@ where /// # Examples /// /// ``` - /// # use zed::{Corner, Corners}; + /// # use gpui::{Corner, Corners}; /// let corners = Corners { /// top_left: 1, /// top_right: 2, @@ -2233,6 +2246,7 @@ where /// }; /// assert_eq!(corners.corner(Corner::BottomLeft), 3); /// ``` + #[must_use] pub fn corner(&self, corner: Corner) -> T { match corner { Corner::TopLeft => self.top_left.clone(), @@ -2257,22 +2271,22 @@ impl Corners { /// # Examples /// /// ``` - /// # use gpui::{Corners, AbsoluteLength, Pixels, Size}; + /// # use gpui::{Corners, AbsoluteLength, Pixels, Rems, Size}; /// let corners = Corners { - /// top_left: AbsoluteLength::Pixels(Pixels(15.0)), + /// top_left: AbsoluteLength::Pixels(Pixels::from(15.0)), /// top_right: AbsoluteLength::Rems(Rems(1.0)), - /// bottom_right: AbsoluteLength::Pixels(Pixels(30.0)), + /// bottom_right: AbsoluteLength::Pixels(Pixels::from(30.0)), /// bottom_left: AbsoluteLength::Rems(Rems(2.0)), /// }; - /// let rem_size = Pixels(16.0); - /// let corners_in_pixels = corners.to_pixels(size, rem_size); + /// let rem_size = Pixels::from(16.0); + /// let corners_in_pixels = corners.to_pixels(rem_size); /// - /// assert_eq!(corners_in_pixels.top_left, Pixels(15.0)); - /// assert_eq!(corners_in_pixels.top_right, Pixels(16.0)); // 1 rem converted to pixels - /// assert_eq!(corners_in_pixels.bottom_right, Pixels(30.0)); - /// assert_eq!(corners_in_pixels.bottom_left, Pixels(32.0)); // 2 rems converted to pixels + /// assert_eq!(corners_in_pixels.top_left, Pixels::from(15.0)); + /// assert_eq!(corners_in_pixels.top_right, Pixels::from(16.0)); // 1 rem converted to pixels + /// assert_eq!(corners_in_pixels.bottom_right, Pixels::from(30.0)); + /// assert_eq!(corners_in_pixels.bottom_left, Pixels::from(32.0)); // 2 rems converted to pixels /// ``` - pub fn to_pixels(&self, rem_size: Pixels) -> Corners { + pub fn to_pixels(self, rem_size: Pixels) -> Corners { Corners { top_left: self.top_left.to_pixels(rem_size), top_right: self.top_right.to_pixels(rem_size), @@ -2298,19 +2312,20 @@ impl Corners { /// # Examples /// /// ``` - /// # use gpui::{Corners, Pixels}; + /// # use gpui::{Corners, Pixels, ScaledPixels}; /// let corners = Corners { - /// top_left: Pixels(10.0), - /// top_right: Pixels(20.0), - /// bottom_right: Pixels(30.0), - /// bottom_left: Pixels(40.0), + /// top_left: Pixels::from(10.0), + /// top_right: Pixels::from(20.0), + /// bottom_right: Pixels::from(30.0), + /// bottom_left: Pixels::from(40.0), /// }; /// let scaled_corners = corners.scale(2.0); - /// assert_eq!(scaled_corners.top_left, ScaledPixels(20.0)); - /// assert_eq!(scaled_corners.top_right, ScaledPixels(40.0)); - /// assert_eq!(scaled_corners.bottom_right, ScaledPixels(60.0)); - /// assert_eq!(scaled_corners.bottom_left, ScaledPixels(80.0)); + /// assert_eq!(scaled_corners.top_left, ScaledPixels::from(20.0)); + /// assert_eq!(scaled_corners.top_right, ScaledPixels::from(40.0)); + /// assert_eq!(scaled_corners.bottom_right, ScaledPixels::from(60.0)); + /// assert_eq!(scaled_corners.bottom_left, ScaledPixels::from(80.0)); /// ``` + #[must_use] pub fn scale(&self, factor: f32) -> Corners { Corners { top_left: self.top_left.scale(factor), @@ -2325,6 +2340,7 @@ impl Corners { /// # Returns /// /// The maximum `Pixels` value among all four corners. + #[must_use] pub fn max(&self) -> Pixels { self.top_left .max(self.top_right) @@ -2343,6 +2359,7 @@ impl + Ord + Clone + Debug + Default + PartialEq> Corner /// # Returns /// /// Corner radii values clamped to fit. + #[must_use] pub fn clamp_radii_for_quad_size(self, size: Size) -> Corners { let max = cmp::min(size.width, size.height) / 2.; Corners { @@ -2372,14 +2389,14 @@ impl Corners { /// # Examples /// /// ``` - /// # use gpui::{Corners, Pixels}; + /// # use gpui::{Corners, Pixels, Rems}; /// let corners = Corners { - /// top_left: Pixels(10.0), - /// top_right: Pixels(20.0), - /// bottom_right: Pixels(30.0), - /// bottom_left: Pixels(40.0), + /// top_left: Pixels::from(10.0), + /// top_right: Pixels::from(20.0), + /// bottom_right: Pixels::from(30.0), + /// bottom_left: Pixels::from(40.0), /// }; - /// let corners_in_rems = corners.map(|&px| Rems(px.0 / 16.0)); + /// let corners_in_rems = corners.map(|&px| Rems(f32::from(px) / 16.0)); /// assert_eq!(corners_in_rems, Corners { /// top_left: Rems(0.625), /// top_right: Rems(1.25), @@ -2387,6 +2404,7 @@ impl Corners { /// bottom_left: Rems(2.5), /// }); /// ``` + #[must_use] pub fn map(&self, f: impl Fn(&T) -> U) -> Corners where U: Clone + Debug + Default + PartialEq, @@ -2411,7 +2429,7 @@ where top_left: self.top_left.clone() * rhs.top_left, top_right: self.top_right.clone() * rhs.top_right, bottom_right: self.bottom_right.clone() * rhs.bottom_right, - bottom_left: self.bottom_left.clone() * rhs.bottom_left, + bottom_left: self.bottom_left * rhs.bottom_left, } } } @@ -2526,14 +2544,14 @@ impl From for Radians { /// # Examples /// /// ``` -/// use gpui::Pixels; +/// use gpui::{Pixels, ScaledPixels}; /// /// // Define a length of 10 pixels -/// let length = Pixels(10.0); +/// let length = Pixels::from(10.0); /// /// // Define a length and scale it by a factor of 2 /// let scaled_length = length.scale(2.0); -/// assert_eq!(scaled_length, Pixels(20.0)); +/// assert_eq!(scaled_length, ScaledPixels::from(20.0)); /// ``` #[derive( Clone, @@ -2552,7 +2570,7 @@ impl From for Radians { JsonSchema, )] #[repr(transparent)] -pub struct Pixels(pub f32); +pub struct Pixels(pub(crate) f32); impl Div for Pixels { type Output = f32; @@ -2687,6 +2705,7 @@ impl Pixels { /// /// The resulting `ScaledPixels` represent the scaled value which can be used for rendering /// calculations where display scaling is considered. + #[must_use] pub fn scale(&self, factor: f32) -> ScaledPixels { ScaledPixels(self.0 * factor) } @@ -2790,6 +2809,12 @@ impl From for u32 { } } +impl From<&Pixels> for u32 { + fn from(pixels: &Pixels) -> Self { + pixels.0 as u32 + } +} + impl From for Pixels { fn from(pixels: u32) -> Self { Pixels(pixels as f32) @@ -2858,7 +2883,7 @@ impl DevicePixels { /// let total_bytes = pixels.to_bytes(bytes_per_pixel); /// assert_eq!(total_bytes, 40); // 10 pixels * 4 bytes/pixel = 40 bytes /// ``` - pub fn to_bytes(&self, bytes_per_pixel: u8) -> u32 { + pub fn to_bytes(self, bytes_per_pixel: u8) -> u32 { self.0 as u32 * bytes_per_pixel as u32 } } @@ -2943,6 +2968,15 @@ impl ScaledPixels { /// # Returns /// /// Returns a new `ScaledPixels` instance with the rounded value. + pub fn round(&self) -> Self { + Self(self.0.round()) + } + + /// Ceils the `ScaledPixels` value to the nearest whole number. + /// + /// # Returns + /// + /// Returns a new `ScaledPixels` instance with the ceiled value. pub fn ceil(&self) -> Self { Self(self.0.ceil()) } @@ -2992,6 +3026,12 @@ impl From for u32 { } } +impl From for ScaledPixels { + fn from(pixels: f32) -> Self { + Self(pixels) + } +} + impl Div for ScaledPixels { type Output = f32; @@ -3073,8 +3113,8 @@ pub struct Rems(pub f32); impl Rems { /// Convert this Rem value to pixels. - pub fn to_pixels(&self, rem_size: Pixels) -> Pixels { - *self * rem_size + pub fn to_pixels(self, rem_size: Pixels) -> Pixels { + self * rem_size } } @@ -3160,17 +3200,17 @@ impl AbsoluteLength { /// # Examples /// /// ``` - /// # use gpui::{AbsoluteLength, Pixels}; - /// let length_in_pixels = AbsoluteLength::Pixels(Pixels(42.0)); + /// # use gpui::{AbsoluteLength, Pixels, Rems}; + /// let length_in_pixels = AbsoluteLength::Pixels(Pixels::from(42.0)); /// let length_in_rems = AbsoluteLength::Rems(Rems(2.0)); - /// let rem_size = Pixels(16.0); + /// let rem_size = Pixels::from(16.0); /// - /// assert_eq!(length_in_pixels.to_pixels(rem_size), Pixels(42.0)); - /// assert_eq!(length_in_rems.to_pixels(rem_size), Pixels(32.0)); + /// assert_eq!(length_in_pixels.to_pixels(rem_size), Pixels::from(42.0)); + /// assert_eq!(length_in_rems.to_pixels(rem_size), Pixels::from(32.0)); /// ``` - pub fn to_pixels(&self, rem_size: Pixels) -> Pixels { + pub fn to_pixels(self, rem_size: Pixels) -> Pixels { match self { - AbsoluteLength::Pixels(pixels) => *pixels, + AbsoluteLength::Pixels(pixels) => pixels, AbsoluteLength::Rems(rems) => rems.to_pixels(rem_size), } } @@ -3184,10 +3224,10 @@ impl AbsoluteLength { /// # Returns /// /// Returns the `AbsoluteLength` as `Pixels`. - pub fn to_rems(&self, rem_size: Pixels) -> Rems { + pub fn to_rems(self, rem_size: Pixels) -> Rems { match self { AbsoluteLength::Pixels(pixels) => Rems(pixels.0 / rem_size.0), - AbsoluteLength::Rems(rems) => *rems, + AbsoluteLength::Rems(rems) => rems, } } } @@ -3311,16 +3351,16 @@ impl DefiniteLength { /// let base_size = AbsoluteLength::Pixels(px(100.0)); /// let rem_size = px(16.0); /// - /// assert_eq!(length_in_pixels.to_pixels(base_size, rem_size), Pixels(42.0)); - /// assert_eq!(length_in_rems.to_pixels(base_size, rem_size), Pixels(32.0)); - /// assert_eq!(length_as_fraction.to_pixels(base_size, rem_size), Pixels(50.0)); + /// assert_eq!(length_in_pixels.to_pixels(base_size, rem_size), Pixels::from(42.0)); + /// assert_eq!(length_in_rems.to_pixels(base_size, rem_size), Pixels::from(32.0)); + /// assert_eq!(length_as_fraction.to_pixels(base_size, rem_size), Pixels::from(50.0)); /// ``` - pub fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Pixels { + pub fn to_pixels(self, base_size: AbsoluteLength, rem_size: Pixels) -> Pixels { match self { DefiniteLength::Absolute(size) => size.to_pixels(rem_size), DefiniteLength::Fraction(fraction) => match base_size { - AbsoluteLength::Pixels(px) => px * *fraction, - AbsoluteLength::Rems(rems) => rems * rem_size * *fraction, + AbsoluteLength::Pixels(px) => px * fraction, + AbsoluteLength::Rems(rems) => rems * rem_size * fraction, }, } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index f0ce04a915bba30fff6988ae42b7973bb286b49e..2e391b6e442126a74884046a5058976c0495abfd 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -1,69 +1,11 @@ -//! # Welcome to GPUI! -//! -//! GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework -//! for Rust, designed to support a wide variety of applications. -//! -//! ## Getting Started -//! -//! GPUI is still in active development as we work on the Zed code editor and isn't yet on crates.io. -//! You'll also need to use the latest version of stable rust. Add the following to your Cargo.toml: -//! -//! ``` -//! gpui = { git = "https://github.com/zed-industries/zed" } -//! ``` -//! -//! Everything in GPUI starts with an [`Application`]. You can create one with [`Application::new`], and -//! kick off your application by passing a callback to [`Application::run`]. Inside this callback, -//! you can create a new window with [`App::open_window`], and register your first root -//! view. See [gpui.rs](https://www.gpui.rs/) for a complete example. -//! -//! ## The Big Picture -//! -//! GPUI offers three different [registers](https://en.wikipedia.org/wiki/Register_(sociolinguistics)) depending on your needs: -//! -//! - State management and communication with [`Entity`]'s. Whenever you need to store application state -//! that communicates between different parts of your application, you'll want to use GPUI's -//! entities. Entities are owned by GPUI and are only accessible through an owned smart pointer -//! similar to an [`std::rc::Rc`]. See the [`app::context`] module for more information. -//! -//! - High level, declarative UI with views. All UI in GPUI starts with a view. A view is simply -//! a [`Entity`] that can be rendered, by implementing the [`Render`] trait. At the start of each frame, GPUI -//! will call this render method on the root view of a given window. Views build a tree of -//! [`Element`]s, lay them out and style them with a tailwind-style API, and then give them to -//! GPUI to turn into pixels. See the [`elements::Div`] element for an all purpose swiss-army -//! knife for UI. -//! -//! - Low level, imperative UI with Elements. Elements are the building blocks of UI in GPUI, and they -//! provide a nice wrapper around an imperative API that provides as much flexibility and control as -//! you need. Elements have total control over how they and their child elements are rendered and -//! can be used for making efficient views into large lists, implement custom layouting for a code editor, -//! and anything else you can think of. See the [`element`] module for more information. -//! -//! Each of these registers has one or more corresponding contexts that can be accessed from all GPUI services. -//! This context is your main interface to GPUI, and is used extensively throughout the framework. -//! -//! ## Other Resources -//! -//! In addition to the systems above, GPUI provides a range of smaller services that are useful for building -//! complex applications: -//! -//! - Actions are user-defined structs that are used for converting keystrokes into logical operations in your UI. -//! Use this for implementing keyboard shortcuts, such as cmd-q (See `action` module for more information). -//! - Platform services, such as `quit the app` or `open a URL` are available as methods on the [`app::App`]. -//! - An async executor that is integrated with the platform's event loop. See the [`executor`] module for more information., -//! - The [`gpui::test`](test) macro provides a convenient way to write tests for your GPUI applications. Tests also have their -//! own kind of context, a [`TestAppContext`] which provides ways of simulating common platform input. See [`app::test_context`] -//! and [`test`] modules for more details. -//! -//! Currently, the best way to learn about these APIs is to read the Zed source code, ask us about it at a fireside hack, or drop -//! a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, -//! and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). - +#![doc = include_str!("../README.md")] #![deny(missing_docs)] #![allow(clippy::type_complexity)] // Not useful, GPUI makes heavy use of callbacks #![allow(clippy::collapsible_else_if)] // False positives in platform specific code #![allow(unused_mut)] // False positives in platform specific code +extern crate self as gpui; + #[macro_use] mod action; mod app; @@ -104,6 +46,9 @@ mod util; mod view; mod window; +#[cfg(doc)] +pub mod _ownership_and_data_flow; + /// Do not touch, here be dragons for use by gpui_macros and such. #[doc(hidden)] pub mod private { @@ -111,13 +56,12 @@ pub mod private { pub use inventory; pub use schemars; pub use serde; - pub use serde_derive; pub use serde_json; } mod seal { /// A mechanism for restricting implementations of a trait to only those in GPUI. - /// See: https://predr.ag/blog/definitive-guide-to-sealed-traits-in-rust/ + /// See: pub trait Sealed {} } @@ -151,12 +95,14 @@ pub use smol::Timer; pub use style::*; pub use styled::*; pub use subscription::*; -use svg_renderer::*; +pub use svg_renderer::*; pub(crate) use tab_stop::*; pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; pub use text_system::*; +#[cfg(any(test, feature = "test-support"))] +pub use util::smol_timeout; pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; pub use view::*; pub use window::*; @@ -172,6 +118,10 @@ pub trait AppContext { type Result; /// Create a new entity in the app context. + #[expect( + clippy::wrong_self_convention, + reason = "`App::new` is an ubiquitous function for creating entities" + )] fn new( &mut self, build_entity: impl FnOnce(&mut Context) -> T, @@ -348,7 +298,7 @@ impl Flatten for Result { } /// Information about the GPU GPUI is running on. -#[derive(Default, Debug)] +#[derive(Default, Debug, serde::Serialize, serde::Deserialize, Clone)] pub struct GpuSpecs { /// Whether the GPU is really a fake (like `llvmpipe`) running on the CPU. pub is_software_emulated: bool, diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 4acd7f90c1273a1eb51b1be2ccc672a79e6f7710..dc36ef9e16feedf31c01cd38327fd12729f894b3 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -72,7 +72,7 @@ pub trait EntityInputHandler: 'static + Sized { ) -> Option; } -/// The canonical implementation of [`PlatformInputHandler`]. Call [`Window::handle_input`] +/// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] /// with an instance during your element's paint. pub struct ElementInputHandler { view: Entity, diff --git a/crates/gpui/src/inspector.rs b/crates/gpui/src/inspector.rs index 23c46edcc11ed36cfbe3ad110dc296af3e129784..ad3ba6a4b693ef3270d570dc98b4e03f7927d388 100644 --- a/crates/gpui/src/inspector.rs +++ b/crates/gpui/src/inspector.rs @@ -39,7 +39,7 @@ mod conditional { impl Clone for InspectorElementPath { fn clone(&self) -> Self { Self { - global_id: crate::GlobalElementId(self.global_id.0.clone()), + global_id: self.global_id.clone(), source_location: self.source_location, } } @@ -164,7 +164,7 @@ mod conditional { if let Some(render_inspector) = cx .inspector_element_registry .renderers_by_type_id - .remove(&type_id) + .remove(type_id) { let mut element = (render_inspector)( active_element.id.clone(), diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 218ae5fcdfbb60b2dd99c8a656d95c3962edc98c..dd521ff718322d663f761e05598edce83432bf2d 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -115,6 +115,16 @@ impl InputEvent for MouseDownEvent { } impl MouseEvent for MouseDownEvent {} +impl MouseDownEvent { + /// Returns true if this mouse up event should focus the element. + pub fn is_focusing(&self) -> bool { + match self.button { + MouseButton::Left => true, + _ => false, + } + } +} + /// A mouse up event from the platform #[derive(Clone, Debug, Default)] pub struct MouseUpEvent { @@ -137,8 +147,19 @@ impl InputEvent for MouseUpEvent { PlatformInput::MouseUp(self) } } + impl MouseEvent for MouseUpEvent {} +impl MouseUpEvent { + /// Returns true if this mouse up event should focus the element. + pub fn is_focusing(&self) -> bool { + match self.button { + MouseButton::Left => true, + _ => false, + } + } +} + /// A click event, generated when a mouse button is pressed and released. #[derive(Clone, Debug, Default)] pub struct MouseClickEvent { @@ -259,6 +280,14 @@ impl ClickEvent { ClickEvent::Mouse(event) => event.up.click_count, } } + + /// Returns whether the click event is generated by a keyboard event + pub fn is_keyboard(&self) -> bool { + match self { + ClickEvent::Mouse(_) => false, + ClickEvent::Keyboard(_) => true, + } + } } /// An enum representing the keyboard button that was pressed for a click event. @@ -474,6 +503,7 @@ impl InputEvent for MouseExitEvent { PlatformInput::MouseExited(self) } } + impl MouseEvent for MouseExitEvent {} impl Deref for MouseExitEvent { diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index c3f5d186030bf2a38c2345724666ca38003cd484..f0c857abd6f3c353105b4272b51ca519f1906078 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -1,54 +1,54 @@ -/// KeyDispatch is where GPUI deals with binding actions to key events. -/// -/// The key pieces to making a key binding work are to define an action, -/// implement a method that takes that action as a type parameter, -/// and then to register the action during render on a focused node -/// with a keymap context: -/// -/// ```rust -/// actions!(editor,[Undo, Redo]); -/// -/// impl Editor { -/// fn undo(&mut self, _: &Undo, _window: &mut Window, _cx: &mut Context) { ... } -/// fn redo(&mut self, _: &Redo, _window: &mut Window, _cx: &mut Context) { ... } -/// } -/// -/// impl Render for Editor { -/// fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { -/// div() -/// .track_focus(&self.focus_handle(cx)) -/// .key_context("Editor") -/// .on_action(cx.listener(Editor::undo)) -/// .on_action(cx.listener(Editor::redo)) -/// ... -/// } -/// } -///``` -/// -/// The keybindings themselves are managed independently by calling cx.bind_keys(). -/// (Though mostly when developing Zed itself, you just need to add a new line to -/// assets/keymaps/default-{platform}.json). -/// -/// ```rust -/// cx.bind_keys([ -/// KeyBinding::new("cmd-z", Editor::undo, Some("Editor")), -/// KeyBinding::new("cmd-shift-z", Editor::redo, Some("Editor")), -/// ]) -/// ``` -/// -/// With all of this in place, GPUI will ensure that if you have an Editor that contains -/// the focus, hitting cmd-z will Undo. -/// -/// In real apps, it is a little more complicated than this, because typically you have -/// several nested views that each register keyboard handlers. In this case action matching -/// bubbles up from the bottom. For example in Zed, the Workspace is the top-level view, which contains Pane's, which contain Editors. If there are conflicting keybindings defined -/// then the Editor's bindings take precedence over the Pane's bindings, which take precedence over the Workspace. -/// -/// In GPUI, keybindings are not limited to just single keystrokes, you can define -/// sequences by separating the keys with a space: -/// -/// KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane")) -/// +//! KeyDispatch is where GPUI deals with binding actions to key events. +//! +//! The key pieces to making a key binding work are to define an action, +//! implement a method that takes that action as a type parameter, +//! and then to register the action during render on a focused node +//! with a keymap context: +//! +//! ```ignore +//! actions!(editor,[Undo, Redo]); +//! +//! impl Editor { +//! fn undo(&mut self, _: &Undo, _window: &mut Window, _cx: &mut Context) { ... } +//! fn redo(&mut self, _: &Redo, _window: &mut Window, _cx: &mut Context) { ... } +//! } +//! +//! impl Render for Editor { +//! fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { +//! div() +//! .track_focus(&self.focus_handle(cx)) +//! .key_context("Editor") +//! .on_action(cx.listener(Editor::undo)) +//! .on_action(cx.listener(Editor::redo)) +//! ... +//! } +//! } +//!``` +//! +//! The keybindings themselves are managed independently by calling cx.bind_keys(). +//! (Though mostly when developing Zed itself, you just need to add a new line to +//! assets/keymaps/default-{platform}.json). +//! +//! ```ignore +//! cx.bind_keys([ +//! KeyBinding::new("cmd-z", Editor::undo, Some("Editor")), +//! KeyBinding::new("cmd-shift-z", Editor::redo, Some("Editor")), +//! ]) +//! ``` +//! +//! With all of this in place, GPUI will ensure that if you have an Editor that contains +//! the focus, hitting cmd-z will Undo. +//! +//! In real apps, it is a little more complicated than this, because typically you have +//! several nested views that each register keyboard handlers. In this case action matching +//! bubbles up from the bottom. For example in Zed, the Workspace is the top-level view, which contains Pane's, which contain Editors. If there are conflicting keybindings defined +//! then the Editor's bindings take precedence over the Pane's bindings, which take precedence over the Workspace. +//! +//! In GPUI, keybindings are not limited to just single keystrokes, you can define +//! sequences by separating the keys with a space: +//! +//! KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane")) + use crate::{ Action, ActionRegistry, App, DispatchPhase, EntityId, FocusId, KeyBinding, KeyContext, Keymap, Keystroke, ModifiersChangedEvent, Window, @@ -408,7 +408,7 @@ impl DispatchTree { keymap .bindings_for_action(action) .filter(|binding| { - Self::binding_matches_predicate_and_not_shadowed(&keymap, &binding, context_stack) + Self::binding_matches_predicate_and_not_shadowed(&keymap, binding, context_stack) }) .cloned() .collect() @@ -426,7 +426,7 @@ impl DispatchTree { .bindings_for_action(action) .rev() .find(|binding| { - Self::binding_matches_predicate_and_not_shadowed(&keymap, &binding, context_stack) + Self::binding_matches_predicate_and_not_shadowed(&keymap, binding, context_stack) }) .cloned() } @@ -458,7 +458,7 @@ impl DispatchTree { .keymap .borrow() .bindings_for_input(input, &context_stack); - return (bindings, partial, context_stack); + (bindings, partial, context_stack) } /// dispatch_key processes the keystroke @@ -552,7 +552,7 @@ impl DispatchTree { let mut current_node_id = Some(target); while let Some(node_id) = current_node_id { dispatch_path.push(node_id); - current_node_id = self.nodes[node_id.0].parent; + current_node_id = self.nodes.get(node_id.0).and_then(|node| node.parent); } dispatch_path.reverse(); // Reverse the path so it goes from the root to the focused node. dispatch_path @@ -572,18 +572,14 @@ impl DispatchTree { focus_path } - pub fn view_path(&self, view_id: EntityId) -> SmallVec<[EntityId; 8]> { - let mut view_path: SmallVec<[EntityId; 8]> = SmallVec::new(); + pub fn view_path_reversed(&self, view_id: EntityId) -> impl Iterator { let mut current_node_id = self.view_node_ids.get(&view_id).copied(); - while let Some(node_id) = current_node_id { - let node = self.node(node_id); - if let Some(view_id) = node.view_id { - view_path.push(view_id); - } - current_node_id = node.parent; - } - view_path.reverse(); // Reverse the path so it goes from the root to the view node. - view_path + + std::iter::successors( + current_node_id.map(|node_id| self.node(node_id)), + |node_id| Some(self.node(node_id.parent?)), + ) + .filter_map(|node| node.view_id) } pub fn node(&self, node_id: DispatchNodeId) -> &DispatchNode { @@ -639,10 +635,7 @@ mod tests { } fn partial_eq(&self, action: &dyn Action) -> bool { - action - .as_any() - .downcast_ref::() - .map_or(false, |a| self == a) + action.as_any().downcast_ref::() == Some(self) } fn boxed_clone(&self) -> std::boxed::Box { diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 83d7479a04423d249a2be69c69756211eb9d485d..33d956917055942cce365e9069cbb007e202eaf2 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -4,7 +4,7 @@ mod context; pub use binding::*; pub use context::*; -use crate::{Action, Keystroke, is_no_action}; +use crate::{Action, AsKeystroke, Keystroke, is_no_action}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::any::TypeId; @@ -118,10 +118,12 @@ impl Keymap { pub fn all_bindings_for_input(&self, input: &[Keystroke]) -> Vec { self.bindings() .rev() - .filter_map(|binding| { - binding.match_keystrokes(input).filter(|pending| !pending)?; - Some(binding.clone()) + .filter(|binding| { + binding + .match_keystrokes(input) + .is_some_and(|pending| !pending) }) + .cloned() .collect() } @@ -141,14 +143,14 @@ impl Keymap { /// only. pub fn bindings_for_input( &self, - input: &[Keystroke], + input: &[impl AsKeystroke], context_stack: &[KeyContext], ) -> (SmallVec<[KeyBinding; 1]>, bool) { let mut matched_bindings = SmallVec::<[(usize, BindingIndex, &KeyBinding); 1]>::new(); let mut pending_bindings = SmallVec::<[(BindingIndex, &KeyBinding); 1]>::new(); for (ix, binding) in self.bindings().enumerate().rev() { - let Some(depth) = self.binding_enabled(binding, &context_stack) else { + let Some(depth) = self.binding_enabled(binding, context_stack) else { continue; }; let Some(pending) = binding.match_keystrokes(input) else { @@ -168,9 +170,21 @@ impl Keymap { let mut bindings: SmallVec<[_; 1]> = SmallVec::new(); let mut first_binding_index = None; + for (_, ix, binding) in matched_bindings { if is_no_action(&*binding.action) { - break; + // Only break if this is a user-defined NoAction binding + // This allows user keymaps to override base keymap NoAction bindings + if let Some(meta) = binding.meta { + if meta.0 == 0 { + break; + } + } else { + // If no meta is set, assume it's a user binding for safety + break; + } + // For non-user NoAction bindings, continue searching for user overrides + continue; } bindings.push(binding.clone()); first_binding_index.get_or_insert(ix); @@ -192,7 +206,6 @@ impl Keymap { (bindings, !pending.is_empty()) } - /// Check if the given binding is enabled, given a certain key context. /// Returns the deepest depth at which the binding matches, or None if it doesn't match. fn binding_enabled(&self, binding: &KeyBinding, contexts: &[KeyContext]) -> Option { @@ -264,7 +277,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let (result, pending) = keymap.bindings_for_input( &[Keystroke::parse("ctrl-a").unwrap()], @@ -290,7 +303,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // binding is only enabled in a specific context assert!( @@ -344,7 +357,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let space = || Keystroke::parse("space").unwrap(); let w = || Keystroke::parse("w").unwrap(); @@ -364,29 +377,29 @@ mod tests { // Ensure `space` results in pending input on the workspace, but not editor let space_workspace = keymap.bindings_for_input(&[space()], &workspace_context()); assert!(space_workspace.0.is_empty()); - assert_eq!(space_workspace.1, true); + assert!(space_workspace.1); let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context()); assert!(space_editor.0.is_empty()); - assert_eq!(space_editor.1, false); + assert!(!space_editor.1); // Ensure `space w` results in pending input on the workspace, but not editor let space_w_workspace = keymap.bindings_for_input(&space_w, &workspace_context()); assert!(space_w_workspace.0.is_empty()); - assert_eq!(space_w_workspace.1, true); + assert!(space_w_workspace.1); let space_w_editor = keymap.bindings_for_input(&space_w, &editor_workspace_context()); assert!(space_w_editor.0.is_empty()); - assert_eq!(space_w_editor.1, false); + assert!(!space_w_editor.1); // Ensure `space w w` results in the binding in the workspace, but not in the editor let space_w_w_workspace = keymap.bindings_for_input(&space_w_w, &workspace_context()); assert!(!space_w_w_workspace.0.is_empty()); - assert_eq!(space_w_w_workspace.1, false); + assert!(!space_w_w_workspace.1); let space_w_w_editor = keymap.bindings_for_input(&space_w_w, &editor_workspace_context()); assert!(space_w_w_editor.0.is_empty()); - assert_eq!(space_w_w_editor.1, false); + assert!(!space_w_w_editor.1); // Now test what happens if we have another binding defined AFTER the NoAction // that should result in pending @@ -396,11 +409,11 @@ mod tests { KeyBinding::new("space w x", ActionAlpha {}, Some("editor")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context()); assert!(space_editor.0.is_empty()); - assert_eq!(space_editor.1, true); + assert!(space_editor.1); // Now test what happens if we have another binding defined BEFORE the NoAction // that should result in pending @@ -410,11 +423,11 @@ mod tests { KeyBinding::new("space w w", NoAction {}, Some("editor")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context()); assert!(space_editor.0.is_empty()); - assert_eq!(space_editor.1, true); + assert!(space_editor.1); // Now test what happens if we have another binding defined at a higher context // that should result in pending @@ -424,11 +437,11 @@ mod tests { KeyBinding::new("space w w", NoAction {}, Some("editor")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context()); assert!(space_editor.0.is_empty()); - assert_eq!(space_editor.1, true); + assert!(space_editor.1); } #[test] @@ -439,7 +452,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // Ensure `space` results in pending input on the workspace, but not editor let (result, pending) = keymap.bindings_for_input( @@ -447,7 +460,7 @@ mod tests { &[KeyContext::parse("editor").unwrap()], ); assert!(result.is_empty()); - assert_eq!(pending, true); + assert!(pending); let bindings = [ KeyBinding::new("ctrl-w left", ActionAlpha {}, Some("editor")), @@ -455,7 +468,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // Ensure `space` results in pending input on the workspace, but not editor let (result, pending) = keymap.bindings_for_input( @@ -463,7 +476,7 @@ mod tests { &[KeyContext::parse("editor").unwrap()], ); assert_eq!(result.len(), 1); - assert_eq!(pending, false); + assert!(!pending); } #[test] @@ -474,7 +487,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // Ensure `space` results in pending input on the workspace, but not editor let (result, pending) = keymap.bindings_for_input( @@ -482,7 +495,7 @@ mod tests { &[KeyContext::parse("editor").unwrap()], ); assert!(result.is_empty()); - assert_eq!(pending, false); + assert!(!pending); } #[test] @@ -494,7 +507,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // Ensure `space` results in pending input on the workspace, but not editor let (result, pending) = keymap.bindings_for_input( @@ -505,7 +518,7 @@ mod tests { ], ); assert_eq!(result.len(), 1); - assert_eq!(pending, false); + assert!(!pending); } #[test] @@ -516,7 +529,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); // Ensure `space` results in pending input on the workspace, but not editor let (result, pending) = keymap.bindings_for_input( @@ -527,7 +540,7 @@ mod tests { ], ); assert_eq!(result.len(), 0); - assert_eq!(pending, false); + assert!(!pending); } #[test] @@ -537,7 +550,7 @@ mod tests { KeyBinding::new("ctrl-x 0", ActionAlpha, Some("Workspace")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let matched = keymap.bindings_for_input( &[Keystroke::parse("ctrl-x")].map(Result::unwrap), @@ -560,7 +573,7 @@ mod tests { KeyBinding::new("ctrl-x 0", NoAction, Some("Workspace")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let matched = keymap.bindings_for_input( &[Keystroke::parse("ctrl-x")].map(Result::unwrap), @@ -579,7 +592,7 @@ mod tests { KeyBinding::new("ctrl-x 0", NoAction, Some("vim_mode == normal")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let matched = keymap.bindings_for_input( &[Keystroke::parse("ctrl-x")].map(Result::unwrap), @@ -602,7 +615,7 @@ mod tests { KeyBinding::new("ctrl-x", ActionBeta, Some("vim_mode == normal")), ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); let matched = keymap.bindings_for_input( &[Keystroke::parse("ctrl-x")].map(Result::unwrap), @@ -618,6 +631,33 @@ mod tests { assert!(!matched.1); } + #[test] + fn test_context_precedence_with_same_source() { + // Test case: User has both Workspace and Editor bindings for the same key + // Editor binding should take precedence over Workspace binding + let bindings = [ + KeyBinding::new("cmd-r", ActionAlpha {}, Some("Workspace")), + KeyBinding::new("cmd-r", ActionBeta {}, Some("Editor")), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + // Test with context stack: [Workspace, Editor] (Editor is deeper) + let (result, _) = keymap.bindings_for_input( + &[Keystroke::parse("cmd-r").unwrap()], + &[ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("Editor").unwrap(), + ], + ); + + // Both bindings should be returned, but Editor binding should be first (highest precedence) + assert_eq!(result.len(), 2); + assert!(result[0].action.partial_eq(&ActionBeta {})); // Editor binding first + assert!(result[1].action.partial_eq(&ActionAlpha {})); // Workspace binding second + } + #[test] fn test_bindings_for_action() { let bindings = [ @@ -629,7 +669,7 @@ mod tests { ]; let mut keymap = Keymap::default(); - keymap.add_bindings(bindings.clone()); + keymap.add_bindings(bindings); assert_bindings(&keymap, &ActionAlpha {}, &["ctrl-a"]); assert_bindings(&keymap, &ActionBeta {}, &[]); @@ -639,9 +679,37 @@ mod tests { fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) { let actual = keymap .bindings_for_action(action) - .map(|binding| binding.keystrokes[0].unparse()) + .map(|binding| binding.keystrokes[0].inner().unparse()) .collect::>(); assert_eq!(actual, expected, "{:?}", action); } } + + #[test] + fn test_source_precedence_sorting() { + // KeybindSource precedence: User (0) > Vim (1) > Base (2) > Default (3) + // Test that user keymaps take precedence over default keymaps at the same context depth + let mut keymap = Keymap::default(); + + // Add a default keymap binding first + let mut default_binding = KeyBinding::new("cmd-r", ActionAlpha {}, Some("Editor")); + default_binding.set_meta(KeyBindingMetaIndex(3)); // Default source + keymap.add_bindings([default_binding]); + + // Add a user keymap binding + let mut user_binding = KeyBinding::new("cmd-r", ActionBeta {}, Some("Editor")); + user_binding.set_meta(KeyBindingMetaIndex(0)); // User source + keymap.add_bindings([user_binding]); + + // Test with Editor context stack + let (result, _) = keymap.bindings_for_input( + &[Keystroke::parse("cmd-r").unwrap()], + &[KeyContext::parse("Editor").unwrap()], + ); + + // User binding should take precedence over default binding + assert_eq!(result.len(), 2); + assert!(result[0].action.partial_eq(&ActionBeta {})); + assert!(result[1].action.partial_eq(&ActionAlpha {})); + } } diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index 1d3f612c5bef76d75cb1bd8ee9d9c686190c3fd7..fc4b32941b85f4cdea31aaba7198d3e7043ee481 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/crates/gpui/src/keymap/binding.rs @@ -1,14 +1,15 @@ use std::rc::Rc; -use collections::HashMap; - -use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString}; +use crate::{ + Action, AsKeystroke, DummyKeyboardMapper, InvalidKeystrokeError, KeyBindingContextPredicate, + KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, +}; use smallvec::SmallVec; /// A keybinding and its associated metadata, from the keymap. pub struct KeyBinding { pub(crate) action: Box, - pub(crate) keystrokes: SmallVec<[Keystroke; 2]>, + pub(crate) keystrokes: SmallVec<[KeybindingKeystroke; 2]>, pub(crate) context_predicate: Option>, pub(crate) meta: Option, /// The json input string used when building the keybinding, if any @@ -30,12 +31,17 @@ impl Clone for KeyBinding { impl KeyBinding { /// Construct a new keybinding from the given data. Panics on parse error. pub fn new(keystrokes: &str, action: A, context: Option<&str>) -> Self { - let context_predicate = if let Some(context) = context { - Some(KeyBindingContextPredicate::parse(context).unwrap().into()) - } else { - None - }; - Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap() + let context_predicate = + context.map(|context| KeyBindingContextPredicate::parse(context).unwrap().into()); + Self::load( + keystrokes, + Box::new(action), + context_predicate, + false, + None, + &DummyKeyboardMapper, + ) + .unwrap() } /// Load a keybinding from the given raw data. @@ -43,24 +49,22 @@ impl KeyBinding { keystrokes: &str, action: Box, context_predicate: Option>, - key_equivalents: Option<&HashMap>, + use_key_equivalents: bool, action_input: Option, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> std::result::Result { - let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes + let keystrokes: SmallVec<[KeybindingKeystroke; 2]> = keystrokes .split_whitespace() - .map(Keystroke::parse) + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new_with_mapper( + keystroke, + use_key_equivalents, + keyboard_mapper, + )) + }) .collect::>()?; - if let Some(equivalents) = key_equivalents { - for keystroke in keystrokes.iter_mut() { - if keystroke.key.chars().count() == 1 { - if let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap()) { - keystroke.key = key.to_string(); - } - } - } - } - Ok(Self { keystrokes, action, @@ -82,13 +86,13 @@ impl KeyBinding { } /// Check if the given keystrokes match this binding. - pub fn match_keystrokes(&self, typed: &[Keystroke]) -> Option { + pub fn match_keystrokes(&self, typed: &[impl AsKeystroke]) -> Option { if self.keystrokes.len() < typed.len() { return None; } for (target, typed) in self.keystrokes.iter().zip(typed.iter()) { - if !typed.should_match(target) { + if !typed.as_keystroke().should_match(target) { return None; } } @@ -97,7 +101,7 @@ impl KeyBinding { } /// Get the keystrokes associated with this binding - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { self.keystrokes.as_slice() } diff --git a/crates/gpui/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs index 281035fe97614dd810f1057c8094b2c698984166..960bd1752fe8c1527b9c593658e429af4cd61029 100644 --- a/crates/gpui/src/keymap/context.rs +++ b/crates/gpui/src/keymap/context.rs @@ -287,7 +287,7 @@ impl KeyBindingContextPredicate { return false; } } - return true; + true } // Workspace > Pane > Editor // @@ -305,7 +305,7 @@ impl KeyBindingContextPredicate { return true; } } - return false; + false } Self::And(left, right) => { left.eval_inner(contexts, all_contexts) && right.eval_inner(contexts, all_contexts) @@ -668,11 +668,7 @@ mod tests { let contexts = vec![other_context.clone(), child_context.clone()]; assert!(!predicate.eval(&contexts)); - let contexts = vec![ - parent_context.clone(), - other_context.clone(), - child_context.clone(), - ]; + let contexts = vec![parent_context.clone(), other_context, child_context.clone()]; assert!(predicate.eval(&contexts)); assert!(!predicate.eval(&[])); @@ -681,7 +677,7 @@ mod tests { let zany_predicate = KeyBindingContextPredicate::parse("child > child").unwrap(); assert!(!zany_predicate.eval(slice::from_ref(&child_context))); - assert!(zany_predicate.eval(&[child_context.clone(), child_context.clone()])); + assert!(zany_predicate.eval(&[child_context.clone(), child_context])); } #[test] @@ -718,7 +714,7 @@ mod tests { let not_descendant = KeyBindingContextPredicate::parse("parent > !child").unwrap(); assert!(!not_descendant.eval(slice::from_ref(&parent_context))); assert!(!not_descendant.eval(slice::from_ref(&child_context))); - assert!(!not_descendant.eval(&[parent_context.clone(), child_context.clone()])); + assert!(!not_descendant.eval(&[parent_context, child_context])); let double_not = KeyBindingContextPredicate::parse("!!editor").unwrap(); assert!(double_not.eval(slice::from_ref(&editor_context))); diff --git a/crates/gpui/src/path_builder.rs b/crates/gpui/src/path_builder.rs index 6c8cfddd523c4d56c81ebcbbf1437b5cc418d73c..40a6e71e0a1738adf1ed261183d2340682826992 100644 --- a/crates/gpui/src/path_builder.rs +++ b/crates/gpui/src/path_builder.rs @@ -278,7 +278,7 @@ impl PathBuilder { options: &StrokeOptions, ) -> Result, Error> { let path = if let Some(dash_array) = dash_array { - let measurements = lyon::algorithms::measure::PathMeasurements::from_path(&path, 0.01); + let measurements = lyon::algorithms::measure::PathMeasurements::from_path(path, 0.01); let mut sampler = measurements .create_sampler(path, lyon::algorithms::measure::SampleType::Normalized); let mut builder = lyon::path::Path::builder(); @@ -318,7 +318,7 @@ impl PathBuilder { Ok(Self::build_path(buf)) } - /// Builds a [`Path`] from a [`lyon::VertexBuffers`]. + /// Builds a [`Path`] from a [`lyon::tessellation::VertexBuffers`]. pub fn build_path(buf: VertexBuffers) -> Path { if buf.vertices.is_empty() { return Path::new(Point::default()); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index bf6ce6870363d432cd49392292b8dda7bb51834d..20a135df51cc935ce725f88e3978abb9f3fc07c9 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -39,8 +39,8 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene, - ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window, + Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, + ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; @@ -48,7 +48,6 @@ use async_task::Runnable; use futures::channel::oneshot; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; -use parking::Unparker; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use schemars::JsonSchema; use seahash::SeaHasher; @@ -83,6 +82,9 @@ pub(crate) use test::*; #[cfg(target_os = "windows")] pub(crate) use windows::*; +#[cfg(all(target_os = "linux", feature = "wayland"))] +pub use linux::layer_shell; + #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; @@ -121,6 +123,15 @@ pub(crate) fn current_platform(headless: bool) -> Rc { } } +#[cfg(target_os = "windows")] +pub(crate) fn current_platform(_headless: bool) -> Rc { + Rc::new( + WindowsPlatform::new() + .inspect_err(|err| show_error("Failed to launch", err.to_string())) + .unwrap(), + ) +} + /// Return which compositor we're guessing we'll use. /// Does not attempt to connect to the given compositor #[cfg(any(target_os = "linux", target_os = "freebsd"))] @@ -152,15 +163,6 @@ pub fn guess_compositor() -> &'static str { } } -#[cfg(target_os = "windows")] -pub(crate) fn current_platform(_headless: bool) -> Rc { - Rc::new( - WindowsPlatform::new() - .inspect_err(|err| show_error("Failed to launch", err.to_string())) - .unwrap(), - ) -} - pub(crate) trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; @@ -231,7 +233,6 @@ pub(crate) trait Platform: 'static { fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); - fn on_keyboard_layout_change(&self, callback: Box); fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { @@ -251,7 +252,6 @@ pub(crate) trait Platform: 'static { fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); - fn keyboard_layout(&self) -> Box; fn compositor_name(&self) -> &'static str { "" @@ -272,6 +272,10 @@ pub(crate) trait Platform: 'static { fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; fn delete_credentials(&self, url: &str) -> Task>; + + fn keyboard_layout(&self) -> Box; + fn keyboard_mapper(&self) -> Rc; + fn on_keyboard_layout_change(&self, callback: Box); } /// A handle to a platform's display, e.g. a monitor or laptop screen. @@ -288,10 +292,13 @@ pub trait PlatformDisplay: Send + Sync + Debug { /// Get the default bounds for this display to place a window fn default_bounds(&self) -> Bounds { - let center = self.bounds().center(); - let offset = DEFAULT_WINDOW_SIZE / 2.0; + let bounds = self.bounds(); + let center = bounds.center(); + let clipped_window_size = DEFAULT_WINDOW_SIZE.min(&bounds.size); + + let offset = clipped_window_size / 2.0; let origin = point(center.x - offset.width, center.y - offset.height); - Bounds::new(origin, DEFAULT_WINDOW_SIZE) + Bounds::new(origin, clipped_window_size) } } @@ -347,8 +354,6 @@ impl Debug for DisplayId { } } -unsafe impl Send for DisplayId {} - /// Which part of the window to resize #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResizeEdge { @@ -500,9 +505,27 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn sprite_atlas(&self) -> Arc; // macOS specific methods + fn get_title(&self) -> String { + String::new() + } + fn tabbed_windows(&self) -> Option> { + None + } + fn tab_bar_visible(&self) -> bool { + false + } fn set_edited(&mut self, _edited: bool) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} + fn on_move_tab_to_new_window(&self, _callback: Box) {} + fn on_merge_all_windows(&self, _callback: Box) {} + fn on_select_previous_tab(&self, _callback: Box) {} + fn on_select_next_tab(&self, _callback: Box) {} + fn on_toggle_tab_bar(&self, _callback: Box) {} + fn merge_all_windows(&self) {} + fn move_tab_to_new_window(&self) {} + fn toggle_window_tab_overview(&self) {} + fn set_tabbing_identifier(&self, _identifier: Option) {} #[cfg(target_os = "windows")] fn get_raw_handle(&self) -> windows::HWND; @@ -528,7 +551,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn set_client_inset(&self, _inset: Pixels) {} fn gpu_specs(&self) -> Option; - fn update_ime_position(&self, _bounds: Bounds); + fn update_ime_position(&self, _bounds: Bounds); #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { @@ -544,8 +567,6 @@ pub trait PlatformDispatcher: Send + Sync { fn dispatch(&self, runnable: Runnable, label: Option); fn dispatch_on_main_thread(&self, runnable: Runnable); fn dispatch_after(&self, duration: Duration, runnable: Runnable); - fn park(&self, timeout: Option) -> bool; - fn unparker(&self) -> Unparker; fn now(&self) -> Instant { Instant::now() } @@ -592,7 +613,7 @@ impl PlatformTextSystem for NoopTextSystem { } fn font_id(&self, _descriptor: &Font) -> Result { - return Ok(FontId(1)); + Ok(FontId(1)) } fn font_metrics(&self, _font_id: FontId) -> FontMetrics { @@ -673,7 +694,7 @@ impl PlatformTextSystem for NoopTextSystem { } } let mut runs = Vec::default(); - if glyphs.len() > 0 { + if !glyphs.is_empty() { runs.push(ShapedRun { font_id: FontId(0), glyphs, @@ -693,6 +714,41 @@ impl PlatformTextSystem for NoopTextSystem { } } +// Adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.cpp +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +#[allow(dead_code)] +pub(crate) fn get_gamma_correction_ratios(gamma: f32) -> [f32; 4] { + const GAMMA_INCORRECT_TARGET_RATIOS: [[f32; 4]; 13] = [ + [0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0, 0.0000 / 4.0], // gamma = 1.0 + [0.0166 / 4.0, -0.0807 / 4.0, 0.2227 / 4.0, -0.0751 / 4.0], // gamma = 1.1 + [0.0350 / 4.0, -0.1760 / 4.0, 0.4325 / 4.0, -0.1370 / 4.0], // gamma = 1.2 + [0.0543 / 4.0, -0.2821 / 4.0, 0.6302 / 4.0, -0.1876 / 4.0], // gamma = 1.3 + [0.0739 / 4.0, -0.3963 / 4.0, 0.8167 / 4.0, -0.2287 / 4.0], // gamma = 1.4 + [0.0933 / 4.0, -0.5161 / 4.0, 0.9926 / 4.0, -0.2616 / 4.0], // gamma = 1.5 + [0.1121 / 4.0, -0.6395 / 4.0, 1.1588 / 4.0, -0.2877 / 4.0], // gamma = 1.6 + [0.1300 / 4.0, -0.7649 / 4.0, 1.3159 / 4.0, -0.3080 / 4.0], // gamma = 1.7 + [0.1469 / 4.0, -0.8911 / 4.0, 1.4644 / 4.0, -0.3234 / 4.0], // gamma = 1.8 + [0.1627 / 4.0, -1.0170 / 4.0, 1.6051 / 4.0, -0.3347 / 4.0], // gamma = 1.9 + [0.1773 / 4.0, -1.1420 / 4.0, 1.7385 / 4.0, -0.3426 / 4.0], // gamma = 2.0 + [0.1908 / 4.0, -1.2652 / 4.0, 1.8650 / 4.0, -0.3476 / 4.0], // gamma = 2.1 + [0.2031 / 4.0, -1.3864 / 4.0, 1.9851 / 4.0, -0.3501 / 4.0], // gamma = 2.2 + ]; + + const NORM13: f32 = ((0x10000 as f64) / (255.0 * 255.0) * 4.0) as f32; + const NORM24: f32 = ((0x100 as f64) / (255.0) * 4.0) as f32; + + let index = ((gamma * 10.0).round() as usize).clamp(10, 22) - 10; + let ratios = GAMMA_INCORRECT_TARGET_RATIOS[index]; + + [ + ratios[0] * NORM13, + ratios[1] * NORM24, + ratios[2] * NORM13, + ratios[3] * NORM24, + ] +} + #[derive(PartialEq, Eq, Hash, Clone)] pub(crate) enum AtlasKey { Glyph(RenderGlyphParams), @@ -1089,6 +1145,12 @@ pub struct WindowOptions { /// Whether the window should be movable by the user pub is_movable: bool, + /// Whether the window should be resizable by the user + pub is_resizable: bool, + + /// Whether the window should be minimized by the user + pub is_minimizable: bool, + /// The display to create the window on, if this is None, /// the window will be created on the main display pub display_id: Option, @@ -1105,6 +1167,9 @@ pub struct WindowOptions { /// Whether to use client or server side decorations. Wayland only /// Note that this may be ignored. pub window_decorations: Option, + + /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. + pub tabbing_identifier: Option, } /// The variables that can be configured when creating a new window @@ -1131,6 +1196,14 @@ pub(crate) struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_movable: bool, + /// Whether the window should be resizable by the user + #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] + pub is_resizable: bool, + + /// Whether the window should be minimized by the user + #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] + pub is_minimizable: bool, + #[cfg_attr( any(target_os = "linux", target_os = "freebsd", target_os = "windows"), allow(dead_code) @@ -1144,6 +1217,8 @@ pub(crate) struct WindowParams { pub display_id: Option, pub window_min_size: Option>, + #[cfg(target_os = "macos")] + pub tabbing_identifier: Option, } /// Represents the status of how a window should be opened. @@ -1174,6 +1249,11 @@ impl WindowBounds { WindowBounds::Fullscreen(bounds) => *bounds, } } + + /// Creates a new window bounds that centers the window on the screen. + pub fn centered(size: Size, cx: &App) -> Self { + WindowBounds::Windowed(Bounds::centered(None, size, cx)) + } } impl Default for WindowOptions { @@ -1189,11 +1269,14 @@ impl Default for WindowOptions { show: true, kind: WindowKind::Normal, is_movable: true, + is_resizable: true, + is_minimizable: true, display_id: None, window_background: WindowBackgroundAppearance::default(), app_id: None, window_min_size: None, window_decorations: None, + tabbing_identifier: None, } } } @@ -1213,7 +1296,7 @@ pub struct TitlebarOptions { } /// The kind of window to create -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum WindowKind { /// A normal application window Normal, @@ -1221,6 +1304,14 @@ pub enum WindowKind { /// A window that appears above all other windows, usually used for alerts or popups /// use sparingly! PopUp, + + /// A floating window that appears on top of its parent window + Floating, + + /// A Wayland LayerShell window, used to draw overlays or backgrounds for applications such as + /// docks, notifications or wallpapers. + #[cfg(all(target_os = "linux", feature = "wayland"))] + LayerShell(layer_shell::LayerShellOptions), } /// The appearance of the window, as defined by the operating system. @@ -1278,7 +1369,7 @@ pub enum WindowBackgroundAppearance { } /// The options that can be configured for a file dialog prompt -#[derive(Copy, Clone, Debug)] +#[derive(Clone, Debug)] pub struct PathPromptOptions { /// Should the prompt allow files to be selected? pub files: bool, @@ -1286,6 +1377,8 @@ pub struct PathPromptOptions { pub directories: bool, /// Should the prompt allow multiple files to be selected? pub multiple: bool, + /// The prompt to show to a user when selecting a path + pub prompt: Option, } /// What kind of prompt styling to show @@ -1506,7 +1599,7 @@ impl ClipboardItem { for entry in self.entries.iter() { if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry { - answer.push_str(&text); + answer.push_str(text); any_entries = true; } } @@ -1594,6 +1687,8 @@ pub enum ImageFormat { Bmp, /// .tif or .tiff Tiff, + /// .ico + Ico, } impl ImageFormat { @@ -1607,6 +1702,7 @@ impl ImageFormat { ImageFormat::Svg => "image/svg+xml", ImageFormat::Bmp => "image/bmp", ImageFormat::Tiff => "image/tiff", + ImageFormat::Ico => "image/ico", } } @@ -1620,6 +1716,7 @@ impl ImageFormat { "image/svg+xml" => Some(Self::Svg), "image/bmp" => Some(Self::Bmp), "image/tiff" | "image/tif" => Some(Self::Tiff), + "image/ico" => Some(Self::Ico), _ => None, } } @@ -1726,14 +1823,11 @@ impl Image { ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?, ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?, ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?, + ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { - let pixmap = svg_renderer.render_pixmap(&self.bytes, SvgSize::ScaleFactor(1.0))?; - - let buffer = - image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) - .unwrap(); - - SmallVec::from_elem(Frame::new(buffer), 1) + return svg_renderer + .render_single_frame(&self.bytes, 1.0, false) + .map_err(Into::into); } }; diff --git a/crates/gpui/src/platform/blade/blade_atlas.rs b/crates/gpui/src/platform/blade/blade_atlas.rs index 74500ebf8324e4747122ac425388bc122953185e..9b9299df9958e71713269312c12610fe176798ed 100644 --- a/crates/gpui/src/platform/blade/blade_atlas.rs +++ b/crates/gpui/src/platform/blade/blade_atlas.rs @@ -163,7 +163,7 @@ impl BladeAtlasState { usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; } AtlasTextureKind::Polychrome => { - format = gpu::TextureFormat::Bgra8UnormSrgb; + format = gpu::TextureFormat::Bgra8Unorm; usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; } } diff --git a/crates/gpui/src/platform/blade/blade_context.rs b/crates/gpui/src/platform/blade/blade_context.rs index 48872f16198a4ed2d1fc8c2a0b1cbce3eb0de477..12c68a1e70188d3ed2ab425b5abc1bac0dfe3a19 100644 --- a/crates/gpui/src/platform/blade/blade_context.rs +++ b/crates/gpui/src/platform/blade/blade_context.rs @@ -49,7 +49,7 @@ fn parse_pci_id(id: &str) -> anyhow::Result { "Expected a 4 digit PCI ID in hexadecimal format" ); - return u32::from_str_radix(id, 16).context("parsing PCI ID as hex"); + u32::from_str_radix(id, 16).context("parsing PCI ID as hex") } #[cfg(test)] diff --git a/crates/gpui/src/platform/blade/blade_renderer.rs b/crates/gpui/src/platform/blade/blade_renderer.rs index 46d3c16c72a9c10c0e686aff425fcc236c253ce7..dd0be7db437fba573a1a552b52cf12d7c72f0361 100644 --- a/crates/gpui/src/platform/blade/blade_renderer.rs +++ b/crates/gpui/src/platform/blade/blade_renderer.rs @@ -5,6 +5,7 @@ use super::{BladeAtlas, BladeContext}; use crate::{ Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, + get_gamma_correction_ratios, }; use blade_graphics as gpu; use blade_util::{BufferBelt, BufferBeltDescriptor}; @@ -83,6 +84,8 @@ struct ShaderUnderlinesData { #[derive(blade_macros::ShaderData)] struct ShaderMonoSpritesData { globals: GlobalParams, + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, t_sprite: gpu::TextureView, s_sprite: gpu::Sampler, b_mono_sprites: gpu::BufferPiece, @@ -334,11 +337,11 @@ pub struct BladeRenderer { atlas_sampler: gpu::Sampler, #[cfg(target_os = "macos")] core_video_texture_cache: CVMetalTextureCache, - path_sample_count: u32, path_intermediate_texture: gpu::Texture, path_intermediate_texture_view: gpu::TextureView, path_intermediate_msaa_texture: Option, path_intermediate_msaa_texture_view: Option, + rendering_parameters: RenderingParameters, } impl BladeRenderer { @@ -351,7 +354,7 @@ impl BladeRenderer { size: config.size, usage: gpu::TextureUsage::TARGET, display_sync: gpu::DisplaySync::Recent, - color_space: gpu::ColorSpace::Linear, + color_space: gpu::ColorSpace::Srgb, allow_exclusive_full_screen: false, transparent: config.transparent, }; @@ -364,17 +367,12 @@ impl BladeRenderer { name: "main", buffer_count: 2, }); - // workaround for https://github.com/zed-industries/zed/issues/26143 - let path_sample_count = std::env::var("ZED_PATH_SAMPLE_COUNT") - .ok() - .and_then(|v| v.parse().ok()) - .or_else(|| { - [4, 2, 1] - .into_iter() - .find(|count| context.gpu.supports_texture_sample_count(*count)) - }) - .unwrap_or(1); - let pipelines = BladePipelines::new(&context.gpu, surface.info(), path_sample_count); + let rendering_parameters = RenderingParameters::from_env(context); + let pipelines = BladePipelines::new( + &context.gpu, + surface.info(), + rendering_parameters.path_sample_count, + ); let instance_belt = BufferBelt::new(BufferBeltDescriptor { memory: gpu::Memory::Shared, min_chunk_size: 0x1000, @@ -401,7 +399,7 @@ impl BladeRenderer { surface.info().format, config.size.width, config.size.height, - path_sample_count, + rendering_parameters.path_sample_count, ) .unzip(); @@ -425,33 +423,33 @@ impl BladeRenderer { atlas_sampler, #[cfg(target_os = "macos")] core_video_texture_cache, - path_sample_count, path_intermediate_texture, path_intermediate_texture_view, path_intermediate_msaa_texture, path_intermediate_msaa_texture_view, + rendering_parameters, }) } fn wait_for_gpu(&mut self) { - if let Some(last_sp) = self.last_sync_point.take() { - if !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) { - log::error!("GPU hung"); - #[cfg(target_os = "linux")] - if self.gpu.device_information().driver_name == "radv" { - log::error!( - "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround" - ); - log::error!( - "if that helps you're running into https://github.com/zed-industries/zed/issues/26143" - ); - } + if let Some(last_sp) = self.last_sync_point.take() + && !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) + { + log::error!("GPU hung"); + #[cfg(target_os = "linux")] + if self.gpu.device_information().driver_name == "radv" { + log::error!( + "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround" + ); log::error!( - "your device information is: {:?}", - self.gpu.device_information() + "if that helps you're running into https://github.com/zed-industries/zed/issues/26143" ); - while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {} } + log::error!( + "your device information is: {:?}", + self.gpu.device_information() + ); + while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {} } } @@ -506,7 +504,7 @@ impl BladeRenderer { self.surface.info().format, gpu_size.width, gpu_size.height, - self.path_sample_count, + self.rendering_parameters.path_sample_count, ) .unzip(); self.path_intermediate_msaa_texture = path_intermediate_msaa_texture; @@ -521,8 +519,11 @@ impl BladeRenderer { self.gpu .reconfigure_surface(&mut self.surface, self.surface_config); self.pipelines.destroy(&self.gpu); - self.pipelines = - BladePipelines::new(&self.gpu, self.surface.info(), self.path_sample_count); + self.pipelines = BladePipelines::new( + &self.gpu, + self.surface.info(), + self.rendering_parameters.path_sample_count, + ); } } @@ -783,6 +784,10 @@ impl BladeRenderer { 0, &ShaderMonoSpritesData { globals, + gamma_ratios: self.rendering_parameters.gamma_ratios, + grayscale_enhanced_contrast: self + .rendering_parameters + .grayscale_enhanced_contrast, t_sprite: tex_info.raw_view, s_sprite: self.atlas_sampler, b_mono_sprites: instance_buf, @@ -984,3 +989,52 @@ fn create_msaa_texture_if_needed( Some((texture_msaa, texture_view_msaa)) } + +/// A set of parameters that can be set using a corresponding environment variable. +struct RenderingParameters { + // Env var: ZED_PATH_SAMPLE_COUNT + // workaround for https://github.com/zed-industries/zed/issues/26143 + path_sample_count: u32, + + // Env var: ZED_FONTS_GAMMA + // Allowed range [1.0, 2.2], other values are clipped + // Default: 1.8 + gamma_ratios: [f32; 4], + // Env var: ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST + // Allowed range: [0.0, ..), other values are clipped + // Default: 1.0 + grayscale_enhanced_contrast: f32, +} + +impl RenderingParameters { + fn from_env(context: &BladeContext) -> Self { + use std::env; + + let path_sample_count = env::var("ZED_PATH_SAMPLE_COUNT") + .ok() + .and_then(|v| v.parse().ok()) + .or_else(|| { + [4, 2, 1] + .into_iter() + .find(|&n| (context.gpu.capabilities().sample_count_mask & n) != 0) + }) + .unwrap_or(1); + let gamma = env::var("ZED_FONTS_GAMMA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.8_f32) + .clamp(1.0, 2.2); + let gamma_ratios = get_gamma_correction_ratios(gamma); + let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.0_f32) + .max(0.0); + + Self { + path_sample_count, + gamma_ratios, + grayscale_enhanced_contrast, + } + } +} diff --git a/crates/gpui/src/platform/blade/shaders.wgsl b/crates/gpui/src/platform/blade/shaders.wgsl index 95980b54fe4f25b3936d6b095219c5674211dd0a..2981b1446c6d5a2c6bd670e6a040b6a830a8e1d9 100644 --- a/crates/gpui/src/platform/blade/shaders.wgsl +++ b/crates/gpui/src/platform/blade/shaders.wgsl @@ -28,6 +28,38 @@ fn heat_map_color(value: f32, minValue: f32, maxValue: f32, position: vec2) */ +// Contrast and gamma correction adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +fn color_brightness(color: vec3) -> f32 { + // REC. 601 luminance coefficients for perceived brightness + return dot(color, vec3(0.30, 0.59, 0.11)); +} + +fn light_on_dark_contrast(enhancedContrast: f32, color: vec3) -> f32 { + let brightness = color_brightness(color); + let multiplier = saturate(4.0 * (0.75 - brightness)); + return enhancedContrast * multiplier; +} + +fn enhance_contrast(alpha: f32, k: f32) -> f32 { + return alpha * (k + 1.0) / (alpha * k + 1.0); +} + +fn apply_alpha_correction(a: f32, b: f32, g: vec4) -> f32 { + let brightness_adjustment = g.x * b + g.y; + let correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0 - a) * correction; +} + +fn apply_contrast_and_gamma_correction(sample: f32, color: vec3, enhanced_contrast_factor: f32, gamma_ratios: vec4) -> f32 { + let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + let brightness = color_brightness(color); + + let contrasted = enhance_contrast(sample, enhanced_contrast); + return apply_alpha_correction(contrasted, brightness, gamma_ratios); +} + struct GlobalParams { viewport_size: vec2, premultiplied_alpha: u32, @@ -35,6 +67,8 @@ struct GlobalParams { } var globals: GlobalParams; +var gamma_ratios: vec4; +var grayscale_enhanced_contrast: f32; var t_sprite: texture_2d; var s_sprite: sampler; @@ -141,6 +175,12 @@ fn distance_from_clip_rect(unit_vertex: vec2, bounds: Bounds, clip_bounds: return distance_from_clip_rect_impl(position, clip_bounds); } +fn distance_from_clip_rect_transformed(unit_vertex: vec2, bounds: Bounds, clip_bounds: Bounds, transform: TransformationMatrix) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + let transformed = transpose(transform.rotation_scale) * position + transform.translation; + return distance_from_clip_rect_impl(transformed, clip_bounds); +} + // https://gamedev.stackexchange.com/questions/92015/optimized-linear-to-srgb-glsl fn srgb_to_linear(srgb: vec3) -> vec3 { let cutoff = srgb < vec3(0.04045); @@ -149,6 +189,13 @@ fn srgb_to_linear(srgb: vec3) -> vec3 { return select(higher, lower, cutoff); } +fn srgb_to_linear_component(a: f32) -> f32 { + let cutoff = a < 0.04045; + let higher = pow((a + 0.055) / 1.055, 2.4); + let lower = a / 12.92; + return select(higher, lower, cutoff); +} + fn linear_to_srgb(linear: vec3) -> vec3 { let cutoff = linear < vec3(0.0031308); let higher = vec3(1.055) * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055); @@ -198,12 +245,7 @@ fn hsla_to_rgba(hsla: Hsla) -> vec4 { color.b += x; } - // Input colors are assumed to be in sRGB space, - // but blending and rendering needs to happen in linear space. - // The output will be converted to sRGB by either the target - // texture format or the swapchain color space. - let linear = srgb_to_linear(color); - return vec4(linear, a); + return vec4(color, a); } /// Convert a linear sRGB to Oklab space. @@ -644,7 +686,24 @@ fn fs_quad(input: QuadVarying) -> @location(0) vec4 { let is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - let border_width = select(border.y, border.x, is_horizontal); + + // When applying dashed borders to just some, not all, the sides. + // The way we chose border widths above sometimes comes with a 0 width value. + // So we choose again to avoid division by zero. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + let dashed_border = vec2( + max( + quad.border_widths.bottom, + quad.border_widths.top, + ), + max( + quad.border_widths.right, + quad.border_widths.left, + ) + ); + + let border_width = select(dashed_border.y, dashed_border.x, is_horizontal); dash_velocity = dv_numerator / border_width; t = select(point.y, point.x, is_horizontal) * dash_velocity; max_t = select(size.y, size.x, is_horizontal) * dash_velocity; @@ -1117,18 +1176,22 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index out.tile_position = to_tile_position(unit_vertex, sprite.tile); out.color = hsla_to_rgba(sprite.color); - out.clip_distances = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); return out; } @fragment fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4 { let sample = textureSample(t_sprite, s_sprite, input.tile_position).r; + let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios); + // Alpha clip after using the derivatives. if (any(input.clip_distances < vec4(0.0))) { return vec4(0.0); } - return blend_color(input.color, sample); + + // convert to srgb space as the rest of the code (output swapchain) expects that + return blend_color(input.color, alpha_corrected); } // --- polychrome sprites --- // diff --git a/crates/gpui/src/platform/keyboard.rs b/crates/gpui/src/platform/keyboard.rs index e28d7815200800b7e3950c6819e6ef3fc42f0306..10b8620258ecffd41e8018fc539c47812df0fe05 100644 --- a/crates/gpui/src/platform/keyboard.rs +++ b/crates/gpui/src/platform/keyboard.rs @@ -1,3 +1,7 @@ +use collections::HashMap; + +use crate::{KeybindingKeystroke, Keystroke}; + /// A trait for platform-specific keyboard layouts pub trait PlatformKeyboardLayout { /// Get the keyboard layout ID, which should be unique to the layout @@ -5,3 +9,33 @@ pub trait PlatformKeyboardLayout { /// Get the keyboard layout display name fn name(&self) -> &str; } + +/// A trait for platform-specific keyboard mappings +pub trait PlatformKeyboardMapper { + /// Map a key equivalent to its platform-specific representation + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke; + /// Get the key equivalents for the current keyboard layout, + /// only used on macOS + fn get_key_equivalents(&self) -> Option<&HashMap>; +} + +/// A dummy implementation of the platform keyboard mapper +pub struct DummyKeyboardMapper; + +impl PlatformKeyboardMapper for DummyKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + _use_key_equivalents: bool, + ) -> KeybindingKeystroke { + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 24601eefd6de450622247caaca5ff680c60a3257..4a2bfc785e3eb7e13a845bb67b4524255affb3bb 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -5,6 +5,14 @@ use std::{ fmt::{Display, Write}, }; +use crate::PlatformKeyboardMapper; + +/// This is a helper trait so that we can simplify the implementation of some functions +pub trait AsKeystroke { + /// Returns the GPUI representation of the keystroke. + fn as_keystroke(&self) -> &Keystroke; +} + /// A keystroke and associated metadata generated by the platform #[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)] pub struct Keystroke { @@ -24,6 +32,19 @@ pub struct Keystroke { pub key_char: Option, } +/// Represents a keystroke that can be used in keybindings and displayed to the user. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeybindingKeystroke { + /// The GPUI representation of the keystroke. + inner: Keystroke, + /// The modifiers to display. + #[cfg(target_os = "windows")] + display_modifiers: Modifiers, + /// The key to display. + #[cfg(target_os = "windows")] + display_key: String, +} + /// Error type for `Keystroke::parse`. This is used instead of `anyhow::Error` so that Zed can use /// markdown to display it. #[derive(Debug)] @@ -58,7 +79,7 @@ impl Keystroke { /// /// This method assumes that `self` was typed and `target' is in the keymap, and checks /// both possibilities for self against the target. - pub fn should_match(&self, target: &Keystroke) -> bool { + pub fn should_match(&self, target: &KeybindingKeystroke) -> bool { #[cfg(not(target_os = "windows"))] if let Some(key_char) = self .key_char @@ -71,7 +92,7 @@ impl Keystroke { ..Default::default() }; - if &target.key == key_char && target.modifiers == ime_modifiers { + if &target.inner.key == key_char && target.inner.modifiers == ime_modifiers { return true; } } @@ -83,12 +104,12 @@ impl Keystroke { .filter(|key_char| key_char != &&self.key) { // On Windows, if key_char is set, then the typed keystroke produced the key_char - if &target.key == key_char && target.modifiers == Modifiers::none() { + if &target.inner.key == key_char && target.inner.modifiers == Modifiers::none() { return true; } } - target.modifiers == self.modifiers && target.key == self.key + target.inner.modifiers == self.modifiers && target.inner.key == self.key } /// key syntax is: @@ -200,31 +221,7 @@ impl Keystroke { /// Produces a representation of this key that Parse can understand. pub fn unparse(&self) -> String { - let mut str = String::new(); - if self.modifiers.function { - str.push_str("fn-"); - } - if self.modifiers.control { - str.push_str("ctrl-"); - } - if self.modifiers.alt { - str.push_str("alt-"); - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - str.push_str("cmd-"); - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - str.push_str("super-"); - - #[cfg(target_os = "windows")] - str.push_str("win-"); - } - if self.modifiers.shift { - str.push_str("shift-"); - } - str.push_str(&self.key); - str + unparse(&self.modifiers, &self.key) } /// Returns true if this keystroke left @@ -266,6 +263,117 @@ impl Keystroke { } } +impl KeybindingKeystroke { + #[cfg(target_os = "windows")] + pub(crate) fn new(inner: Keystroke, display_modifiers: Modifiers, display_key: String) -> Self { + KeybindingKeystroke { + inner, + display_modifiers, + display_key, + } + } + + /// Create a new keybinding keystroke from the given keystroke using the given keyboard mapper. + pub fn new_with_mapper( + inner: Keystroke, + use_key_equivalents: bool, + keyboard_mapper: &dyn PlatformKeyboardMapper, + ) -> Self { + keyboard_mapper.map_key_equivalent(inner, use_key_equivalents) + } + + /// Create a new keybinding keystroke from the given keystroke, without any platform-specific mapping. + pub fn from_keystroke(keystroke: Keystroke) -> Self { + #[cfg(target_os = "windows")] + { + let key = keystroke.key.clone(); + let modifiers = keystroke.modifiers; + KeybindingKeystroke { + inner: keystroke, + display_modifiers: modifiers, + display_key: key, + } + } + #[cfg(not(target_os = "windows"))] + { + KeybindingKeystroke { inner: keystroke } + } + } + + /// Returns the GPUI representation of the keystroke. + pub fn inner(&self) -> &Keystroke { + &self.inner + } + + /// Returns the modifiers. + /// + /// Platform-specific behavior: + /// - On macOS and Linux, this modifiers is the same as `inner.modifiers`, which is the GPUI representation of the keystroke. + /// - On Windows, this modifiers is the display modifiers, for example, a `ctrl-@` keystroke will have `inner.modifiers` as + /// `Modifiers::control()` and `display_modifiers` as `Modifiers::control_shift()`. + pub fn modifiers(&self) -> &Modifiers { + #[cfg(target_os = "windows")] + { + &self.display_modifiers + } + #[cfg(not(target_os = "windows"))] + { + &self.inner.modifiers + } + } + + /// Returns the key. + /// + /// Platform-specific behavior: + /// - On macOS and Linux, this key is the same as `inner.key`, which is the GPUI representation of the keystroke. + /// - On Windows, this key is the display key, for example, a `ctrl-@` keystroke will have `inner.key` as `@` and `display_key` as `2`. + pub fn key(&self) -> &str { + #[cfg(target_os = "windows")] + { + &self.display_key + } + #[cfg(not(target_os = "windows"))] + { + &self.inner.key + } + } + + /// Sets the modifiers. On Windows this modifies both `inner.modifiers` and `display_modifiers`. + pub fn set_modifiers(&mut self, modifiers: Modifiers) { + self.inner.modifiers = modifiers; + #[cfg(target_os = "windows")] + { + self.display_modifiers = modifiers; + } + } + + /// Sets the key. On Windows this modifies both `inner.key` and `display_key`. + pub fn set_key(&mut self, key: String) { + #[cfg(target_os = "windows")] + { + self.display_key = key.clone(); + } + self.inner.key = key; + } + + /// Produces a representation of this key that Parse can understand. + pub fn unparse(&self) -> String { + #[cfg(target_os = "windows")] + { + unparse(&self.display_modifiers, &self.display_key) + } + #[cfg(not(target_os = "windows"))] + { + unparse(&self.inner.modifiers, &self.inner.key) + } + } + + /// Removes the key_char + pub fn remove_key_char(&mut self) { + self.inner.key_char = None; + } +} + fn is_printable_key(key: &str) -> bool { !matches!( key, @@ -322,65 +430,15 @@ fn is_printable_key(key: &str) -> bool { impl std::fmt::Display for Keystroke { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.modifiers.control { - #[cfg(target_os = "macos")] - f.write_char('^')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "ctrl-")?; - } - if self.modifiers.alt { - #[cfg(target_os = "macos")] - f.write_char('⌥')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "alt-")?; - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - f.write_char('⌘')?; - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - f.write_char('❖')?; - - #[cfg(target_os = "windows")] - f.write_char('⊞')?; - } - if self.modifiers.shift { - #[cfg(target_os = "macos")] - f.write_char('⇧')?; + display_modifiers(&self.modifiers, f)?; + display_key(&self.key, f) + } +} - #[cfg(not(target_os = "macos"))] - write!(f, "shift-")?; - } - let key = match self.key.as_str() { - #[cfg(target_os = "macos")] - "backspace" => '⌫', - #[cfg(target_os = "macos")] - "up" => '↑', - #[cfg(target_os = "macos")] - "down" => '↓', - #[cfg(target_os = "macos")] - "left" => '←', - #[cfg(target_os = "macos")] - "right" => '→', - #[cfg(target_os = "macos")] - "tab" => '⇥', - #[cfg(target_os = "macos")] - "escape" => '⎋', - #[cfg(target_os = "macos")] - "shift" => '⇧', - #[cfg(target_os = "macos")] - "control" => '⌃', - #[cfg(target_os = "macos")] - "alt" => '⌥', - #[cfg(target_os = "macos")] - "platform" => '⌘', - - key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), - key => return f.write_str(key), - }; - f.write_char(key) +impl std::fmt::Display for KeybindingKeystroke { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + display_modifiers(self.modifiers(), f)?; + display_key(self.key(), f) } } @@ -600,3 +658,110 @@ pub struct Capslock { #[serde(default)] pub on: bool, } + +impl AsKeystroke for Keystroke { + fn as_keystroke(&self) -> &Keystroke { + self + } +} + +impl AsKeystroke for KeybindingKeystroke { + fn as_keystroke(&self) -> &Keystroke { + &self.inner + } +} + +fn display_modifiers(modifiers: &Modifiers, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if modifiers.control { + #[cfg(target_os = "macos")] + f.write_char('^')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "ctrl-")?; + } + if modifiers.alt { + #[cfg(target_os = "macos")] + f.write_char('⌥')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "alt-")?; + } + if modifiers.platform { + #[cfg(target_os = "macos")] + f.write_char('⌘')?; + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + f.write_char('❖')?; + + #[cfg(target_os = "windows")] + f.write_char('⊞')?; + } + if modifiers.shift { + #[cfg(target_os = "macos")] + f.write_char('⇧')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "shift-")?; + } + Ok(()) +} + +fn display_key(key: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let key = match key { + #[cfg(target_os = "macos")] + "backspace" => '⌫', + #[cfg(target_os = "macos")] + "up" => '↑', + #[cfg(target_os = "macos")] + "down" => '↓', + #[cfg(target_os = "macos")] + "left" => '←', + #[cfg(target_os = "macos")] + "right" => '→', + #[cfg(target_os = "macos")] + "tab" => '⇥', + #[cfg(target_os = "macos")] + "escape" => '⎋', + #[cfg(target_os = "macos")] + "shift" => '⇧', + #[cfg(target_os = "macos")] + "control" => '⌃', + #[cfg(target_os = "macos")] + "alt" => '⌥', + #[cfg(target_os = "macos")] + "platform" => '⌘', + + key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), + key => return f.write_str(key), + }; + f.write_char(key) +} + +#[inline] +fn unparse(modifiers: &Modifiers, key: &str) -> String { + let mut result = String::new(); + if modifiers.function { + result.push_str("fn-"); + } + if modifiers.control { + result.push_str("ctrl-"); + } + if modifiers.alt { + result.push_str("alt-"); + } + if modifiers.platform { + #[cfg(target_os = "macos")] + result.push_str("cmd-"); + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + result.push_str("super-"); + + #[cfg(target_os = "windows")] + result.push_str("win-"); + } + if modifiers.shift { + result.push_str("shift-"); + } + result.push_str(&key); + result +} diff --git a/crates/gpui/src/platform/linux.rs b/crates/gpui/src/platform/linux.rs index 5221f71f9970eb24508954304055acf974ed059d..f7d7ed0ebaa4165065f9963ee1be6d05601cf4ce 100644 --- a/crates/gpui/src/platform/linux.rs +++ b/crates/gpui/src/platform/linux.rs @@ -27,3 +27,6 @@ pub(crate) use x11::*; pub(crate) type PlatformScreenCaptureFrame = scap::frame::Frame; #[cfg(not(all(feature = "screen-capture", any(feature = "wayland", feature = "x11"))))] pub(crate) type PlatformScreenCaptureFrame = (); + +#[cfg(feature = "wayland")] +pub use wayland::layer_shell; diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index 3d32dbd2fdece5259f48e52550f6983b6a8c5b1d..9ca1f76fd6996ffbd376d8254cbbe63a1c8d8fd0 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -5,8 +5,6 @@ use calloop::{ channel::{self, Sender}, timer::TimeoutAction, }; -use parking::{Parker, Unparker}; -use parking_lot::Mutex; use std::{ thread, time::{Duration, Instant}, @@ -19,7 +17,6 @@ struct TimerAfter { } pub(crate) struct LinuxDispatcher { - parker: Mutex, main_sender: Sender, timer_sender: Sender, background_sender: flume::Sender, @@ -37,56 +34,61 @@ impl LinuxDispatcher { let mut background_threads = (0..thread_count) .map(|i| { let receiver = background_receiver.clone(); - std::thread::spawn(move || { - for runnable in receiver { - let start = Instant::now(); - - runnable.run(); - - log::trace!( - "background thread {}: ran runnable. took: {:?}", - i, - start.elapsed() - ); - } - }) + std::thread::Builder::new() + .name(format!("Worker-{i}")) + .spawn(move || { + for runnable in receiver { + let start = Instant::now(); + + runnable.run(); + + log::trace!( + "background thread {}: ran runnable. took: {:?}", + i, + start.elapsed() + ); + } + }) + .unwrap() }) .collect::>(); let (timer_sender, timer_channel) = calloop::channel::channel::(); - let timer_thread = std::thread::spawn(|| { - let mut event_loop: EventLoop<()> = - EventLoop::try_new().expect("Failed to initialize timer loop!"); - - let handle = event_loop.handle(); - let timer_handle = event_loop.handle(); - handle - .insert_source(timer_channel, move |e, _, _| { - if let channel::Event::Msg(timer) = e { - // This has to be in an option to satisfy the borrow checker. The callback below should only be scheduled once. - let mut runnable = Some(timer.runnable); - timer_handle - .insert_source( - calloop::timer::Timer::from_duration(timer.duration), - move |_, _, _| { - if let Some(runnable) = runnable.take() { - runnable.run(); - } - TimeoutAction::Drop - }, - ) - .expect("Failed to start timer"); - } - }) - .expect("Failed to start timer thread"); - - event_loop.run(None, &mut (), |_| {}).log_err(); - }); + let timer_thread = std::thread::Builder::new() + .name("Timer".to_owned()) + .spawn(|| { + let mut event_loop: EventLoop<()> = + EventLoop::try_new().expect("Failed to initialize timer loop!"); + + let handle = event_loop.handle(); + let timer_handle = event_loop.handle(); + handle + .insert_source(timer_channel, move |e, _, _| { + if let channel::Event::Msg(timer) = e { + // This has to be in an option to satisfy the borrow checker. The callback below should only be scheduled once. + let mut runnable = Some(timer.runnable); + timer_handle + .insert_source( + calloop::timer::Timer::from_duration(timer.duration), + move |_, _, _| { + if let Some(runnable) = runnable.take() { + runnable.run(); + } + TimeoutAction::Drop + }, + ) + .expect("Failed to start timer"); + } + }) + .expect("Failed to start timer thread"); + + event_loop.run(None, &mut (), |_| {}).log_err(); + }) + .unwrap(); background_threads.push(timer_thread); Self { - parker: Mutex::new(Parker::new()), main_sender, timer_sender, background_sender, @@ -124,17 +126,4 @@ impl PlatformDispatcher for LinuxDispatcher { .send(TimerAfter { duration, runnable }) .ok(); } - - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) - } else { - self.parker.lock().park(); - true - } - } - - fn unparker(&self) -> Unparker { - self.parker.lock().unparker() - } } diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 31d445be5274309f2e84a0e8df0a446cdb79736b..322f5d76110ee36e3cfdf26449bbec85c3d51af5 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -25,8 +25,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, - Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, - Point, Result, Task, WindowAppearance, WindowParams, px, + Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -73,6 +73,13 @@ pub trait LinuxClient { fn active_window(&self) -> Option; fn window_stack(&self) -> Option>; fn run(&self); + + #[cfg(any(feature = "wayland", feature = "x11"))] + fn window_identifier( + &self, + ) -> impl Future> + Send + 'static { + std::future::ready::>(None) + } } #[derive(Default)] @@ -108,13 +115,13 @@ impl LinuxCommon { let callbacks = PlatformHandlers::default(); - let dispatcher = Arc::new(LinuxDispatcher::new(main_sender.clone())); + let dispatcher = Arc::new(LinuxDispatcher::new(main_sender)); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let common = LinuxCommon { background_executor, - foreground_executor: ForegroundExecutor::new(dispatcher.clone()), + foreground_executor: ForegroundExecutor::new(dispatcher), text_system, appearance: WindowAppearance::Light, auto_hide_scrollbars: false, @@ -144,6 +151,10 @@ impl Platform for P { self.keyboard_layout() } + fn keyboard_mapper(&self) -> Rc { + Rc::new(crate::DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, callback: Box) { self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback)); } @@ -200,6 +211,10 @@ impl Platform for P { app_path = app_path.display() ); + #[allow( + clippy::disallowed_methods, + reason = "We are restarting ourselves, using std command thus is fine" + )] let restart_process = Command::new("/usr/bin/env") .arg("bash") .arg("-c") @@ -282,6 +297,9 @@ impl Platform for P { #[cfg(not(any(feature = "wayland", feature = "x11")))] let _ = (done_tx.send(Ok(None)), options); + #[cfg(any(feature = "wayland", feature = "x11"))] + let identifier = self.window_identifier(); + #[cfg(any(feature = "wayland", feature = "x11"))] self.foreground_executor() .spawn(async move { @@ -292,8 +310,10 @@ impl Platform for P { }; let request = match ashpd::desktop::file_chooser::OpenFileRequest::default() + .identifier(identifier.await) .modal(true) .title(title) + .accept_label(options.prompt.as_ref().map(crate::SharedString::as_str)) .multiple(options.multiple) .directory(options.directories) .send() @@ -337,6 +357,9 @@ impl Platform for P { #[cfg(not(any(feature = "wayland", feature = "x11")))] let _ = (done_tx.send(Ok(None)), directory, suggested_name); + #[cfg(any(feature = "wayland", feature = "x11"))] + let identifier = self.window_identifier(); + #[cfg(any(feature = "wayland", feature = "x11"))] self.foreground_executor() .spawn({ @@ -346,6 +369,7 @@ impl Platform for P { async move { let mut request_builder = ashpd::desktop::file_chooser::SaveFileRequest::default() + .identifier(identifier.await) .modal(true) .title("Save File") .current_folder(directory) @@ -398,11 +422,15 @@ impl Platform for P { let path = path.to_owned(); self.background_executor() .spawn(async move { - let _ = std::process::Command::new("xdg-open") + let _ = smol::process::Command::new("xdg-open") .arg(path) .spawn() .context("invoking xdg-open") - .log_err(); + .log_err()? + .status() + .await + .log_err()?; + Some(()) }) .detach(); } @@ -440,7 +468,7 @@ impl Platform for P { fn app_path(&self) -> Result { // get the path of the executable of the current process let app_path = env::current_exe()?; - return Ok(app_path); + Ok(app_path) } fn set_menus(&self, menus: Vec, _keymap: &Keymap) { @@ -586,10 +614,14 @@ pub(super) fn open_uri_internal( if let Some(token) = activation_token.as_ref() { command.env("XDG_ACTIVATION_TOKEN", token); } - match command.spawn() { - Ok(_) => return, + let program = format!("{:?}", command.get_program()); + match smol::process::Command::from(command).spawn() { + Ok(mut cmd) => { + cmd.status().await.log_err(); + return; + } Err(e) => { - log::error!("Failed to open with {:?}: {}", command.get_program(), e) + log::error!("Failed to open with {}: {}", program, e) } } } @@ -641,7 +673,7 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option = None; for locale in locales { if let Ok(table) = - xkb::compose::Table::new_from_locale(&cx, &locale, xkb::compose::COMPILE_NO_FLAGS) + xkb::compose::Table::new_from_locale(cx, &locale, xkb::compose::COMPILE_NO_FLAGS) { state = Some(xkb::compose::State::new( &table, @@ -666,7 +698,7 @@ pub(super) const DEFAULT_CURSOR_ICON_NAME: &str = "left_ptr"; impl CursorStyle { #[cfg(any(feature = "wayland", feature = "x11"))] - pub(super) fn to_icon_names(&self) -> &'static [&'static str] { + pub(super) fn to_icon_names(self) -> &'static [&'static str] { // Based on cursor names from chromium: // https://github.com/chromium/chromium/blob/d3069cf9c973dc3627fa75f64085c6a86c8f41bf/ui/base/cursor/cursor_factory.cc#L113 match self { @@ -843,6 +875,7 @@ impl crate::Keystroke { Keysym::Down => "down".to_owned(), Keysym::Home => "home".to_owned(), Keysym::End => "end".to_owned(), + Keysym::Insert => "insert".to_owned(), _ => { let name = xkb::keysym_get_name(key_sym).to_lowercase(); @@ -989,21 +1022,18 @@ mod tests { #[test] fn test_is_within_click_distance() { let zero = Point::new(px(0.0), px(0.0)); - assert_eq!( - is_within_click_distance(zero, Point::new(px(5.0), px(5.0))), - true - ); - assert_eq!( - is_within_click_distance(zero, Point::new(px(-4.9), px(5.0))), - true - ); - assert_eq!( - is_within_click_distance(Point::new(px(3.0), px(2.0)), Point::new(px(-2.0), px(-2.0))), - true - ); - assert_eq!( - is_within_click_distance(zero, Point::new(px(5.0), px(5.1))), - false - ); + assert!(is_within_click_distance(zero, Point::new(px(5.0), px(5.0)))); + assert!(is_within_click_distance( + zero, + Point::new(px(-4.9), px(5.0)) + )); + assert!(is_within_click_distance( + Point::new(px(3.0), px(2.0)), + Point::new(px(-2.0), px(-2.0)) + )); + assert!(!is_within_click_distance( + zero, + Point::new(px(5.0), px(5.1)) + ),); } } diff --git a/crates/gpui/src/platform/linux/text_system.rs b/crates/gpui/src/platform/linux/text_system.rs index f66a2e71d49f39c0e82770e23aa8eca752970daf..958d509d5317aea32815eee2850e3a196d6586ed 100644 --- a/crates/gpui/src/platform/linux/text_system.rs +++ b/crates/gpui/src/platform/linux/text_system.rs @@ -1,7 +1,7 @@ use crate::{ Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, FontStyle, FontWeight, - GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, RenderGlyphParams, SUBPIXEL_VARIANTS, - ShapedGlyph, ShapedRun, SharedString, Size, point, size, + GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, point, size, }; use anyhow::{Context as _, Ok, Result}; use collections::HashMap; @@ -274,9 +274,10 @@ impl CosmicTextSystemState { fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { let font = &self.loaded_fonts[params.font_id.0].font; - let subpixel_shift = params - .subpixel_variant - .map(|v| v as f32 / (SUBPIXEL_VARIANTS as f32 * params.scale_factor)); + let subpixel_shift = point( + params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, + params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, + ); let image = self .swash_cache .get_image( @@ -309,9 +310,10 @@ impl CosmicTextSystemState { } else { let bitmap_size = glyph_bounds.size; let font = &self.loaded_fonts[params.font_id.0].font; - let subpixel_shift = params - .subpixel_variant - .map(|v| v as f32 / (SUBPIXEL_VARIANTS as f32 * params.scale_factor)); + let subpixel_shift = point( + params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, + params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, + ); let mut image = self .swash_cache .get_image( diff --git a/crates/gpui/src/platform/linux/wayland.rs b/crates/gpui/src/platform/linux/wayland.rs index cf73832b11fb1baad08bf5ee3142e461876fbe92..366b5703e448522a59d397e00cbd268951cb1873 100644 --- a/crates/gpui/src/platform/linux/wayland.rs +++ b/crates/gpui/src/platform/linux/wayland.rs @@ -5,6 +5,9 @@ mod display; mod serial; mod window; +/// Contains Types for configuring layer_shell surfaces. +pub mod layer_shell; + pub(crate) use client::*; use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; @@ -12,7 +15,7 @@ use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1:: use crate::CursorStyle; impl CursorStyle { - pub(super) fn to_shape(&self) -> Shape { + pub(super) fn to_shape(self) -> Shape { match self { CursorStyle::Arrow => Shape::Default, CursorStyle::IBeam => Shape::Text, diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index 72e4477ecf697a9f6443dffb80e0637202d3b848..6461bf69738cfae2f791bf8eea69fe9a2a038a43 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -7,6 +7,7 @@ use std::{ time::{Duration, Instant}, }; +use ashpd::WindowIdentifier; use calloop::{ EventLoop, LoopHandle, timer::{TimeoutAction, Timer}, @@ -61,6 +62,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{ }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; use xkbcommon::xkb::{self, KEYMAP_COMPILE_NO_FLAGS, Keycode}; @@ -75,8 +77,8 @@ use crate::{ FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon, LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, - PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScaledPixels, ScrollDelta, - ScrollWheelEvent, Size, TouchPhase, WindowParams, point, px, size, + PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScrollDelta, ScrollWheelEvent, + Size, TouchPhase, WindowParams, point, px, size, }; use crate::{ SharedString, @@ -114,6 +116,7 @@ pub struct Globals { pub fractional_scale_manager: Option, pub decoration_manager: Option, + pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, pub executor: ForegroundExecutor, @@ -151,6 +154,7 @@ impl Globals { viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), + layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), executor, @@ -323,7 +327,7 @@ impl WaylandClientStatePtr { } } - pub fn update_ime_position(&self, bounds: Bounds) { + pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); let mut state = client.borrow_mut(); if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() { @@ -359,13 +363,13 @@ impl WaylandClientStatePtr { } changed }; - if changed { - if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() { - drop(state); - callback(); - state = client.borrow_mut(); - state.common.callbacks.keyboard_layout_change = Some(callback); - } + + if changed && let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() + { + drop(state); + callback(); + state = client.borrow_mut(); + state.common.callbacks.keyboard_layout_change = Some(callback); } } @@ -373,15 +377,15 @@ impl WaylandClientStatePtr { let mut client = self.get_client(); let mut state = client.borrow_mut(); let closed_window = state.windows.remove(surface_id).unwrap(); - if let Some(window) = state.mouse_focused_window.take() { - if !window.ptr_eq(&closed_window) { - state.mouse_focused_window = Some(window); - } + if let Some(window) = state.mouse_focused_window.take() + && !window.ptr_eq(&closed_window) + { + state.mouse_focused_window = Some(window); } - if let Some(window) = state.keyboard_focused_window.take() { - if !window.ptr_eq(&closed_window) { - state.keyboard_focused_window = Some(window); - } + if let Some(window) = state.keyboard_focused_window.take() + && !window.ptr_eq(&closed_window) + { + state.keyboard_focused_window = Some(window); } if state.windows.is_empty() { state.common.signal.stop(); @@ -528,7 +532,7 @@ impl WaylandClient { client.common.appearance = appearance; - for (_, window) in &mut client.windows { + for window in client.windows.values_mut() { window.set_appearance(appearance); } } @@ -694,6 +698,11 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); + let parent = state + .keyboard_focused_window + .as_ref() + .and_then(|w| w.toplevel()); + let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), @@ -701,6 +710,7 @@ impl LinuxClient for WaylandClient { WaylandClientStatePtr(Rc::downgrade(&self.0)), params, state.common.appearance, + parent, )?; state.windows.insert(surface_id, window.0.clone()); @@ -710,9 +720,7 @@ impl LinuxClient for WaylandClient { fn set_cursor_style(&self, style: CursorStyle) { let mut state = self.0.borrow_mut(); - let need_update = state - .cursor_style - .map_or(true, |current_style| current_style != style); + let need_update = state.cursor_style != Some(style); if need_update { let serial = state.serial_tracker.get(SerialKind::MouseEnter); @@ -860,6 +868,20 @@ impl LinuxClient for WaylandClient { fn compositor_name(&self) -> &'static str { "Wayland" } + + fn window_identifier(&self) -> impl Future> + Send + 'static { + async fn inner(surface: Option) -> Option { + if let Some(surface) = surface { + ashpd::WindowIdentifier::from_wayland(&surface).await + } else { + None + } + } + + let client_state = self.0.borrow(); + let active_window = client_state.keyboard_focused_window.as_ref(); + inner(active_window.map(|aw| aw.surface())) + } } impl Dispatch for WaylandClientStatePtr { @@ -929,6 +951,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer); delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); +delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -951,11 +974,8 @@ impl Dispatch for WaylandClientStatePtr { }; drop(state); - match event { - wl_callback::Event::Done { .. } => { - window.frame(); - } - _ => {} + if let wl_callback::Event::Done { .. } = event { + window.frame(); } } } @@ -1074,6 +1094,31 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + + drop(state); + let should_close = window.handle_layersurface_event(event); + + if should_close { + // The close logic will be handled in drop_window() + window.close(); + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( _: &mut Self, @@ -1145,7 +1190,7 @@ impl Dispatch for WaylandClientStatePtr { .globals .text_input_manager .as_ref() - .map(|text_input_manager| text_input_manager.get_text_input(&seat, qh, ())); + .map(|text_input_manager| text_input_manager.get_text_input(seat, qh, ())); if let Some(wl_keyboard) = &state.wl_keyboard { wl_keyboard.release(); @@ -1285,7 +1330,6 @@ impl Dispatch for WaylandClientStatePtr { let Some(focused_window) = focused_window else { return; }; - let focused_window = focused_window.clone(); let keymap_state = state.keymap_state.as_ref().unwrap(); let keycode = Keycode::from(key + MIN_KEYCODE); @@ -1294,7 +1338,7 @@ impl Dispatch for WaylandClientStatePtr { match key_state { wl_keyboard::KeyState::Pressed if !keysym.is_modifier_key() => { let mut keystroke = - Keystroke::from_xkb(&keymap_state, state.modifiers, keycode); + Keystroke::from_xkb(keymap_state, state.modifiers, keycode); if let Some(mut compose) = state.compose_state.take() { compose.feed(keysym); match compose.status() { @@ -1538,12 +1582,9 @@ impl Dispatch for WaylandClientStatePtr { cursor_shape_device.set_shape(serial, style.to_shape()); } else { let scale = window.primary_output_scale(); - state.cursor.set_icon( - &wl_pointer, - serial, - style.to_icon_names(), - scale, - ); + state + .cursor + .set_icon(wl_pointer, serial, style.to_icon_names(), scale); } } drop(state); @@ -1580,7 +1621,7 @@ impl Dispatch for WaylandClientStatePtr { if state .keyboard_focused_window .as_ref() - .map_or(false, |keyboard_window| window.ptr_eq(&keyboard_window)) + .is_some_and(|keyboard_window| window.ptr_eq(keyboard_window)) { state.enter_token = None; } @@ -1787,17 +1828,17 @@ impl Dispatch for WaylandClientStatePtr { drop(state); window.handle_input(input); } - } else if let Some(discrete) = discrete { - if let Some(window) = state.mouse_focused_window.clone() { - let input = PlatformInput::ScrollWheel(ScrollWheelEvent { - position: state.mouse_location.unwrap(), - delta: ScrollDelta::Lines(discrete), - modifiers: state.modifiers, - touch_phase: TouchPhase::Moved, - }); - drop(state); - window.handle_input(input); - } + } else if let Some(discrete) = discrete + && let Some(window) = state.mouse_focused_window.clone() + { + let input = PlatformInput::ScrollWheel(ScrollWheelEvent { + position: state.mouse_location.unwrap(), + delta: ScrollDelta::Lines(discrete), + modifiers: state.modifiers, + touch_phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); } } } @@ -2019,25 +2060,22 @@ impl Dispatch for WaylandClientStatePtr { let client = this.get_client(); let mut state = client.borrow_mut(); - match event { - wl_data_offer::Event::Offer { mime_type } => { - // Drag and drop - if mime_type == FILE_LIST_MIME_TYPE { - let serial = state.serial_tracker.get(SerialKind::DataDevice); - let mime_type = mime_type.clone(); - data_offer.accept(serial, Some(mime_type)); - } + if let wl_data_offer::Event::Offer { mime_type } = event { + // Drag and drop + if mime_type == FILE_LIST_MIME_TYPE { + let serial = state.serial_tracker.get(SerialKind::DataDevice); + let mime_type = mime_type.clone(); + data_offer.accept(serial, Some(mime_type)); + } - // Clipboard - if let Some(offer) = state - .data_offers - .iter_mut() - .find(|wrapper| wrapper.inner.id() == data_offer.id()) - { - offer.add_mime_type(mime_type); - } + // Clipboard + if let Some(offer) = state + .data_offers + .iter_mut() + .find(|wrapper| wrapper.inner.id() == data_offer.id()) + { + offer.add_mime_type(mime_type); } - _ => {} } } } @@ -2118,13 +2156,10 @@ impl Dispatch let client = this.get_client(); let mut state = client.borrow_mut(); - match event { - zwp_primary_selection_offer_v1::Event::Offer { mime_type } => { - if let Some(offer) = state.primary_data_offer.as_mut() { - offer.add_mime_type(mime_type); - } - } - _ => {} + if let zwp_primary_selection_offer_v1::Event::Offer { mime_type } = event + && let Some(offer) = state.primary_data_offer.as_mut() + { + offer.add_mime_type(mime_type); } } } diff --git a/crates/gpui/src/platform/linux/wayland/cursor.rs b/crates/gpui/src/platform/linux/wayland/cursor.rs index 2a24d0e1ba347fb718da126120bc809c65d93b33..c7c9139dea795701e459387a309b1817e2f60971 100644 --- a/crates/gpui/src/platform/linux/wayland/cursor.rs +++ b/crates/gpui/src/platform/linux/wayland/cursor.rs @@ -45,10 +45,11 @@ impl Cursor { } fn set_theme_internal(&mut self, theme_name: Option) { - if let Some(loaded_theme) = self.loaded_theme.as_ref() { - if loaded_theme.name == theme_name && loaded_theme.scaled_size == self.scaled_size { - return; - } + if let Some(loaded_theme) = self.loaded_theme.as_ref() + && loaded_theme.name == theme_name + && loaded_theme.scaled_size == self.scaled_size + { + return; } let result = if let Some(theme_name) = theme_name.as_ref() { CursorTheme::load_from_name( @@ -66,7 +67,7 @@ impl Cursor { { self.loaded_theme = Some(LoadedTheme { theme, - name: theme_name.map(|name| name.to_string()), + name: theme_name, scaled_size: self.scaled_size, }); } @@ -144,7 +145,7 @@ impl Cursor { hot_y as i32 / scale, ); - self.surface.attach(Some(&buffer), 0, 0); + self.surface.attach(Some(buffer), 0, 0); self.surface.damage(0, 0, width as i32, height as i32); self.surface.commit(); } diff --git a/crates/gpui/src/platform/linux/wayland/layer_shell.rs b/crates/gpui/src/platform/linux/wayland/layer_shell.rs new file mode 100644 index 0000000000000000000000000000000000000000..0f165ed8e0ca2c1ec8d5b7c4cbdfea6cb5eec71b --- /dev/null +++ b/crates/gpui/src/platform/linux/wayland/layer_shell.rs @@ -0,0 +1,111 @@ +use bitflags::bitflags; +use thiserror::Error; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; + +use crate::Pixels; + +/// The layer the surface is rendered on. Multiple surfaces can share a layer, and ordering within +/// a single layer is undefined. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum Layer { + /// The background layer, typically used for wallpapers. + Background, + + /// The bottom layer. + Bottom, + + /// The top layer, typically used for fullscreen windows. + Top, + + /// The overlay layer, used for surfaces that should always be on top. + #[default] + Overlay, +} + +impl From for zwlr_layer_shell_v1::Layer { + fn from(layer: Layer) -> Self { + match layer { + Layer::Background => Self::Background, + Layer::Bottom => Self::Bottom, + Layer::Top => Self::Top, + Layer::Overlay => Self::Overlay, + } + } +} + +bitflags! { + /// Screen anchor point for layer_shell surfaces. These can be used in any combination, e.g. + /// specifying `Anchor::LEFT | Anchor::RIGHT` will stretch the surface across the width of the + /// screen. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct Anchor: u32 { + /// Anchor to the top edge of the screen. + const TOP = 1; + /// Anchor to the bottom edge of the screen. + const BOTTOM = 2; + /// Anchor to the left edge of the screen. + const LEFT = 4; + /// Anchor to the right edge of the screen. + const RIGHT = 8; + } +} + +impl From for zwlr_layer_surface_v1::Anchor { + fn from(anchor: Anchor) -> Self { + Self::from_bits_truncate(anchor.bits()) + } +} + +/// Keyboard interactivity mode for the layer_shell surfaces. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum KeyboardInteractivity { + /// No keyboard inputs will be delivered to the surface and it won't be able to receive + /// keyboard focus. + None, + + /// The surface will receive exclusive keyboard focus as long as it is above the shell surface + /// layer, and no other layer_shell surfaces are above it. + Exclusive, + + /// The surface can be focused similarly to a normal window. + #[default] + OnDemand, +} + +impl From for zwlr_layer_surface_v1::KeyboardInteractivity { + fn from(value: KeyboardInteractivity) -> Self { + match value { + KeyboardInteractivity::None => Self::None, + KeyboardInteractivity::Exclusive => Self::Exclusive, + KeyboardInteractivity::OnDemand => Self::OnDemand, + } + } +} + +/// Options for creating a layer_shell window. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LayerShellOptions { + /// The namespace for the surface, mostly used by compositors to apply rules, can not be + /// changed after the surface is created. + pub namespace: String, + /// The layer the surface is rendered on. + pub layer: Layer, + /// The anchor point of the surface. + pub anchor: Anchor, + /// Requests that the compositor avoids occluding an area with other surfaces. + pub exclusive_zone: Option, + /// The anchor point of the exclusive zone, will be determined using the anchor if left + /// unspecified. + pub exclusive_edge: Option, + /// Margins between the surface and its anchor point(s). + /// Specified in CSS order: top, right, bottom, left. + pub margin: Option<(Pixels, Pixels, Pixels, Pixels)>, + /// How keyboard events should be delivered to the surface. + pub keyboard_interactivity: KeyboardInteractivity, +} + +/// An error indicating that an action failed because the compositor doesn't support the required +/// layer_shell protocol. +#[derive(Debug, Error)] +#[error("Compositor doesn't support zwlr_layer_shell_v1")] +pub struct LayerShellNotSupportedError; diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 2b2207e22c86fc25e6387581bb92b9c304f4bc9d..c02d1f3bc3d0d1ecf7589ae959f8c9b0e3f0fde5 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -14,20 +14,23 @@ use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{Proxy, protocol::wl_surface}; -use wayland_protocols::wp::fractional_scale::v1::client::wp_fractional_scale_v1; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; +use wayland_protocols::{ + wp::fractional_scale::v1::client::wp_fractional_scale_v1, + xdg::shell::client::xdg_toplevel::XdgToplevel, +}; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; -use crate::scene::Scene; use crate::{ AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, - ResizeEdge, ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, - WindowParams, px, size, + ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, + layer_shell::LayerShellNotSupportedError, px, size, }; use crate::{ Capslock, @@ -37,6 +40,7 @@ use crate::{ linux::wayland::{display::WaylandDisplay, serial::SerialKind}, }, }; +use crate::{WindowKind, scene::Scene}; #[derive(Default)] pub(crate) struct Callbacks { @@ -81,14 +85,12 @@ struct InProgressConfigure { } pub struct WaylandWindowState { - xdg_surface: xdg_surface::XdgSurface, + surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, pub surface: wl_surface::WlSurface, - decoration: Option, app_id: Option, appearance: WindowAppearance, blur: Option, - toplevel: xdg_toplevel::XdgToplevel, viewport: Option, outputs: HashMap, display: Option<(ObjectId, Output)>, @@ -114,6 +116,161 @@ pub struct WaylandWindowState { client_inset: Option, } +pub enum WaylandSurfaceState { + Xdg(WaylandXdgSurfaceState), + LayerShell(WaylandLayerSurfaceState), +} + +impl WaylandSurfaceState { + fn new( + surface: &wl_surface::WlSurface, + globals: &Globals, + params: &WindowParams, + parent: Option, + ) -> anyhow::Result { + // For layer_shell windows, create a layer surface instead of an xdg surface + if let WindowKind::LayerShell(options) = ¶ms.kind { + let Some(layer_shell) = globals.layer_shell.as_ref() else { + return Err(LayerShellNotSupportedError.into()); + }; + + let layer_surface = layer_shell.get_layer_surface( + &surface, + None, + options.layer.into(), + options.namespace.clone(), + &globals.qh, + surface.id(), + ); + + let width = params.bounds.size.width.0; + let height = params.bounds.size.height.0; + layer_surface.set_size(width as u32, height as u32); + + layer_surface.set_anchor(options.anchor.into()); + layer_surface.set_keyboard_interactivity(options.keyboard_interactivity.into()); + + if let Some(margin) = options.margin { + layer_surface.set_margin( + margin.0.0 as i32, + margin.1.0 as i32, + margin.2.0 as i32, + margin.3.0 as i32, + ) + } + + if let Some(exclusive_zone) = options.exclusive_zone { + layer_surface.set_exclusive_zone(exclusive_zone.0 as i32); + } + + if let Some(exclusive_edge) = options.exclusive_edge { + layer_surface.set_exclusive_edge(exclusive_edge.into()); + } + + return Ok(WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { + layer_surface, + })); + } + + // All other WindowKinds result in a regular xdg surface + let xdg_surface = globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); + if params.kind == WindowKind::Floating { + toplevel.set_parent(parent.as_ref()); + } + + if let Some(size) = params.window_min_size { + toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); + } + + // Attempt to set up window decorations based on the requested configuration + let decoration = globals + .decoration_manager + .as_ref() + .map(|decoration_manager| { + decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id()) + }); + + Ok(WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration, + })) + } +} + +pub struct WaylandXdgSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + toplevel: xdg_toplevel::XdgToplevel, + decoration: Option, +} + +pub struct WaylandLayerSurfaceState { + layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, +} + +impl WaylandSurfaceState { + fn ack_configure(&self, serial: u32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + layer_surface.ack_configure(serial); + } + } + } + + fn decoration(&self) -> Option<&zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { decoration, .. }) = self { + decoration.as_ref() + } else { + None + } + } + + fn toplevel(&self) -> Option<&xdg_toplevel::XdgToplevel> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { toplevel, .. }) = self { + Some(toplevel) + } else { + None + } + } + + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + // cannot set window position of a layer surface + layer_surface.set_size(width as u32, height as u32); + } + } + } + + fn destroy(&mut self) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration: _decoration, + }) => { + // The role object (toplevel) must always be destroyed before the xdg_surface. + // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy + toplevel.destroy(); + xdg_surface.destroy(); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { + layer_surface.destroy(); + } + } + } +} + #[derive(Clone)] pub struct WaylandWindowStatePtr { state: Rc>, @@ -124,9 +281,7 @@ impl WaylandWindowState { pub(crate) fn new( handle: AnyWindowHandle, surface: wl_surface::WlSurface, - xdg_surface: xdg_surface::XdgSurface, - toplevel: xdg_toplevel::XdgToplevel, - decoration: Option, + surface_state: WaylandSurfaceState, appearance: WindowAppearance, viewport: Option, client: WaylandClientStatePtr, @@ -155,14 +310,18 @@ impl WaylandWindowState { BladeRenderer::new(gpu_context, &raw_window, config)? }; + if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { + if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { + xdg_state.toplevel.set_title(title.to_string()); + } + } + Ok(Self { - xdg_surface, + surface_state, acknowledged_first_configure: false, surface, - decoration, app_id: None, blur: None, - toplevel, viewport, globals, outputs: HashMap::default(), @@ -235,17 +394,29 @@ impl Drop for WaylandWindow { let client = state.client.clone(); state.renderer.destroy(); - if let Some(decoration) = &state.decoration { - decoration.destroy(); - } + + // Destroy blur first, this has no dependencies. if let Some(blur) = &state.blur { blur.release(); } - state.toplevel.destroy(); + + // Decorations must be destroyed before the xdg state. + // See https://wayland.app/protocols/xdg-decoration-unstable-v1#zxdg_toplevel_decoration_v1 + if let Some(decoration) = &state.surface_state.decoration() { + decoration.destroy(); + } + + // Surface state might contain xdg_toplevel/xdg_surface which can be destroyed now that + // decorations are gone. layer_surface has no dependencies. + state.surface_state.destroy(); + + // Viewport must be destroyed before the wl_surface. + // See https://wayland.app/protocols/viewporter#wp_viewport if let Some(viewport) = &state.viewport { viewport.destroy(); } - state.xdg_surface.destroy(); + + // The wl_surface itself should always be destroyed last. state.surface.destroy(); let state_ptr = self.0.clone(); @@ -277,29 +448,15 @@ impl WaylandWindow { client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, + parent: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let xdg_surface = globals - .wm_base - .get_xdg_surface(&surface, &globals.qh, surface.id()); - let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - - if let Some(size) = params.window_min_size { - toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); - } + let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); } - // Attempt to set up window decorations based on the requested configuration - let decoration = globals - .decoration_manager - .as_ref() - .map(|decoration_manager| { - decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id()) - }); - let viewport = globals .viewporter .as_ref() @@ -309,9 +466,7 @@ impl WaylandWindow { state: Rc::new(RefCell::new(WaylandWindowState::new( handle, surface.clone(), - xdg_surface, - toplevel, - decoration, + surface_state, appearance, viewport, client, @@ -338,6 +493,10 @@ impl WaylandWindowStatePtr { self.state.borrow().surface.clone() } + pub fn toplevel(&self) -> Option { + self.state.borrow().surface_state.toplevel().cloned() + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } @@ -355,85 +514,82 @@ impl WaylandWindowStatePtr { } pub fn handle_xdg_surface_event(&self, event: xdg_surface::Event) { - match event { - xdg_surface::Event::Configure { serial } => { - { - let mut state = self.state.borrow_mut(); - if let Some(window_controls) = state.in_progress_window_controls.take() { - state.window_controls = window_controls; - - drop(state); - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(appearance_changed) = callbacks.appearance_changed.as_mut() { - appearance_changed(); - } + if let xdg_surface::Event::Configure { serial } = event { + { + let mut state = self.state.borrow_mut(); + if let Some(window_controls) = state.in_progress_window_controls.take() { + state.window_controls = window_controls; + + drop(state); + let mut callbacks = self.callbacks.borrow_mut(); + if let Some(appearance_changed) = callbacks.appearance_changed.as_mut() { + appearance_changed(); } } - { - let mut state = self.state.borrow_mut(); - - if let Some(mut configure) = state.in_progress_configure.take() { - let got_unmaximized = state.maximized && !configure.maximized; - state.fullscreen = configure.fullscreen; - state.maximized = configure.maximized; - state.tiling = configure.tiling; - // Limit interactive resizes to once per vblank - if configure.resizing && state.resize_throttle { - return; - } else if configure.resizing { - state.resize_throttle = true; - } - if !configure.fullscreen && !configure.maximized { - configure.size = if got_unmaximized { - Some(state.window_bounds.size) - } else { - compute_outer_size(state.inset(), configure.size, state.tiling) - }; - if let Some(size) = configure.size { - state.window_bounds = Bounds { - origin: Point::default(), - size, - }; - } - } - drop(state); + } + { + let mut state = self.state.borrow_mut(); + + if let Some(mut configure) = state.in_progress_configure.take() { + let got_unmaximized = state.maximized && !configure.maximized; + state.fullscreen = configure.fullscreen; + state.maximized = configure.maximized; + state.tiling = configure.tiling; + // Limit interactive resizes to once per vblank + if configure.resizing && state.resize_throttle { + return; + } else if configure.resizing { + state.resize_throttle = true; + } + if !configure.fullscreen && !configure.maximized { + configure.size = if got_unmaximized { + Some(state.window_bounds.size) + } else { + compute_outer_size(state.inset(), configure.size, state.tiling) + }; if let Some(size) = configure.size { - self.resize(size); + state.window_bounds = Bounds { + origin: Point::default(), + size, + }; } } - } - let mut state = self.state.borrow_mut(); - state.xdg_surface.ack_configure(serial); - - let window_geometry = inset_by_tiling( - state.bounds.map_origin(|_| px(0.0)), - state.inset(), - state.tiling, - ) - .map(|v| v.0 as i32) - .map_size(|v| if v <= 0 { 1 } else { v }); - - state.xdg_surface.set_window_geometry( - window_geometry.origin.x, - window_geometry.origin.y, - window_geometry.size.width, - window_geometry.size.height, - ); - - let request_frame_callback = !state.acknowledged_first_configure; - if request_frame_callback { - state.acknowledged_first_configure = true; drop(state); - self.frame(); + if let Some(size) = configure.size { + self.resize(size); + } } } - _ => {} + let mut state = self.state.borrow_mut(); + state.surface_state.ack_configure(serial); + + let window_geometry = inset_by_tiling( + state.bounds.map_origin(|_| px(0.0)), + state.inset(), + state.tiling, + ) + .map(|v| v.0 as i32) + .map_size(|v| if v <= 0 { 1 } else { v }); + + state.surface_state.set_geometry( + window_geometry.origin.x, + window_geometry.origin.y, + window_geometry.size.width, + window_geometry.size.height, + ); + + let request_frame_callback = !state.acknowledged_first_configure; + if request_frame_callback { + state.acknowledged_first_configure = true; + drop(state); + self.frame(); + } } } pub fn handle_toplevel_decoration_event(&self, event: zxdg_toplevel_decoration_v1::Event) { - match event { - zxdg_toplevel_decoration_v1::Event::Configure { mode } => match mode { + if let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event { + match mode { WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => { self.state.borrow_mut().decorations = WindowDecorations::Server; if let Some(mut appearance_changed) = @@ -457,17 +613,13 @@ impl WaylandWindowStatePtr { WEnum::Unknown(v) => { log::warn!("Unknown decoration mode: {}", v); } - }, - _ => {} + } } } pub fn handle_fractional_scale_event(&self, event: wp_fractional_scale_v1::Event) { - match event { - wp_fractional_scale_v1::Event::PreferredScale { scale } => { - self.rescale(scale as f32 / 120.0); - } - _ => {} + if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event { + self.rescale(scale as f32 / 120.0); } } @@ -578,6 +730,42 @@ impl WaylandWindowStatePtr { } } + pub fn handle_layersurface_event(&self, event: zwlr_layer_surface_v1::Event) -> bool { + match event { + zwlr_layer_surface_v1::Event::Configure { + width, + height, + serial, + } => { + let mut size = if width == 0 || height == 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + let mut state = self.state.borrow_mut(); + state.in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + drop(state); + + // just do the same thing we'd do as an xdg_surface + self.handle_xdg_surface_event(xdg_surface::Event::Configure { serial }); + + false + } + zwlr_layer_surface_v1::Event::Closed => { + // unlike xdg, we don't have a choice here: the surface is closing. + true + } + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -669,8 +857,8 @@ impl WaylandWindowStatePtr { pub fn set_size_and_scale(&self, size: Option>, scale: Option) { let (size, scale) = { let mut state = self.state.borrow_mut(); - if size.map_or(true, |size| size == state.bounds.size) - && scale.map_or(true, |scale| scale == state.scale) + if size.is_none_or(|size| size == state.bounds.size) + && scale.is_none_or(|scale| scale == state.scale) { return; } @@ -713,21 +901,20 @@ impl WaylandWindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().input { - if !fun(input.clone()).propagate { - return; - } + if let Some(ref mut fun) = self.callbacks.borrow_mut().input + && !fun(input.clone()).propagate + { + return; } - if let PlatformInput::KeyDown(event) = input { - if event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) { - if let Some(key_char) = &event.keystroke.key_char { - let mut state = self.state.borrow_mut(); - if let Some(mut input_handler) = state.input_handler.take() { - drop(state); - input_handler.replace_text_in_range(None, key_char); - self.state.borrow_mut().input_handler = Some(input_handler); - } - } + if let PlatformInput::KeyDown(event) = input + && event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) + && let Some(key_char) = &event.keystroke.key_char + { + let mut state = self.state.borrow_mut(); + if let Some(mut input_handler) = state.input_handler.take() { + drop(state); + input_handler.replace_text_in_range(None, key_char); + self.state.borrow_mut().input_handler = Some(input_handler); } } } @@ -840,7 +1027,7 @@ impl PlatformWindow for WaylandWindow { let state_ptr = self.0.clone(); let dp_size = size.to_device_pixels(self.scale_factor()); - state.xdg_surface.set_window_geometry( + state.surface_state.set_geometry( state.bounds.origin.x.0 as i32, state.bounds.origin.y.0 as i32, dp_size.width.0, @@ -934,12 +1121,16 @@ impl PlatformWindow for WaylandWindow { } fn set_title(&mut self, title: &str) { - self.borrow().toplevel.set_title(title.to_string()); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_title(title.to_string()); + } } fn set_app_id(&mut self, app_id: &str) { let mut state = self.borrow_mut(); - state.toplevel.set_app_id(app_id.to_owned()); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.set_app_id(app_id.to_owned()); + } state.app_id = Some(app_id.to_owned()); } @@ -950,24 +1141,30 @@ impl PlatformWindow for WaylandWindow { } fn minimize(&self) { - self.borrow().toplevel.set_minimized(); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_minimized(); + } } fn zoom(&self) { let state = self.borrow(); - if !state.maximized { - state.toplevel.set_maximized(); - } else { - state.toplevel.unset_maximized(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.maximized { + toplevel.set_maximized(); + } else { + toplevel.unset_maximized(); + } } } fn toggle_fullscreen(&self) { - let mut state = self.borrow_mut(); - if !state.fullscreen { - state.toplevel.set_fullscreen(None); - } else { - state.toplevel.unset_fullscreen(); + let mut state = self.borrow(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.fullscreen { + toplevel.set_fullscreen(None); + } else { + toplevel.unset_fullscreen(); + } } } @@ -1032,27 +1229,33 @@ impl PlatformWindow for WaylandWindow { fn show_window_menu(&self, position: Point) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel.show_window_menu( - &state.globals.seat, - serial, - position.x.0 as i32, - position.y.0 as i32, - ); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.show_window_menu( + &state.globals.seat, + serial, + position.x.0 as i32, + position.y.0 as i32, + ); + } } fn start_window_move(&self) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel._move(&state.globals.seat, serial); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel._move(&state.globals.seat, serial); + } } fn start_window_resize(&self, edge: crate::ResizeEdge) { let state = self.borrow(); - state.toplevel.resize( - &state.globals.seat, - state.client.get_serial(SerialKind::MousePress), - edge.to_xdg(), - ) + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.resize( + &state.globals.seat, + state.client.get_serial(SerialKind::MousePress), + edge.to_xdg(), + ) + } } fn window_decorations(&self) -> Decorations { @@ -1068,7 +1271,7 @@ impl PlatformWindow for WaylandWindow { fn request_decorations(&self, decorations: WindowDecorations) { let mut state = self.borrow_mut(); state.decorations = decorations; - if let Some(decoration) = state.decoration.as_ref() { + if let Some(decoration) = state.surface_state.decoration() { decoration.set_mode(decorations.to_xdg()); update_window(state); } @@ -1086,7 +1289,7 @@ impl PlatformWindow for WaylandWindow { } } - fn update_ime_position(&self, bounds: Bounds) { + fn update_ime_position(&self, bounds: Bounds) { let state = self.borrow(); state.client.update_ime_position(bounds); } @@ -1147,7 +1350,7 @@ fn update_window(mut state: RefMut) { } impl WindowDecorations { - fn to_xdg(&self) -> zxdg_toplevel_decoration_v1::Mode { + fn to_xdg(self) -> zxdg_toplevel_decoration_v1::Mode { match self { WindowDecorations::Client => zxdg_toplevel_decoration_v1::Mode::ClientSide, WindowDecorations::Server => zxdg_toplevel_decoration_v1::Mode::ServerSide, @@ -1156,7 +1359,7 @@ impl WindowDecorations { } impl ResizeEdge { - fn to_xdg(&self) -> xdg_toplevel::ResizeEdge { + fn to_xdg(self) -> xdg_toplevel::ResizeEdge { match self { ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top, ResizeEdge::TopRight => xdg_toplevel::ResizeEdge::TopRight, diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 053cd0387b25f418696f12838187088229aaf044..fa9d0181c095819823553da9e7f6be27598aea78 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,5 +1,6 @@ use crate::{Capslock, xcb_flush}; use anyhow::{Context as _, anyhow}; +use ashpd::WindowIdentifier; use calloop::{ EventLoop, LoopHandle, RegistrationToken, generic::{FdWrapper, Generic}, @@ -28,7 +29,7 @@ use x11rb::{ protocol::xkb::ConnectionExt as _, protocol::xproto::{ AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent, - ConnectionExt as _, EventMask, KeyPressEvent, Visibility, + ConnectionExt as _, EventMask, Visibility, }, protocol::{Event, randr, render, xinput, xkb, xproto}, resource_manager::Database, @@ -62,8 +63,7 @@ use crate::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, Platform, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, Point, RequestFrameOptions, - ScaledPixels, ScrollDelta, Size, TouchPhase, WindowParams, X11Window, - modifiers_from_xinput_info, point, px, + ScrollDelta, Size, TouchPhase, WindowParams, X11Window, modifiers_from_xinput_info, point, px, }; /// Value for DeviceId parameters which selects all devices. @@ -232,15 +232,12 @@ impl X11ClientStatePtr { }; let mut state = client.0.borrow_mut(); - if let Some(window_ref) = state.windows.remove(&x_window) { - match window_ref.refresh_state { - Some(RefreshState::PeriodicRefresh { - event_loop_token, .. - }) => { - state.loop_handle.remove(event_loop_token); - } - _ => {} - } + if let Some(window_ref) = state.windows.remove(&x_window) + && let Some(RefreshState::PeriodicRefresh { + event_loop_token, .. + }) = window_ref.refresh_state + { + state.loop_handle.remove(event_loop_token); } if state.mouse_focused_window == Some(x_window) { state.mouse_focused_window = None; @@ -255,7 +252,7 @@ impl X11ClientStatePtr { } } - pub fn update_ime_position(&self, bounds: Bounds) { + pub fn update_ime_position(&self, bounds: Bounds) { let Some(client) = self.get_client() else { return; }; @@ -273,6 +270,7 @@ impl X11ClientStatePtr { state.ximc = Some(ximc); return; }; + let scaled_bounds = bounds.scale(state.scale_factor); let ic_attributes = ximc .build_ic_attributes() .push( @@ -285,8 +283,8 @@ impl X11ClientStatePtr { b.push( xim::AttributeName::SpotLocation, xim::Point { - x: u32::from(bounds.origin.x + bounds.size.width) as i16, - y: u32::from(bounds.origin.y + bounds.size.height) as i16, + x: u32::from(scaled_bounds.origin.x + scaled_bounds.size.width) as i16, + y: u32::from(scaled_bounds.origin.y + scaled_bounds.size.height) as i16, }, ); }) @@ -459,7 +457,7 @@ impl X11Client { move |event, _, client| match event { XDPEvent::WindowAppearance(appearance) => { client.with_common(|common| common.appearance = appearance); - for (_, window) in &mut client.0.borrow_mut().windows { + for window in client.0.borrow_mut().windows.values_mut() { window.window.set_appearance(appearance); } } @@ -528,7 +526,6 @@ impl X11Client { let mut windows_to_refresh = HashSet::new(); let mut last_key_release = None; - let mut last_key_press: Option = None; // event handlers for new keyboard / remapping refresh the state without using event // details, this deduplicates them. @@ -548,7 +545,6 @@ impl X11Client { if let Some(last_key_release) = last_key_release.take() { events.push(last_key_release); } - last_key_press = None; events.push(last_keymap_change_event); } @@ -561,16 +557,9 @@ impl X11Client { if let Some(last_key_release) = last_key_release.take() { events.push(last_key_release); } - last_key_press = None; events.push(last_keymap_change_event); } - if let Some(last_press) = last_key_press.as_ref() { - if last_press.detail == key_press.detail { - continue; - } - } - if let Some(Event::KeyRelease(key_release)) = last_key_release.take() { @@ -583,7 +572,6 @@ impl X11Client { } } events.push(Event::KeyPress(key_press)); - last_key_press = Some(key_press); } Event::XkbNewKeyboardNotify(_) | Event::XkbMapNotify(_) => { if let Some(release_event) = last_key_release.take() { @@ -706,14 +694,14 @@ impl X11Client { state.xim_handler = Some(xim_handler); return; }; - if let Some(area) = window.get_ime_area() { + if let Some(scaled_area) = window.get_ime_area() { ic_attributes = ic_attributes.nested_list(xim::AttributeName::PreeditAttributes, |b| { b.push( xim::AttributeName::SpotLocation, xim::Point { - x: u32::from(area.origin.x + area.size.width) as i16, - y: u32::from(area.origin.y + area.size.height) as i16, + x: u32::from(scaled_area.origin.x + scaled_area.size.width) as i16, + y: u32::from(scaled_area.origin.y + scaled_area.size.height) as i16, }, ); }); @@ -876,22 +864,19 @@ impl X11Client { let Some(reply) = reply else { return Some(()); }; - match str::from_utf8(&reply.value) { - Ok(file_list) => { - let paths: SmallVec<[_; 2]> = file_list - .lines() - .filter_map(|path| Url::parse(path).log_err()) - .filter_map(|url| url.to_file_path().log_err()) - .collect(); - let input = PlatformInput::FileDrop(FileDropEvent::Entered { - position: state.xdnd_state.position, - paths: crate::ExternalPaths(paths), - }); - drop(state); - window.handle_input(input); - self.0.borrow_mut().xdnd_state.retrieved = true; - } - Err(_) => {} + if let Ok(file_list) = str::from_utf8(&reply.value) { + let paths: SmallVec<[_; 2]> = file_list + .lines() + .filter_map(|path| Url::parse(path).log_err()) + .filter_map(|url| url.to_file_path().log_err()) + .collect(); + let input = PlatformInput::FileDrop(FileDropEvent::Entered { + position: state.xdnd_state.position, + paths: crate::ExternalPaths(paths), + }); + drop(state); + window.handle_input(input); + self.0.borrow_mut().xdnd_state.retrieved = true; } } Event::ConfigureNotify(event) => { @@ -1212,7 +1197,7 @@ impl X11Client { state = self.0.borrow_mut(); if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) { - let scroll_delta = get_scroll_delta_and_update_state(&mut pointer, &event); + let scroll_delta = get_scroll_delta_and_update_state(pointer, &event); drop(state); if let Some(scroll_delta) = scroll_delta { window.handle_input(PlatformInput::ScrollWheel(make_scroll_wheel_event( @@ -1271,7 +1256,7 @@ impl X11Client { Event::XinputDeviceChanged(event) => { let mut state = self.0.borrow_mut(); if let Some(mut pointer) = state.pointer_device_states.get_mut(&event.sourceid) { - reset_pointer_device_scroll_positions(&mut pointer); + reset_pointer_device_scroll_positions(pointer); } } _ => {} @@ -1331,17 +1316,9 @@ impl X11Client { return None; }; let mut state = self.0.borrow_mut(); - let keystroke = state.pre_key_char_down.take(); state.composing = false; drop(state); - if let Some(mut keystroke) = keystroke { - keystroke.key_char = Some(text.clone()); - window.handle_input(PlatformInput::KeyDown(crate::KeyDownEvent { - keystroke, - is_held: false, - })); - } - + window.handle_ime_commit(text); Some(()) } @@ -1357,7 +1334,7 @@ impl X11Client { drop(state); window.handle_ime_preedit(text); - if let Some(area) = window.get_ime_area() { + if let Some(scaled_area) = window.get_ime_area() { let ic_attributes = ximc .build_ic_attributes() .push( @@ -1370,8 +1347,8 @@ impl X11Client { b.push( xim::AttributeName::SpotLocation, xim::Point { - x: u32::from(area.origin.x + area.size.width) as i16, - y: u32::from(area.origin.y + area.size.height) as i16, + x: u32::from(scaled_area.origin.x + scaled_area.size.width) as i16, + y: u32::from(scaled_area.origin.y + scaled_area.size.height) as i16, }, ); }) @@ -1471,6 +1448,10 @@ impl LinuxClient for X11Client { params: WindowParams, ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); + let parent_window = state + .keyboard_focused_window + .and_then(|focused_window| state.windows.get(&focused_window)) + .map(|window| window.window.x_window); let x_window = state .xcb_connection .generate_id() @@ -1489,6 +1470,7 @@ impl LinuxClient for X11Client { &state.atoms, state.scale_factor, state.common.appearance, + parent_window, )?; check_reply( || "Failed to set XdndAware property", @@ -1586,11 +1568,11 @@ impl LinuxClient for X11Client { fn read_from_primary(&self) -> Option { let state = self.0.borrow_mut(); - return state + state .clipboard .get_any(clipboard::ClipboardKind::Primary) .context("X11: Failed to read from clipboard (primary)") - .log_with_level(log::Level::Debug); + .log_with_level(log::Level::Debug) } fn read_from_clipboard(&self) -> Option { @@ -1603,11 +1585,11 @@ impl LinuxClient for X11Client { { return state.clipboard_item.clone(); } - return state + state .clipboard .get_any(clipboard::ClipboardKind::Clipboard) .context("X11: Failed to read from clipboard (clipboard)") - .log_with_level(log::Level::Debug); + .log_with_level(log::Level::Debug) } fn run(&self) { @@ -1676,6 +1658,16 @@ impl LinuxClient for X11Client { Some(handles) } + + fn window_identifier(&self) -> impl Future> + Send + 'static { + let state = self.0.borrow(); + state + .keyboard_focused_window + .and_then(|focused_window| state.windows.get(&focused_window)) + .map(|window| window.window.x_window as u64) + .map(|x_window| std::future::ready(Some(WindowIdentifier::from_xid(x_window)))) + .unwrap_or(std::future::ready(None)) + } } impl X11ClientState { @@ -2010,12 +2002,12 @@ fn check_gtk_frame_extents_supported( } fn xdnd_is_atom_supported(atom: u32, atoms: &XcbAtoms) -> bool { - return atom == atoms.TEXT + atom == atoms.TEXT || atom == atoms.STRING || atom == atoms.UTF8_STRING || atom == atoms.TEXT_PLAIN || atom == atoms.TEXT_PLAIN_UTF8 - || atom == atoms.TextUriList; + || atom == atoms.TextUriList } fn xdnd_get_supported_atom( @@ -2035,16 +2027,15 @@ fn xdnd_get_supported_atom( ), ) .log_with_level(Level::Warn) + && let Some(atoms) = reply.value32() { - if let Some(atoms) = reply.value32() { - for atom in atoms { - if xdnd_is_atom_supported(atom, &supported_atoms) { - return atom; - } + for atom in atoms { + if xdnd_is_atom_supported(atom, supported_atoms) { + return atom; } } } - return 0; + 0 } fn xdnd_send_finished( @@ -2115,7 +2106,7 @@ fn current_pointer_device_states( .classes .iter() .filter_map(|class| class.data.as_scroll()) - .map(|class| *class) + .copied() .rev() .collect::>(); let old_state = scroll_values_to_preserve.get(&info.deviceid); @@ -2145,7 +2136,7 @@ fn current_pointer_device_states( if pointer_device_states.is_empty() { log::error!("Found no xinput mouse pointers."); } - return Some(pointer_device_states); + Some(pointer_device_states) } /// Returns true if the device is a pointer device. Does not include pointer device groups. @@ -2411,11 +2402,13 @@ fn legacy_get_randr_scale_factor(connection: &XCBConnection, root: u32) -> Optio let mut crtc_infos: HashMap = HashMap::default(); let mut valid_outputs: HashSet = HashSet::new(); for (crtc, cookie) in crtc_cookies { - if let Ok(reply) = cookie.reply() { - if reply.width > 0 && reply.height > 0 && !reply.outputs.is_empty() { - crtc_infos.insert(crtc, reply.clone()); - valid_outputs.extend(&reply.outputs); - } + if let Ok(reply) = cookie.reply() + && reply.width > 0 + && reply.height > 0 + && !reply.outputs.is_empty() + { + crtc_infos.insert(crtc, reply.clone()); + valid_outputs.extend(&reply.outputs); } } diff --git a/crates/gpui/src/platform/linux/x11/clipboard.rs b/crates/gpui/src/platform/linux/x11/clipboard.rs index 5d42eadaaf04e0ad7811b980e6d31b4bca935139..3be5008505446e8ca6c6fd93b559fec4779ae85c 100644 --- a/crates/gpui/src/platform/linux/x11/clipboard.rs +++ b/crates/gpui/src/platform/linux/x11/clipboard.rs @@ -86,6 +86,7 @@ x11rb::atom_manager! { SVG__MIME: ImageFormat::mime_type(ImageFormat::Svg ).as_bytes(), BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(), TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(), + ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. @@ -957,15 +958,17 @@ impl Clipboard { } // At this point we know that the clipboard does not exist. let ctx = Arc::new(Inner::new()?); - let join_handle; - { - let ctx = Arc::clone(&ctx); - join_handle = std::thread::spawn(move || { - if let Err(error) = serve_requests(ctx) { - log::error!("Worker thread errored with: {}", error); + let join_handle = std::thread::Builder::new() + .name("Clipboard".to_owned()) + .spawn({ + let ctx = Arc::clone(&ctx); + move || { + if let Err(error) = serve_requests(ctx) { + log::error!("Worker thread errored with: {}", error); + } } - }); - } + }) + .unwrap(); *global_cb = Some(GlobalClipboard { inner: Arc::clone(&ctx), server_handle: join_handle, @@ -1001,6 +1004,7 @@ impl Clipboard { ImageFormat::Svg => self.inner.atoms.SVG__MIME, ImageFormat::Bmp => self.inner.atoms.BMP__MIME, ImageFormat::Tiff => self.inner.atoms.TIFF_MIME, + ImageFormat::Ico => self.inner.atoms.ICO__MIME, }; let data = vec![ClipboardData { bytes: image.bytes, @@ -1078,11 +1082,11 @@ impl Clipboard { } else { String::from_utf8(result.bytes).map_err(|_| Error::ConversionFailure)? }; - return Ok(ClipboardItem::new_string(text)); + Ok(ClipboardItem::new_string(text)) } pub fn is_owner(&self, selection: ClipboardKind) -> bool { - return self.inner.is_owner(selection).unwrap_or(false); + self.inner.is_owner(selection).unwrap_or(false) } } @@ -1120,25 +1124,25 @@ impl Drop for Clipboard { log::error!("Failed to flush the clipboard window. Error: {}", e); return; } - if let Some(global_cb) = global_cb { - if let Err(e) = global_cb.server_handle.join() { - // Let's try extracting the error message - let message; - if let Some(msg) = e.downcast_ref::<&'static str>() { - message = Some((*msg).to_string()); - } else if let Some(msg) = e.downcast_ref::() { - message = Some(msg.clone()); - } else { - message = None; - } - if let Some(message) = message { - log::error!( - "The clipboard server thread panicked. Panic message: '{}'", - message, - ); - } else { - log::error!("The clipboard server thread panicked."); - } + if let Some(global_cb) = global_cb + && let Err(e) = global_cb.server_handle.join() + { + // Let's try extracting the error message + let message; + if let Some(msg) = e.downcast_ref::<&'static str>() { + message = Some((*msg).to_string()); + } else if let Some(msg) = e.downcast_ref::() { + message = Some(msg.clone()); + } else { + message = None; + } + if let Some(message) = message { + log::error!( + "The clipboard server thread panicked. Panic message: '{}'", + message, + ); + } else { + log::error!("The clipboard server thread panicked."); } } } diff --git a/crates/gpui/src/platform/linux/x11/event.rs b/crates/gpui/src/platform/linux/x11/event.rs index cd4cef24a33f33aaa2f2e685089eb1a2368719e2..17bcc908d3a6bdd48f16a8f5db69f08290b9444f 100644 --- a/crates/gpui/src/platform/linux/x11/event.rs +++ b/crates/gpui/src/platform/linux/x11/event.rs @@ -73,8 +73,8 @@ pub(crate) fn get_valuator_axis_index( // valuator present in this event's axisvalues. Axisvalues is ordered from // lowest valuator number to highest, so counting bits before the 1 bit for // this valuator yields the index in axisvalues. - if bit_is_set_in_vec(&valuator_mask, valuator_number) { - Some(popcount_upto_bit_index(&valuator_mask, valuator_number) as usize) + if bit_is_set_in_vec(valuator_mask, valuator_number) { + Some(popcount_upto_bit_index(valuator_mask, valuator_number) as usize) } else { None } @@ -104,7 +104,7 @@ fn bit_is_set_in_vec(bit_vec: &Vec, bit_index: u16) -> bool { let array_index = bit_index as usize / 32; bit_vec .get(array_index) - .map_or(false, |bits| bit_is_set(*bits, bit_index % 32)) + .is_some_and(|bits| bit_is_set(*bits, bit_index % 32)) } fn bit_is_set(bits: u32, bit_index: u16) -> bool { diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index 1a3c323c35129b9ea56595b7f81775de4b036454..fe197a670177689ce776b6b55d439483c43921e0 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -57,6 +57,7 @@ x11rb::atom_manager! { WM_PROTOCOLS, WM_DELETE_WINDOW, WM_CHANGE_STATE, + WM_TRANSIENT_FOR, _NET_WM_PID, _NET_WM_NAME, _NET_WM_STATE, @@ -72,6 +73,7 @@ x11rb::atom_manager! { _NET_WM_MOVERESIZE, _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, + _NET_WM_WINDOW_TYPE_DIALOG, _NET_WM_SYNC, _NET_SUPPORTED, _MOTIF_WM_HINTS, @@ -95,7 +97,7 @@ fn query_render_extent( } impl ResizeEdge { - fn to_moveresize(&self) -> u32 { + fn to_moveresize(self) -> u32 { match self { ResizeEdge::TopLeft => 0, ResizeEdge::Top => 1, @@ -284,7 +286,7 @@ pub(crate) struct X11WindowStatePtr { pub state: Rc>, pub(crate) callbacks: Rc>, xcb: Rc, - x_window: xproto::Window, + pub(crate) x_window: xproto::Window, } impl rwh::HasWindowHandle for RawWindow { @@ -392,12 +394,13 @@ impl X11WindowState { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, + parent_window: Option, ) -> anyhow::Result { let x_screen_index = params .display_id .map_or(x_main_screen_index, |did| did.0 as usize); - let visual_set = find_visuals(&xcb, x_screen_index); + let visual_set = find_visuals(xcb, x_screen_index); let visual = match visual_set.transparent { Some(visual) => visual, @@ -515,29 +518,62 @@ impl X11WindowState { xcb.configure_window(x_window, &xproto::ConfigureWindowAux::new().x(x).y(y)), )?; } - if let Some(titlebar) = params.titlebar { - if let Some(title) = titlebar.title { + if let Some(titlebar) = params.titlebar + && let Some(title) = titlebar.title + { + check_reply( + || "X11 ChangeProperty8 on window title failed.", + xcb.change_property8( + xproto::PropMode::REPLACE, + x_window, + xproto::AtomEnum::WM_NAME, + xproto::AtomEnum::STRING, + title.as_bytes(), + ), + )?; + } + + if params.kind == WindowKind::PopUp { + check_reply( + || "X11 ChangeProperty32 setting window type for pop-up failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_WINDOW_TYPE, + xproto::AtomEnum::ATOM, + &[atoms._NET_WM_WINDOW_TYPE_NOTIFICATION], + ), + )?; + } + + if params.kind == WindowKind::Floating { + if let Some(parent_window) = parent_window { + // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set + // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to + // place the floating window in relation to the main window. + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html check_reply( - || "X11 ChangeProperty8 on window title failed.", - xcb.change_property8( + || "X11 ChangeProperty32 setting WM_TRANSIENT_FOR for floating window failed.", + xcb.change_property32( xproto::PropMode::REPLACE, x_window, - xproto::AtomEnum::WM_NAME, - xproto::AtomEnum::STRING, - title.as_bytes(), + atoms.WM_TRANSIENT_FOR, + xproto::AtomEnum::WINDOW, + &[parent_window], ), )?; } - } - if params.kind == WindowKind::PopUp { + + // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html check_reply( - || "X11 ChangeProperty32 setting window type for pop-up failed.", + || "X11 ChangeProperty32 setting window type for floating window failed.", xcb.change_property32( xproto::PropMode::REPLACE, x_window, atoms._NET_WM_WINDOW_TYPE, xproto::AtomEnum::ATOM, - &[atoms._NET_WM_WINDOW_TYPE_NOTIFICATION], + &[atoms._NET_WM_WINDOW_TYPE_DIALOG], ), )?; } @@ -604,7 +640,7 @@ impl X11WindowState { ), )?; - xcb_flush(&xcb); + xcb_flush(xcb); let renderer = { let raw_window = RawWindow { @@ -664,7 +700,7 @@ impl X11WindowState { || "X11 DestroyWindow failed while cleaning it up after setup failure.", xcb.destroy_window(x_window), )?; - xcb_flush(&xcb); + xcb_flush(xcb); } setup_result @@ -737,6 +773,7 @@ impl X11Window { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, + parent_window: Option, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -752,6 +789,7 @@ impl X11Window { atoms, scale_factor, appearance, + parent_window, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), xcb: xcb.clone(), @@ -956,10 +994,10 @@ impl X11WindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().input { - if !fun(input.clone()).propagate { - return; - } + if let Some(ref mut fun) = self.callbacks.borrow_mut().input + && !fun(input.clone()).propagate + { + return; } if let PlatformInput::KeyDown(event) = input { // only allow shift modifier when inserting text @@ -1019,8 +1057,9 @@ impl X11WindowStatePtr { } } - pub fn get_ime_area(&self) -> Option> { + pub fn get_ime_area(&self) -> Option> { let mut state = self.state.borrow_mut(); + let scale_factor = state.scale_factor; let mut bounds: Option> = None; if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1030,7 +1069,7 @@ impl X11WindowStatePtr { let mut state = self.state.borrow_mut(); state.input_handler = Some(input_handler); }; - bounds + bounds.map(|b| b.scale(scale_factor)) } pub fn set_bounds(&self, bounds: Bounds) -> anyhow::Result<()> { @@ -1068,15 +1107,14 @@ impl X11WindowStatePtr { } let mut callbacks = self.callbacks.borrow_mut(); - if let Some((content_size, scale_factor)) = resize_args { - if let Some(ref mut fun) = callbacks.resize { - fun(content_size, scale_factor) - } + if let Some((content_size, scale_factor)) = resize_args + && let Some(ref mut fun) = callbacks.resize + { + fun(content_size, scale_factor) } - if !is_resize { - if let Some(ref mut fun) = callbacks.moved { - fun(); - } + + if !is_resize && let Some(ref mut fun) = callbacks.moved { + fun(); } Ok(()) @@ -1204,7 +1242,7 @@ impl PlatformWindow for X11Window { self.0.xcb.query_pointer(self.0.x_window), ) .log_err() - .map_or(Point::new(Pixels(0.0), Pixels(0.0)), |reply| { + .map_or(Point::new(Pixels::ZERO, Pixels::ZERO), |reply| { Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into()) }) } @@ -1619,7 +1657,7 @@ impl PlatformWindow for X11Window { } } - fn update_ime_position(&self, bounds: Bounds) { + fn update_ime_position(&self, bounds: Bounds) { let mut state = self.0.state.borrow_mut(); let client = state.client.clone(); drop(state); diff --git a/crates/gpui/src/platform/mac/dispatcher.rs b/crates/gpui/src/platform/mac/dispatcher.rs index 137295fb916e893c193962a3076b83ee5dce1436..c72f791f850469287cf66021558032902982ccec 100644 --- a/crates/gpui/src/platform/mac/dispatcher.rs +++ b/crates/gpui/src/platform/mac/dispatcher.rs @@ -9,12 +9,9 @@ use objc::{ runtime::{BOOL, YES}, sel, sel_impl, }; -use parking::{Parker, Unparker}; -use parking_lot::Mutex; use std::{ ffi::c_void, ptr::{NonNull, addr_of}, - sync::Arc, time::Duration, }; @@ -29,23 +26,7 @@ pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t { addr_of!(_dispatch_main_q) as *const _ as dispatch_queue_t } -pub(crate) struct MacDispatcher { - parker: Arc>, -} - -impl Default for MacDispatcher { - fn default() -> Self { - Self::new() - } -} - -impl MacDispatcher { - pub fn new() -> Self { - MacDispatcher { - parker: Arc::new(Mutex::new(Parker::new())), - } - } -} +pub(crate) struct MacDispatcher; impl PlatformDispatcher for MacDispatcher { fn is_main_thread(&self) -> bool { @@ -86,19 +67,6 @@ impl PlatformDispatcher for MacDispatcher { ); } } - - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) - } else { - self.parker.lock().park(); - true - } - } - - fn unparker(&self) -> Unparker { - self.parker.lock().unparker() - } } extern "C" fn trampoline(runnable: *mut c_void) { diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index 0dc361b9dcfdb0980561037484cf51b84dc251e8..938db4b76205ee43eb979995c240b8d96e2aa57a 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -311,9 +311,8 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { let mut shift = modifiers.contains(NSEventModifierFlags::NSShiftKeyMask); let command = modifiers.contains(NSEventModifierFlags::NSCommandKeyMask); let function = modifiers.contains(NSEventModifierFlags::NSFunctionKeyMask) - && first_char.map_or(true, |ch| { - !(NSUpArrowFunctionKey..=NSModeSwitchFunctionKey).contains(&ch) - }); + && first_char + .is_none_or(|ch| !(NSUpArrowFunctionKey..=NSModeSwitchFunctionKey).contains(&ch)); #[allow(non_upper_case_globals)] let key = match first_char { @@ -427,7 +426,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { key_char = Some(chars_for_modified_key(native_event.keyCode(), mods)); } - let mut key = if shift + if shift && chars_ignoring_modifiers .chars() .all(|c| c.is_ascii_lowercase()) @@ -438,9 +437,7 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke { chars_with_shift } else { chars_ignoring_modifiers - }; - - key + } } }; diff --git a/crates/gpui/src/platform/mac/keyboard.rs b/crates/gpui/src/platform/mac/keyboard.rs index a9f6af3edb584157b72b0df25f6389472410883b..14097312468cbb732b46f004dbb0970c26f6e821 100644 --- a/crates/gpui/src/platform/mac/keyboard.rs +++ b/crates/gpui/src/platform/mac/keyboard.rs @@ -1,8 +1,9 @@ +use collections::HashMap; use std::ffi::{CStr, c_void}; use objc::{msg_send, runtime::Object, sel, sel_impl}; -use crate::PlatformKeyboardLayout; +use crate::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; use super::{ TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID, @@ -14,6 +15,10 @@ pub(crate) struct MacKeyboardLayout { name: String, } +pub(crate) struct MacKeyboardMapper { + key_equivalents: Option>, +} + impl PlatformKeyboardLayout for MacKeyboardLayout { fn id(&self) -> &str { &self.id @@ -24,6 +29,27 @@ impl PlatformKeyboardLayout for MacKeyboardLayout { } } +impl PlatformKeyboardMapper for MacKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + if use_key_equivalents && let Some(key_equivalents) = &self.key_equivalents { + if keystroke.key.chars().count() == 1 + && let Some(key) = key_equivalents.get(&keystroke.key.chars().next().unwrap()) + { + keystroke.key = key.to_string(); + } + } + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + self.key_equivalents.as_ref() + } +} + impl MacKeyboardLayout { pub(crate) fn new() -> Self { unsafe { @@ -47,3 +73,1428 @@ impl MacKeyboardLayout { } } } + +impl MacKeyboardMapper { + pub(crate) fn new(layout_id: &str) -> Self { + let key_equivalents = get_key_equivalents(layout_id); + + Self { key_equivalents } + } +} + +// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range +// without using option. This means that some of our built in keyboard shortcuts do not work +// for those users. +// +// The way macOS solves this problem is to move shortcuts around so that they are all reachable, +// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct +// +// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. +// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves +// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position +// as cmd-> on a QWERTY layout. +// +// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö +// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard +// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the +// specific key moves) +// +// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every +// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... +// +// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the +// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: +// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' +// From there I used multi-cursor to produce this match statement. +fn get_key_equivalents(layout_id: &str) -> Option> { + let mappings: &[(char, char)] = match layout_id { + "com.apple.keylayout.ABC-AZERTY" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.ABC-QWERTZ" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Albanian" => &[ + ('"', '\''), + (':', 'Ç'), + (';', 'ç'), + ('<', ';'), + ('>', ':'), + ('@', '"'), + ('\'', '@'), + ('\\', 'ë'), + ('`', '<'), + ('|', 'Ë'), + ('~', '>'), + ], + "com.apple.keylayout.Austrian" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Azeri" => &[ + ('"', 'Ə'), + (',', 'ç'), + ('.', 'ş'), + ('/', '.'), + (':', 'I'), + (';', 'ı'), + ('<', 'Ç'), + ('>', 'Ş'), + ('?', ','), + ('W', 'Ü'), + ('[', 'ö'), + ('\'', 'ə'), + (']', 'ğ'), + ('w', 'ü'), + ('{', 'Ö'), + ('|', '/'), + ('}', 'Ğ'), + ], + "com.apple.keylayout.Belgian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Brazilian-ABNT2" => &[ + ('"', '`'), + ('/', 'ç'), + ('?', 'Ç'), + ('\'', '´'), + ('\\', '~'), + ('^', '¨'), + ('`', '\''), + ('|', '^'), + ('~', '"'), + ], + "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.British" => &[('#', '£')], + "com.apple.keylayout.Canadian-CSA" => &[ + ('"', 'È'), + ('/', 'é'), + ('<', '\''), + ('>', '"'), + ('?', 'É'), + ('[', '^'), + ('\'', 'è'), + ('\\', 'à'), + (']', 'ç'), + ('`', 'ù'), + ('{', '¨'), + ('|', 'À'), + ('}', 'Ç'), + ('~', 'Ù'), + ], + "com.apple.keylayout.Croatian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Croatian-PC" => &[ + ('"', 'Ć'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Czech" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Czech-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Danish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ø'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', '*'), + ('}', 'Ø'), + ('~', '>'), + ], + "com.apple.keylayout.Faroese" => &[ + ('"', 'Ø'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Æ'), + (';', 'æ'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'å'), + ('\'', 'ø'), + ('\\', '\''), + (']', 'ð'), + ('^', '&'), + ('`', '<'), + ('{', 'Å'), + ('|', '*'), + ('}', 'Ð'), + ('~', '>'), + ], + "com.apple.keylayout.Finnish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishExtended" => &[ + ('"', 'ˆ'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.French" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.French-PC" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('-', ')'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '-'), + ('7', 'è'), + ('8', '_'), + ('9', 'ç'), + (':', '§'), + (';', '!'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '*'), + (']', '$'), + ('^', '6'), + ('_', '°'), + ('`', '<'), + ('{', '¨'), + ('|', 'μ'), + ('}', '£'), + ('~', '>'), + ], + "com.apple.keylayout.French-numerical" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.German" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.German-DIN-2137" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')], + "com.apple.keylayout.Hungarian" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Hungarian-QWERTY" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Icelandic" => &[ + ('"', 'Ö'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ð'), + (';', 'ð'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', 'ö'), + ('\\', 'þ'), + (']', '´'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', 'Þ'), + ('}', '´'), + ('~', '>'), + ], + "com.apple.keylayout.Irish" => &[('#', '£')], + "com.apple.keylayout.IrishExtended" => &[('#', '£')], + "com.apple.keylayout.Italian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + (',', ';'), + ('.', ':'), + ('/', ','), + ('0', 'é'), + ('1', '&'), + ('2', '"'), + ('3', '\''), + ('4', '('), + ('5', 'ç'), + ('6', 'è'), + ('7', ')'), + ('8', '£'), + ('9', 'à'), + (':', '!'), + (';', 'ò'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', 'ì'), + ('\'', 'ù'), + ('\\', '§'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '^'), + ('|', '°'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Italian-Pro" => &[ + ('"', '^'), + ('#', '£'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'é'), + (';', 'è'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ò'), + ('\'', 'ì'), + ('\\', 'ù'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ç'), + ('|', '§'), + ('}', '°'), + ('~', '>'), + ], + "com.apple.keylayout.LatinAmerican" => &[ + ('"', '¨'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ñ'), + (';', 'ñ'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', '{'), + ('\'', '´'), + ('\\', '¿'), + (']', '}'), + ('^', '&'), + ('`', '<'), + ('{', '['), + ('|', '¡'), + ('}', ']'), + ('~', '>'), + ], + "com.apple.keylayout.Lithuanian" => &[ + ('!', 'Ą'), + ('#', 'Ę'), + ('$', 'Ė'), + ('%', 'Į'), + ('&', 'Ų'), + ('*', 'Ū'), + ('+', 'Ž'), + ('1', 'ą'), + ('2', 'č'), + ('3', 'ę'), + ('4', 'ė'), + ('5', 'į'), + ('6', 'š'), + ('7', 'ų'), + ('8', 'ū'), + ('=', 'ž'), + ('@', 'Č'), + ('^', 'Š'), + ], + "com.apple.keylayout.Maltese" => &[ + ('#', '£'), + ('[', 'ġ'), + (']', 'ħ'), + ('`', 'ż'), + ('{', 'Ġ'), + ('}', 'Ħ'), + ('~', 'Ż'), + ], + "com.apple.keylayout.NorthernSami" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Norwegian" => &[ + ('"', '^'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianExtended" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\\', '@'), + (']', 'æ'), + ('`', '<'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.Polish" => &[ + ('!', '§'), + ('"', 'ę'), + ('#', '!'), + ('$', '?'), + ('%', '+'), + ('&', ':'), + ('(', '/'), + (')', '"'), + ('*', '_'), + ('+', ']'), + (',', '.'), + ('.', ','), + ('/', 'ż'), + (':', 'Ł'), + (';', 'ł'), + ('<', 'ś'), + ('=', '['), + ('>', 'ń'), + ('?', 'Ż'), + ('@', '%'), + ('[', 'ó'), + ('\'', 'ą'), + ('\\', ';'), + (']', '('), + ('^', '='), + ('_', 'ć'), + ('`', '<'), + ('{', 'ź'), + ('|', '$'), + ('}', ')'), + ('~', '>'), + ], + "com.apple.keylayout.Portuguese" => &[ + ('"', '`'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'ª'), + (';', 'º'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ç'), + ('\'', '´'), + (']', '~'), + ('^', '&'), + ('`', '<'), + ('{', 'Ç'), + ('}', '^'), + ('~', '>'), + ], + "com.apple.keylayout.Sami-PC" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Serbian-Latin" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Slovak" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovak-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovenian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish" => &[ + ('!', '¡'), + ('"', '¨'), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '!'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '/'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', ':'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish-ISO" => &[ + ('"', '¨'), + ('#', '·'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '"'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '&'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', '`'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish-Pro" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwedishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissFrench" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'ü'), + (';', 'è'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'é'), + ('\'', '^'), + ('\\', '$'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ö'), + ('|', '£'), + ('}', 'ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissGerman" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'è'), + (';', 'ü'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '^'), + ('\\', '$'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'é'), + ('|', '£'), + ('}', 'à'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish" => &[ + ('"', '-'), + ('#', '"'), + ('$', '\''), + ('%', '('), + ('&', ')'), + ('(', '%'), + (')', ':'), + ('*', '_'), + (',', 'ö'), + ('-', 'ş'), + ('.', 'ç'), + ('/', '.'), + (':', '$'), + ('<', 'Ö'), + ('>', 'Ç'), + ('@', '*'), + ('[', 'ğ'), + ('\'', ','), + ('\\', 'ü'), + (']', 'ı'), + ('^', '/'), + ('_', 'Ş'), + ('`', '<'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-QWERTY-PC" => &[ + ('"', 'I'), + ('#', '^'), + ('$', '+'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', ':'), + (',', 'ö'), + ('.', 'ç'), + ('/', '*'), + (':', 'Ş'), + (';', 'ş'), + ('<', 'Ö'), + ('=', '.'), + ('>', 'Ç'), + ('@', '\''), + ('[', 'ğ'), + ('\'', 'ı'), + ('\\', ','), + (']', 'ü'), + ('^', '&'), + ('`', '<'), + ('{', 'Ğ'), + ('|', ';'), + ('}', 'Ü'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-Standard" => &[ + ('"', 'Ş'), + ('#', '^'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (',', '.'), + ('.', ','), + (':', 'Ç'), + (';', 'ç'), + ('<', ':'), + ('=', '*'), + ('>', ';'), + ('@', '"'), + ('[', 'ğ'), + ('\'', 'ş'), + ('\\', 'ü'), + (']', 'ı'), + ('^', '&'), + ('`', 'ö'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', 'Ö'), + ], + "com.apple.keylayout.Turkmen" => &[ + ('C', 'Ç'), + ('Q', 'Ä'), + ('V', 'Ý'), + ('X', 'Ü'), + ('[', 'ň'), + ('\\', 'ş'), + (']', 'ö'), + ('^', '№'), + ('`', 'ž'), + ('c', 'ç'), + ('q', 'ä'), + ('v', 'ý'), + ('x', 'ü'), + ('{', 'Ň'), + ('|', 'Ş'), + ('}', 'Ö'), + ('~', 'Ž'), + ], + "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.Welsh" => &[('#', '£')], + + _ => return None, + }; + + Some(HashMap::from_iter(mappings.iter().cloned())) +} diff --git a/crates/gpui/src/platform/mac/metal_atlas.rs b/crates/gpui/src/platform/mac/metal_atlas.rs index 5d2d8e63e06a1ea6251c1fd2edf461eeeedec612..8282530c5efdc13ca95a1f04c0f6ef1a23c8366c 100644 --- a/crates/gpui/src/platform/mac/metal_atlas.rs +++ b/crates/gpui/src/platform/mac/metal_atlas.rs @@ -167,11 +167,14 @@ impl MetalAtlasState { if let Some(ix) = index { texture_list.textures[ix] = Some(atlas_texture); - texture_list.textures.get_mut(ix).unwrap().as_mut().unwrap() + texture_list.textures.get_mut(ix) } else { texture_list.textures.push(Some(atlas_texture)); - texture_list.textures.last_mut().unwrap().as_mut().unwrap() + texture_list.textures.last_mut() } + .unwrap() + .as_mut() + .unwrap() } fn texture(&self, id: AtlasTextureId) -> &MetalAtlasTexture { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index a686d8c45bf846fe7f36123cb559e5c412bf1783..9e5d6ec5ff02c74b4f0acfada8eee3d002bfd06b 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -332,7 +332,7 @@ impl MetalRenderer { self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor)); if self.path_sample_count > 1 { - let mut msaa_descriptor = texture_descriptor.clone(); + let mut msaa_descriptor = texture_descriptor; msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample); msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private); msaa_descriptor.set_sample_count(self.path_sample_count as _); @@ -445,14 +445,14 @@ impl MetalRenderer { instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), PrimitiveBatch::Quads(quads) => self.draw_quads( quads, instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), PrimitiveBatch::Paths(paths) => { command_encoder.end_encoding(); @@ -480,7 +480,7 @@ impl MetalRenderer { instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ) } else { false @@ -491,7 +491,7 @@ impl MetalRenderer { instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), PrimitiveBatch::MonochromeSprites { texture_id, @@ -502,7 +502,7 @@ impl MetalRenderer { instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), PrimitiveBatch::PolychromeSprites { texture_id, @@ -513,14 +513,14 @@ impl MetalRenderer { instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), PrimitiveBatch::Surfaces(surfaces) => self.draw_surfaces( surfaces, instance_buffer, &mut instance_offset, viewport_size, - &command_encoder, + command_encoder, ), }; if !ok { @@ -763,7 +763,7 @@ impl MetalRenderer { viewport_size: Size, command_encoder: &metal::RenderCommandEncoderRef, ) -> bool { - let Some(ref first_path) = paths.first() else { + let Some(first_path) = paths.first() else { return true; }; diff --git a/crates/gpui/src/platform/mac/open_type.rs b/crates/gpui/src/platform/mac/open_type.rs index 2ae5e8f87ab78a70e423a4645c96e69f098828a6..37a29559fdfbc284ffd1021cc6c2c6ed717ca228 100644 --- a/crates/gpui/src/platform/mac/open_type.rs +++ b/crates/gpui/src/platform/mac/open_type.rs @@ -35,14 +35,14 @@ pub fn apply_features_and_fallbacks( unsafe { let mut keys = vec![kCTFontFeatureSettingsAttribute]; let mut values = vec![generate_feature_array(features)]; - if let Some(fallbacks) = fallbacks { - if !fallbacks.fallback_list().is_empty() { - keys.push(kCTFontCascadeListAttribute); - values.push(generate_fallback_array( - fallbacks, - font.native_font().as_concrete_TypeRef(), - )); - } + if let Some(fallbacks) = fallbacks + && !fallbacks.fallback_list().is_empty() + { + keys.push(kCTFontCascadeListAttribute); + values.push(generate_fallback_array( + fallbacks, + font.native_font().as_concrete_TypeRef(), + )); } let attrs = CFDictionaryCreate( kCFAllocatorDefault, diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 533423229cf2448ea70cf0140d5e3d6bc77fb32a..244350169caffef10ea2740a30e36772506e6145 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,5 +1,5 @@ use super::{ - BoolExt, MacKeyboardLayout, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, renderer, @@ -8,8 +8,9 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, - SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Result, SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, + hash, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -81,6 +82,10 @@ unsafe fn build_classes() { APP_DELEGATE_CLASS = unsafe { let mut decl = ClassDecl::new("GPUIApplicationDelegate", class!(NSResponder)).unwrap(); decl.add_ivar::<*mut c_void>(MAC_PLATFORM_IVAR); + decl.add_method( + sel!(applicationWillFinishLaunching:), + will_finish_launching as extern "C" fn(&mut Object, Sel, id), + ); decl.add_method( sel!(applicationDidFinishLaunching:), did_finish_launching as extern "C" fn(&mut Object, Sel, id), @@ -171,6 +176,7 @@ pub(crate) struct MacPlatformState { finish_launching: Option>, dock_menu: Option, menus: Option>, + keyboard_mapper: Rc, } impl Default for MacPlatform { @@ -181,7 +187,7 @@ impl Default for MacPlatform { impl MacPlatform { pub(crate) fn new(headless: bool) -> Self { - let dispatcher = Arc::new(MacDispatcher::new()); + let dispatcher = Arc::new(MacDispatcher); #[cfg(feature = "font-kit")] let text_system = Arc::new(crate::MacTextSystem::new()); @@ -189,6 +195,9 @@ impl MacPlatform { #[cfg(not(feature = "font-kit"))] let text_system = Arc::new(crate::NoopTextSystem::new()); + let keyboard_layout = MacKeyboardLayout::new(); + let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); + Self(Mutex::new(MacPlatformState { headless, text_system, @@ -209,6 +218,7 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, menus: None, + keyboard_mapper, })) } @@ -348,19 +358,19 @@ impl MacPlatform { let mut mask = NSEventModifierFlags::empty(); for (modifier, flag) in &[ ( - keystroke.modifiers.platform, + keystroke.modifiers().platform, NSEventModifierFlags::NSCommandKeyMask, ), ( - keystroke.modifiers.control, + keystroke.modifiers().control, NSEventModifierFlags::NSControlKeyMask, ), ( - keystroke.modifiers.alt, + keystroke.modifiers().alt, NSEventModifierFlags::NSAlternateKeyMask, ), ( - keystroke.modifiers.shift, + keystroke.modifiers().shift, NSEventModifierFlags::NSShiftKeyMask, ), ] { @@ -371,9 +381,9 @@ impl MacPlatform { item = NSMenuItem::alloc(nil) .initWithTitle_action_keyEquivalent_( - ns_string(&name), + ns_string(name), selector, - ns_string(key_to_native(&keystroke.key).as_ref()), + ns_string(key_to_native(keystroke.key()).as_ref()), ) .autorelease(); if Self::os_version() >= SemanticVersion::new(12, 0, 0) { @@ -383,7 +393,7 @@ impl MacPlatform { } else { item = NSMenuItem::alloc(nil) .initWithTitle_action_keyEquivalent_( - ns_string(&name), + ns_string(name), selector, ns_string(""), ) @@ -392,7 +402,7 @@ impl MacPlatform { } else { item = NSMenuItem::alloc(nil) .initWithTitle_action_keyEquivalent_( - ns_string(&name), + ns_string(name), selector, ns_string(""), ) @@ -412,7 +422,7 @@ impl MacPlatform { submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap)); } item.setSubmenu_(submenu); - item.setTitle_(ns_string(&name)); + item.setTitle_(ns_string(name)); item } MenuItem::SystemMenu(OsMenu { name, menu_type }) => { @@ -420,7 +430,7 @@ impl MacPlatform { let submenu = NSMenu::new(nil).autorelease(); submenu.setDelegate_(delegate); item.setSubmenu_(submenu); - item.setTitle_(ns_string(&name)); + item.setTitle_(ns_string(name)); match menu_type { SystemMenuType::Services => { @@ -533,6 +543,10 @@ impl Platform for MacPlatform { open "$1" "#; + #[allow( + clippy::disallowed_methods, + reason = "We are restarting ourselves, using std command thus is fine" + )] let restart_process = Command::new("/bin/bash") .arg("-c") .arg(script) @@ -705,6 +719,7 @@ impl Platform for MacPlatform { panel.setCanChooseDirectories_(options.directories.to_objc()); panel.setCanChooseFiles_(options.files.to_objc()); panel.setAllowsMultipleSelection_(options.multiple.to_objc()); + panel.setCanCreateDirectories(true.to_objc()); panel.setResolvesAliases_(false.to_objc()); let done_tx = Cell::new(Some(done_tx)); @@ -714,10 +729,10 @@ impl Platform for MacPlatform { let urls = panel.URLs(); for i in 0..urls.count() { let url = urls.objectAtIndex(i); - if url.isFileURL() == YES { - if let Ok(path) = ns_url_to_path(url) { - result.push(path) - } + if url.isFileURL() == YES + && let Ok(path) = ns_url_to_path(url) + { + result.push(path) } } Some(result) @@ -730,6 +745,11 @@ impl Platform for MacPlatform { } }); let block = block.copy(); + + if let Some(prompt) = options.prompt { + let _: () = msg_send![panel, setPrompt: ns_string(&prompt)]; + } + let _: () = msg_send![panel, beginWithCompletionHandler: block]; } }) @@ -780,17 +800,18 @@ impl Platform for MacPlatform { // This is conditional on OS version because I'd like to get rid of it, so that // you can manually create a file called `a.sql.s`. That said it seems better // to break that use-case than breaking `a.sql`. - if chunks.len() == 3 && chunks[1].starts_with(chunks[2]) { - if Self::os_version() >= SemanticVersion::new(15, 0, 0) { - let new_filename = OsStr::from_bytes( - &filename.as_bytes() - [..chunks[0].len() + 1 + chunks[1].len()], - ) - .to_owned(); - result.set_file_name(&new_filename); - } + if chunks.len() == 3 + && chunks[1].starts_with(chunks[2]) + && Self::os_version() >= SemanticVersion::new(15, 0, 0) + { + let new_filename = OsStr::from_bytes( + &filename.as_bytes() + [..chunks[0].len() + 1 + chunks[1].len()], + ) + .to_owned(); + result.set_file_name(&new_filename); } - return result; + result }) } } @@ -838,11 +859,14 @@ impl Platform for MacPlatform { .lock() .background_executor .spawn(async move { - let _ = std::process::Command::new("open") + if let Some(mut child) = smol::process::Command::new("open") .arg(path) .spawn() .context("invoking open command") - .log_err(); + .log_err() + { + child.status().await.log_err(); + } }) .detach(); } @@ -875,6 +899,10 @@ impl Platform for MacPlatform { Box::new(MacKeyboardLayout::new()) } + fn keyboard_mapper(&self) -> Rc { + self.0.lock().keyboard_mapper.clone() + } + fn app_path(&self) -> Result { unsafe { let bundle: id = NSBundle::mainBundle(); @@ -1339,6 +1367,23 @@ unsafe fn get_mac_platform(object: &mut Object) -> &MacPlatform { } } +extern "C" fn will_finish_launching(_this: &mut Object, _: Sel, _: id) { + unsafe { + let user_defaults: id = msg_send![class!(NSUserDefaults), standardUserDefaults]; + + // The autofill heuristic controller causes slowdown and high CPU usage. + // We don't know exactly why. This disables the full heuristic controller. + // + // Adapted from: https://github.com/ghostty-org/ghostty/pull/8625 + let name = ns_string("NSAutoFillHeuristicControllerEnabled"); + let existing_value: id = msg_send![user_defaults, objectForKey: name]; + if existing_value == nil { + let false_value: id = msg_send![class!(NSNumber), numberWithBool:false]; + let _: () = msg_send![user_defaults, setObject: false_value forKey: name]; + } + } +} + extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { unsafe { let app: id = msg_send![APP_CLASS, sharedApplication]; @@ -1386,6 +1431,8 @@ extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; let mut lock = platform.0.lock(); + let keyboard_layout = MacKeyboardLayout::new(); + lock.keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); if let Some(mut callback) = lock.on_keyboard_layout_change.take() { drop(lock); callback(); @@ -1560,6 +1607,7 @@ impl From for UTType { ImageFormat::Gif => Self::gif(), ImageFormat::Bmp => Self::bmp(), ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), } } } @@ -1598,6 +1646,11 @@ impl UTType { Self(unsafe { ns_string("public.svg-image") }) } + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + pub fn tiff() -> Self { // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType diff --git a/crates/gpui/src/platform/mac/shaders.metal b/crates/gpui/src/platform/mac/shaders.metal index 83c978b853443d5c612f514625f94b6d6725be8a..7c3886031ab915e674469a6d85ef368c35b2b759 100644 --- a/crates/gpui/src/platform/mac/shaders.metal +++ b/crates/gpui/src/platform/mac/shaders.metal @@ -18,6 +18,8 @@ float2 to_tile_position(float2 unit_vertex, AtlasTile tile, constant Size_DevicePixels *atlas_size); float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds, Bounds_ScaledPixels clip_bounds); +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds, + Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation); float corner_dash_velocity(float dv1, float dv2); float dash_alpha(float t, float period, float length, float dash_velocity, float antialias_threshold); @@ -243,7 +245,15 @@ fragment float4 quad_fragment(QuadFragmentInput input [[stage_in]], // out on each straight line, rather than around the whole // perimeter. This way each line starts and ends with a dash. bool is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - float border_width = is_horizontal ? border.x : border.y; + + // Choosing the right border width for dashed borders. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + float2 dashed_border = float2( + fmax(quad.border_widths.bottom, quad.border_widths.top), + fmax(quad.border_widths.right, quad.border_widths.left)); + + float border_width = is_horizontal ? dashed_border.x : dashed_border.y; dash_velocity = dv_numerator / border_width; t = is_horizontal ? point.x : point.y; t *= dash_velocity; @@ -599,13 +609,14 @@ struct MonochromeSpriteVertexOutput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; - float clip_distance [[clip_distance]][4]; + float4 clip_distance; }; struct MonochromeSpriteFragmentInput { float4 position [[position]]; float2 tile_position; float4 color [[flat]]; + float4 clip_distance; }; vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex( @@ -620,8 +631,8 @@ vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex( MonochromeSprite sprite = sprites[sprite_id]; float4 device_position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation, viewport_size); - float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds, - sprite.content_mask.bounds); + float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, + sprite.content_mask.bounds, sprite.transformation); float2 tile_position = to_tile_position(unit_vertex, sprite.tile, atlas_size); float4 color = hsla_to_rgba(sprite.color); return MonochromeSpriteVertexOutput{ @@ -635,6 +646,10 @@ fragment float4 monochrome_sprite_fragment( MonochromeSpriteFragmentInput input [[stage_in]], constant MonochromeSprite *sprites [[buffer(SpriteInputIndex_Sprites)]], texture2d atlas_texture [[texture(SpriteInputIndex_AtlasTexture)]]) { + if (any(input.clip_distance < float4(0.0))) { + return float4(0.0); + } + constexpr sampler atlas_texture_sampler(mag_filter::linear, min_filter::linear); float4 sample = @@ -1096,6 +1111,23 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds, clip_bounds.origin.y + clip_bounds.size.height - position.y); } +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds, + Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation) { + float2 position = + unit_vertex * float2(bounds.size.width, bounds.size.height) + + float2(bounds.origin.x, bounds.origin.y); + float2 transformed_position = float2(0, 0); + transformed_position[0] = position[0] * transformation.rotation_scale[0][0] + position[1] * transformation.rotation_scale[0][1]; + transformed_position[1] = position[0] * transformation.rotation_scale[1][0] + position[1] * transformation.rotation_scale[1][1]; + transformed_position[0] += transformation.translation[0]; + transformed_position[1] += transformation.translation[1]; + + return float4(transformed_position.x - clip_bounds.origin.x, + clip_bounds.origin.x + clip_bounds.size.width - transformed_position.x, + transformed_position.y - clip_bounds.origin.y, + clip_bounds.origin.y + clip_bounds.size.height - transformed_position.y); +} + float4 over(float4 below, float4 above) { float4 result; float alpha = above.a + below.a * (1.0 - above.a); diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 849925c72772b70162f09fc680c0be2d6510878a..92135a2c96e5cb4c3587f7f01225be5b1fcd8b43 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -1,7 +1,7 @@ use crate::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, FontStyle, FontWeight, GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, - RenderGlyphParams, Result, SUBPIXEL_VARIANTS, ShapedGlyph, ShapedRun, SharedString, Size, + RenderGlyphParams, Result, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, Size, point, px, size, swap_rgba_pa_to_bgra, }; use anyhow::anyhow; @@ -16,7 +16,7 @@ use core_foundation::{ use core_graphics::{ base::{CGGlyph, kCGImageAlphaPremultipliedLast}, color_space::CGColorSpace, - context::CGContext, + context::{CGContext, CGTextDrawingMode}, display::CGPoint, }; use core_text::{ @@ -43,7 +43,7 @@ use pathfinder_geometry::{ vector::{Vector2F, Vector2I}, }; use smallvec::SmallVec; -use std::{borrow::Cow, char, cmp, convert::TryFrom, sync::Arc}; +use std::{borrow::Cow, char, convert::TryFrom, sync::Arc}; use super::open_type::apply_features_and_fallbacks; @@ -319,7 +319,7 @@ impl MacTextSystemState { fn is_emoji(&self, font_id: FontId) -> bool { self.postscript_names_by_font_id .get(&font_id) - .map_or(false, |postscript_name| { + .is_some_and(|postscript_name| { postscript_name == "AppleColorEmoji" || postscript_name == ".AppleColorEmojiUI" }) } @@ -395,7 +395,11 @@ impl MacTextSystemState { let subpixel_shift = params .subpixel_variant - .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32); + .map(|v| v as f32 / SUBPIXEL_VARIANTS_X as f32); + cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill); + cx.set_gray_fill_color(0.0, 1.0); + cx.set_allows_antialiasing(true); + cx.set_should_antialias(true); cx.set_allows_font_subpixel_positioning(true); cx.set_should_subpixel_position_fonts(true); cx.set_allows_font_subpixel_quantization(false); @@ -426,26 +430,27 @@ impl MacTextSystemState { fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { // Construct the attributed string, converting UTF8 ranges to UTF16 ranges. let mut string = CFMutableAttributedString::new(); - { - string.replace_str(&CFString::new(text), CFRange::init(0, 0)); - let utf16_line_len = string.char_len() as usize; + let mut max_ascent = 0.0f32; + let mut max_descent = 0.0f32; - let mut ix_converter = StringIndexConverter::new(text); + { + let mut text = text; for run in font_runs { - let utf8_end = ix_converter.utf8_ix + run.len; - let utf16_start = ix_converter.utf16_ix; - - if utf16_start >= utf16_line_len { - break; - } + let text_run; + (text_run, text) = text.split_at(run.len); - ix_converter.advance_to_utf8_ix(utf8_end); - let utf16_end = cmp::min(ix_converter.utf16_ix, utf16_line_len); + let utf16_start = string.char_len(); // insert at end of string + // note: replace_str may silently ignore codepoints it dislikes (e.g., BOM at start of string) + string.replace_str(&CFString::new(text_run), CFRange::init(utf16_start, 0)); + let utf16_end = string.char_len(); - let cf_range = - CFRange::init(utf16_start as isize, (utf16_end - utf16_start) as isize); + let cf_range = CFRange::init(utf16_start, utf16_end - utf16_start); + let font = &self.fonts[run.font_id.0]; - let font: &FontKitFont = &self.fonts[run.font_id.0]; + let font_metrics = font.metrics(); + let font_scale = font_size.0 / font_metrics.units_per_em as f32; + max_ascent = max_ascent.max(font_metrics.ascent * font_scale); + max_descent = max_descent.max(-font_metrics.descent * font_scale); unsafe { string.set_attribute( @@ -454,17 +459,12 @@ impl MacTextSystemState { &font.native_font().clone_with_font_size(font_size.into()), ); } - - if utf16_end == utf16_line_len { - break; - } } } - // Retrieve the glyphs from the shaped line, converting UTF16 offsets to UTF8 offsets. let line = CTLine::new_with_attributed_string(string.as_concrete_TypeRef()); let glyph_runs = line.glyph_runs(); - let mut runs = Vec::with_capacity(glyph_runs.len() as usize); + let mut runs = >::with_capacity(glyph_runs.len() as usize); let mut ix_converter = StringIndexConverter::new(text); for run in glyph_runs.into_iter() { let attributes = run.attributes().unwrap(); @@ -476,45 +476,54 @@ impl MacTextSystemState { }; let font_id = self.id_for_native_font(font); - let mut glyphs = Vec::with_capacity(run.glyph_count().try_into().unwrap_or(0)); - for ((glyph_id, position), glyph_utf16_ix) in run + let mut glyphs = match runs.last_mut() { + Some(run) if run.font_id == font_id => &mut run.glyphs, + _ => { + runs.push(ShapedRun { + font_id, + glyphs: Vec::with_capacity(run.glyph_count().try_into().unwrap_or(0)), + }); + &mut runs.last_mut().unwrap().glyphs + } + }; + for ((&glyph_id, position), &glyph_utf16_ix) in run .glyphs() .iter() .zip(run.positions().iter()) .zip(run.string_indices().iter()) { - let glyph_utf16_ix = usize::try_from(*glyph_utf16_ix).unwrap(); + let mut glyph_utf16_ix = usize::try_from(glyph_utf16_ix).unwrap(); if ix_converter.utf16_ix > glyph_utf16_ix { // We cannot reuse current index converter, as it can only seek forward. Restart the search. ix_converter = StringIndexConverter::new(text); } ix_converter.advance_to_utf16_ix(glyph_utf16_ix); glyphs.push(ShapedGlyph { - id: GlyphId(*glyph_id as u32), + id: GlyphId(glyph_id as u32), position: point(position.x as f32, position.y as f32).map(px), index: ix_converter.utf8_ix, is_emoji: self.is_emoji(font_id), }); } - - runs.push(ShapedRun { font_id, glyphs }); } let typographic_bounds = line.get_typographic_bounds(); LineLayout { runs, font_size, width: typographic_bounds.width.into(), - ascent: typographic_bounds.ascent.into(), - descent: typographic_bounds.descent.into(), + ascent: max_ascent.into(), + descent: max_descent.into(), len: text.len(), } } } -#[derive(Clone)] +#[derive(Debug, Clone)] struct StringIndexConverter<'a> { text: &'a str, + /// Index in UTF-8 bytes utf8_ix: usize, + /// Index in UTF-16 code units utf16_ix: usize, } @@ -527,17 +536,6 @@ impl<'a> StringIndexConverter<'a> { } } - fn advance_to_utf8_ix(&mut self, utf8_target: usize) { - for (ix, c) in self.text[self.utf8_ix..].char_indices() { - if self.utf8_ix + ix >= utf8_target { - self.utf8_ix += ix; - return; - } - self.utf16_ix += c.len_utf16(); - } - self.utf8_ix = self.text.len(); - } - fn advance_to_utf16_ix(&mut self, utf16_target: usize) { for (ix, c) in self.text[self.utf8_ix..].char_indices() { if self.utf16_ix >= utf16_target { @@ -695,5 +693,113 @@ mod tests { assert_eq!(layout.runs[0].glyphs[0].id, GlyphId(68u32)); // a // There's no glyph for \u{feff} assert_eq!(layout.runs[0].glyphs[1].id, GlyphId(69u32)); // b + + let line = "\u{feff}ab"; + let font_runs = &[ + FontRun { + len: "\u{feff}".len(), + font_id, + }, + FontRun { + len: "ab".len(), + font_id, + }, + ]; + let layout = fonts.layout_line(line, px(16.), font_runs); + assert_eq!(layout.len, line.len()); + assert_eq!(layout.runs.len(), 1); + assert_eq!(layout.runs[0].glyphs.len(), 2); + // There's no glyph for \u{feff} + assert_eq!(layout.runs[0].glyphs[0].id, GlyphId(68u32)); // a + assert_eq!(layout.runs[0].glyphs[1].id, GlyphId(69u32)); // b + } + + #[test] + fn test_layout_line_zwnj_insertion() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); + + let text = "hello world"; + let font_runs = &[ + FontRun { font_id, len: 5 }, // "hello" + FontRun { font_id, len: 6 }, // " world" + ]; + + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + for run in &layout.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + + // Test with different font runs - should not insert ZWNJ + let font_id2 = fonts.font_id(&font("Times")).unwrap_or(font_id); + let font_runs_different = &[ + FontRun { font_id, len: 5 }, // "hello" + // " world" + FontRun { + font_id: font_id2, + len: 6, + }, + ]; + + let layout2 = fonts.layout_line(text, px(16.), font_runs_different); + assert_eq!(layout2.len, text.len()); + + for run in &layout2.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + } + + #[test] + fn test_layout_line_zwnj_edge_cases() { + let fonts = MacTextSystem::new(); + let font_id = fonts.font_id(&font("Helvetica")).unwrap(); + + let text = "hello"; + let font_runs = &[FontRun { font_id, len: 5 }]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + let text = "abc"; + let font_runs = &[ + FontRun { font_id, len: 1 }, // "a" + FontRun { font_id, len: 1 }, // "b" + FontRun { font_id, len: 1 }, // "c" + ]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, text.len()); + + for run in &layout.runs { + for glyph in &run.glyphs { + assert!( + glyph.index < text.len(), + "Glyph index {} is out of bounds for text length {}", + glyph.index, + text.len() + ); + } + } + + // Test with empty text + let text = ""; + let font_runs = &[]; + let layout = fonts.layout_line(text, px(16.), font_runs); + assert_eq!(layout.len, 0); + assert!(layout.runs.is_empty()); } } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index aedf131909a6956e9a4501b107c81ce242b80a49..95efffa3e77cdbeebf53acd47dd1aa9b33cb24ab 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -4,8 +4,9 @@ use crate::{ ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, - ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size, + SharedString, Size, SystemWindowTab, Timer, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControlArea, WindowKind, WindowParams, dispatch_get_main_queue, + dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point, px, size, }; use block::ConcreteBlock; use cocoa::{ @@ -24,6 +25,7 @@ use cocoa::{ NSUserDefaults, }, }; + use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; use futures::channel::oneshot; @@ -82,6 +84,12 @@ type NSDragOperation = NSUInteger; const NSDragOperationNone: NSDragOperation = 0; #[allow(non_upper_case_globals)] const NSDragOperationCopy: NSDragOperation = 1; +#[derive(PartialEq)] +pub enum UserTabbingPreference { + Never, + Always, + InFullScreen, +} #[link(name = "CoreGraphics", kind = "framework")] unsafe extern "C" { @@ -343,6 +351,36 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C conclude_drag_operation as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(addTitlebarAccessoryViewController:), + add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(moveTabToNewWindow:), + move_tab_to_new_window as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(mergeAllWindows:), + merge_all_windows as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectNextTab:), + select_next_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(selectPreviousTab:), + select_previous_tab as extern "C" fn(&Object, Sel, id), + ); + + decl.add_method( + sel!(toggleTabBar:), + toggle_tab_bar as extern "C" fn(&Object, Sel, id), + ); + decl.register() } } @@ -375,6 +413,12 @@ struct MacWindowState { // Whether the next left-mouse click is also the focusing click. first_mouse: bool, fullscreen_restore_bounds: Bounds, + move_tab_to_new_window_callback: Option>, + merge_all_windows_callback: Option>, + select_next_tab_callback: Option>, + select_previous_tab_callback: Option>, + toggle_tab_bar_callback: Option>, + activated_least_once: bool, } impl MacWindowState { @@ -470,10 +514,11 @@ impl MacWindowState { fn bounds(&self) -> Bounds { let mut window_frame = unsafe { NSWindow::frame(self.native_window) }; - let screen_frame = unsafe { - let screen = NSWindow::screen(self.native_window); - NSScreen::frame(screen) - }; + let screen = unsafe { NSWindow::screen(self.native_window) }; + if screen == nil { + return Bounds::new(point(px(0.), px(0.)), crate::DEFAULT_WINDOW_SIZE); + } + let screen_frame = unsafe { NSScreen::frame(screen) }; // Flip the y coordinate to be top-left origin window_frame.origin.y = @@ -530,10 +575,13 @@ impl MacWindow { titlebar, kind, is_movable, + is_resizable, + is_minimizable, focus, show, display_id, window_min_size, + tabbing_identifier, }: WindowParams, executor: ForegroundExecutor, renderer_context: renderer::Context, @@ -541,14 +589,25 @@ impl MacWindow { unsafe { let pool = NSAutoreleasePool::new(nil); - let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + let allows_automatic_window_tabbing = tabbing_identifier.is_some(); + if allows_automatic_window_tabbing { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES]; + } else { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + } let mut style_mask; if let Some(titlebar) = titlebar.as_ref() { - style_mask = NSWindowStyleMask::NSClosableWindowMask - | NSWindowStyleMask::NSMiniaturizableWindowMask - | NSWindowStyleMask::NSResizableWindowMask - | NSWindowStyleMask::NSTitledWindowMask; + style_mask = + NSWindowStyleMask::NSClosableWindowMask | NSWindowStyleMask::NSTitledWindowMask; + + if is_resizable { + style_mask |= NSWindowStyleMask::NSResizableWindowMask; + } + + if is_minimizable { + style_mask |= NSWindowStyleMask::NSMiniaturizableWindowMask; + } if titlebar.appears_transparent { style_mask |= NSWindowStyleMask::NSFullSizeContentViewWindowMask; @@ -559,7 +618,7 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc], WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] @@ -653,13 +712,19 @@ impl MacWindow { .and_then(|titlebar| titlebar.traffic_light_position), transparent_titlebar: titlebar .as_ref() - .map_or(true, |titlebar| titlebar.appears_transparent), + .is_none_or(|titlebar| titlebar.appears_transparent), previous_modifiers_changed_event: None, keystroke_for_do_command: None, do_command_handled: None, external_files_dragged: false, first_mouse: false, fullscreen_restore_bounds: Bounds::default(), + move_tab_to_new_window_callback: None, + merge_all_windows_callback: None, + select_next_tab_callback: None, + select_previous_tab_callback: None, + toggle_tab_bar_callback: None, + activated_least_once: false, }))); (*native_window).set_ivar( @@ -688,7 +753,7 @@ impl MacWindow { }); } - if titlebar.map_or(true, |titlebar| titlebar.appears_transparent) { + if titlebar.is_none_or(|titlebar| titlebar.appears_transparent) { native_window.setTitlebarAppearsTransparent_(YES); native_window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden); } @@ -711,9 +776,16 @@ impl MacWindow { native_window.makeFirstResponder_(native_view); match kind { - WindowKind::Normal => { + WindowKind::Normal | WindowKind::Floating => { native_window.setLevel_(NSNormalWindowLevel); native_window.setAcceptsMouseMovedEvents_(YES); + + if let Some(tabbing_identifier) = tabbing_identifier { + let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; + } else { + let _: () = msg_send![native_window, setTabbingIdentifier:nil]; + } } WindowKind::PopUp => { // Use a tracking area to allow receiving MouseMoved events even when @@ -742,6 +814,38 @@ impl MacWindow { } } + let app = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + if allows_automatic_window_tabbing + && !main_window.is_null() + && main_window != native_window + { + let main_window_is_fullscreen = main_window + .styleMask() + .contains(NSWindowStyleMask::NSFullScreenWindowMask); + let user_tabbing_preference = Self::get_user_tabbing_preference() + .unwrap_or(UserTabbingPreference::InFullScreen); + let should_add_as_tab = user_tabbing_preference == UserTabbingPreference::Always + || user_tabbing_preference == UserTabbingPreference::InFullScreen + && main_window_is_fullscreen; + + if should_add_as_tab { + let main_window_can_tab: BOOL = + msg_send![main_window, respondsToSelector: sel!(addTabbedWindow:ordered:)]; + let main_window_visible: BOOL = msg_send![main_window, isVisible]; + + if main_window_can_tab == YES && main_window_visible == YES { + let _: () = msg_send![main_window, addTabbedWindow: native_window ordered: NSWindowOrderingMode::NSWindowAbove]; + + // Ensure the window is visible immediately after adding the tab, since the tab bar is updated with a new entry at this point. + // Note: Calling orderFront here can break fullscreen mode (makes fullscreen windows exit fullscreen), so only do this if the main window is not fullscreen. + if !main_window_is_fullscreen { + let _: () = msg_send![native_window, orderFront: nil]; + } + } + } + } + if focus && show { native_window.makeKeyAndOrderFront_(nil); } else if show { @@ -796,6 +900,33 @@ impl MacWindow { window_handles } } + + pub fn get_user_tabbing_preference() -> Option { + unsafe { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); + let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + + let dict: id = msg_send![defaults, persistentDomainForName: domain]; + let value: id = if !dict.is_null() { + msg_send![dict, objectForKey: key] + } else { + nil + }; + + let value_str = if !value.is_null() { + CStr::from_ptr(NSString::UTF8String(value)).to_string_lossy() + } else { + "".into() + }; + + match value_str.as_ref() { + "manual" => Some(UserTabbingPreference::Never), + "always" => Some(UserTabbingPreference::Always), + _ => Some(UserTabbingPreference::InFullScreen), + } + } + } } impl Drop for MacWindow { @@ -851,6 +982,65 @@ impl PlatformWindow for MacWindow { .detach(); } + fn merge_all_windows(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, mergeAllWindows:nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(merge_windows_async), + ); + } + } + + fn move_tab_to_new_window(&self) { + let native_window = self.0.lock().native_window; + unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) { + let native_window = context as id; + let _: () = msg_send![native_window, moveTabToNewWindow:nil]; + let _: () = msg_send![native_window, makeKeyAndOrderFront: nil]; + } + + unsafe { + dispatch_async_f( + dispatch_get_main_queue(), + native_window as *mut std::ffi::c_void, + Some(move_tab_async), + ); + } + } + + fn toggle_window_tab_overview(&self) { + let native_window = self.0.lock().native_window; + unsafe { + let _: () = msg_send![native_window, toggleTabOverview:nil]; + } + } + + fn set_tabbing_identifier(&self, tabbing_identifier: Option) { + let native_window = self.0.lock().native_window; + unsafe { + let allows_automatic_window_tabbing = tabbing_identifier.is_some(); + if allows_automatic_window_tabbing { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES]; + } else { + let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO]; + } + + if let Some(tabbing_identifier) = tabbing_identifier { + let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; + } else { + let _: () = msg_send![native_window, setTabbingIdentifier:nil]; + } + } + } + fn scale_factor(&self) -> f32 { self.0.as_ref().lock().scale_factor() } @@ -1051,6 +1241,17 @@ impl PlatformWindow for MacWindow { } } + fn get_title(&self) -> String { + unsafe { + let title: id = msg_send![self.0.lock().native_window, title]; + if title.is_null() { + "".to_string() + } else { + title.to_str().to_string() + } + } + } + fn set_app_id(&mut self, _app_id: &str) {} fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) { @@ -1090,7 +1291,7 @@ impl PlatformWindow for MacWindow { NSView::removeFromSuperview(blur_view); this.blurred_view = None; } - } else if this.blurred_view == None { + } else if this.blurred_view.is_none() { let content_view = this.native_window.contentView(); let frame = NSView::bounds(content_view); let mut blur_view: id = msg_send![BLURRED_VIEW_CLASS, alloc]; @@ -1212,6 +1413,62 @@ impl PlatformWindow for MacWindow { self.0.lock().appearance_changed_callback = Some(callback); } + fn tabbed_windows(&self) -> Option> { + unsafe { + let windows: id = msg_send![self.0.lock().native_window, tabbedWindows]; + if windows.is_null() { + return None; + } + + let count: NSUInteger = msg_send![windows, count]; + let mut result = Vec::new(); + for i in 0..count { + let window: id = msg_send![windows, objectAtIndex:i]; + if msg_send![window, isKindOfClass: WINDOW_CLASS] { + let handle = get_window_state(&*window).lock().handle; + let title: id = msg_send![window, title]; + let title = SharedString::from(title.to_str().to_string()); + + result.push(SystemWindowTab::new(title, handle)); + } + } + + Some(result) + } + } + + fn tab_bar_visible(&self) -> bool { + unsafe { + let tab_group: id = msg_send![self.0.lock().native_window, tabGroup]; + if tab_group.is_null() { + false + } else { + let tab_bar_visible: BOOL = msg_send![tab_group, isTabBarVisible]; + tab_bar_visible == YES + } + } + } + + fn on_move_tab_to_new_window(&self, callback: Box) { + self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback); + } + + fn on_merge_all_windows(&self, callback: Box) { + self.0.as_ref().lock().merge_all_windows_callback = Some(callback); + } + + fn on_select_next_tab(&self, callback: Box) { + self.0.as_ref().lock().select_next_tab_callback = Some(callback); + } + + fn on_select_previous_tab(&self, callback: Box) { + self.0.as_ref().lock().select_previous_tab_callback = Some(callback); + } + + fn on_toggle_tab_bar(&self, callback: Box) { + self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); + } + fn draw(&self, scene: &crate::Scene) { let mut this = self.0.lock(); this.renderer.draw(scene); @@ -1225,7 +1482,7 @@ impl PlatformWindow for MacWindow { None } - fn update_ime_position(&self, _bounds: Bounds) { + fn update_ime_position(&self, _bounds: Bounds) { let executor = self.0.lock().executor.clone(); executor .spawn(async move { @@ -1265,6 +1522,9 @@ impl PlatformWindow for MacWindow { }; match action_str.as_ref() { + "None" => { + // "Do Nothing" selected, so do no action + } "Minimize" => { window.miniaturize_(nil); } @@ -1311,7 +1571,7 @@ fn get_scale_factor(native_window: id) -> f32 { let factor = unsafe { let screen: id = msg_send![native_window, screen]; if screen.is_null() { - return 1.0; + return 2.0; } NSScreen::backingScaleFactor(screen) as f32 }; @@ -1478,18 +1738,18 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: return YES; } - if key_down_event.is_held { - if let Some(key_char) = key_down_event.keystroke.key_char.as_ref() { - let handled = with_input_handler(&this, |input_handler| { - if !input_handler.apple_press_and_hold_enabled() { - input_handler.replace_text_in_range(None, &key_char); - return YES; - } - NO - }); - if handled == Some(YES) { + if key_down_event.is_held + && let Some(key_char) = key_down_event.keystroke.key_char.as_ref() + { + let handled = with_input_handler(this, |input_handler| { + if !input_handler.apple_press_and_hold_enabled() { + input_handler.replace_text_in_range(None, key_char); return YES; } + NO + }); + if handled == Some(YES) { + return YES; } } @@ -1624,10 +1884,10 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { modifiers: prev_modifiers, capslock: prev_capslock, })) = &lock.previous_modifiers_changed_event + && prev_modifiers == modifiers + && prev_capslock == capslock { - if prev_modifiers == modifiers && prev_capslock == capslock { - return; - } + return; } lock.previous_modifiers_changed_event = Some(event.clone()); @@ -1653,6 +1913,7 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { .occlusionState() .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) { + lock.move_traffic_light(); lock.start_display_link(); } else { lock.stop_display_link(); @@ -1714,7 +1975,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) { extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = window_state.lock(); + let mut lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; // When opening a pop-up while the application isn't active, Cocoa sends a spurious @@ -1735,9 +1996,43 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.executor.clone(); drop(lock); + + // When a window becomes active, trigger an immediate synchronous frame request to prevent + // tab flicker when switching between windows in native tabs mode. + // + // This is only done on subsequent activations (not the first) to ensure the initial focus + // path is properly established. Without this guard, the focus state would remain unset until + // the first mouse click, causing keybindings to be non-functional. + if selector == sel!(windowDidBecomeKey:) && is_active { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.lock(); + + if lock.activated_least_once { + if let Some(mut callback) = lock.request_frame_callback.take() { + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(true); + lock.stop_display_link(); + drop(lock); + callback(Default::default()); + + let mut lock = window_state.lock(); + lock.request_frame_callback = Some(callback); + #[cfg(not(feature = "macos-blade"))] + lock.renderer.set_presents_with_transaction(false); + lock.start_display_link(); + } + } else { + lock.activated_least_once = true; + } + } + executor .spawn(async move { let mut lock = window_state.as_ref().lock(); + if is_active { + lock.move_traffic_light(); + } + if let Some(mut callback) = lock.activate_callback.take() { drop(lock); callback(is_active); @@ -1949,7 +2244,7 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS let text = text.to_str(); let replacement_range = replacement_range.to_range(); with_input_handler(this, |input_handler| { - input_handler.replace_text_in_range(replacement_range, &text) + input_handler.replace_text_in_range(replacement_range, text) }); } } @@ -1973,7 +2268,7 @@ extern "C" fn set_marked_text( let replacement_range = replacement_range.to_range(); let text = text.to_str(); with_input_handler(this, |input_handler| { - input_handler.replace_and_mark_text_in_range(replacement_range, &text, selected_range) + input_handler.replace_and_mark_text_in_range(replacement_range, text, selected_range) }); } } @@ -1995,10 +2290,10 @@ extern "C" fn attributed_substring_for_proposed_range( let mut adjusted: Option> = None; let selected_text = input_handler.text_for_range(range.clone(), &mut adjusted)?; - if let Some(adjusted) = adjusted { - if adjusted != range { - unsafe { (actual_range as *mut NSRange).write(NSRange::from(adjusted)) }; - } + if let Some(adjusted) = adjusted + && adjusted != range + { + unsafe { (actual_range as *mut NSRange).write(NSRange::from(adjusted)) }; } unsafe { let string: id = msg_send![class!(NSAttributedString), alloc]; @@ -2063,8 +2358,8 @@ fn screen_point_to_gpui_point(this: &Object, position: NSPoint) -> Point let frame = get_frame(this); let window_x = position.x - frame.origin.x; let window_y = frame.size.height - (position.y - frame.origin.y); - let position = point(px(window_x as f32), px(window_y as f32)); - position + + point(px(window_x as f32), px(window_y as f32)) } extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation { @@ -2073,11 +2368,10 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr let paths = external_paths_from_event(dragging_info); if let Some(event) = paths.map(|paths| PlatformInput::FileDrop(FileDropEvent::Entered { position, paths })) + && send_new_event(&window_state, event) { - if send_new_event(&window_state, event) { - window_state.lock().external_files_dragged = true; - return NSDragOperationCopy; - } + window_state.lock().external_files_dragged = true; + return NSDragOperationCopy; } NSDragOperationNone } @@ -2274,3 +2568,80 @@ unsafe fn remove_layer_background(layer: id) { } } } + +extern "C" fn add_titlebar_accessory_view_controller(this: &Object, _: Sel, view_controller: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), addTitlebarAccessoryViewController: view_controller]; + + // Hide the native tab bar and set its height to 0, since we render our own. + let accessory_view: id = msg_send![view_controller, view]; + let _: () = msg_send![accessory_view, setHidden: YES]; + let mut frame: NSRect = msg_send![accessory_view, frame]; + frame.size.height = 0.0; + let _: () = msg_send![accessory_view, setFrame: frame]; + } +} + +extern "C" fn move_tab_to_new_window(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), moveTabToNewWindow:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.move_tab_to_new_window_callback.take() { + drop(lock); + callback(); + window_state.lock().move_tab_to_new_window_callback = Some(callback); + } + } +} + +extern "C" fn merge_all_windows(this: &Object, _: Sel, _: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), mergeAllWindows:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.merge_all_windows_callback.take() { + drop(lock); + callback(); + window_state.lock().merge_all_windows_callback = Some(callback); + } + } +} + +extern "C" fn select_next_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_next_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_next_tab_callback = Some(callback); + } +} + +extern "C" fn select_previous_tab(this: &Object, _sel: Sel, _id: id) { + let window_state = unsafe { get_window_state(this) }; + let mut lock = window_state.as_ref().lock(); + if let Some(mut callback) = lock.select_previous_tab_callback.take() { + drop(lock); + callback(); + window_state.lock().select_previous_tab_callback = Some(callback); + } +} + +extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { + unsafe { + let _: () = msg_send![super(this, class!(NSWindow)), toggleTabBar:nil]; + + let window_state = get_window_state(this); + let mut lock = window_state.as_ref().lock(); + lock.move_traffic_light(); + + if let Some(mut callback) = lock.toggle_tab_bar_callback.take() { + drop(lock); + callback(); + window_state.lock().toggle_tab_bar_callback = Some(callback); + } + } +} diff --git a/crates/gpui/src/platform/scap_screen_capture.rs b/crates/gpui/src/platform/scap_screen_capture.rs index 32041b655fdc20b046717291c623dcb5c4d5146c..d6d19cd8102d58ceaa9bc87bff348eaeda9adfef 100644 --- a/crates/gpui/src/platform/scap_screen_capture.rs +++ b/crates/gpui/src/platform/scap_screen_capture.rs @@ -228,7 +228,7 @@ fn run_capture( display, size, })); - if let Err(_) = stream_send_result { + if stream_send_result.is_err() { return; } while !cancel_stream.load(std::sync::atomic::Ordering::SeqCst) { diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 16edabfa4bfee9c66dcf6ed8abc5eeb7957a7fa0..017c29bfb558f77874a9729a52b518d9d41fb256 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -2,7 +2,7 @@ use crate::{PlatformDispatcher, TaskLabel}; use async_task::Runnable; use backtrace::Backtrace; use collections::{HashMap, HashSet, VecDeque}; -use parking::{Parker, Unparker}; +use parking::Unparker; use parking_lot::Mutex; use rand::prelude::*; use std::{ @@ -22,8 +22,6 @@ struct TestDispatcherId(usize); pub struct TestDispatcher { id: TestDispatcherId, state: Arc>, - parker: Arc>, - unparker: Unparker, } struct TestDispatcherState { @@ -41,11 +39,11 @@ struct TestDispatcherState { waiting_backtrace: Option, deprioritized_task_labels: HashSet, block_on_ticks: RangeInclusive, + last_parked: Option, } impl TestDispatcher { pub fn new(random: StdRng) -> Self { - let (parker, unparker) = parking::pair(); let state = TestDispatcherState { random, foreground: HashMap::default(), @@ -61,13 +59,12 @@ impl TestDispatcher { waiting_backtrace: None, deprioritized_task_labels: Default::default(), block_on_ticks: 0..=1000, + last_parked: None, }; TestDispatcher { id: TestDispatcherId(0), state: Arc::new(Mutex::new(state)), - parker: Arc::new(Mutex::new(parker)), - unparker, } } @@ -78,11 +75,11 @@ impl TestDispatcher { let state = self.state.lock(); let next_due_time = state.delayed.first().map(|(time, _)| *time); drop(state); - if let Some(due_time) = next_due_time { - if due_time <= new_now { - self.state.lock().time = due_time; - continue; - } + if let Some(due_time) = next_due_time + && due_time <= new_now + { + self.state.lock().time = due_time; + continue; } break; } @@ -118,7 +115,7 @@ impl TestDispatcher { } YieldNow { - count: self.state.lock().random.gen_range(0..10), + count: self.state.lock().random.random_range(0..10), } } @@ -151,11 +148,11 @@ impl TestDispatcher { if deprioritized_background_len == 0 { return false; } - let ix = state.random.gen_range(0..deprioritized_background_len); + let ix = state.random.random_range(0..deprioritized_background_len); main_thread = false; runnable = state.deprioritized_background.swap_remove(ix); } else { - main_thread = state.random.gen_ratio( + main_thread = state.random.random_ratio( foreground_len as u32, (foreground_len + background_len) as u32, ); @@ -170,7 +167,7 @@ impl TestDispatcher { .pop_front() .unwrap(); } else { - let ix = state.random.gen_range(0..background_len); + let ix = state.random.random_range(0..background_len); runnable = state.background.swap_remove(ix); }; }; @@ -241,7 +238,22 @@ impl TestDispatcher { pub fn gen_block_on_ticks(&self) -> usize { let mut lock = self.state.lock(); let block_on_ticks = lock.block_on_ticks.clone(); - lock.random.gen_range(block_on_ticks) + lock.random.random_range(block_on_ticks) + } + pub fn unpark_last(&self) { + self.state + .lock() + .last_parked + .take() + .as_ref() + .map(Unparker::unpark); + } + + pub fn set_unparker(&self, unparker: Unparker) { + let last = { self.state.lock().last_parked.replace(unparker) }; + if let Some(last) = last { + last.unpark(); + } } } @@ -251,8 +263,6 @@ impl Clone for TestDispatcher { Self { id: TestDispatcherId(id), state: self.state.clone(), - parker: self.parker.clone(), - unparker: self.unparker.clone(), } } } @@ -270,15 +280,13 @@ impl PlatformDispatcher for TestDispatcher { fn dispatch(&self, runnable: Runnable, label: Option) { { let mut state = self.state.lock(); - if label.map_or(false, |label| { - state.deprioritized_task_labels.contains(&label) - }) { + if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) { state.deprioritized_background.push(runnable); } else { state.background.push(runnable); } } - self.unparker.unpark(); + self.unpark_last(); } fn dispatch_on_main_thread(&self, runnable: Runnable) { @@ -288,7 +296,7 @@ impl PlatformDispatcher for TestDispatcher { .entry(self.id) .or_default() .push_back(runnable); - self.unparker.unpark(); + self.unpark_last(); } fn dispatch_after(&self, duration: std::time::Duration, runnable: Runnable) { @@ -299,14 +307,6 @@ impl PlatformDispatcher for TestDispatcher { }; state.delayed.insert(ix, (next_time, runnable)); } - fn park(&self, _: Option) -> bool { - self.parker.lock().park(); - true - } - - fn unparker(&self) -> Unparker { - self.unparker.clone() - } fn as_test(&self) -> Option<&TestDispatcher> { Some(self) diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 69371bc8c4aae38c48e1f14ae223fd9c8b1fb75e..15b909199fbd53b974e6a140f3223641dc0ac6ae 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,8 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout, - PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SourceMetadata, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, + ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, + TestDisplay, TestWindow, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -187,24 +188,24 @@ impl TestPlatform { .push_back(TestPrompt { msg: msg.to_string(), detail: detail.map(|s| s.to_string()), - answers: answers.clone(), + answers, tx, }); rx } pub(crate) fn set_active_window(&self, window: Option) { - let executor = self.foreground_executor().clone(); + let executor = self.foreground_executor(); let previous_window = self.active_window.borrow_mut().take(); self.active_window.borrow_mut().clone_from(&window); executor .spawn(async move { if let Some(previous_window) = previous_window { - if let Some(window) = window.as_ref() { - if Rc::ptr_eq(&previous_window.0, &window.0) { - return; - } + if let Some(window) = window.as_ref() + && Rc::ptr_eq(&previous_window.0, &window.0) + { + return; } previous_window.simulate_active_status_change(false); } @@ -237,6 +238,10 @@ impl Platform for TestPlatform { Box::new(TestKeyboardLayout) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, _: Box) {} fn run(&self, _on_finish_launching: Box) { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index e15bd7aeecec5932eb6386bd47d168eda906dd63..9e87f4504ddd61e34b645ea69ea394c4940f9d55 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,8 +1,8 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, PromptButton, RequestFrameOptions, ScaledPixels, Size, TestPlatform, TileId, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance, + WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; use parking_lot::Mutex; @@ -289,7 +289,7 @@ impl PlatformWindow for TestWindow { unimplemented!() } - fn update_ime_position(&self, _bounds: Bounds) {} + fn update_ime_position(&self, _bounds: Bounds) {} fn gpu_specs(&self) -> Option { None diff --git a/crates/gpui/src/platform/windows.rs b/crates/gpui/src/platform/windows.rs index 77e0ca41bf8b394dc8bdd75e521aab3ba63dce2c..9cd1a7d05f4bcc6aa097db5dad64bdbc502575fc 100644 --- a/crates/gpui/src/platform/windows.rs +++ b/crates/gpui/src/platform/windows.rs @@ -2,6 +2,7 @@ mod clipboard; mod destination_list; mod direct_write; mod directx_atlas; +mod directx_devices; mod directx_renderer; mod dispatcher; mod display; @@ -18,6 +19,7 @@ pub(crate) use clipboard::*; pub(crate) use destination_list::*; pub(crate) use direct_write::*; pub(crate) use directx_atlas::*; +pub(crate) use directx_devices::*; pub(crate) use directx_renderer::*; pub(crate) use dispatcher::*; pub(crate) use display::*; diff --git a/crates/gpui/src/platform/windows/alpha_correction.hlsl b/crates/gpui/src/platform/windows/alpha_correction.hlsl new file mode 100644 index 0000000000000000000000000000000000000000..b0a9ca2e6b60a515ad2c1f9d95cd3e19079d326c --- /dev/null +++ b/crates/gpui/src/platform/windows/alpha_correction.hlsl @@ -0,0 +1,32 @@ +// Adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +float color_brightness(float3 color) { + // REC. 601 luminance coefficients for perceived brightness + return dot(color, float3(0.30f, 0.59f, 0.11f)); +} + +float light_on_dark_contrast(float enhancedContrast, float3 color) { + float brightness = color_brightness(color); + float multiplier = saturate(4.0f * (0.75f - brightness)); + return enhancedContrast * multiplier; +} + +float enhance_contrast(float alpha, float k) { + return alpha * (k + 1.0f) / (alpha * k + 1.0f); +} + +float apply_alpha_correction(float a, float b, float4 g) { + float brightness_adjustment = g.x * b + g.y; + float correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0f - a) * correction; +} + +float apply_contrast_and_gamma_correction(float sample, float3 color, float enhanced_contrast_factor, float4 gamma_ratios) { + float enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + float brightness = color_brightness(color); + + float contrasted = enhance_contrast(sample, enhanced_contrast); + return apply_alpha_correction(contrasted, brightness, gamma_ratios); +} diff --git a/crates/gpui/src/platform/windows/clipboard.rs b/crates/gpui/src/platform/windows/clipboard.rs index 915dbab3901aef2fc092626f13fbad783f68858a..90d97a84c0bedcc241f7432a7f14f09d46018b49 100644 --- a/crates/gpui/src/platform/windows/clipboard.rs +++ b/crates/gpui/src/platform/windows/clipboard.rs @@ -3,7 +3,6 @@ use std::sync::LazyLock; use anyhow::Result; use collections::{FxHashMap, FxHashSet}; use itertools::Itertools; -use util::ResultExt; use windows::Win32::{ Foundation::{HANDLE, HGLOBAL}, System::{ @@ -76,14 +75,18 @@ enum ClipboardFormatType { } pub(crate) fn write_to_clipboard(item: ClipboardItem) { - write_to_clipboard_inner(item).log_err(); - unsafe { CloseClipboard().log_err() }; + with_clipboard(|| write_to_clipboard_inner(item)); } pub(crate) fn read_from_clipboard() -> Option { - let result = read_from_clipboard_inner(); - unsafe { CloseClipboard().log_err() }; - result + with_clipboard(|| { + with_best_match_format(|item_format| match format_to_type(item_format) { + ClipboardFormatType::Text => read_string_from_clipboard(), + ClipboardFormatType::Image => read_image_from_clipboard(item_format), + ClipboardFormatType::Files => read_files_from_clipboard(), + }) + }) + .flatten() } pub(crate) fn with_file_names(hdrop: HDROP, mut f: F) @@ -96,11 +99,33 @@ where let mut buffer = vec![0u16; filename_length + 1]; let ret = unsafe { DragQueryFileW(hdrop, file_index, Some(buffer.as_mut_slice())) }; if ret == 0 { - log::error!("unable to read file name"); + log::error!("unable to read file name of dragged file"); continue; } - if let Some(file_name) = String::from_utf16(&buffer[0..filename_length]).log_err() { - f(file_name); + match String::from_utf16(&buffer[0..filename_length]) { + Ok(file_name) => f(file_name), + Err(e) => { + log::error!("dragged file name is not UTF-16: {}", e) + } + } + } +} + +fn with_clipboard(f: F) -> Option +where + F: FnOnce() -> T, +{ + match unsafe { OpenClipboard(None) } { + Ok(()) => { + let result = f(); + if let Err(e) = unsafe { CloseClipboard() } { + log::error!("Failed to close clipboard: {e}",); + } + Some(result) + } + Err(e) => { + log::error!("Failed to open clipboard: {e}",); + None } } } @@ -124,7 +149,6 @@ fn format_to_type(item_format: u32) -> &'static ClipboardFormatType { // Currently, we only write the first item. fn write_to_clipboard_inner(item: ClipboardItem) -> Result<()> { unsafe { - OpenClipboard(None)?; EmptyClipboard()?; } match item.entries().first() { @@ -215,15 +239,6 @@ fn convert_image_to_png_format(bytes: &[u8], image_format: ImageFormat) -> Resul Ok(output_buf) } -fn read_from_clipboard_inner() -> Option { - unsafe { OpenClipboard(None) }.log_err()?; - with_best_match_format(|item_format| match format_to_type(item_format) { - ClipboardFormatType::Text => read_string_from_clipboard(), - ClipboardFormatType::Image => read_image_from_clipboard(item_format), - ClipboardFormatType::Files => read_files_from_clipboard(), - }) -} - // Here, we enumerate all formats on the clipboard and find the first one that we can process. // The reason we don't use `GetPriorityClipboardFormat` is that it sometimes returns the // wrong format. @@ -266,7 +281,7 @@ where } fn read_string_from_clipboard() -> Option { - let text = with_clipboard_data(CF_UNICODETEXT.0 as u32, |data_ptr| { + let text = with_clipboard_data(CF_UNICODETEXT.0 as u32, |data_ptr, _| { let pcwstr = PCWSTR(data_ptr as *const u16); String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) })?; @@ -290,20 +305,22 @@ fn read_hash_from_clipboard() -> Option { if unsafe { IsClipboardFormatAvailable(*CLIPBOARD_HASH_FORMAT).is_err() } { return None; } - with_clipboard_data(*CLIPBOARD_HASH_FORMAT, |data_ptr| { + with_clipboard_data(*CLIPBOARD_HASH_FORMAT, |data_ptr, size| { + if size < 8 { + return None; + } let hash_bytes: [u8; 8] = unsafe { std::slice::from_raw_parts(data_ptr.cast::(), 8) - .to_vec() .try_into() - .log_err() + .ok() }?; Some(u64::from_ne_bytes(hash_bytes)) })? } fn read_metadata_from_clipboard() -> Option { - unsafe { IsClipboardFormatAvailable(*CLIPBOARD_METADATA_FORMAT).log_err()? }; - with_clipboard_data(*CLIPBOARD_METADATA_FORMAT, |data_ptr| { + unsafe { IsClipboardFormatAvailable(*CLIPBOARD_METADATA_FORMAT).ok()? }; + with_clipboard_data(*CLIPBOARD_METADATA_FORMAT, |data_ptr, _size| { let pcwstr = PCWSTR(data_ptr as *const u16); String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) }) @@ -320,7 +337,7 @@ fn format_number_to_image_format(format_number: u32) -> Option<&'static ImageFor } fn read_image_for_type(format_number: u32, format: ImageFormat) -> Option { - let (bytes, id) = with_clipboard_data_and_size(format_number, |data_ptr, size| { + let (bytes, id) = with_clipboard_data(format_number, |data_ptr, size| { let bytes = unsafe { std::slice::from_raw_parts(data_ptr as *mut u8 as _, size).to_vec() }; let id = hash(&bytes); (bytes, id) @@ -329,7 +346,7 @@ fn read_image_for_type(format_number: u32, format: ImageFormat) -> Option Option { - let text = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr| { + let text = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| { let hdrop = HDROP(data_ptr); let mut filenames = String::new(); with_file_names(hdrop, |file_name| { @@ -344,25 +361,14 @@ fn read_files_from_clipboard() -> Option { } fn with_clipboard_data(format: u32, f: F) -> Option -where - F: FnOnce(*mut std::ffi::c_void) -> R, -{ - let global = HGLOBAL(unsafe { GetClipboardData(format).log_err() }?.0); - let data_ptr = unsafe { GlobalLock(global) }; - let result = f(data_ptr); - unsafe { GlobalUnlock(global).log_err() }; - Some(result) -} - -fn with_clipboard_data_and_size(format: u32, f: F) -> Option where F: FnOnce(*mut std::ffi::c_void, usize) -> R, { - let global = HGLOBAL(unsafe { GetClipboardData(format).log_err() }?.0); + let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0); let size = unsafe { GlobalSize(global) }; let data_ptr = unsafe { GlobalLock(global) }; let result = f(data_ptr, size); - unsafe { GlobalUnlock(global).log_err() }; + unsafe { GlobalUnlock(global).ok() }; Some(result) } diff --git a/crates/gpui/src/platform/windows/color_text_raster.hlsl b/crates/gpui/src/platform/windows/color_text_raster.hlsl index ccc5fa26f00d57f2b69e85965a66b6ecea98a833..2fbc156ba5ea9e443366558d10d0b8791c2eb488 100644 --- a/crates/gpui/src/platform/windows/color_text_raster.hlsl +++ b/crates/gpui/src/platform/windows/color_text_raster.hlsl @@ -1,3 +1,5 @@ +#include "alpha_correction.hlsl" + struct RasterVertexOutput { float4 position : SV_Position; float2 texcoord : TEXCOORD0; @@ -23,17 +25,20 @@ struct Bounds { int2 size; }; -Texture2D t_layer : register(t0); +Texture2D t_layer : register(t0); SamplerState s_layer : register(s0); cbuffer GlyphLayerTextureParams : register(b0) { Bounds bounds; float4 run_color; + float4 gamma_ratios; + float grayscale_enhanced_contrast; + float3 _pad; }; float4 emoji_rasterization_fragment(PixelInput input): SV_Target { - float3 sampled = t_layer.Sample(s_layer, input.texcoord.xy).rgb; - float alpha = (sampled.r + sampled.g + sampled.b) / 3; - - return float4(run_color.rgb, alpha); + float sample = t_layer.Sample(s_layer, input.texcoord.xy).r; + float alpha_corrected = apply_contrast_and_gamma_correction(sample, run_color.rgb, grayscale_enhanced_contrast, gamma_ratios); + float alpha = alpha_corrected * run_color.a; + return float4(run_color.rgb * alpha, alpha); } diff --git a/crates/gpui/src/platform/windows/direct_write.rs b/crates/gpui/src/platform/windows/direct_write.rs index 75cb50243b9c8ec845e256f4095cdedc40d2eea2..e187fc4b09176906102a1bf8fe50b410aae3cb2b 100644 --- a/crates/gpui/src/platform/windows/direct_write.rs +++ b/crates/gpui/src/platform/windows/direct_write.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, sync::Arc}; use ::util::ResultExt; -use anyhow::Result; +use anyhow::{Context, Result}; use collections::HashMap; use itertools::Itertools; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; @@ -10,12 +10,8 @@ use windows::{ Foundation::*, Globalization::GetUserDefaultLocaleName, Graphics::{ - Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, - Direct3D11::*, - DirectWrite::*, - Dxgi::Common::*, - Gdi::{IsRectEmpty, LOGFONTW}, - Imaging::*, + Direct3D::D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, Direct3D11::*, DirectWrite::*, + Dxgi::Common::*, Gdi::LOGFONTW, }, System::SystemServices::LOCALE_NAME_MAX_LENGTH, UI::WindowsAndMessaging::*, @@ -40,12 +36,10 @@ pub(crate) struct DirectWriteTextSystem(RwLock); struct DirectWriteComponent { locale: String, factory: IDWriteFactory5, - bitmap_factory: AgileReference, in_memory_loader: IDWriteInMemoryFontFileLoader, builder: IDWriteFontSetBuilder1, text_renderer: Arc, - render_params: IDWriteRenderingParams3, gpu_state: GPUState, } @@ -76,11 +70,10 @@ struct FontIdentifier { } impl DirectWriteComponent { - pub fn new(bitmap_factory: &IWICImagingFactory, gpu_context: &DirectXDevices) -> Result { + pub fn new(directx_devices: &DirectXDevices) -> Result { // todo: ideally this would not be a large unsafe block but smaller isolated ones for easier auditing unsafe { let factory: IDWriteFactory5 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; - let bitmap_factory = AgileReference::new(bitmap_factory)?; // The `IDWriteInMemoryFontFileLoader` here is supported starting from // Windows 10 Creators Update, which consequently requires the entire // `DirectWriteTextSystem` to run on `win10 1703`+. @@ -92,36 +85,14 @@ impl DirectWriteComponent { let locale = String::from_utf16_lossy(&locale_vec); let text_renderer = Arc::new(TextRendererWrapper::new(&locale)); - let render_params = { - let default_params: IDWriteRenderingParams3 = - factory.CreateRenderingParams()?.cast()?; - let gamma = default_params.GetGamma(); - let enhanced_contrast = default_params.GetEnhancedContrast(); - let gray_contrast = default_params.GetGrayscaleEnhancedContrast(); - let cleartype_level = default_params.GetClearTypeLevel(); - let grid_fit_mode = default_params.GetGridFitMode(); - - factory.CreateCustomRenderingParams( - gamma, - enhanced_contrast, - gray_contrast, - cleartype_level, - DWRITE_PIXEL_GEOMETRY_RGB, - DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC, - grid_fit_mode, - )? - }; - - let gpu_state = GPUState::new(gpu_context)?; + let gpu_state = GPUState::new(directx_devices)?; Ok(DirectWriteComponent { locale, factory, - bitmap_factory, in_memory_loader, builder, text_renderer, - render_params, gpu_state, }) } @@ -129,9 +100,9 @@ impl DirectWriteComponent { } impl GPUState { - fn new(gpu_context: &DirectXDevices) -> Result { - let device = gpu_context.device.clone(); - let device_context = gpu_context.device_context.clone(); + fn new(directx_devices: &DirectXDevices) -> Result { + let device = directx_devices.device.clone(); + let device_context = directx_devices.device_context.clone(); let blend_state = { let mut blend_state = None; @@ -141,10 +112,10 @@ impl GPUState { RenderTarget: [ D3D11_RENDER_TARGET_BLEND_DESC { BlendEnable: true.into(), - SrcBlend: D3D11_BLEND_SRC_ALPHA, + SrcBlend: D3D11_BLEND_ONE, DestBlend: D3D11_BLEND_INV_SRC_ALPHA, BlendOp: D3D11_BLEND_OP_ADD, - SrcBlendAlpha: D3D11_BLEND_SRC_ALPHA, + SrcBlendAlpha: D3D11_BLEND_ONE, DestBlendAlpha: D3D11_BLEND_INV_SRC_ALPHA, BlendOpAlpha: D3D11_BLEND_OP_ADD, RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, @@ -212,11 +183,8 @@ impl GPUState { } impl DirectWriteTextSystem { - pub(crate) fn new( - gpu_context: &DirectXDevices, - bitmap_factory: &IWICImagingFactory, - ) -> Result { - let components = DirectWriteComponent::new(bitmap_factory, gpu_context)?; + pub(crate) fn new(directx_devices: &DirectXDevices) -> Result { + let components = DirectWriteComponent::new(directx_devices)?; let system_font_collection = unsafe { let mut result = std::mem::zeroed(); components @@ -242,6 +210,10 @@ impl DirectWriteTextSystem { font_id_by_identifier: HashMap::default(), }))) } + + pub(crate) fn handle_gpu_lost(&self, directx_devices: &DirectXDevices) { + self.0.write().handle_gpu_lost(directx_devices); + } } impl PlatformTextSystem for DirectWriteTextSystem { @@ -751,29 +723,32 @@ impl DirectWriteState { dx: 0.0, dy: 0.0, }; - let subpixel_shift = params - .subpixel_variant - .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32); - let baseline_origin_x = subpixel_shift.x / params.scale_factor; - let baseline_origin_y = subpixel_shift.y / params.scale_factor; + let baseline_origin_x = + params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor; + let baseline_origin_y = + params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor; let mut rendering_mode = DWRITE_RENDERING_MODE1::default(); let mut grid_fit_mode = DWRITE_GRID_FIT_MODE::default(); unsafe { font.font_face.GetRecommendedRenderingMode( params.font_size.0, - // The dpi here seems that it has the same effect with `Some(&transform)` - 1.0, - 1.0, + // Using 96 as scale is applied by the transform + 96.0, + 96.0, Some(&transform), false, DWRITE_OUTLINE_THRESHOLD_ANTIALIASED, DWRITE_MEASURING_MODE_NATURAL, - &self.components.render_params, + None, &mut rendering_mode, &mut grid_fit_mode, )?; } + let rendering_mode = match rendering_mode { + DWRITE_RENDERING_MODE1_OUTLINE => DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC, + m => m, + }; let glyph_analysis = unsafe { self.components.factory.CreateGlyphRunAnalysis( @@ -782,8 +757,7 @@ impl DirectWriteState { rendering_mode, DWRITE_MEASURING_MODE_NATURAL, grid_fit_mode, - // We're using cleartype not grayscale for monochrome is because it provides better quality - DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE, + DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE, baseline_origin_x, baseline_origin_y, ) @@ -794,10 +768,14 @@ impl DirectWriteState { fn raster_bounds(&self, params: &RenderGlyphParams) -> Result> { let glyph_analysis = self.create_glyph_run_analysis(params)?; - let bounds = unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_CLEARTYPE_3x1)? }; - // Some glyphs cannot be drawn with ClearType, such as bitmap fonts. In that case - // GetAlphaTextureBounds() supposedly returns an empty RECT, but I haven't tested that yet. - if !unsafe { IsRectEmpty(&bounds) }.as_bool() { + let bounds = unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_ALIASED_1x1)? }; + + if bounds.right < bounds.left { + Ok(Bounds { + origin: point(0.into(), 0.into()), + size: size(0.into(), 0.into()), + }) + } else { Ok(Bounds { origin: point(bounds.left.into(), bounds.top.into()), size: size( @@ -805,25 +783,6 @@ impl DirectWriteState { (bounds.bottom - bounds.top).into(), ), }) - } else { - // If it's empty, retry with grayscale AA. - let bounds = - unsafe { glyph_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_ALIASED_1x1)? }; - - if bounds.right < bounds.left { - Ok(Bounds { - origin: point(0.into(), 0.into()), - size: size(0.into(), 0.into()), - }) - } else { - Ok(Bounds { - origin: point(bounds.left.into(), bounds.top.into()), - size: size( - (bounds.right - bounds.left).into(), - (bounds.bottom - bounds.top).into(), - ), - }) - } } } @@ -850,7 +809,7 @@ impl DirectWriteState { } let bitmap_data = if params.is_emoji { - if let Ok(color) = self.rasterize_color(¶ms, glyph_bounds) { + if let Ok(color) = self.rasterize_color(params, glyph_bounds) { color } else { let monochrome = self.rasterize_monochrome(params, glyph_bounds)?; @@ -872,13 +831,12 @@ impl DirectWriteState { glyph_bounds: Bounds, ) -> Result> { let mut bitmap_data = - vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize * 3]; + vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize]; let glyph_analysis = self.create_glyph_run_analysis(params)?; unsafe { glyph_analysis.CreateAlphaTexture( - // We're using cleartype not grayscale for monochrome is because it provides better quality - DWRITE_TEXTURE_CLEARTYPE_3x1, + DWRITE_TEXTURE_ALIASED_1x1, &RECT { left: glyph_bounds.origin.x.0, top: glyph_bounds.origin.y.0, @@ -889,30 +847,6 @@ impl DirectWriteState { )?; } - let bitmap_factory = self.components.bitmap_factory.resolve()?; - let bitmap = unsafe { - bitmap_factory.CreateBitmapFromMemory( - glyph_bounds.size.width.0 as u32, - glyph_bounds.size.height.0 as u32, - &GUID_WICPixelFormat24bppRGB, - glyph_bounds.size.width.0 as u32 * 3, - &bitmap_data, - ) - }?; - - let grayscale_bitmap = - unsafe { WICConvertBitmapSource(&GUID_WICPixelFormat8bppGray, &bitmap) }?; - - let mut bitmap_data = - vec![0u8; glyph_bounds.size.width.0 as usize * glyph_bounds.size.height.0 as usize]; - unsafe { - grayscale_bitmap.CopyPixels( - std::ptr::null() as _, - glyph_bounds.size.width.0 as u32, - &mut bitmap_data, - ) - }?; - Ok(bitmap_data) } @@ -924,7 +858,7 @@ impl DirectWriteState { let bitmap_size = glyph_bounds.size; let subpixel_shift = params .subpixel_variant - .map(|v| v as f32 / SUBPIXEL_VARIANTS as f32); + .map(|v| v as f32 / SUBPIXEL_VARIANTS_X as f32); let baseline_origin_x = subpixel_shift.x / params.scale_factor; let baseline_origin_y = subpixel_shift.y / params.scale_factor; @@ -981,25 +915,24 @@ impl DirectWriteState { DWRITE_RENDERING_MODE1_NATURAL_SYMMETRIC, DWRITE_MEASURING_MODE_NATURAL, DWRITE_GRID_FIT_MODE_DEFAULT, - DWRITE_TEXT_ANTIALIAS_MODE_CLEARTYPE, + DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE, baseline_origin_x, baseline_origin_y, ) }?; let color_bounds = - unsafe { color_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_CLEARTYPE_3x1) }?; + unsafe { color_analysis.GetAlphaTextureBounds(DWRITE_TEXTURE_ALIASED_1x1) }?; let color_size = size( color_bounds.right - color_bounds.left, color_bounds.bottom - color_bounds.top, ); if color_size.width > 0 && color_size.height > 0 { - let mut alpha_data = - vec![0u8; (color_size.width * color_size.height * 3) as usize]; + let mut alpha_data = vec![0u8; (color_size.width * color_size.height) as usize]; unsafe { color_analysis.CreateAlphaTexture( - DWRITE_TEXTURE_CLEARTYPE_3x1, + DWRITE_TEXTURE_ALIASED_1x1, &color_bounds, &mut alpha_data, ) @@ -1015,10 +948,6 @@ impl DirectWriteState { } }; let bounds = bounds(point(color_bounds.left, color_bounds.top), color_size); - let alpha_data = alpha_data - .chunks_exact(3) - .flat_map(|chunk| [chunk[0], chunk[1], chunk[2], 255]) - .collect::>(); glyph_layers.push(GlyphLayerTexture::new( &self.components.gpu_state, run_color, @@ -1135,10 +1064,18 @@ impl DirectWriteState { unsafe { device_context.PSSetSamplers(0, Some(&gpu_state.sampler)) }; unsafe { device_context.OMSetBlendState(&gpu_state.blend_state, None, 0xffffffff) }; + let crate::FontInfo { + gamma_ratios, + grayscale_enhanced_contrast, + } = DirectXRenderer::get_font_info(); + for layer in glyph_layers { let params = GlyphLayerTextureParams { run_color: layer.run_color, bounds: layer.bounds, + gamma_ratios: *gamma_ratios, + grayscale_enhanced_contrast: *grayscale_enhanced_contrast, + _pad: [0f32; 3], }; unsafe { let mut dest = std::mem::zeroed(); @@ -1202,6 +1139,20 @@ impl DirectWriteState { }; } + // Convert from premultiplied to straight alpha + for chunk in rasterized.chunks_exact_mut(4) { + let b = chunk[0] as f32; + let g = chunk[1] as f32; + let r = chunk[2] as f32; + let a = chunk[3] as f32; + if a > 0.0 { + let inv_a = 255.0 / a; + chunk[0] = (b * inv_a).clamp(0.0, 255.0) as u8; + chunk[1] = (g * inv_a).clamp(0.0, 255.0) as u8; + chunk[2] = (r * inv_a).clamp(0.0, 255.0) as u8; + } + } + Ok(rasterized) } @@ -1263,6 +1214,20 @@ impl DirectWriteState { )); result } + + fn handle_gpu_lost(&mut self, directx_devices: &DirectXDevices) { + try_to_recover_from_device_lost( + || GPUState::new(directx_devices).context("Recreating GPU state for DirectWrite"), + |gpu_state| self.components.gpu_state = gpu_state, + || { + log::error!( + "Failed to recreate GPU state for DirectWrite after multiple attempts." + ); + // Do something here? + // At this point, the device loss is considered unrecoverable. + }, + ); + } } impl Drop for DirectWriteState { @@ -1298,14 +1263,14 @@ impl GlyphLayerTexture { Height: texture_size.height as u32, MipLevels: 1, ArraySize: 1, - Format: DXGI_FORMAT_R8G8B8A8_UNORM, + Format: DXGI_FORMAT_R8_UNORM, SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0, }, Usage: D3D11_USAGE_DEFAULT, BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + CPUAccessFlags: 0, MiscFlags: 0, }; @@ -1334,7 +1299,7 @@ impl GlyphLayerTexture { 0, None, alpha_data.as_ptr() as _, - (texture_size.width * 4) as u32, + texture_size.width as u32, 0, ) }; @@ -1352,6 +1317,9 @@ impl GlyphLayerTexture { struct GlyphLayerTextureParams { bounds: Bounds, run_color: Rgba, + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + _pad: [f32; 3], } struct TextRendererWrapper(pub IDWriteTextRenderer); @@ -1784,7 +1752,7 @@ fn apply_font_features( } unsafe { - direct_write_features.AddFontFeature(make_direct_write_feature(&tag, *value))?; + direct_write_features.AddFontFeature(make_direct_write_feature(tag, *value))?; } } unsafe { diff --git a/crates/gpui/src/platform/windows/directx_atlas.rs b/crates/gpui/src/platform/windows/directx_atlas.rs index 6bced4c11d922ed2c514b9a70fe7e582d7b15a6b..38c22a41bf9d32cf43f585050390b75602a6bf42 100644 --- a/crates/gpui/src/platform/windows/directx_atlas.rs +++ b/crates/gpui/src/platform/windows/directx_atlas.rs @@ -3,9 +3,8 @@ use etagere::BucketedAtlasAllocator; use parking_lot::Mutex; use windows::Win32::Graphics::{ Direct3D11::{ - D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_CPU_ACCESS_WRITE, D3D11_TEXTURE2D_DESC, - D3D11_USAGE_DEFAULT, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, - ID3D11Texture2D, + D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, + ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, }, Dxgi::Common::*, }; @@ -189,7 +188,7 @@ impl DirectXAtlasState { }, Usage: D3D11_USAGE_DEFAULT, BindFlags: bind_flag.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + CPUAccessFlags: 0, MiscFlags: 0, }; let mut texture: Option = None; diff --git a/crates/gpui/src/platform/windows/directx_devices.rs b/crates/gpui/src/platform/windows/directx_devices.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6a2381777b11fd1863735f3c7f4b71aafbf6a39 --- /dev/null +++ b/crates/gpui/src/platform/windows/directx_devices.rs @@ -0,0 +1,197 @@ +use anyhow::{Context, Result}; +use util::ResultExt; +use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::{ + D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL, D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1, + }, + Direct3D11::{ + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_DEBUG, + D3D11_FEATURE_D3D10_X_HARDWARE_OPTIONS, D3D11_FEATURE_DATA_D3D10_X_HARDWARE_OPTIONS, + D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, + }, + Dxgi::{ + CreateDXGIFactory2, DXGI_CREATE_FACTORY_DEBUG, DXGI_CREATE_FACTORY_FLAGS, + IDXGIAdapter1, IDXGIFactory6, + }, + }, +}; +use windows::core::Interface; + +pub(crate) fn try_to_recover_from_device_lost( + mut f: impl FnMut() -> Result, + on_success: impl FnOnce(T), + on_error: impl FnOnce(), +) { + let result = (0..5).find_map(|i| { + if i > 0 { + // Add a small delay before retrying + std::thread::sleep(std::time::Duration::from_millis(100)); + } + f().log_err() + }); + + if let Some(result) = result { + on_success(result); + } else { + on_error(); + } +} + +#[derive(Clone)] +pub(crate) struct DirectXDevices { + pub(crate) adapter: IDXGIAdapter1, + pub(crate) dxgi_factory: IDXGIFactory6, + pub(crate) device: ID3D11Device, + pub(crate) device_context: ID3D11DeviceContext, +} + +impl DirectXDevices { + pub(crate) fn new() -> Result { + let debug_layer_available = check_debug_layer_available(); + let dxgi_factory = + get_dxgi_factory(debug_layer_available).context("Creating DXGI factory")?; + let adapter = + get_adapter(&dxgi_factory, debug_layer_available).context("Getting DXGI adapter")?; + let (device, device_context) = { + let mut context: Option = None; + let mut feature_level = D3D_FEATURE_LEVEL::default(); + let device = get_device( + &adapter, + Some(&mut context), + Some(&mut feature_level), + debug_layer_available, + ) + .context("Creating Direct3D device")?; + match feature_level { + D3D_FEATURE_LEVEL_11_1 => { + log::info!("Created device with Direct3D 11.1 feature level.") + } + D3D_FEATURE_LEVEL_11_0 => { + log::info!("Created device with Direct3D 11.0 feature level.") + } + D3D_FEATURE_LEVEL_10_1 => { + log::info!("Created device with Direct3D 10.1 feature level.") + } + _ => unreachable!(), + } + (device, context.unwrap()) + }; + + Ok(Self { + adapter, + dxgi_factory, + device, + device_context, + }) + } +} + +#[inline] +fn check_debug_layer_available() -> bool { + #[cfg(debug_assertions)] + { + use windows::Win32::Graphics::Dxgi::{DXGIGetDebugInterface1, IDXGIInfoQueue}; + + unsafe { DXGIGetDebugInterface1::(0) } + .log_err() + .is_some() + } + #[cfg(not(debug_assertions))] + { + false + } +} + +#[inline] +fn get_dxgi_factory(debug_layer_available: bool) -> Result { + let factory_flag = if debug_layer_available { + DXGI_CREATE_FACTORY_DEBUG + } else { + #[cfg(debug_assertions)] + log::warn!( + "Failed to get DXGI debug interface. DirectX debugging features will be disabled." + ); + DXGI_CREATE_FACTORY_FLAGS::default() + }; + unsafe { Ok(CreateDXGIFactory2(factory_flag)?) } +} + +#[inline] +fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result { + for adapter_index in 0.. { + let adapter: IDXGIAdapter1 = unsafe { dxgi_factory.EnumAdapters(adapter_index)?.cast()? }; + if let Ok(desc) = unsafe { adapter.GetDesc1() } { + let gpu_name = String::from_utf16_lossy(&desc.Description) + .trim_matches(char::from(0)) + .to_string(); + log::info!("Using GPU: {}", gpu_name); + } + // Check to see whether the adapter supports Direct3D 11, but don't + // create the actual device yet. + if get_device(&adapter, None, None, debug_layer_available) + .log_err() + .is_some() + { + return Ok(adapter); + } + } + + unreachable!() +} + +#[inline] +fn get_device( + adapter: &IDXGIAdapter1, + context: Option<*mut Option>, + feature_level: Option<*mut D3D_FEATURE_LEVEL>, + debug_layer_available: bool, +) -> Result { + let mut device: Option = None; + let device_flags = if debug_layer_available { + D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_DEBUG + } else { + D3D11_CREATE_DEVICE_BGRA_SUPPORT + }; + unsafe { + D3D11CreateDevice( + adapter, + D3D_DRIVER_TYPE_UNKNOWN, + HMODULE::default(), + device_flags, + // 4x MSAA is required for Direct3D Feature Level 10.1 or better + Some(&[ + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + ]), + D3D11_SDK_VERSION, + Some(&mut device), + feature_level, + context, + )?; + } + let device = device.unwrap(); + let mut data = D3D11_FEATURE_DATA_D3D10_X_HARDWARE_OPTIONS::default(); + unsafe { + device + .CheckFeatureSupport( + D3D11_FEATURE_D3D10_X_HARDWARE_OPTIONS, + &mut data as *mut _ as _, + std::mem::size_of::() as u32, + ) + .context("Checking GPU device feature support")?; + } + if data + .ComputeShaders_Plus_RawAndStructuredBuffers_Via_Shader_4_x + .as_bool() + { + Ok(device) + } else { + Err(anyhow::anyhow!( + "Required feature StructuredBuffer is not supported by GPU/driver" + )) + } +} diff --git a/crates/gpui/src/platform/windows/directx_renderer.rs b/crates/gpui/src/platform/windows/directx_renderer.rs index 4e72ded5341479c2d861c441fc3c43d5fee7056c..220876b4a98693f514886c14ca4b58725f2583d2 100644 --- a/crates/gpui/src/platform/windows/directx_renderer.rs +++ b/crates/gpui/src/platform/windows/directx_renderer.rs @@ -1,14 +1,18 @@ -use std::{mem::ManuallyDrop, sync::Arc}; +use std::{ + mem::ManuallyDrop, + sync::{Arc, OnceLock}, +}; use ::util::ResultExt; use anyhow::{Context, Result}; use windows::{ Win32::{ - Foundation::{HMODULE, HWND}, + Foundation::HWND, Graphics::{ Direct3D::*, Direct3D11::*, DirectComposition::*, + DirectWrite::*, Dxgi::{Common::*, *}, }, }, @@ -27,21 +31,27 @@ const RENDER_TARGET_FORMAT: DXGI_FORMAT = DXGI_FORMAT_B8G8R8A8_UNORM; // This configuration is used for MSAA rendering on paths only, and it's guaranteed to be supported by DirectX 11. const PATH_MULTISAMPLE_COUNT: u32 = 4; +pub(crate) struct FontInfo { + pub gamma_ratios: [f32; 4], + pub grayscale_enhanced_contrast: f32, +} + pub(crate) struct DirectXRenderer { hwnd: HWND, atlas: Arc, - devices: ManuallyDrop, + devices: ManuallyDrop, resources: ManuallyDrop, globals: DirectXGlobalElements, pipelines: DirectXRenderPipelines, direct_composition: Option, + font_info: &'static FontInfo, } /// Direct3D objects #[derive(Clone)] -pub(crate) struct DirectXDevices { - adapter: IDXGIAdapter1, - dxgi_factory: IDXGIFactory6, +pub(crate) struct DirectXRendererDevices { + pub(crate) adapter: IDXGIAdapter1, + pub(crate) dxgi_factory: IDXGIFactory6, pub(crate) device: ID3D11Device, pub(crate) device_context: ID3D11DeviceContext, dxgi_device: Option, @@ -86,39 +96,17 @@ struct DirectComposition { comp_visual: IDCompositionVisual, } -impl DirectXDevices { - pub(crate) fn new(disable_direct_composition: bool) -> Result> { - let debug_layer_available = check_debug_layer_available(); - let dxgi_factory = - get_dxgi_factory(debug_layer_available).context("Creating DXGI factory")?; - let adapter = - get_adapter(&dxgi_factory, debug_layer_available).context("Getting DXGI adapter")?; - let (device, device_context) = { - let mut device: Option = None; - let mut context: Option = None; - let mut feature_level = D3D_FEATURE_LEVEL::default(); - get_device( - &adapter, - Some(&mut device), - Some(&mut context), - Some(&mut feature_level), - debug_layer_available, - ) - .context("Creating Direct3D device")?; - match feature_level { - D3D_FEATURE_LEVEL_11_1 => { - log::info!("Created device with Direct3D 11.1 feature level.") - } - D3D_FEATURE_LEVEL_11_0 => { - log::info!("Created device with Direct3D 11.0 feature level.") - } - D3D_FEATURE_LEVEL_10_1 => { - log::info!("Created device with Direct3D 10.1 feature level.") - } - _ => unreachable!(), - } - (device.unwrap(), context.unwrap()) - }; +impl DirectXRendererDevices { + pub(crate) fn new( + directx_devices: &DirectXDevices, + disable_direct_composition: bool, + ) -> Result> { + let DirectXDevices { + adapter, + dxgi_factory, + device, + device_context, + } = directx_devices; let dxgi_device = if disable_direct_composition { None } else { @@ -126,23 +114,27 @@ impl DirectXDevices { }; Ok(ManuallyDrop::new(Self { - adapter, - dxgi_factory, + adapter: adapter.clone(), + dxgi_factory: dxgi_factory.clone(), + device: device.clone(), + device_context: device_context.clone(), dxgi_device, - device, - device_context, })) } } impl DirectXRenderer { - pub(crate) fn new(hwnd: HWND, disable_direct_composition: bool) -> Result { + pub(crate) fn new( + hwnd: HWND, + directx_devices: &DirectXDevices, + disable_direct_composition: bool, + ) -> Result { if disable_direct_composition { log::info!("Direct Composition is disabled."); } - let devices = - DirectXDevices::new(disable_direct_composition).context("Creating DirectX devices")?; + let devices = DirectXRendererDevices::new(directx_devices, disable_direct_composition) + .context("Creating DirectX devices")?; let atlas = Arc::new(DirectXAtlas::new(&devices.device, &devices.device_context)); let resources = DirectXResources::new(&devices, 1, 1, hwnd, disable_direct_composition) @@ -171,6 +163,7 @@ impl DirectXRenderer { globals, pipelines, direct_composition, + font_info: Self::get_font_info(), }) } @@ -183,10 +176,12 @@ impl DirectXRenderer { &self.devices.device_context, self.globals.global_params_buffer[0].as_ref().unwrap(), &[GlobalParams { + gamma_ratios: self.font_info.gamma_ratios, viewport_size: [ self.resources.viewport[0].Width, self.resources.viewport[0].Height, ], + grayscale_enhanced_contrast: self.font_info.grayscale_enhanced_contrast, _pad: 0, }], )?; @@ -205,28 +200,30 @@ impl DirectXRenderer { Ok(()) } + #[inline] fn present(&mut self) -> Result<()> { - unsafe { - let result = self.resources.swap_chain.Present(0, DXGI_PRESENT(0)); - // Presenting the swap chain can fail if the DirectX device was removed or reset. - if result == DXGI_ERROR_DEVICE_REMOVED || result == DXGI_ERROR_DEVICE_RESET { - let reason = self.devices.device.GetDeviceRemovedReason(); + let result = unsafe { self.resources.swap_chain.Present(0, DXGI_PRESENT(0)) }; + result.ok().context("Presenting swap chain failed") + } + + pub(crate) fn handle_device_lost(&mut self, directx_devices: &DirectXDevices) { + try_to_recover_from_device_lost( + || { + self.handle_device_lost_impl(directx_devices) + .context("DirectXRenderer handling device lost") + }, + |_| {}, + || { log::error!( - "DirectX device removed or reset when drawing. Reason: {:?}", - reason + "DirectXRenderer failed to recover from device lost after multiple attempts" ); - self.handle_device_lost()?; - } else { - result.ok()?; - } - } - Ok(()) + // Do something here? + // At this point, the device loss is considered unrecoverable. + }, + ); } - fn handle_device_lost(&mut self) -> Result<()> { - // Here we wait a bit to ensure the the system has time to recover from the device lost state. - // If we don't wait, the final drawing result will be blank. - std::thread::sleep(std::time::Duration::from_millis(300)); + fn handle_device_lost_impl(&mut self, directx_devices: &DirectXDevices) -> Result<()> { let disable_direct_composition = self.direct_composition.is_none(); unsafe { @@ -249,7 +246,7 @@ impl DirectXRenderer { ManuallyDrop::drop(&mut self.devices); } - let devices = DirectXDevices::new(disable_direct_composition) + let devices = DirectXRendererDevices::new(directx_devices, disable_direct_composition) .context("Recreating DirectX devices")?; let resources = DirectXResources::new( &devices, @@ -324,49 +321,39 @@ impl DirectXRenderer { if self.resources.width == width && self.resources.height == height { return Ok(()); } + self.resources.width = width; + self.resources.height = height; + + // Clear the render target before resizing + unsafe { self.devices.device_context.OMSetRenderTargets(None, None) }; + unsafe { ManuallyDrop::drop(&mut self.resources.render_target) }; + drop(self.resources.render_target_view[0].take().unwrap()); + + // Resizing the swap chain requires a call to the underlying DXGI adapter, which can return the device removed error. + // The app might have moved to a monitor that's attached to a different graphics device. + // When a graphics device is removed or reset, the desktop resolution often changes, resulting in a window size change. + // But here we just return the error, because we are handling device lost scenarios elsewhere. unsafe { - // Clear the render target before resizing - self.devices.device_context.OMSetRenderTargets(None, None); - ManuallyDrop::drop(&mut self.resources.render_target); - drop(self.resources.render_target_view[0].take().unwrap()); - - let result = self.resources.swap_chain.ResizeBuffers( - BUFFER_COUNT as u32, - width, - height, - RENDER_TARGET_FORMAT, - DXGI_SWAP_CHAIN_FLAG(0), - ); - // Resizing the swap chain requires a call to the underlying DXGI adapter, which can return the device removed error. - // The app might have moved to a monitor that's attached to a different graphics device. - // When a graphics device is removed or reset, the desktop resolution often changes, resulting in a window size change. - match result { - Ok(_) => {} - Err(e) => { - if e.code() == DXGI_ERROR_DEVICE_REMOVED || e.code() == DXGI_ERROR_DEVICE_RESET - { - let reason = self.devices.device.GetDeviceRemovedReason(); - log::error!( - "DirectX device removed or reset when resizing. Reason: {:?}", - reason - ); - self.resources.width = width; - self.resources.height = height; - self.handle_device_lost()?; - return Ok(()); - } else { - log::error!("Failed to resize swap chain: {:?}", e); - return Err(e.into()); - } - } - } - self.resources - .recreate_resources(&self.devices, width, height)?; + .swap_chain + .ResizeBuffers( + BUFFER_COUNT as u32, + width, + height, + RENDER_TARGET_FORMAT, + DXGI_SWAP_CHAIN_FLAG(0), + ) + .context("Failed to resize swap chain")?; + } + + self.resources + .recreate_resources(&self.devices, width, height)?; + unsafe { self.devices .device_context .OMSetRenderTargets(Some(&self.resources.render_target_view), None); } + Ok(()) } @@ -617,11 +604,24 @@ impl DirectXRenderer { driver_info: driver_version, }) } + + pub(crate) fn get_font_info() -> &'static FontInfo { + static CACHED_FONT_INFO: OnceLock = OnceLock::new(); + CACHED_FONT_INFO.get_or_init(|| unsafe { + let factory: IDWriteFactory5 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap(); + let render_params: IDWriteRenderingParams1 = + factory.CreateRenderingParams().unwrap().cast().unwrap(); + FontInfo { + gamma_ratios: get_gamma_correction_ratios(render_params.GetGamma()), + grayscale_enhanced_contrast: render_params.GetGrayscaleEnhancedContrast(), + } + }) + } } impl DirectXResources { pub fn new( - devices: &DirectXDevices, + devices: &DirectXRendererDevices, width: u32, height: u32, hwnd: HWND, @@ -666,7 +666,7 @@ impl DirectXResources { #[inline] fn recreate_resources( &mut self, - devices: &DirectXDevices, + devices: &DirectXRendererDevices, width: u32, height: u32, ) -> Result<()> { @@ -686,8 +686,6 @@ impl DirectXResources { self.path_intermediate_msaa_view = path_intermediate_msaa_view; self.path_intermediate_srv = path_intermediate_srv; self.viewport = viewport; - self.width = width; - self.height = height; Ok(()) } } @@ -758,7 +756,7 @@ impl DirectXRenderPipelines { impl DirectComposition { pub fn new(dxgi_device: &IDXGIDevice, hwnd: HWND) -> Result { - let comp_device = get_comp_device(&dxgi_device)?; + let comp_device = get_comp_device(dxgi_device)?; let comp_target = unsafe { comp_device.CreateTargetForHwnd(hwnd, true) }?; let comp_visual = unsafe { comp_device.CreateVisual() }?; @@ -822,8 +820,10 @@ impl DirectXGlobalElements { #[derive(Debug, Default)] #[repr(C)] struct GlobalParams { + gamma_ratios: [f32; 4], viewport_size: [f32; 2], - _pad: u64, + grayscale_enhanced_contrast: f32, + _pad: u32, } struct PipelineState { @@ -980,92 +980,6 @@ impl Drop for DirectXResources { } } -#[inline] -fn check_debug_layer_available() -> bool { - #[cfg(debug_assertions)] - { - unsafe { DXGIGetDebugInterface1::(0) } - .log_err() - .is_some() - } - #[cfg(not(debug_assertions))] - { - false - } -} - -#[inline] -fn get_dxgi_factory(debug_layer_available: bool) -> Result { - let factory_flag = if debug_layer_available { - DXGI_CREATE_FACTORY_DEBUG - } else { - #[cfg(debug_assertions)] - log::warn!( - "Failed to get DXGI debug interface. DirectX debugging features will be disabled." - ); - DXGI_CREATE_FACTORY_FLAGS::default() - }; - unsafe { Ok(CreateDXGIFactory2(factory_flag)?) } -} - -fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result { - for adapter_index in 0.. { - let adapter: IDXGIAdapter1 = unsafe { - dxgi_factory - .EnumAdapterByGpuPreference(adapter_index, DXGI_GPU_PREFERENCE_MINIMUM_POWER) - }?; - if let Ok(desc) = unsafe { adapter.GetDesc1() } { - let gpu_name = String::from_utf16_lossy(&desc.Description) - .trim_matches(char::from(0)) - .to_string(); - log::info!("Using GPU: {}", gpu_name); - } - // Check to see whether the adapter supports Direct3D 11, but don't - // create the actual device yet. - if get_device(&adapter, None, None, None, debug_layer_available) - .log_err() - .is_some() - { - return Ok(adapter); - } - } - - unreachable!() -} - -fn get_device( - adapter: &IDXGIAdapter1, - device: Option<*mut Option>, - context: Option<*mut Option>, - feature_level: Option<*mut D3D_FEATURE_LEVEL>, - debug_layer_available: bool, -) -> Result<()> { - let device_flags = if debug_layer_available { - D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_DEBUG - } else { - D3D11_CREATE_DEVICE_BGRA_SUPPORT - }; - unsafe { - D3D11CreateDevice( - adapter, - D3D_DRIVER_TYPE_UNKNOWN, - HMODULE::default(), - device_flags, - // 4x MSAA is required for Direct3D Feature Level 10.1 or better - Some(&[ - D3D_FEATURE_LEVEL_11_1, - D3D_FEATURE_LEVEL_11_0, - D3D_FEATURE_LEVEL_10_1, - ]), - D3D11_SDK_VERSION, - device, - feature_level, - context, - )?; - } - Ok(()) -} - #[inline] fn get_comp_device(dxgi_device: &IDXGIDevice) -> Result { Ok(unsafe { DCompositionCreateDevice(dxgi_device)? }) @@ -1130,7 +1044,7 @@ fn create_swap_chain( #[inline] fn create_resources( - devices: &DirectXDevices, + devices: &DirectXRendererDevices, swap_chain: &IDXGISwapChain1, width: u32, height: u32, @@ -1144,7 +1058,7 @@ fn create_resources( [D3D11_VIEWPORT; 1], )> { let (render_target, render_target_view) = - create_render_target_and_its_view(&swap_chain, &devices.device)?; + create_render_target_and_its_view(swap_chain, &devices.device)?; let (path_intermediate_texture, path_intermediate_srv) = create_path_intermediate_texture(&devices.device, width, height)?; let (path_intermediate_msaa_texture, path_intermediate_msaa_view) = @@ -1544,6 +1458,10 @@ pub(crate) mod shader_resources { #[cfg(debug_assertions)] pub(super) fn build_shader_blob(entry: ShaderModule, target: ShaderTarget) -> Result { unsafe { + use windows::Win32::Graphics::{ + Direct3D::ID3DInclude, Hlsl::D3D_COMPILE_STANDARD_FILE_INCLUDE, + }; + let shader_name = if matches!(entry, ShaderModule::EmojiRasterization) { "color_text_raster.hlsl" } else { @@ -1572,10 +1490,15 @@ pub(crate) mod shader_resources { let entry_point = PCSTR::from_raw(entry.as_ptr()); let target_cstr = PCSTR::from_raw(target.as_ptr()); + // really dirty trick because winapi bindings are unhappy otherwise + let include_handler = &std::mem::transmute::( + D3D_COMPILE_STANDARD_FILE_INCLUDE as usize, + ); + let ret = D3DCompileFromFile( &HSTRING::from(shader_path.to_str().unwrap()), None, - None, + include_handler, entry_point, target_cstr, D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION, @@ -1760,7 +1683,7 @@ mod amd { anyhow::bail!("Failed to initialize AMD AGS, error code: {}", result); } - // Vulkan acctually returns this as the driver version + // Vulkan actually returns this as the driver version let software_version = if !gpu_info.radeon_software_version.is_null() { std::ffi::CStr::from_ptr(gpu_info.radeon_software_version) .to_string_lossy() diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index e5b9c020d511b478779dc1affb3927018f8f7b3f..6759a573e6c04ecf943f6cc17616743bcab4ef28 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -5,45 +5,41 @@ use std::{ use async_task::Runnable; use flume::Sender; -use parking::Parker; -use parking_lot::Mutex; use util::ResultExt; use windows::{ - Foundation::TimeSpan, System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemOptions, - WorkItemPriority, + ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, }, Win32::{ Foundation::{LPARAM, WPARAM}, - UI::WindowsAndMessaging::PostThreadMessageW, + UI::WindowsAndMessaging::PostMessageW, }, }; -use crate::{PlatformDispatcher, TaskLabel, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; +use crate::{ + HWND, PlatformDispatcher, SafeHwnd, TaskLabel, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, +}; pub(crate) struct WindowsDispatcher { main_sender: Sender, - parker: Mutex, main_thread_id: ThreadId, - main_thread_id_win32: u32, + platform_window_handle: SafeHwnd, validation_number: usize, } impl WindowsDispatcher { pub(crate) fn new( main_sender: Sender, - main_thread_id_win32: u32, + platform_window_handle: HWND, validation_number: usize, ) -> Self { - let parker = Mutex::new(Parker::new()); let main_thread_id = current().id(); + let platform_window_handle = platform_window_handle.into(); WindowsDispatcher { main_sender, - parker, main_thread_id, - main_thread_id_win32, + platform_window_handle, validation_number, } } @@ -56,12 +52,7 @@ impl WindowsDispatcher { Ok(()) }) }; - ThreadPool::RunWithPriorityAndOptionsAsync( - &handler, - WorkItemPriority::High, - WorkItemOptions::TimeSliced, - ) - .log_err(); + ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err(); } fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) { @@ -72,12 +63,7 @@ impl WindowsDispatcher { Ok(()) }) }; - let delay = TimeSpan { - // A time period expressed in 100-nanosecond units. - // 10,000,000 ticks per second - Duration: (duration.as_nanos() / 100) as i64, - }; - ThreadPoolTimer::CreateTimer(&handler, delay).log_err(); + ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); } } @@ -94,15 +80,27 @@ impl PlatformDispatcher for WindowsDispatcher { } fn dispatch_on_main_thread(&self, runnable: Runnable) { + let was_empty = self.main_sender.is_empty(); match self.main_sender.send(runnable) { Ok(_) => unsafe { - PostThreadMessageW( - self.main_thread_id_win32, - WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, - WPARAM(self.validation_number), - LPARAM(0), - ) - .log_err(); + // Only send a `WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD` to the + // queue if we have no runnables queued up yet, otherwise we + // risk filling the message queue with gpui messages causing us + // to starve the message loop of system messages, resulting in a + // process hang. + // + // When the message loop receives a + // `WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD` message we drain the + // runnable queue entirely. + if was_empty { + PostMessageW( + Some(self.platform_window_handle.as_raw()), + WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD, + WPARAM(self.validation_number), + LPARAM(0), + ) + .log_err(); + } }, Err(runnable) => { // NOTE: Runnable may wrap a Future that is !Send. @@ -121,17 +119,4 @@ impl PlatformDispatcher for WindowsDispatcher { fn dispatch_after(&self, duration: Duration, runnable: Runnable) { self.dispatch_on_threadpool_after(runnable, duration); } - - fn park(&self, timeout: Option) -> bool { - if let Some(timeout) = timeout { - self.parker.lock().park_timeout(timeout) - } else { - self.parker.lock().park(); - true - } - } - - fn unparker(&self) -> parking::Unparker { - self.parker.lock().unparker() - } } diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 4ab257d27a69fc5fed458655150e1c09c3ebbba8..9c10dcec4bb629bfbc78b76e74db099ed605d8be 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -24,6 +24,8 @@ pub(crate) const WM_GPUI_CLOSE_ONE_WINDOW: u32 = WM_USER + 2; pub(crate) const WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD: u32 = WM_USER + 3; pub(crate) const WM_GPUI_DOCK_MENU_ACTION: u32 = WM_USER + 4; pub(crate) const WM_GPUI_FORCE_UPDATE_WINDOW: u32 = WM_USER + 5; +pub(crate) const WM_GPUI_KEYBOARD_LAYOUT_CHANGED: u32 = WM_USER + 6; +pub(crate) const WM_GPUI_GPU_DEVICE_LOST: u32 = WM_USER + 7; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; const AUTO_HIDE_TASKBAR_THICKNESS_PX: i32 = 1; @@ -39,7 +41,6 @@ impl WindowsWindowInner { let handled = match msg { WM_ACTIVATE => self.handle_activate_msg(wparam), WM_CREATE => self.handle_create_msg(handle), - WM_DEVICECHANGE => self.handle_device_change_msg(handle, wparam), WM_MOVE => self.handle_move_msg(handle, lparam), WM_SIZE => self.handle_size_msg(wparam, lparam), WM_GETMINMAXINFO => self.handle_get_min_max_info_msg(lparam), @@ -56,7 +57,10 @@ impl WindowsWindowInner { WM_MOUSEMOVE => self.handle_mouse_move_msg(handle, lparam, wparam), WM_MOUSELEAVE | WM_NCMOUSELEAVE => self.handle_mouse_leave_msg(), WM_NCMOUSEMOVE => self.handle_nc_mouse_move_msg(handle, lparam), - WM_NCLBUTTONDOWN => { + // Treat double click as a second single click, since we track the double clicks ourselves. + // If you don't interact with any elements, this will fall through to the windows default + // behavior of toggling whether the window is maximized. + WM_NCLBUTTONDBLCLK | WM_NCLBUTTONDOWN => { self.handle_nc_mouse_down_msg(handle, MouseButton::Left, wparam, lparam) } WM_NCRBUTTONDOWN => { @@ -99,9 +103,11 @@ impl WindowsWindowInner { WM_IME_COMPOSITION => self.handle_ime_composition(handle, lparam), WM_SETCURSOR => self.handle_set_cursor(handle, lparam), WM_SETTINGCHANGE => self.handle_system_settings_changed(handle, wparam, lparam), - WM_INPUTLANGCHANGE => self.handle_input_language_changed(lparam), + WM_INPUTLANGCHANGE => self.handle_input_language_changed(), + WM_SHOWWINDOW => self.handle_window_visibility_changed(handle, wparam), WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam), WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), + WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), _ => None, }; if let Some(n) = handled { @@ -263,8 +269,8 @@ impl WindowsWindowInner { callback(); } unsafe { - PostThreadMessageW( - self.main_thread_id_win32, + PostMessageW( + Some(self.platform_window_handle), WM_GPUI_CLOSE_ONE_WINDOW, WPARAM(self.validation_number), LPARAM(handle.0 as isize), @@ -524,8 +530,18 @@ impl WindowsWindowInner { }; let scale_factor = lock.scale_factor; let wheel_scroll_amount = match modifiers.shift { - true => lock.system_settings.mouse_wheel_settings.wheel_scroll_chars, - false => lock.system_settings.mouse_wheel_settings.wheel_scroll_lines, + true => { + self.system_settings + .borrow() + .mouse_wheel_settings + .wheel_scroll_chars + } + false => { + self.system_settings + .borrow() + .mouse_wheel_settings + .wheel_scroll_lines + } }; drop(lock); @@ -568,7 +584,11 @@ impl WindowsWindowInner { return Some(1); }; let scale_factor = lock.scale_factor; - let wheel_scroll_chars = lock.system_settings.mouse_wheel_settings.wheel_scroll_chars; + let wheel_scroll_chars = self + .system_settings + .borrow() + .mouse_wheel_settings + .wheel_scroll_chars; drop(lock); let wheel_distance = @@ -700,29 +720,25 @@ impl WindowsWindowInner { // Fix auto hide taskbar not showing. This solution is based on the approach // used by Chrome. However, it may result in one row of pixels being obscured // in our client area. But as Chrome says, "there seems to be no better solution." - if is_maximized { - if let Some(ref taskbar_position) = self - .state - .borrow() - .system_settings - .auto_hide_taskbar_position - { - // Fot the auto-hide taskbar, adjust in by 1 pixel on taskbar edge, - // so the window isn't treated as a "fullscreen app", which would cause - // the taskbar to disappear. - match taskbar_position { - AutoHideTaskbarPosition::Left => { - requested_client_rect[0].left += AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Top => { - requested_client_rect[0].top += AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Right => { - requested_client_rect[0].right -= AUTO_HIDE_TASKBAR_THICKNESS_PX - } - AutoHideTaskbarPosition::Bottom => { - requested_client_rect[0].bottom -= AUTO_HIDE_TASKBAR_THICKNESS_PX - } + if is_maximized + && let Some(ref taskbar_position) = + self.system_settings.borrow().auto_hide_taskbar_position + { + // For the auto-hide taskbar, adjust in by 1 pixel on taskbar edge, + // so the window isn't treated as a "fullscreen app", which would cause + // the taskbar to disappear. + match taskbar_position { + AutoHideTaskbarPosition::Left => { + requested_client_rect[0].left += AUTO_HIDE_TASKBAR_THICKNESS_PX + } + AutoHideTaskbarPosition::Top => { + requested_client_rect[0].top += AUTO_HIDE_TASKBAR_THICKNESS_PX + } + AutoHideTaskbarPosition::Right => { + requested_client_rect[0].right -= AUTO_HIDE_TASKBAR_THICKNESS_PX + } + AutoHideTaskbarPosition::Bottom => { + requested_client_rect[0].bottom -= AUTO_HIDE_TASKBAR_THICKNESS_PX } } } @@ -956,7 +972,7 @@ impl WindowsWindowInner { click_count, first_mouse: false, }); - let result = func(input.clone()); + let result = func(input); let handled = !result.propagate || result.default_prevented; self.state.borrow_mut().callbacks.input = Some(func); @@ -1096,9 +1112,11 @@ impl WindowsWindowInner { if wparam.0 != 0 { let mut lock = self.state.borrow_mut(); let display = lock.display; - lock.system_settings.update(display, wparam.0); lock.click_state.system_update(wparam.0); lock.border_offset.update(handle).log_err(); + // system settings may emit a window message which wants to take the refcell lock, so drop it + drop(lock); + self.system_settings.borrow_mut().update(display, wparam.0); } else { self.handle_system_theme_changed(handle, lparam)?; }; @@ -1124,62 +1142,54 @@ impl WindowsWindowInner { // lParam is a pointer to a string that indicates the area containing the system parameter // that was changed. let parameter = PCWSTR::from_raw(lparam.0 as _); - if unsafe { !parameter.is_null() && !parameter.is_empty() } { - if let Some(parameter_string) = unsafe { parameter.to_string() }.log_err() { - log::info!("System settings changed: {}", parameter_string); - match parameter_string.as_str() { - "ImmersiveColorSet" => { - let new_appearance = system_appearance() - .context( - "unable to get system appearance when handling ImmersiveColorSet", - ) - .log_err()?; - let mut lock = self.state.borrow_mut(); - if new_appearance != lock.appearance { - lock.appearance = new_appearance; - let mut callback = lock.callbacks.appearance_changed.take()?; - drop(lock); - callback(); - self.state.borrow_mut().callbacks.appearance_changed = Some(callback); - configure_dwm_dark_mode(handle, new_appearance); - } - } - _ => {} + if unsafe { !parameter.is_null() && !parameter.is_empty() } + && let Some(parameter_string) = unsafe { parameter.to_string() }.log_err() + { + log::info!("System settings changed: {}", parameter_string); + if parameter_string.as_str() == "ImmersiveColorSet" { + let new_appearance = system_appearance() + .context("unable to get system appearance when handling ImmersiveColorSet") + .log_err()?; + let mut lock = self.state.borrow_mut(); + if new_appearance != lock.appearance { + lock.appearance = new_appearance; + let mut callback = lock.callbacks.appearance_changed.take()?; + drop(lock); + callback(); + self.state.borrow_mut().callbacks.appearance_changed = Some(callback); + configure_dwm_dark_mode(handle, new_appearance); } } } Some(0) } - fn handle_input_language_changed(&self, lparam: LPARAM) -> Option { - let thread = self.main_thread_id_win32; - let validation = self.validation_number; + fn handle_input_language_changed(&self) -> Option { unsafe { - PostThreadMessageW(thread, WM_INPUTLANGCHANGE, WPARAM(validation), lparam).log_err(); + PostMessageW( + Some(self.platform_window_handle), + WM_GPUI_KEYBOARD_LAYOUT_CHANGED, + WPARAM(self.validation_number), + LPARAM(0), + ) + .log_err(); } Some(0) } - fn handle_device_change_msg(&self, handle: HWND, wparam: WPARAM) -> Option { - if wparam.0 == DBT_DEVNODES_CHANGED as usize { - // The reason for sending this message is to actually trigger a redraw of the window. - unsafe { - PostMessageW( - Some(handle), - WM_GPUI_FORCE_UPDATE_WINDOW, - WPARAM(0), - LPARAM(0), - ) - .log_err(); - } - // If the GPU device is lost, this redraw will take care of recreating the device context. - // The WM_GPUI_FORCE_UPDATE_WINDOW message will take care of redrawing the window, after - // the device context has been recreated. - self.draw_window(handle, true) - } else { - // Other device change messages are not handled. - None + fn handle_window_visibility_changed(&self, handle: HWND, wparam: WPARAM) -> Option { + if wparam.0 == 1 { + self.draw_window(handle, false); } + None + } + + fn handle_device_lost(&self, lparam: LPARAM) -> Option { + let mut lock = self.state.borrow_mut(); + let devices = lparam.0 as *const DirectXDevices; + let devices = unsafe { &*devices }; + lock.renderer.handle_device_lost(&devices); + Some(0) } #[inline] @@ -1297,10 +1307,10 @@ where F: FnOnce(Keystroke) -> PlatformInput, { let virtual_key = VIRTUAL_KEY(wparam.loword()); - let mut modifiers = current_modifiers(); + let modifiers = current_modifiers(); match virtual_key { - VK_SHIFT | VK_CONTROL | VK_MENU | VK_LWIN | VK_RWIN => { + VK_SHIFT | VK_CONTROL | VK_MENU | VK_LMENU | VK_RMENU | VK_LWIN | VK_RWIN => { if state .last_reported_modifiers .is_some_and(|prev_modifiers| prev_modifiers == modifiers) @@ -1450,11 +1460,25 @@ fn is_virtual_key_pressed(vkey: VIRTUAL_KEY) -> bool { unsafe { GetKeyState(vkey.0 as i32) < 0 } } +fn keyboard_uses_altgr() -> bool { + use crate::platform::windows::keyboard::WindowsKeyboardLayout; + WindowsKeyboardLayout::new() + .map(|layout| layout.uses_altgr()) + .unwrap_or(false) +} + #[inline] pub(crate) fn current_modifiers() -> Modifiers { + let lmenu_pressed = is_virtual_key_pressed(VK_LMENU); + let rmenu_pressed = is_virtual_key_pressed(VK_RMENU); + let lcontrol_pressed = is_virtual_key_pressed(VK_LCONTROL); + + // Only treat right Alt + left Ctrl as AltGr on keyboards that actually use it + let altgr = keyboard_uses_altgr() && rmenu_pressed && lcontrol_pressed; + Modifiers { - control: is_virtual_key_pressed(VK_CONTROL), - alt: is_virtual_key_pressed(VK_MENU), + control: is_virtual_key_pressed(VK_CONTROL) && !altgr, + alt: (lmenu_pressed || rmenu_pressed) && !altgr, shift: is_virtual_key_pressed(VK_SHIFT), platform: is_virtual_key_pressed(VK_LWIN) || is_virtual_key_pressed(VK_RWIN), function: false, @@ -1464,7 +1488,7 @@ pub(crate) fn current_modifiers() -> Modifiers { #[inline] pub(crate) fn current_capslock() -> Capslock { let on = unsafe { GetKeyState(VK_CAPITAL.0 as i32) & 1 } > 0; - Capslock { on: on } + Capslock { on } } fn get_client_area_insets( diff --git a/crates/gpui/src/platform/windows/keyboard.rs b/crates/gpui/src/platform/windows/keyboard.rs index 371feb70c25ab593ce612c7a90381a4cffdeff7d..7a8478d5910d35fb98a913ed799f2fa1447e9a65 100644 --- a/crates/gpui/src/platform/windows/keyboard.rs +++ b/crates/gpui/src/platform/windows/keyboard.rs @@ -1,22 +1,31 @@ use anyhow::Result; +use collections::HashMap; use windows::Win32::UI::{ Input::KeyboardAndMouse::{ - GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MapVirtualKeyW, ToUnicode, VIRTUAL_KEY, VK_0, - VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, VK_CONTROL, VK_MENU, - VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_102, - VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, + GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode, + VIRTUAL_KEY, VK_0, VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, + VK_CONTROL, VK_MENU, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, + VK_OEM_8, VK_OEM_102, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, }, WindowsAndMessaging::KL_NAMELENGTH, }; use windows_core::HSTRING; -use crate::{Modifiers, PlatformKeyboardLayout}; +use crate::{ + KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper, +}; pub(crate) struct WindowsKeyboardLayout { id: String, name: String, } +pub(crate) struct WindowsKeyboardMapper { + key_to_vkey: HashMap, + vkey_to_key: HashMap, + vkey_to_shifted: HashMap, +} + impl PlatformKeyboardLayout for WindowsKeyboardLayout { fn id(&self) -> &str { &self.id @@ -27,6 +36,61 @@ impl PlatformKeyboardLayout for WindowsKeyboardLayout { } } +impl PlatformKeyboardMapper for WindowsKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + let Some((vkey, shifted_key)) = self.get_vkey_from_key(&keystroke.key, use_key_equivalents) + else { + return KeybindingKeystroke::from_keystroke(keystroke); + }; + if shifted_key && keystroke.modifiers.shift { + log::warn!( + "Keystroke '{}' has both shift and a shifted key, this is likely a bug", + keystroke.key + ); + } + + let shift = shifted_key || keystroke.modifiers.shift; + keystroke.modifiers.shift = false; + + let Some(key) = self.vkey_to_key.get(&vkey).cloned() else { + log::error!( + "Failed to map key equivalent '{:?}' to a valid key", + keystroke + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + + keystroke.key = if shift { + let Some(shifted_key) = self.vkey_to_shifted.get(&vkey).cloned() else { + log::error!( + "Failed to map keystroke {:?} with virtual key '{:?}' to a shifted key", + keystroke, + vkey + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + shifted_key + } else { + key.clone() + }; + + let modifiers = Modifiers { + shift, + ..keystroke.modifiers + }; + + KeybindingKeystroke::new(keystroke, modifiers, key) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} + impl WindowsKeyboardLayout { pub(crate) fn new() -> Result { let mut buffer = [0u16; KL_NAMELENGTH as usize]; @@ -46,6 +110,73 @@ impl WindowsKeyboardLayout { name: "unknown".to_string(), } } + + pub(crate) fn uses_altgr(&self) -> bool { + // Check if this is a known AltGr layout by examining the layout ID + // The layout ID is a hex string like "00000409" (US) or "00000407" (German) + // Extract the language ID (last 4 bytes) + let id_bytes = self.id.as_bytes(); + if id_bytes.len() >= 4 { + let lang_id = &id_bytes[id_bytes.len() - 4..]; + // List of keyboard layouts that use AltGr (non-exhaustive) + matches!( + lang_id, + b"0407" | // German + b"040C" | // French + b"040A" | // Spanish + b"0415" | // Polish + b"0413" | // Dutch + b"0816" | // Portuguese + b"041D" | // Swedish + b"0414" | // Norwegian + b"040B" | // Finnish + b"041F" | // Turkish + b"0419" | // Russian + b"0405" | // Czech + b"040E" | // Hungarian + b"0424" | // Slovenian + b"041B" | // Slovak + b"0418" // Romanian + ) + } else { + false + } + } +} + +impl WindowsKeyboardMapper { + pub(crate) fn new() -> Self { + let mut key_to_vkey = HashMap::default(); + let mut vkey_to_key = HashMap::default(); + let mut vkey_to_shifted = HashMap::default(); + for vkey in CANDIDATE_VKEYS { + if let Some(key) = get_key_from_vkey(*vkey) { + key_to_vkey.insert(key.clone(), (vkey.0, false)); + vkey_to_key.insert(vkey.0, key); + } + let scan_code = unsafe { MapVirtualKeyW(vkey.0 as u32, MAPVK_VK_TO_VSC) }; + if scan_code == 0 { + continue; + } + if let Some(shifted_key) = get_shifted_key(*vkey, scan_code) { + key_to_vkey.insert(shifted_key.clone(), (vkey.0, true)); + vkey_to_shifted.insert(vkey.0, shifted_key); + } + } + Self { + key_to_vkey, + vkey_to_key, + vkey_to_shifted, + } + } + + fn get_vkey_from_key(&self, key: &str, use_key_equivalents: bool) -> Option<(u16, bool)> { + if use_key_equivalents { + get_vkey_from_key_with_us_layout(key) + } else { + self.key_to_vkey.get(key).cloned() + } + } } pub(crate) fn get_keystroke_key( @@ -140,3 +271,134 @@ pub(crate) fn generate_key_char( _ => None, } } + +fn get_vkey_from_key_with_us_layout(key: &str) -> Option<(u16, bool)> { + match key { + // ` => VK_OEM_3 + "`" => Some((VK_OEM_3.0, false)), + "~" => Some((VK_OEM_3.0, true)), + "1" => Some((VK_1.0, false)), + "!" => Some((VK_1.0, true)), + "2" => Some((VK_2.0, false)), + "@" => Some((VK_2.0, true)), + "3" => Some((VK_3.0, false)), + "#" => Some((VK_3.0, true)), + "4" => Some((VK_4.0, false)), + "$" => Some((VK_4.0, true)), + "5" => Some((VK_5.0, false)), + "%" => Some((VK_5.0, true)), + "6" => Some((VK_6.0, false)), + "^" => Some((VK_6.0, true)), + "7" => Some((VK_7.0, false)), + "&" => Some((VK_7.0, true)), + "8" => Some((VK_8.0, false)), + "*" => Some((VK_8.0, true)), + "9" => Some((VK_9.0, false)), + "(" => Some((VK_9.0, true)), + "0" => Some((VK_0.0, false)), + ")" => Some((VK_0.0, true)), + "-" => Some((VK_OEM_MINUS.0, false)), + "_" => Some((VK_OEM_MINUS.0, true)), + "=" => Some((VK_OEM_PLUS.0, false)), + "+" => Some((VK_OEM_PLUS.0, true)), + "[" => Some((VK_OEM_4.0, false)), + "{" => Some((VK_OEM_4.0, true)), + "]" => Some((VK_OEM_6.0, false)), + "}" => Some((VK_OEM_6.0, true)), + "\\" => Some((VK_OEM_5.0, false)), + "|" => Some((VK_OEM_5.0, true)), + ";" => Some((VK_OEM_1.0, false)), + ":" => Some((VK_OEM_1.0, true)), + "'" => Some((VK_OEM_7.0, false)), + "\"" => Some((VK_OEM_7.0, true)), + "," => Some((VK_OEM_COMMA.0, false)), + "<" => Some((VK_OEM_COMMA.0, true)), + "." => Some((VK_OEM_PERIOD.0, false)), + ">" => Some((VK_OEM_PERIOD.0, true)), + "/" => Some((VK_OEM_2.0, false)), + "?" => Some((VK_OEM_2.0, true)), + _ => None, + } +} + +const CANDIDATE_VKEYS: &[VIRTUAL_KEY] = &[ + VK_OEM_3, + VK_OEM_MINUS, + VK_OEM_PLUS, + VK_OEM_4, + VK_OEM_5, + VK_OEM_6, + VK_OEM_1, + VK_OEM_7, + VK_OEM_COMMA, + VK_OEM_PERIOD, + VK_OEM_2, + VK_OEM_102, + VK_OEM_8, + VK_ABNT_C1, + VK_0, + VK_1, + VK_2, + VK_3, + VK_4, + VK_5, + VK_6, + VK_7, + VK_8, + VK_9, +]; + +#[cfg(test)] +mod tests { + use crate::{Keystroke, Modifiers, PlatformKeyboardMapper, WindowsKeyboardMapper}; + + #[test] + fn test_keyboard_mapper() { + let mapper = WindowsKeyboardMapper::new(); + + // Normal case + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "a".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(*mapped.inner(), keystroke); + assert_eq!(mapped.key(), "a"); + assert_eq!(*mapped.modifiers(), Modifiers::control()); + + // Shifted case, ctrl-$ + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(*mapped.inner(), keystroke); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + + // Shifted case, but shift is true + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner().modifiers, Modifiers::control()); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + + // Windows style + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "4".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner().modifiers, Modifiers::control()); + assert_eq!(mapped.inner().key, "$"); + assert_eq!(mapped.key(), "4"); + assert_eq!(*mapped.modifiers(), Modifiers::control_shift()); + } +} diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index c1fb0cabc4fcf5759aedbdc8c045fdaa354fd2b3..361d8e114308323da8629fae93d257cc38147dba 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -1,8 +1,9 @@ use std::{ cell::RefCell, + ffi::OsStr, mem::ManuallyDrop, path::{Path, PathBuf}, - rc::Rc, + rc::{Rc, Weak}, sync::Arc, }; @@ -17,12 +18,9 @@ use windows::{ UI::ViewManagement::UISettings, Win32::{ Foundation::*, - Graphics::{ - Gdi::*, - Imaging::{CLSID_WICImagingFactory, IWICImagingFactory}, - }, + Graphics::{Direct3D11::ID3D11Device, Gdi::*}, Security::Credentials::*, - System::{Com::*, LibraryLoader::*, Ole::*, SystemInformation::*, Threading::*}, + System::{Com::*, LibraryLoader::*, Ole::*, SystemInformation::*}, UI::{Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, @@ -31,28 +29,34 @@ use windows::{ use crate::*; pub(crate) struct WindowsPlatform { - state: RefCell, + inner: Rc, raw_window_handles: Arc>>, // The below members will never change throughout the entire lifecycle of the app. icon: HICON, - main_receiver: flume::Receiver, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, text_system: Arc, windows_version: WindowsVersion, - bitmap_factory: ManuallyDrop, drop_target_helper: IDropTargetHelper, - validation_number: usize, - main_thread_id_win32: u32, + handle: HWND, disable_direct_composition: bool, } +struct WindowsPlatformInner { + state: RefCell, + raw_window_handles: std::sync::Weak>>, + // The below members will never change throughout the entire lifecycle of the app. + validation_number: usize, + main_receiver: flume::Receiver, +} + pub(crate) struct WindowsPlatformState { callbacks: PlatformCallbacks, menus: Vec, jump_list: JumpList, // NOTE: standard cursor handles don't need to close. pub(crate) current_cursor: Option, + directx_devices: ManuallyDrop, } #[derive(Default)] @@ -67,15 +71,17 @@ struct PlatformCallbacks { } impl WindowsPlatformState { - fn new() -> Self { + fn new(directx_devices: DirectXDevices) -> Self { let callbacks = PlatformCallbacks::default(); let jump_list = JumpList::new(); let current_cursor = load_cursor(CursorStyle::Arrow); + let directx_devices = ManuallyDrop::new(directx_devices); Self { callbacks, jump_list, current_cursor, + directx_devices, menus: Vec::new(), } } @@ -86,51 +92,72 @@ impl WindowsPlatform { unsafe { OleInitialize(None).context("unable to initialize Windows OLE")?; } + let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?; let (main_sender, main_receiver) = flume::unbounded::(); - let main_thread_id_win32 = unsafe { GetCurrentThreadId() }; - let validation_number = rand::random::(); + let validation_number = if usize::BITS == 64 { + rand::random::() as usize + } else { + rand::random::() as usize + }; + let raw_window_handles = Arc::new(RwLock::new(SmallVec::new())); + let text_system = Arc::new( + DirectWriteTextSystem::new(&directx_devices) + .context("Error creating DirectWriteTextSystem")?, + ); + register_platform_window_class(); + let mut context = PlatformWindowCreateContext { + inner: None, + raw_window_handles: Arc::downgrade(&raw_window_handles), + validation_number, + main_receiver: Some(main_receiver), + directx_devices: Some(directx_devices), + }; + let result = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE(0), + PLATFORM_WINDOW_CLASS_NAME, + None, + WINDOW_STYLE(0), + 0, + 0, + 0, + 0, + Some(HWND_MESSAGE), + None, + None, + Some(&context as *const _ as *const _), + ) + }; + let inner = context.inner.take().unwrap()?; + let handle = result?; let dispatcher = Arc::new(WindowsDispatcher::new( main_sender, - main_thread_id_win32, + handle, validation_number, )); let disable_direct_composition = std::env::var(DISABLE_DIRECT_COMPOSITION) .is_ok_and(|value| value == "true" || value == "1"); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher); - let directx_devices = DirectXDevices::new(disable_direct_composition) - .context("Unable to init directx devices.")?; - let bitmap_factory = ManuallyDrop::new(unsafe { - CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_INPROC_SERVER) - .context("Error creating bitmap factory.")? - }); - let text_system = Arc::new( - DirectWriteTextSystem::new(&directx_devices, &bitmap_factory) - .context("Error creating DirectWriteTextSystem")?, - ); + let drop_target_helper: IDropTargetHelper = unsafe { CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER) .context("Error creating drop target helper.")? }; let icon = load_icon().unwrap_or_default(); - let state = RefCell::new(WindowsPlatformState::new()); - let raw_window_handles = Arc::new(RwLock::new(SmallVec::new())); let windows_version = WindowsVersion::new().context("Error retrieve windows version")?; Ok(Self { - state, + inner, + handle, raw_window_handles, icon, - main_receiver, background_executor, foreground_executor, text_system, disable_direct_composition, windows_version, - bitmap_factory, drop_target_helper, - validation_number, - main_thread_id_win32, }) } @@ -152,119 +179,21 @@ impl WindowsPlatform { }); } - fn close_one_window(&self, target_window: HWND) -> bool { - let mut lock = self.raw_window_handles.write(); - let index = lock - .iter() - .position(|handle| handle.as_raw() == target_window) - .unwrap(); - lock.remove(index); - - lock.is_empty() - } - - #[inline] - fn run_foreground_task(&self) { - for runnable in self.main_receiver.drain() { - runnable.run(); - } - } - fn generate_creation_info(&self) -> WindowCreationInfo { WindowCreationInfo { icon: self.icon, executor: self.foreground_executor.clone(), - current_cursor: self.state.borrow().current_cursor, + current_cursor: self.inner.state.borrow().current_cursor, windows_version: self.windows_version, drop_target_helper: self.drop_target_helper.clone(), - validation_number: self.validation_number, - main_receiver: self.main_receiver.clone(), - main_thread_id_win32: self.main_thread_id_win32, + validation_number: self.inner.validation_number, + main_receiver: self.inner.main_receiver.clone(), + platform_window_handle: self.handle, disable_direct_composition: self.disable_direct_composition, + directx_devices: (*self.inner.state.borrow().directx_devices).clone(), } } - fn handle_dock_action_event(&self, action_idx: usize) { - let mut lock = self.state.borrow_mut(); - if let Some(mut callback) = lock.callbacks.app_menu_action.take() { - let Some(action) = lock - .jump_list - .dock_menus - .get(action_idx) - .map(|dock_menu| dock_menu.action.boxed_clone()) - else { - lock.callbacks.app_menu_action = Some(callback); - log::error!("Dock menu for index {action_idx} not found"); - return; - }; - drop(lock); - callback(&*action); - self.state.borrow_mut().callbacks.app_menu_action = Some(callback); - } - } - - fn handle_input_lang_change(&self) { - let mut lock = self.state.borrow_mut(); - if let Some(mut callback) = lock.callbacks.keyboard_layout_change.take() { - drop(lock); - callback(); - self.state - .borrow_mut() - .callbacks - .keyboard_layout_change - .get_or_insert(callback); - } - } - - // Returns if the app should quit. - fn handle_events(&self) { - let mut msg = MSG::default(); - unsafe { - while GetMessageW(&mut msg, None, 0, 0).as_bool() { - match msg.message { - WM_QUIT => return, - WM_INPUTLANGCHANGE - | WM_GPUI_CLOSE_ONE_WINDOW - | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD - | WM_GPUI_DOCK_MENU_ACTION => { - if self.handle_gpui_evnets(msg.message, msg.wParam, msg.lParam, &msg) { - return; - } - } - _ => { - DispatchMessageW(&msg); - } - } - } - } - } - - // Returns true if the app should quit. - fn handle_gpui_evnets( - &self, - message: u32, - wparam: WPARAM, - lparam: LPARAM, - msg: *const MSG, - ) -> bool { - if wparam.0 != self.validation_number { - unsafe { DispatchMessageW(msg) }; - return false; - } - match message { - WM_GPUI_CLOSE_ONE_WINDOW => { - if self.close_one_window(HWND(lparam.0 as _)) { - return true; - } - } - WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => self.run_foreground_task(), - WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _), - WM_INPUTLANGCHANGE => self.handle_input_lang_change(), - _ => unreachable!(), - } - false - } - fn set_dock_menus(&self, menus: Vec) { let mut actions = Vec::new(); menus.into_iter().for_each(|menu| { @@ -272,7 +201,7 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.state.borrow_mut(); + let mut lock = self.inner.state.borrow_mut(); lock.jump_list.dock_menus = actions; update_jump_list(&lock.jump_list).log_err(); } @@ -288,7 +217,7 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.state.borrow_mut(); + let mut lock = self.inner.state.borrow_mut(); lock.jump_list.dock_menus = actions; lock.jump_list.recent_workspaces = entries; update_jump_list(&lock.jump_list) @@ -309,23 +238,37 @@ impl WindowsPlatform { } fn begin_vsync_thread(&self) { + let mut directx_device = (*self.inner.state.borrow().directx_devices).clone(); + let platform_window: SafeHwnd = self.handle.into(); + let validation_number = self.inner.validation_number; let all_windows = Arc::downgrade(&self.raw_window_handles); - std::thread::spawn(move || { - let vsync_provider = VSyncProvider::new(); - loop { - vsync_provider.wait_for_vsync(); - let Some(all_windows) = all_windows.upgrade() else { - break; - }; - for hwnd in all_windows.read().iter() { - unsafe { - RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE) - .ok() - .log_err(); + let text_system = Arc::downgrade(&self.text_system); + std::thread::Builder::new() + .name("VSyncProvider".to_owned()) + .spawn(move || { + let vsync_provider = VSyncProvider::new(); + loop { + vsync_provider.wait_for_vsync(); + if check_device_lost(&directx_device.device) { + handle_gpu_device_lost( + &mut directx_device, + platform_window.as_raw(), + validation_number, + &all_windows, + &text_system, + ); + } + let Some(all_windows) = all_windows.upgrade() else { + break; + }; + for hwnd in all_windows.read().iter() { + unsafe { + let _ = RedrawWindow(Some(hwnd.as_raw()), None, None, RDW_INVALIDATE); + } } } - } - }); + }) + .unwrap(); } } @@ -350,16 +293,30 @@ impl Platform for WindowsPlatform { ) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(WindowsKeyboardMapper::new()) + } + fn on_keyboard_layout_change(&self, callback: Box) { - self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); + self.inner + .state + .borrow_mut() + .callbacks + .keyboard_layout_change = Some(callback); } fn run(&self, on_finish_launching: Box) { on_finish_launching(); self.begin_vsync_thread(); - self.handle_events(); - if let Some(ref mut callback) = self.state.borrow_mut().callbacks.quit { + let mut msg = MSG::default(); + unsafe { + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + DispatchMessageW(&msg); + } + } + + if let Some(ref mut callback) = self.inner.state.borrow_mut().callbacks.quit { callback(); } } @@ -392,6 +349,11 @@ impl Platform for WindowsPlatform { pid, app_path.display(), ); + + #[allow( + clippy::disallowed_methods, + reason = "We are restarting ourselves, using std command thus is fine" + )] let restart_process = util::command::new_std_command("powershell.exe") .arg("-command") .arg(script) @@ -460,19 +422,21 @@ impl Platform for WindowsPlatform { } fn open_url(&self, url: &str) { + if url.is_empty() { + return; + } let url_string = url.to_string(); self.background_executor() .spawn(async move { - if url_string.is_empty() { - return; - } - open_target(url_string.as_str()); + open_target(&url_string) + .with_context(|| format!("Opening url: {}", url_string)) + .log_err(); }) .detach(); } fn on_open_urls(&self, callback: Box)>) { - self.state.borrow_mut().callbacks.open_urls = Some(callback); + self.inner.state.borrow_mut().callbacks.open_urls = Some(callback); } fn prompt_for_paths( @@ -514,55 +478,47 @@ impl Platform for WindowsPlatform { } fn reveal_path(&self, path: &Path) { - let Ok(file_full_path) = path.canonicalize() else { - log::error!("unable to parse file path"); + if path.as_os_str().is_empty() { return; - }; + } + let path = path.to_path_buf(); self.background_executor() .spawn(async move { - let Some(path) = file_full_path.to_str() else { - return; - }; - if path.is_empty() { - return; - } - open_target_in_explorer(path); + open_target_in_explorer(&path) + .with_context(|| format!("Revealing path {} in explorer", path.display())) + .log_err(); }) .detach(); } fn open_with_system(&self, path: &Path) { - let Ok(full_path) = path.canonicalize() else { - log::error!("unable to parse file full path: {}", path.display()); + if path.as_os_str().is_empty() { return; - }; + } + let path = path.to_path_buf(); self.background_executor() .spawn(async move { - let Some(full_path_str) = full_path.to_str() else { - return; - }; - if full_path_str.is_empty() { - return; - }; - open_target(full_path_str); + open_target(&path) + .with_context(|| format!("Opening {} with system", path.display())) + .log_err(); }) .detach(); } fn on_quit(&self, callback: Box) { - self.state.borrow_mut().callbacks.quit = Some(callback); + self.inner.state.borrow_mut().callbacks.quit = Some(callback); } fn on_reopen(&self, callback: Box) { - self.state.borrow_mut().callbacks.reopen = Some(callback); + self.inner.state.borrow_mut().callbacks.reopen = Some(callback); } fn set_menus(&self, menus: Vec, _keymap: &Keymap) { - self.state.borrow_mut().menus = menus.into_iter().map(|menu| menu.owned()).collect(); + self.inner.state.borrow_mut().menus = menus.into_iter().map(|menu| menu.owned()).collect(); } fn get_menus(&self) -> Option> { - Some(self.state.borrow().menus.clone()) + Some(self.inner.state.borrow().menus.clone()) } fn set_dock_menu(&self, menus: Vec, _keymap: &Keymap) { @@ -570,15 +526,19 @@ impl Platform for WindowsPlatform { } fn on_app_menu_action(&self, callback: Box) { - self.state.borrow_mut().callbacks.app_menu_action = Some(callback); + self.inner.state.borrow_mut().callbacks.app_menu_action = Some(callback); } fn on_will_open_app_menu(&self, callback: Box) { - self.state.borrow_mut().callbacks.will_open_app_menu = Some(callback); + self.inner.state.borrow_mut().callbacks.will_open_app_menu = Some(callback); } fn on_validate_app_menu_command(&self, callback: Box bool>) { - self.state.borrow_mut().callbacks.validate_app_menu_command = Some(callback); + self.inner + .state + .borrow_mut() + .callbacks + .validate_app_menu_command = Some(callback); } fn app_path(&self) -> Result { @@ -592,7 +552,7 @@ impl Platform for WindowsPlatform { fn set_cursor_style(&self, style: CursorStyle) { let hcursor = load_cursor(style); - let mut lock = self.state.borrow_mut(); + let mut lock = self.inner.state.borrow_mut(); if lock.current_cursor.map(|c| c.0) != hcursor.map(|c| c.0) { self.post_message( WM_GPUI_CURSOR_STYLE_CHANGED, @@ -695,10 +655,10 @@ impl Platform for WindowsPlatform { fn perform_dock_menu_action(&self, action: usize) { unsafe { - PostThreadMessageW( - self.main_thread_id_win32, + PostMessageW( + Some(self.handle), WM_GPUI_DOCK_MENU_ACTION, - WPARAM(self.validation_number), + WPARAM(self.inner.validation_number), LPARAM(action as isize), ) .log_err(); @@ -714,15 +674,147 @@ impl Platform for WindowsPlatform { } } +impl WindowsPlatformInner { + fn new(context: &mut PlatformWindowCreateContext) -> Result> { + let state = RefCell::new(WindowsPlatformState::new( + context.directx_devices.take().unwrap(), + )); + Ok(Rc::new(Self { + state, + raw_window_handles: context.raw_window_handles.clone(), + validation_number: context.validation_number, + main_receiver: context.main_receiver.take().unwrap(), + })) + } + + fn handle_msg( + self: &Rc, + handle: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + let handled = match msg { + WM_GPUI_CLOSE_ONE_WINDOW + | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD + | WM_GPUI_DOCK_MENU_ACTION + | WM_GPUI_KEYBOARD_LAYOUT_CHANGED + | WM_GPUI_GPU_DEVICE_LOST => self.handle_gpui_events(msg, wparam, lparam), + _ => None, + }; + if let Some(result) = handled { + LRESULT(result) + } else { + unsafe { DefWindowProcW(handle, msg, wparam, lparam) } + } + } + + fn handle_gpui_events(&self, message: u32, wparam: WPARAM, lparam: LPARAM) -> Option { + if wparam.0 != self.validation_number { + log::error!("Wrong validation number while processing message: {message}"); + return None; + } + match message { + WM_GPUI_CLOSE_ONE_WINDOW => { + if self.close_one_window(HWND(lparam.0 as _)) { + unsafe { PostQuitMessage(0) }; + } + Some(0) + } + WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => self.run_foreground_task(), + WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _), + WM_GPUI_KEYBOARD_LAYOUT_CHANGED => self.handle_keyboard_layout_change(), + WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + _ => unreachable!(), + } + } + + fn close_one_window(&self, target_window: HWND) -> bool { + let Some(all_windows) = self.raw_window_handles.upgrade() else { + log::error!("Failed to upgrade raw window handles"); + return false; + }; + let mut lock = all_windows.write(); + let index = lock + .iter() + .position(|handle| handle.as_raw() == target_window) + .unwrap(); + lock.remove(index); + + lock.is_empty() + } + + #[inline] + fn run_foreground_task(&self) -> Option { + for runnable in self.main_receiver.drain() { + runnable.run(); + } + Some(0) + } + + fn handle_dock_action_event(&self, action_idx: usize) -> Option { + let mut lock = self.state.borrow_mut(); + let mut callback = lock.callbacks.app_menu_action.take()?; + let Some(action) = lock + .jump_list + .dock_menus + .get(action_idx) + .map(|dock_menu| dock_menu.action.boxed_clone()) + else { + lock.callbacks.app_menu_action = Some(callback); + log::error!("Dock menu for index {action_idx} not found"); + return Some(1); + }; + drop(lock); + callback(&*action); + self.state.borrow_mut().callbacks.app_menu_action = Some(callback); + Some(0) + } + + fn handle_keyboard_layout_change(&self) -> Option { + let mut callback = self + .state + .borrow_mut() + .callbacks + .keyboard_layout_change + .take()?; + callback(); + self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); + Some(0) + } + + fn handle_device_lost(&self, lparam: LPARAM) -> Option { + let mut lock = self.state.borrow_mut(); + let directx_devices = lparam.0 as *const DirectXDevices; + let directx_devices = unsafe { &*directx_devices }; + unsafe { + ManuallyDrop::drop(&mut lock.directx_devices); + } + lock.directx_devices = ManuallyDrop::new(directx_devices.clone()); + + Some(0) + } +} + impl Drop for WindowsPlatform { fn drop(&mut self) { unsafe { - ManuallyDrop::drop(&mut self.bitmap_factory); + DestroyWindow(self.handle) + .context("Destroying platform window") + .log_err(); OleUninitialize(); } } } +impl Drop for WindowsPlatformState { + fn drop(&mut self) { + unsafe { + ManuallyDrop::drop(&mut self.directx_devices); + } + } +} + pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, @@ -731,43 +823,80 @@ pub(crate) struct WindowCreationInfo { pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, pub(crate) main_receiver: flume::Receiver, - pub(crate) main_thread_id_win32: u32, + pub(crate) platform_window_handle: HWND, pub(crate) disable_direct_composition: bool, + pub(crate) directx_devices: DirectXDevices, } -fn open_target(target: &str) { - unsafe { - let ret = ShellExecuteW( +struct PlatformWindowCreateContext { + inner: Option>>, + raw_window_handles: std::sync::Weak>>, + validation_number: usize, + main_receiver: Option>, + directx_devices: Option, +} + +fn open_target(target: impl AsRef) -> Result<()> { + let target = target.as_ref(); + let ret = unsafe { + ShellExecuteW( None, windows::core::w!("open"), &HSTRING::from(target), None, None, SW_SHOWDEFAULT, - ); - if ret.0 as isize <= 32 { - log::error!("Unable to open target: {}", std::io::Error::last_os_error()); - } + ) + }; + if ret.0 as isize <= 32 { + Err(anyhow::anyhow!( + "Unable to open target: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(()) } } -fn open_target_in_explorer(target: &str) { +fn open_target_in_explorer(target: &Path) -> Result<()> { + let dir = target.parent().context("No parent folder found")?; + let desktop = unsafe { SHGetDesktopFolder()? }; + + let mut dir_item = std::ptr::null_mut(); unsafe { - let ret = ShellExecuteW( + desktop.ParseDisplayName( + HWND::default(), None, - windows::core::w!("open"), - windows::core::w!("explorer.exe"), - &HSTRING::from(format!("/select,{}", target).as_str()), + &HSTRING::from(dir), None, - SW_SHOWDEFAULT, - ); - if ret.0 as isize <= 32 { - log::error!( - "Unable to open target in explorer: {}", - std::io::Error::last_os_error() - ); - } + &mut dir_item, + std::ptr::null_mut(), + )?; } + + let mut file_item = std::ptr::null_mut(); + unsafe { + desktop.ParseDisplayName( + HWND::default(), + None, + &HSTRING::from(target), + None, + &mut file_item, + std::ptr::null_mut(), + )?; + } + + let highlight = [file_item as *const _]; + unsafe { SHOpenFolderAndSelectItems(dir_item as _, Some(&highlight), 0) }.or_else(|err| { + if err.code().0 == ERROR_FILE_NOT_FOUND.0 as i32 { + // On some systems, the above call mysteriously fails with "file not + // found" even though the file is there. In these cases, ShellExecute() + // seems to work as a fallback (although it won't select the file). + open_target(dir).context("Opening target parent folder") + } else { + Err(anyhow::anyhow!("Can not open target path: {}", err)) + } + }) } fn file_open_dialog( @@ -787,6 +916,12 @@ fn file_open_dialog( unsafe { folder_dialog.SetOptions(dialog_options)?; + + if let Some(prompt) = options.prompt { + let prompt: &str = &prompt; + folder_dialog.SetOkButtonLabel(&HSTRING::from(prompt))?; + } + if folder_dialog.Show(window).is_err() { // User cancelled return Ok(None); @@ -815,18 +950,31 @@ fn file_save_dialog( window: Option, ) -> Result> { let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? }; - if !directory.to_string_lossy().is_empty() { - if let Some(full_path) = directory.canonicalize().log_err() { - let full_path = SanitizedPath::from(full_path); - let full_path_string = full_path.to_string(); - let path_item: IShellItem = - unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; - unsafe { dialog.SetFolder(&path_item).log_err() }; - } + if !directory.to_string_lossy().is_empty() + && let Some(full_path) = directory + .canonicalize() + .context("failed to canonicalize directory") + .log_err() + { + let full_path = SanitizedPath::new(&full_path); + let full_path_string = full_path.to_string(); + let path_item: IShellItem = + unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; + unsafe { + dialog + .SetFolder(&path_item) + .context("failed to set dialog folder") + .log_err() + }; } if let Some(suggested_name) = suggested_name { - unsafe { dialog.SetFileName(&HSTRING::from(suggested_name)).log_err() }; + unsafe { + dialog + .SetFileName(&HSTRING::from(suggested_name)) + .context("failed to set file name") + .log_err() + }; } unsafe { @@ -871,6 +1019,135 @@ fn should_auto_hide_scrollbars() -> Result { Ok(ui_settings.AutoHideScrollBars()?) } +fn check_device_lost(device: &ID3D11Device) -> bool { + let device_state = unsafe { device.GetDeviceRemovedReason() }; + match device_state { + Ok(_) => false, + Err(err) => { + log::error!("DirectX device lost detected: {:?}", err); + true + } + } +} + +fn handle_gpu_device_lost( + directx_devices: &mut DirectXDevices, + platform_window: HWND, + validation_number: usize, + all_windows: &std::sync::Weak>>, + text_system: &std::sync::Weak, +) { + // Here we wait a bit to ensure the system has time to recover from the device lost state. + // If we don't wait, the final drawing result will be blank. + std::thread::sleep(std::time::Duration::from_millis(350)); + + try_to_recover_from_device_lost( + || { + DirectXDevices::new() + .context("Failed to recreate new DirectX devices after device lost") + }, + |new_devices| *directx_devices = new_devices, + || { + log::error!("Failed to recover DirectX devices after multiple attempts."); + // Do something here? + // At this point, the device loss is considered unrecoverable. + // std::process::exit(1); + }, + ); + log::info!("DirectX devices successfully recreated."); + + unsafe { + SendMessageW( + platform_window, + WM_GPUI_GPU_DEVICE_LOST, + Some(WPARAM(validation_number)), + Some(LPARAM(directx_devices as *const _ as _)), + ); + } + + if let Some(text_system) = text_system.upgrade() { + text_system.handle_gpu_lost(&directx_devices); + } + if let Some(all_windows) = all_windows.upgrade() { + for window in all_windows.read().iter() { + unsafe { + SendMessageW( + window.as_raw(), + WM_GPUI_GPU_DEVICE_LOST, + Some(WPARAM(validation_number)), + Some(LPARAM(directx_devices as *const _ as _)), + ); + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + for window in all_windows.read().iter() { + unsafe { + SendMessageW( + window.as_raw(), + WM_GPUI_FORCE_UPDATE_WINDOW, + Some(WPARAM(validation_number)), + None, + ); + } + } + } +} + +const PLATFORM_WINDOW_CLASS_NAME: PCWSTR = w!("Zed::PlatformWindow"); + +fn register_platform_window_class() { + let wc = WNDCLASSW { + lpfnWndProc: Some(window_procedure), + lpszClassName: PCWSTR(PLATFORM_WINDOW_CLASS_NAME.as_ptr()), + ..Default::default() + }; + unsafe { RegisterClassW(&wc) }; +} + +unsafe extern "system" fn window_procedure( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + if msg == WM_NCCREATE { + let params = lparam.0 as *const CREATESTRUCTW; + let params = unsafe { &*params }; + let creation_context = params.lpCreateParams as *mut PlatformWindowCreateContext; + let creation_context = unsafe { &mut *creation_context }; + return match WindowsPlatformInner::new(creation_context) { + Ok(inner) => { + let weak = Box::new(Rc::downgrade(&inner)); + unsafe { set_window_long(hwnd, GWLP_USERDATA, Box::into_raw(weak) as isize) }; + creation_context.inner = Some(Ok(inner)); + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + Err(error) => { + creation_context.inner = Some(Err(error)); + LRESULT(0) + } + }; + } + + let ptr = unsafe { get_window_long(hwnd, GWLP_USERDATA) } as *mut Weak; + if ptr.is_null() { + return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }; + } + let inner = unsafe { &*ptr }; + let result = if let Some(inner) = inner.upgrade() { + inner.handle_msg(hwnd, msg, wparam, lparam) + } else { + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + }; + + if msg == WM_NCDESTROY { + unsafe { set_window_long(hwnd, GWLP_USERDATA, 0) }; + unsafe { drop(Box::from_raw(ptr)) }; + } + + result +} + #[cfg(test)] mod tests { use crate::{ClipboardItem, read_from_clipboard, write_to_clipboard}; diff --git a/crates/gpui/src/platform/windows/shaders.hlsl b/crates/gpui/src/platform/windows/shaders.hlsl index 6fabe859e3fe6de58c438642455964e135258860..d6168eea09b4c7da705f5ecfc3e4002222f3149d 100644 --- a/crates/gpui/src/platform/windows/shaders.hlsl +++ b/crates/gpui/src/platform/windows/shaders.hlsl @@ -1,6 +1,10 @@ +#include "alpha_correction.hlsl" + cbuffer GlobalParams: register(b0) { + float4 gamma_ratios; float2 global_viewport_size; - uint2 _pad; + float grayscale_enhanced_contrast; + uint _pad; }; Texture2D t_sprite: register(t0); @@ -103,6 +107,12 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds bounds, Bounds clip_bo return distance_from_clip_rect_impl(position, clip_bounds); } +float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds bounds, Bounds clip_bounds, TransformationMatrix transformation) { + float2 position = unit_vertex * bounds.size + bounds.origin; + float2 transformed = mul(position, transformation.rotation_scale) + transformation.translation; + return distance_from_clip_rect_impl(transformed, clip_bounds); +} + // Convert linear RGB to sRGB float3 linear_to_srgb(float3 color) { return pow(color, float3(2.2, 2.2, 2.2)); @@ -380,7 +390,7 @@ float4 gradient_color(Background background, float pattern_period = pattern_height * sin(stripe_angle); float2x2 rotation = rotate2d(stripe_angle); float2 relative_position = position - bounds.origin; - float2 rotated_point = mul(rotation, relative_position); + float2 rotated_point = mul(relative_position, rotation); float pattern = fmod(rotated_point.x, pattern_period); float distance = min(pattern, pattern_period - pattern) - pattern_period * (pattern_width / pattern_height) / 2.0f; color = solid_color; @@ -650,7 +660,14 @@ float4 quad_fragment(QuadFragmentInput input): SV_Target { // out on each straight line, rather than around the whole // perimeter. This way each line starts and ends with a dash. bool is_horizontal = corner_center_to_point.x < corner_center_to_point.y; - float border_width = is_horizontal ? border.x : border.y; + // Choosing the right border width for dashed borders. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + float2 dashed_border = float2( + max(quad.border_widths.bottom, quad.border_widths.top), + max(quad.border_widths.right, quad.border_widths.left) + ); + float border_width = is_horizontal ? dashed_border.x : dashed_border.y; dash_velocity = dv_numerator / border_width; t = is_horizontal ? the_point.x : the_point.y; t *= dash_velocity; @@ -1084,7 +1101,7 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI MonochromeSprite sprite = mono_sprites[sprite_id]; float4 device_position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); - float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask); + float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); float2 tile_position = to_tile_position(unit_vertex, sprite.tile); float4 color = hsla_to_rgba(sprite.color); @@ -1098,7 +1115,8 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI float4 monochrome_sprite_fragment(MonochromeSpriteFragmentInput input): SV_Target { float sample = t_sprite.Sample(s_sprite, input.tile_position).r; - return float4(input.color.rgb, input.color.a * sample); + float alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios); + return float4(input.color.rgb, input.color.a * alpha_corrected); } /* diff --git a/crates/gpui/src/platform/windows/vsync.rs b/crates/gpui/src/platform/windows/vsync.rs index 6d09b0960f11cefce007413066620b3b332e1ae9..73c32cf9b92b93278d4f88a8c784fd3b2a8fc2d3 100644 --- a/crates/gpui/src/platform/windows/vsync.rs +++ b/crates/gpui/src/platform/windows/vsync.rs @@ -5,23 +5,10 @@ use std::{ use anyhow::{Context, Result}; use util::ResultExt; -use windows::{ - Win32::{ - Foundation::{HANDLE, HWND}, - Graphics::{ - DirectComposition::{ - COMPOSITION_FRAME_ID_COMPLETED, COMPOSITION_FRAME_ID_TYPE, COMPOSITION_FRAME_STATS, - COMPOSITION_TARGET_ID, - }, - Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, - }, - System::{ - LibraryLoader::{GetModuleHandleA, GetProcAddress}, - Performance::QueryPerformanceFrequency, - Threading::INFINITE, - }, - }, - core::{HRESULT, s}, +use windows::Win32::{ + Foundation::HWND, + Graphics::Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, + System::Performance::QueryPerformanceFrequency, }; static QPC_TICKS_PER_SECOND: LazyLock = LazyLock::new(|| { @@ -35,20 +22,6 @@ static QPC_TICKS_PER_SECOND: LazyLock = LazyLock::new(|| { const VSYNC_INTERVAL_THRESHOLD: Duration = Duration::from_millis(1); const DEFAULT_VSYNC_INTERVAL: Duration = Duration::from_micros(16_666); // ~60Hz -// Here we are using dynamic loading of DirectComposition functions, -// or the app will refuse to start on windows systems that do not support DirectComposition. -type DCompositionGetFrameId = - unsafe extern "system" fn(frameidtype: COMPOSITION_FRAME_ID_TYPE, frameid: *mut u64) -> HRESULT; -type DCompositionGetStatistics = unsafe extern "system" fn( - frameid: u64, - framestats: *mut COMPOSITION_FRAME_STATS, - targetidcount: u32, - targetids: *mut COMPOSITION_TARGET_ID, - actualtargetidcount: *mut u32, -) -> HRESULT; -type DCompositionWaitForCompositorClock = - unsafe extern "system" fn(count: u32, handles: *const HANDLE, timeoutinms: u32) -> u32; - pub(crate) struct VSyncProvider { interval: Duration, f: Box bool>, @@ -56,35 +29,12 @@ pub(crate) struct VSyncProvider { impl VSyncProvider { pub(crate) fn new() -> Self { - if let Some((get_frame_id, get_statistics, wait_for_comp_clock)) = - initialize_direct_composition() - .context("Retrieving DirectComposition functions") - .log_with_level(log::Level::Warn) - { - let interval = get_dwm_interval_from_direct_composition(get_frame_id, get_statistics) - .context("Failed to get DWM interval from DirectComposition") - .log_err() - .unwrap_or(DEFAULT_VSYNC_INTERVAL); - log::info!( - "DirectComposition is supported for VSync, interval: {:?}", - interval - ); - let f = Box::new(move || unsafe { - wait_for_comp_clock(0, std::ptr::null(), INFINITE) == 0 - }); - Self { interval, f } - } else { - let interval = get_dwm_interval() - .context("Failed to get DWM interval") - .log_err() - .unwrap_or(DEFAULT_VSYNC_INTERVAL); - log::info!( - "DirectComposition is not supported for VSync, falling back to DWM, interval: {:?}", - interval - ); - let f = Box::new(|| unsafe { DwmFlush().is_ok() }); - Self { interval, f } - } + let interval = get_dwm_interval() + .context("Failed to get DWM interval") + .log_err() + .unwrap_or(DEFAULT_VSYNC_INTERVAL); + let f = Box::new(|| unsafe { DwmFlush().is_ok() }); + Self { interval, f } } pub(crate) fn wait_for_vsync(&self) { @@ -94,7 +44,7 @@ impl VSyncProvider { // DwmFlush and DCompositionWaitForCompositorClock returns very early // instead of waiting until vblank when the monitor goes to sleep or is // unplugged (nothing to present due to desktop occlusion). We use 1ms as - // a threshhold for the duration of the wait functions and fallback to + // a threshold for the duration of the wait functions and fallback to // Sleep() if it returns before that. This could happen during normal // operation for the first call after the vsync thread becomes non-idle, // but it shouldn't happen often. @@ -105,49 +55,6 @@ impl VSyncProvider { } } -fn initialize_direct_composition() -> Result<( - DCompositionGetFrameId, - DCompositionGetStatistics, - DCompositionWaitForCompositorClock, -)> { - unsafe { - // Load DLL at runtime since older Windows versions don't have dcomp. - let hmodule = GetModuleHandleA(s!("dcomp.dll")).context("Loading dcomp.dll")?; - let get_frame_id_addr = GetProcAddress(hmodule, s!("DCompositionGetFrameId")) - .context("Function DCompositionGetFrameId not found")?; - let get_statistics_addr = GetProcAddress(hmodule, s!("DCompositionGetStatistics")) - .context("Function DCompositionGetStatistics not found")?; - let wait_for_compositor_clock_addr = - GetProcAddress(hmodule, s!("DCompositionWaitForCompositorClock")) - .context("Function DCompositionWaitForCompositorClock not found")?; - let get_frame_id: DCompositionGetFrameId = std::mem::transmute(get_frame_id_addr); - let get_statistics: DCompositionGetStatistics = std::mem::transmute(get_statistics_addr); - let wait_for_compositor_clock: DCompositionWaitForCompositorClock = - std::mem::transmute(wait_for_compositor_clock_addr); - Ok((get_frame_id, get_statistics, wait_for_compositor_clock)) - } -} - -fn get_dwm_interval_from_direct_composition( - get_frame_id: DCompositionGetFrameId, - get_statistics: DCompositionGetStatistics, -) -> Result { - let mut frame_id = 0; - unsafe { get_frame_id(COMPOSITION_FRAME_ID_COMPLETED, &mut frame_id) }.ok()?; - let mut stats = COMPOSITION_FRAME_STATS::default(); - unsafe { - get_statistics( - frame_id, - &mut stats, - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - } - .ok()?; - Ok(retrieve_duration(stats.framePeriod, *QPC_TICKS_PER_SECOND)) -} - fn get_dwm_interval() -> Result { let mut timing_info = DWM_TIMING_INFO { cbSize: std::mem::size_of::() as u32, diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 32a6da23915d1e2bdf61c662e364119e9c6a8c64..e765fa1a22d54a645d094f0df3250f75c94387af 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -51,7 +51,6 @@ pub struct WindowsWindowState { pub renderer: DirectXRenderer, pub click_state: ClickState, - pub system_settings: WindowsSystemSettings, pub current_cursor: Option, pub nc_button_pressed: Option, @@ -66,6 +65,7 @@ pub(crate) struct WindowsWindowInner { pub(super) this: Weak, drop_target_helper: IDropTargetHelper, pub(crate) state: RefCell, + pub(crate) system_settings: RefCell, pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, @@ -73,12 +73,13 @@ pub(crate) struct WindowsWindowInner { pub(crate) windows_version: WindowsVersion, pub(crate) validation_number: usize, pub(crate) main_receiver: flume::Receiver, - pub(crate) main_thread_id_win32: u32, + pub(crate) platform_window_handle: HWND, } impl WindowsWindowState { fn new( hwnd: HWND, + directx_devices: &DirectXDevices, window_params: &CREATESTRUCTW, current_cursor: Option, display: WindowsDisplay, @@ -104,7 +105,7 @@ impl WindowsWindowState { }; let border_offset = WindowBorderOffset::default(); let restore_from_minimized = None; - let renderer = DirectXRenderer::new(hwnd, disable_direct_composition) + let renderer = DirectXRenderer::new(hwnd, directx_devices, disable_direct_composition) .context("Creating DirectX renderer")?; let callbacks = Callbacks::default(); let input_handler = None; @@ -114,7 +115,6 @@ impl WindowsWindowState { let system_key_handled = false; let hovered = false; let click_state = ClickState::new(); - let system_settings = WindowsSystemSettings::new(display); let nc_button_pressed = None; let fullscreen = None; let initial_placement = None; @@ -137,7 +137,6 @@ impl WindowsWindowState { hovered, renderer, click_state, - system_settings, current_cursor, nc_button_pressed, display, @@ -170,7 +169,9 @@ impl WindowsWindowState { length: std::mem::size_of::() as u32, ..Default::default() }; - GetWindowPlacement(self.hwnd, &mut placement).log_err(); + GetWindowPlacement(self.hwnd, &mut placement) + .context("failed to get window placement") + .log_err(); placement }; ( @@ -205,9 +206,10 @@ impl WindowsWindowState { } impl WindowsWindowInner { - fn new(context: &WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { + fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { let state = RefCell::new(WindowsWindowState::new( hwnd, + &context.directx_devices, cs, context.current_cursor, context.display, @@ -228,7 +230,8 @@ impl WindowsWindowInner { windows_version: context.windows_version, validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), - main_thread_id_win32: context.main_thread_id_win32, + platform_window_handle: context.platform_window_handle, + system_settings: RefCell::new(WindowsSystemSettings::new(context.display)), })) } @@ -253,7 +256,9 @@ impl WindowsWindowInner { lock.fullscreen_restore_bounds = window_bounds; let style = WINDOW_STYLE(unsafe { get_window_long(this.hwnd, GWL_STYLE) } as _); let mut rc = RECT::default(); - unsafe { GetWindowRect(this.hwnd, &mut rc) }.log_err(); + unsafe { GetWindowRect(this.hwnd, &mut rc) } + .context("failed to get window rect") + .log_err(); let _ = lock.fullscreen.insert(StyleAndBounds { style, x: rc.left, @@ -300,15 +305,20 @@ impl WindowsWindowInner { }; match open_status.state { WindowOpenState::Maximized => unsafe { - SetWindowPlacement(self.hwnd, &open_status.placement)?; + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")?; ShowWindowAsync(self.hwnd, SW_MAXIMIZE).ok()?; }, WindowOpenState::Fullscreen => { - unsafe { SetWindowPlacement(self.hwnd, &open_status.placement)? }; + unsafe { + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")? + }; self.toggle_fullscreen(); } WindowOpenState::Windowed => unsafe { - SetWindowPlacement(self.hwnd, &open_status.placement)?; + SetWindowPlacement(self.hwnd, &open_status.placement) + .context("failed to set window placement")?; }, } Ok(()) @@ -342,9 +352,10 @@ struct WindowCreateContext { drop_target_helper: IDropTargetHelper, validation_number: usize, main_receiver: flume::Receiver, - main_thread_id_win32: u32, + platform_window_handle: HWND, appearance: WindowAppearance, disable_direct_composition: bool, + directx_devices: DirectXDevices, } impl WindowsWindow { @@ -361,8 +372,9 @@ impl WindowsWindow { drop_target_helper, validation_number, main_receiver, - main_thread_id_win32, + platform_window_handle, disable_direct_composition, + directx_devices, } = creation_info; register_window_class(icon); let hide_title_bar = params @@ -382,10 +394,17 @@ impl WindowsWindow { let (mut dwexstyle, dwstyle) = if params.kind == WindowKind::PopUp { (WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0)) } else { - ( - WS_EX_APPWINDOW, - WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX, - ) + let mut dwstyle = WS_SYSMENU; + + if params.is_resizable { + dwstyle |= WS_THICKFRAME | WS_MAXIMIZEBOX; + } + + if params.is_minimizable { + dwstyle |= WS_MINIMIZEBOX; + } + + (WS_EX_APPWINDOW, dwstyle) }; if !disable_direct_composition { dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; @@ -412,9 +431,10 @@ impl WindowsWindow { drop_target_helper, validation_number, main_receiver, - main_thread_id_win32, + platform_window_handle, appearance, disable_direct_composition, + directx_devices, }; let creation_result = unsafe { CreateWindowExW( @@ -592,10 +612,7 @@ impl PlatformWindow for WindowsWindow { ) -> Option> { let (done_tx, done_rx) = oneshot::channel(); let msg = msg.to_string(); - let detail_string = match detail { - Some(info) => Some(info.to_string()), - None => None, - }; + let detail_string = detail.map(|detail| detail.to_string()); let handle = self.0.hwnd; let answers = answers.to_vec(); self.0 @@ -635,10 +652,12 @@ impl PlatformWindow for WindowsWindow { let mut btn_encoded = Vec::new(); for (index, btn) in answers.iter().enumerate() { let encoded = HSTRING::from(btn.label().as_ref()); - let button_id = if btn.is_cancel() { - IDCANCEL.0 - } else { - index as i32 - 100 + let button_id = match btn { + PromptButton::Ok(_) => IDOK.0, + PromptButton::Cancel(_) => IDCANCEL.0, + // the first few low integer values are reserved for known buttons + // so for simplicity we just go backwards from -1 + PromptButton::Other(_) => -(index as i32) - 1, }; button_id_map.push(button_id); buttons.push(TASKDIALOG_BUTTON { @@ -656,11 +675,11 @@ impl PlatformWindow for WindowsWindow { .context("unable to create task dialog") .log_err(); - let clicked = button_id_map - .iter() - .position(|&button_id| button_id == res) - .unwrap(); - let _ = done_tx.send(clicked); + if let Some(clicked) = + button_id_map.iter().position(|&button_id| button_id == res) + { + let _ = done_tx.send(clicked); + } } }) .detach(); @@ -675,8 +694,16 @@ impl PlatformWindow for WindowsWindow { .executor .spawn(async move { this.set_window_placement().log_err(); - unsafe { SetActiveWindow(hwnd).log_err() }; - unsafe { SetFocus(Some(hwnd)).log_err() }; + + unsafe { + // If the window is minimized, restore it. + if IsIconic(hwnd).as_bool() { + ShowWindowAsync(hwnd, SW_RESTORE).ok().log_err(); + } + + SetActiveWindow(hwnd).log_err(); + SetFocus(Some(hwnd)).log_err(); + } // premium ragebait by windows, this is needed because the window // must have received an input event to be able to set itself to foreground @@ -830,7 +857,7 @@ impl PlatformWindow for WindowsWindow { self.0.state.borrow().renderer.gpu_specs().log_err() } - fn update_ime_position(&self, _bounds: Bounds) { + fn update_ime_position(&self, _bounds: Bounds) { // There is no such thing on Windows. } } diff --git a/crates/gpui/src/shared_string.rs b/crates/gpui/src/shared_string.rs index c325f98cd243121264875d7a9452308772d49e86..350184d350aec8c5995fe7d2f0856f1fe1cfea0f 100644 --- a/crates/gpui/src/shared_string.rs +++ b/crates/gpui/src/shared_string.rs @@ -23,6 +23,11 @@ impl SharedString { pub fn new(str: impl Into>) -> Self { SharedString(ArcCow::Owned(str.into())) } + + /// Get a &str from the underlying string. + pub fn as_str(&self) -> &str { + &self.0 + } } impl JsonSchema for SharedString { @@ -103,7 +108,7 @@ impl From for Arc { fn from(val: SharedString) -> Self { match val.0 { ArcCow::Borrowed(borrowed) => Arc::from(borrowed), - ArcCow::Owned(owned) => owned.clone(), + ArcCow::Owned(owned) => owned, } } } diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 869ecc04687933348e5c986807d1fa941544d9c4..1313944d12a2587f0e3be35e876089bf1f6040ad 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -153,7 +153,7 @@ pub struct Style { #[refineable] pub overflow: Point, /// How much space (in points) should be reserved for the scrollbars of `Overflow::Scroll` and `Overflow::Auto` nodes. - pub scrollbar_width: f32, + pub scrollbar_width: AbsoluteLength, /// Whether both x and y axis should be scrollable at the same time. pub allow_concurrent_scroll: bool, /// Whether scrolling should be restricted to the axis indicated by the mouse wheel. @@ -406,13 +406,7 @@ impl Default for TextStyle { TextStyle { color: black(), // todo(linux) make this configurable or choose better default - font_family: if cfg!(any(target_os = "linux", target_os = "freebsd")) { - "FreeMono".into() - } else if cfg!(target_os = "windows") { - "Segoe UI".into() - } else { - "Helvetica".into() - }, + font_family: ".SystemUIFont".into(), font_features: FontFeatures::default(), font_fallbacks: None, font_size: rems(1.).into(), @@ -576,7 +570,7 @@ impl Style { if self .border_color - .map_or(false, |color| !color.is_transparent()) + .is_some_and(|color| !color.is_transparent()) { min.x += self.border_widths.left.to_pixels(rem_size); max.x -= self.border_widths.right.to_pixels(rem_size); @@ -636,7 +630,7 @@ impl Style { window.paint_shadows(bounds, corner_radii, &self.box_shadow); let background_color = self.background.as_ref().and_then(Fill::color); - if background_color.map_or(false, |color| !color.is_transparent()) { + if background_color.is_some_and(|color| !color.is_transparent()) { let mut border_color = match background_color { Some(color) => match color.tag { BackgroundTag::Solid => color.solid, @@ -732,7 +726,7 @@ impl Style { fn is_border_visible(&self) -> bool { self.border_color - .map_or(false, |color| !color.is_transparent()) + .is_some_and(|color| !color.is_transparent()) && self.border_widths.any(|length| !length.is_zero()) } } @@ -748,7 +742,7 @@ impl Default for Style { }, allow_concurrent_scroll: false, restrict_scroll_to_axis: false, - scrollbar_width: 0.0, + scrollbar_width: AbsoluteLength::default(), position: Position::Relative, inset: Edges::auto(), margin: Edges::::zero(), @@ -890,43 +884,32 @@ impl HighlightStyle { } /// Blend this highlight style with another. /// Non-continuous properties, like font_weight and font_style, are overwritten. - pub fn highlight(&mut self, other: HighlightStyle) { - match (self.color, other.color) { - (Some(self_color), Some(other_color)) => { - self.color = Some(Hsla::blend(other_color, self_color)); - } - (None, Some(other_color)) => { - self.color = Some(other_color); - } - _ => {} - } - - if other.font_weight.is_some() { - self.font_weight = other.font_weight; - } - - if other.font_style.is_some() { - self.font_style = other.font_style; - } - - if other.background_color.is_some() { - self.background_color = other.background_color; - } - - if other.underline.is_some() { - self.underline = other.underline; - } - - if other.strikethrough.is_some() { - self.strikethrough = other.strikethrough; - } - - match (other.fade_out, self.fade_out) { - (Some(source_fade), None) => self.fade_out = Some(source_fade), - (Some(source_fade), Some(dest_fade)) => { - self.fade_out = Some((dest_fade * (1. + source_fade)).clamp(0., 1.)); - } - _ => {} + #[must_use] + pub fn highlight(self, other: HighlightStyle) -> Self { + Self { + color: other + .color + .map(|other_color| { + if let Some(color) = self.color { + color.blend(other_color) + } else { + other_color + } + }) + .or(self.color), + font_weight: other.font_weight.or(self.font_weight), + font_style: other.font_style.or(self.font_style), + background_color: other.background_color.or(self.background_color), + underline: other.underline.or(self.underline), + strikethrough: other.strikethrough.or(self.strikethrough), + fade_out: other + .fade_out + .map(|source_fade| { + self.fade_out + .map(|dest_fade| (dest_fade * (1. + source_fade)).clamp(0., 1.)) + .unwrap_or(source_fade) + }) + .or(self.fade_out), } } } @@ -991,10 +974,11 @@ pub fn combine_highlights( while let Some((endpoint_ix, highlight_id, is_start)) = endpoints.peek() { let prev_index = mem::replace(&mut ix, *endpoint_ix); if ix > prev_index && !active_styles.is_empty() { - let mut current_style = HighlightStyle::default(); - for highlight_id in &active_styles { - current_style.highlight(highlights[*highlight_id]); - } + let current_style = active_styles + .iter() + .fold(HighlightStyle::default(), |acc, highlight_id| { + acc.highlight(highlights[*highlight_id]) + }); return Some((prev_index..ix, current_style)); } @@ -1310,11 +1294,98 @@ impl From for taffy::style::Position { #[cfg(test)] mod tests { - use crate::{blue, green, red, yellow}; + use crate::{blue, green, px, red, yellow}; use super::*; - #[test] + use util_macros::perf; + + #[perf] + fn test_basic_highlight_style_combination() { + let style_a = HighlightStyle::default(); + let style_b = HighlightStyle::default(); + let style_a = style_a.highlight(style_b); + assert_eq!( + style_a, + HighlightStyle::default(), + "Combining empty styles should not produce a non-empty style." + ); + + let mut style_b = HighlightStyle { + color: Some(red()), + strikethrough: Some(StrikethroughStyle { + thickness: px(2.), + color: Some(blue()), + }), + fade_out: Some(0.), + font_style: Some(FontStyle::Italic), + font_weight: Some(FontWeight(300.)), + background_color: Some(yellow()), + underline: Some(UnderlineStyle { + thickness: px(2.), + color: Some(red()), + wavy: true, + }), + }; + let expected_style = style_b; + + let style_a = style_a.highlight(style_b); + assert_eq!( + style_a, expected_style, + "Blending an empty style with another style should return the other style" + ); + + let style_b = style_b.highlight(Default::default()); + assert_eq!( + style_b, expected_style, + "Blending a style with an empty style should not change the style." + ); + + let mut style_c = expected_style; + + let style_d = HighlightStyle { + color: Some(blue().alpha(0.7)), + strikethrough: Some(StrikethroughStyle { + thickness: px(4.), + color: Some(crate::red()), + }), + fade_out: Some(0.), + font_style: Some(FontStyle::Oblique), + font_weight: Some(FontWeight(800.)), + background_color: Some(green()), + underline: Some(UnderlineStyle { + thickness: px(4.), + color: None, + wavy: false, + }), + }; + + let expected_style = HighlightStyle { + color: Some(red().blend(blue().alpha(0.7))), + strikethrough: Some(StrikethroughStyle { + thickness: px(4.), + color: Some(red()), + }), + // TODO this does not seem right + fade_out: Some(0.), + font_style: Some(FontStyle::Oblique), + font_weight: Some(FontWeight(800.)), + background_color: Some(green()), + underline: Some(UnderlineStyle { + thickness: px(4.), + color: None, + wavy: false, + }), + }; + + let style_c = style_c.highlight(style_d); + assert_eq!( + style_c, expected_style, + "Blending styles should blend properties where possible and override all others" + ); + } + + #[perf] fn test_combine_highlights() { assert_eq!( combine_highlights( @@ -1341,14 +1412,14 @@ mod tests { ( 1..2, HighlightStyle { - color: Some(green()), + color: Some(blue()), ..Default::default() } ), ( 2..3, HighlightStyle { - color: Some(green()), + color: Some(blue()), font_style: Some(FontStyle::Italic), ..Default::default() } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 96db82f4584c3d32a2decc479c2d4e9658005239..46d970e7196fe610e53a0c2c61a15f3b15e152b8 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -9,7 +9,6 @@ pub use gpui_macros::{ overflow_style_methods, padding_style_methods, position_style_methods, visibility_style_methods, }; - const ELLIPSIS: SharedString = SharedString::new_static("…"); /// A trait for elements that can be styled. @@ -53,6 +52,13 @@ pub trait Styled: Sized { self } + /// Sets the display type of the element to `none`. + /// [Docs](https://tailwindcss.com/docs/display) + fn hidden(mut self) -> Self { + self.style().display = Some(Display::None); + self + } + /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { @@ -302,6 +308,16 @@ pub trait Styled: Sized { self } + /// Sets the element to justify items along the container's main axis such + /// that there is an equal amount of space around each item, but also + /// accounting for the doubling of space you would normally see between + /// each item when using justify-around. + /// [Docs](https://tailwindcss.com/docs/justify-content#space-evenly) + fn justify_evenly(mut self) -> Self { + self.style().justify_content = Some(JustifyContent::SpaceEvenly); + self + } + /// Sets the element to pack content items in their default position as if no align-content value was set. /// [Docs](https://tailwindcss.com/docs/align-content#normal) fn content_normal(mut self) -> Self { diff --git a/crates/gpui/src/subscription.rs b/crates/gpui/src/subscription.rs index a584f1a45f82094ce9b867bc5f43805c48f93ebe..bd869f8d32cdfc81917fc2287b7dc62fac7d727d 100644 --- a/crates/gpui/src/subscription.rs +++ b/crates/gpui/src/subscription.rs @@ -201,3 +201,9 @@ impl Drop for Subscription { } } } + +impl std::fmt::Debug for Subscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Subscription").finish() + } +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 0107624bc8d0e6a26c6acc4a085cbddc7e14c4c5..1e2e34897af0b550542f9af148bb7c19f8f8ed18 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -1,5 +1,10 @@ -use crate::{AssetSource, DevicePixels, IsZero, Result, SharedString, Size}; +use crate::{ + AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size, + swap_rgba_pa_to_bgra, +}; +use image::Frame; use resvg::tiny_skia::Pixmap; +use smallvec::SmallVec; use std::{ hash::Hash, sync::{Arc, LazyLock}, @@ -15,17 +20,22 @@ pub(crate) struct RenderSvgParams { } #[derive(Clone)] +/// A struct holding everything necessary to render SVGs. pub struct SvgRenderer { asset_source: Arc, usvg_options: Arc>, } +/// The size in which to render the SVG. pub enum SvgSize { + /// An absolute size in device pixels. Size(Size), + /// A scaling factor to apply to the size provided by the SVG. ScaleFactor(f32), } impl SvgRenderer { + /// Creates a new SVG renderer with the provided asset source. pub fn new(asset_source: Arc) -> Self { static FONT_DB: LazyLock> = LazyLock::new(|| { let mut db = usvg::fontdb::Database::new(); @@ -54,7 +64,38 @@ impl SvgRenderer { } } - pub(crate) fn render(&self, params: &RenderSvgParams) -> Result>> { + /// Renders the given bytes into an image buffer. + pub fn render_single_frame( + &self, + bytes: &[u8], + scale_factor: f32, + to_brga: bool, + ) -> Result, usvg::Error> { + self.render_pixmap( + bytes, + SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR), + ) + .map(|pixmap| { + let mut buffer = + image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) + .unwrap(); + + if to_brga { + for pixel in buffer.chunks_exact_mut(4) { + swap_rgba_pa_to_bgra(pixel); + } + } + + let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)])); + image.scale_factor = SMOOTH_SVG_SCALE_FACTOR; + Arc::new(image) + }) + } + + pub(crate) fn render_alpha_mask( + &self, + params: &RenderSvgParams, + ) -> Result, Vec)>> { anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); // Load the tree. @@ -65,30 +106,33 @@ impl SvgRenderer { let pixmap = self.render_pixmap(&bytes, SvgSize::Size(params.size))?; // Convert the pixmap's pixels into an alpha mask. + let size = Size::new( + DevicePixels(pixmap.width() as i32), + DevicePixels(pixmap.height() as i32), + ); let alpha_mask = pixmap .pixels() .iter() .map(|p| p.alpha()) .collect::>(); - Ok(Some(alpha_mask)) + Ok(Some((size, alpha_mask))) } - pub fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { + fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; - - let size = match size { - SvgSize::Size(size) => size, - SvgSize::ScaleFactor(scale) => crate::size( - DevicePixels((tree.size().width() * scale) as i32), - DevicePixels((tree.size().height() * scale) as i32), - ), + let svg_size = tree.size(); + let scale = match size { + SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), + SvgSize::ScaleFactor(scale) => scale, }; // Render the SVG to a pixmap with the specified width and height. - let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width.into(), size.height.into()) - .ok_or(usvg::Error::InvalidSize)?; + let mut pixmap = resvg::tiny_skia::Pixmap::new( + (svg_size.width() * scale) as u32, + (svg_size.height() * scale) as u32, + ) + .ok_or(usvg::Error::InvalidSize)?; - let scale = size.width.0 as f32 / tree.size().width(); let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index 7dde42efed8a138de3a29657683d95c60e27dda0..8a95a3975af736d544e01cbf6e212994b8e7e8c6 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -1,83 +1,324 @@ +use std::fmt::Debug; + +use ::sum_tree::SumTree; +use collections::FxHashMap; +use sum_tree::Bias; + use crate::{FocusHandle, FocusId}; -/// Represents a collection of tab handles. -/// -/// Used to manage the `Tab` event to switch between focus handles. -#[derive(Default)] -pub(crate) struct TabHandles { - pub(crate) handles: Vec, +/// Represents a collection of focus handles using the tab-index APIs. +#[derive(Debug)] +pub(crate) struct TabStopMap { + current_path: TabStopPath, + pub(crate) insertion_history: Vec, + by_id: FxHashMap, + order: SumTree, } -impl TabHandles { - pub(crate) fn insert(&mut self, focus_handle: &FocusHandle) { - if !focus_handle.tab_stop { - return; +#[derive(Debug, Clone)] +pub enum TabStopOperation { + Insert(FocusHandle), + Group(TabIndex), + GroupEnd, +} + +impl TabStopOperation { + fn focus_handle(&self) -> Option<&FocusHandle> { + match self { + TabStopOperation::Insert(focus_handle) => Some(focus_handle), + _ => None, } + } +} - let focus_handle = focus_handle.clone(); +type TabIndex = isize; - // Insert handle with same tab_index last - if let Some(ix) = self - .handles - .iter() - .position(|tab| tab.tab_index > focus_handle.tab_index) - { - self.handles.insert(ix, focus_handle); - } else { - self.handles.push(focus_handle); +#[derive(Debug, Default, PartialEq, Eq, Clone, Ord, PartialOrd)] +struct TabStopPath(smallvec::SmallVec<[TabIndex; 6]>); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TabStopNode { + /// Path to access the node in the tree + /// The final node in the list is a leaf node corresponding to an actual focus handle, + /// all other nodes are group nodes + path: TabStopPath, + /// index into the backing array of nodes. Corresponds to insertion order + node_insertion_index: usize, + + /// Whether this node is a tab stop + tab_stop: bool, +} + +impl Ord for TabStopNode { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.path + .cmp(&other.path) + .then(self.node_insertion_index.cmp(&other.node_insertion_index)) + } +} + +impl PartialOrd for TabStopNode { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(&other)) + } +} + +impl Default for TabStopMap { + fn default() -> Self { + Self { + current_path: TabStopPath::default(), + insertion_history: Vec::new(), + by_id: FxHashMap::default(), + order: SumTree::new(()), } } +} - pub(crate) fn clear(&mut self) { - self.handles.clear(); +impl TabStopMap { + pub fn insert(&mut self, focus_handle: &FocusHandle) { + self.insertion_history + .push(TabStopOperation::Insert(focus_handle.clone())); + let mut path = self.current_path.clone(); + path.0.push(focus_handle.tab_index); + let order = TabStopNode { + node_insertion_index: self.insertion_history.len() - 1, + tab_stop: focus_handle.tab_stop, + path, + }; + self.by_id.insert(focus_handle.id, order.clone()); + self.order.insert_or_replace(order, ()); } - fn current_index(&self, focused_id: Option<&FocusId>) -> Option { - self.handles.iter().position(|h| Some(&h.id) == focused_id) + pub fn begin_group(&mut self, tab_index: isize) { + self.insertion_history + .push(TabStopOperation::Group(tab_index)); + self.current_path.0.push(tab_index); } - pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option { - let next_ix = self - .current_index(focused_id) - .and_then(|ix| { - let next_ix = ix + 1; - (next_ix < self.handles.len()).then_some(next_ix) - }) - .unwrap_or_default(); + pub fn end_group(&mut self) { + self.insertion_history.push(TabStopOperation::GroupEnd); + self.current_path.0.pop(); + } - if let Some(next_handle) = self.handles.get(next_ix) { - Some(next_handle.clone()) + pub fn clear(&mut self) { + *self = Self::default(); + self.current_path.0.clear(); + self.insertion_history.clear(); + self.by_id.clear(); + self.order = SumTree::new(()); + } + + pub fn next(&self, focused_id: Option<&FocusId>) -> Option { + let Some(focused_id) = focused_id else { + let first = self.order.first()?; + if first.tab_stop { + return self.focus_handle_for_order(first); + } else { + return self + .next_inner(first) + .and_then(|order| self.focus_handle_for_order(order)); + } + }; + + let Some(node) = self.tab_node_for_focus_id(focused_id) else { + return self.next(None); + }; + let item = self.next_inner(node); + + if let Some(item) = item { + self.focus_handle_for_order(&item) } else { - None + self.next(None) } } - pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option { - let ix = self.current_index(focused_id).unwrap_or_default(); - let prev_ix; - if ix == 0 { - prev_ix = self.handles.len().saturating_sub(1); - } else { - prev_ix = ix.saturating_sub(1); + fn next_inner(&self, node: &TabStopNode) -> Option<&TabStopNode> { + let mut cursor = self.order.cursor::(()); + cursor.seek(&node, Bias::Left); + cursor.next(); + while let Some(item) = cursor.item() + && !item.tab_stop + { + cursor.next(); } - if let Some(prev_handle) = self.handles.get(prev_ix) { - Some(prev_handle.clone()) + cursor.item() + } + + pub fn prev(&self, focused_id: Option<&FocusId>) -> Option { + let Some(focused_id) = focused_id else { + let last = self.order.last()?; + if last.tab_stop { + return self.focus_handle_for_order(last); + } else { + return self + .prev_inner(last) + .and_then(|order| self.focus_handle_for_order(order)); + } + }; + + let Some(node) = self.tab_node_for_focus_id(focused_id) else { + return self.prev(None); + }; + let item = self.prev_inner(node); + + if let Some(item) = item { + self.focus_handle_for_order(&item) } else { - None + self.prev(None) + } + } + + fn prev_inner(&self, node: &TabStopNode) -> Option<&TabStopNode> { + let mut cursor = self.order.cursor::(()); + cursor.seek(&node, Bias::Left); + cursor.prev(); + while let Some(item) = cursor.item() + && !item.tab_stop + { + cursor.prev(); + } + + cursor.item() + } + + pub fn replay(&mut self, nodes: &[TabStopOperation]) { + for node in nodes { + match node { + TabStopOperation::Insert(focus_handle) => self.insert(focus_handle), + TabStopOperation::Group(tab_index) => self.begin_group(*tab_index), + TabStopOperation::GroupEnd => self.end_group(), + } + } + } + + pub fn paint_index(&self) -> usize { + self.insertion_history.len() + } + + fn focus_handle_for_order(&self, order: &TabStopNode) -> Option { + let handle = self.insertion_history[order.node_insertion_index].focus_handle(); + debug_assert!( + handle.is_some(), + "The order node did not correspond to an element, this is a GPUI bug" + ); + handle.cloned() + } + + fn tab_node_for_focus_id(&self, focused_id: &FocusId) -> Option<&TabStopNode> { + let Some(order) = self.by_id.get(focused_id) else { + return None; + }; + Some(order) + } +} + +mod sum_tree_impl { + use sum_tree::SeekTarget; + + use crate::tab_stop::{TabStopNode, TabStopPath}; + + #[derive(Clone, Debug)] + pub struct TabStopOrderNodeSummary { + max_index: usize, + max_path: TabStopPath, + pub tab_stops: usize, + } + + pub type TabStopCount = usize; + + impl sum_tree::ContextLessSummary for TabStopOrderNodeSummary { + fn zero() -> Self { + TabStopOrderNodeSummary { + max_index: 0, + max_path: TabStopPath::default(), + tab_stops: 0, + } + } + + fn add_summary(&mut self, summary: &Self) { + self.max_index = summary.max_index; + self.max_path = summary.max_path.clone(); + self.tab_stops += summary.tab_stops; + } + } + + impl sum_tree::KeyedItem for TabStopNode { + type Key = Self; + + fn key(&self) -> Self::Key { + self.clone() + } + } + + impl sum_tree::Item for TabStopNode { + type Summary = TabStopOrderNodeSummary; + + fn summary(&self, _cx: ::Context<'_>) -> Self::Summary { + TabStopOrderNodeSummary { + max_index: self.node_insertion_index, + max_path: self.path.clone(), + tab_stops: if self.tab_stop { 1 } else { 0 }, + } + } + } + + impl<'a> sum_tree::Dimension<'a, TabStopOrderNodeSummary> for TabStopCount { + fn zero(_: ::Context<'_>) -> Self { + 0 + } + + fn add_summary( + &mut self, + summary: &'a TabStopOrderNodeSummary, + _: ::Context<'_>, + ) { + *self += summary.tab_stops; + } + } + + impl<'a> sum_tree::Dimension<'a, TabStopOrderNodeSummary> for TabStopNode { + fn zero(_: ::Context<'_>) -> Self { + TabStopNode::default() + } + + fn add_summary( + &mut self, + summary: &'a TabStopOrderNodeSummary, + _: ::Context<'_>, + ) { + self.node_insertion_index = summary.max_index; + self.path = summary.max_path.clone(); + } + } + + impl<'a, 'b> SeekTarget<'a, TabStopOrderNodeSummary, TabStopNode> for &'b TabStopNode { + fn cmp( + &self, + cursor_location: &TabStopNode, + _: ::Context<'_>, + ) -> std::cmp::Ordering { + Iterator::cmp(self.path.0.iter(), cursor_location.path.0.iter()).then( + ::cmp( + &self.node_insertion_index, + &cursor_location.node_insertion_index, + ), + ) } } } #[cfg(test)] mod tests { - use crate::{FocusHandle, FocusMap, TabHandles}; + use itertools::Itertools as _; + + use crate::{FocusHandle, FocusId, FocusMap, TabStopMap}; use std::sync::Arc; #[test] fn test_tab_handles() { let focus_map = Arc::new(FocusMap::default()); - let mut tab = TabHandles::default(); + let mut tab_index_map = TabStopMap::default(); let focus_handles = vec![ FocusHandle::new(&focus_map).tab_stop(true).tab_index(0), @@ -90,72 +331,281 @@ mod tests { ]; for handle in focus_handles.iter() { - tab.insert(&handle); + tab_index_map.insert(handle); + } + let expected = [ + focus_handles[0].clone(), + focus_handles[5].clone(), + focus_handles[1].clone(), + focus_handles[2].clone(), + focus_handles[6].clone(), + ]; + + let mut prev = None; + let mut found = vec![]; + for _ in 0..expected.len() { + let handle = tab_index_map.next(prev.as_ref()).unwrap(); + prev = Some(handle.id); + found.push(handle.id); } + assert_eq!( - tab.handles - .iter() - .map(|handle| handle.id) - .collect::>(), - vec![ - focus_handles[0].id, - focus_handles[5].id, - focus_handles[1].id, - focus_handles[2].id, - focus_handles[6].id, - ] + found, + expected.iter().map(|handle| handle.id).collect::>() ); // Select first tab index if no handle is currently focused. - assert_eq!(tab.next(None), Some(tab.handles[0].clone())); + assert_eq!(tab_index_map.next(None), Some(expected[0].clone())); // Select last tab index if no handle is currently focused. - assert_eq!( - tab.prev(None), - Some(tab.handles[tab.handles.len() - 1].clone()) - ); + assert_eq!(tab_index_map.prev(None), expected.last().cloned(),); assert_eq!( - tab.next(Some(&tab.handles[0].id)), - Some(tab.handles[1].clone()) + tab_index_map.next(Some(&expected[0].id)), + Some(expected[1].clone()) ); assert_eq!( - tab.next(Some(&tab.handles[1].id)), - Some(tab.handles[2].clone()) + tab_index_map.next(Some(&expected[1].id)), + Some(expected[2].clone()) ); assert_eq!( - tab.next(Some(&tab.handles[2].id)), - Some(tab.handles[3].clone()) + tab_index_map.next(Some(&expected[2].id)), + Some(expected[3].clone()) ); assert_eq!( - tab.next(Some(&tab.handles[3].id)), - Some(tab.handles[4].clone()) + tab_index_map.next(Some(&expected[3].id)), + Some(expected[4].clone()) ); assert_eq!( - tab.next(Some(&tab.handles[4].id)), - Some(tab.handles[0].clone()) + tab_index_map.next(Some(&expected[4].id)), + Some(expected[0].clone()) ); // prev - assert_eq!(tab.prev(None), Some(tab.handles[4].clone())); + assert_eq!(tab_index_map.prev(None), Some(expected[4].clone())); assert_eq!( - tab.prev(Some(&tab.handles[0].id)), - Some(tab.handles[4].clone()) + tab_index_map.prev(Some(&expected[0].id)), + Some(expected[4].clone()) ); assert_eq!( - tab.prev(Some(&tab.handles[1].id)), - Some(tab.handles[0].clone()) + tab_index_map.prev(Some(&expected[1].id)), + Some(expected[0].clone()) ); assert_eq!( - tab.prev(Some(&tab.handles[2].id)), - Some(tab.handles[1].clone()) + tab_index_map.prev(Some(&expected[2].id)), + Some(expected[1].clone()) ); assert_eq!( - tab.prev(Some(&tab.handles[3].id)), - Some(tab.handles[2].clone()) + tab_index_map.prev(Some(&expected[3].id)), + Some(expected[2].clone()) ); assert_eq!( - tab.prev(Some(&tab.handles[4].id)), - Some(tab.handles[3].clone()) + tab_index_map.prev(Some(&expected[4].id)), + Some(expected[3].clone()) ); } + + #[test] + fn test_tab_non_stop_filtering() { + let focus_map = Arc::new(FocusMap::default()); + let mut tab_index_map = TabStopMap::default(); + + // Check that we can query next from a non-stop tab + let tab_non_stop_1 = FocusHandle::new(&focus_map).tab_stop(false).tab_index(1); + let tab_stop_2 = FocusHandle::new(&focus_map).tab_stop(true).tab_index(2); + tab_index_map.insert(&tab_non_stop_1); + tab_index_map.insert(&tab_stop_2); + let result = tab_index_map.next(Some(&tab_non_stop_1.id)).unwrap(); + assert_eq!(result.id, tab_stop_2.id); + + // Check that we skip over non-stop tabs + let tab_stop_0 = FocusHandle::new(&focus_map).tab_stop(true).tab_index(0); + let tab_non_stop_0 = FocusHandle::new(&focus_map).tab_stop(false).tab_index(0); + tab_index_map.insert(&tab_stop_0); + tab_index_map.insert(&tab_non_stop_0); + let result = tab_index_map.next(Some(&tab_stop_0.id)).unwrap(); + assert_eq!(result.id, tab_stop_2.id); + } + + #[must_use] + struct TabStopMapTest { + tab_map: TabStopMap, + focus_map: Arc, + expected: Vec<(usize, FocusId)>, + } + + impl TabStopMapTest { + #[must_use] + fn new() -> Self { + Self { + tab_map: TabStopMap::default(), + focus_map: Arc::new(FocusMap::default()), + expected: Vec::default(), + } + } + + #[must_use] + fn tab_non_stop(mut self, index: isize) -> Self { + let handle = FocusHandle::new(&self.focus_map) + .tab_stop(false) + .tab_index(index); + self.tab_map.insert(&handle); + self + } + + #[must_use] + fn tab_stop(mut self, index: isize, expected: usize) -> Self { + let handle = FocusHandle::new(&self.focus_map) + .tab_stop(true) + .tab_index(index); + self.tab_map.insert(&handle); + self.expected.push((expected, handle.id)); + self.expected.sort_by_key(|(expected, _)| *expected); + self + } + + #[must_use] + fn tab_group(mut self, tab_index: isize, children: impl FnOnce(Self) -> Self) -> Self { + self.tab_map.begin_group(tab_index); + self = children(self); + self.tab_map.end_group(); + self + } + + fn traverse_tab_map( + &self, + traverse: impl Fn(&TabStopMap, Option<&FocusId>) -> Option, + ) -> Vec { + let mut last_focus_id = None; + let mut found = vec![]; + for _ in 0..self.expected.len() { + let handle = traverse(&self.tab_map, last_focus_id.as_ref()).unwrap(); + last_focus_id = Some(handle.id); + found.push(handle.id); + } + found + } + + fn assert(self) { + let mut expected = self.expected.iter().map(|(_, id)| *id).collect_vec(); + + // Check next order + let forward_found = self.traverse_tab_map(|tab_map, prev| tab_map.next(prev)); + assert_eq!(forward_found, expected); + + // Test overflow. Last to first + assert_eq!( + self.tab_map + .next(forward_found.last()) + .map(|handle| handle.id), + expected.first().cloned() + ); + + // Check previous order + let reversed_found = self.traverse_tab_map(|tab_map, prev| tab_map.prev(prev)); + expected.reverse(); + assert_eq!(reversed_found, expected); + + // Test overflow. First to last + assert_eq!( + self.tab_map + .prev(reversed_found.last()) + .map(|handle| handle.id), + expected.first().cloned(), + ); + } + } + + #[test] + fn test_with_disabled_tab_stop() { + TabStopMapTest::new() + .tab_stop(0, 0) + .tab_non_stop(1) + .tab_stop(2, 1) + .tab_stop(3, 2) + .assert(); + } + + #[test] + fn test_with_multiple_disabled_tab_stops() { + TabStopMapTest::new() + .tab_non_stop(0) + .tab_stop(1, 0) + .tab_non_stop(3) + .tab_stop(3, 1) + .tab_non_stop(4) + .assert(); + } + + #[test] + fn test_tab_group_functionality() { + TabStopMapTest::new() + .tab_stop(0, 0) + .tab_stop(0, 1) + .tab_group(2, |t| t.tab_stop(0, 2).tab_stop(1, 3)) + .tab_stop(3, 4) + .tab_stop(4, 5) + .assert() + } + + #[test] + fn test_sibling_groups() { + TabStopMapTest::new() + .tab_stop(0, 0) + .tab_stop(1, 1) + .tab_group(2, |test| test.tab_stop(0, 2).tab_stop(1, 3)) + .tab_stop(3, 4) + .tab_stop(4, 5) + .tab_group(6, |test| test.tab_stop(0, 6).tab_stop(1, 7)) + .tab_stop(7, 8) + .tab_stop(8, 9) + .assert(); + } + + #[test] + fn test_nested_group() { + TabStopMapTest::new() + .tab_stop(0, 0) + .tab_stop(1, 1) + .tab_group(2, |t| { + t.tab_group(0, |t| t.tab_stop(0, 2).tab_stop(1, 3)) + .tab_stop(1, 4) + }) + .tab_stop(3, 5) + .tab_stop(4, 6) + .assert(); + } + + #[test] + fn test_sibling_nested_groups() { + TabStopMapTest::new() + .tab_stop(0, 0) + .tab_stop(1, 1) + .tab_group(2, |builder| { + builder + .tab_stop(0, 2) + .tab_stop(2, 5) + .tab_group(1, |builder| builder.tab_stop(0, 3).tab_stop(1, 4)) + .tab_group(3, |builder| builder.tab_stop(0, 6).tab_stop(1, 7)) + }) + .tab_stop(3, 8) + .tab_stop(4, 9) + .assert(); + } + + #[test] + fn test_sibling_nested_groups_out_of_order() { + TabStopMapTest::new() + .tab_stop(9, 9) + .tab_stop(8, 8) + .tab_group(7, |builder| { + builder + .tab_stop(0, 2) + .tab_stop(2, 5) + .tab_group(3, |builder| builder.tab_stop(1, 7).tab_stop(0, 6)) + .tab_group(1, |builder| builder.tab_stop(0, 3).tab_stop(1, 4)) + }) + .tab_stop(3, 0) + .tab_stop(4, 1) + .assert(); + } } diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index ee21ecd8c4a4b5b4c4a3853b56af9fe210bc5481..11cb0872861321c3c06c3f8a5bf79fdd30eb2275 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -1,8 +1,9 @@ use crate::{ AbsoluteLength, App, Bounds, DefiniteLength, Edges, Length, Pixels, Point, Size, Style, Window, + point, size, }; use collections::{FxHashMap, FxHashSet}; -use smallvec::SmallVec; +use stacksafe::{StackSafe, stacksafe}; use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, @@ -11,8 +12,15 @@ use taffy::{ tree::NodeId, }; -type NodeMeasureFn = Box< - dyn FnMut(Size>, Size, &mut Window, &mut App) -> Size, +type NodeMeasureFn = StackSafe< + Box< + dyn FnMut( + Size>, + Size, + &mut Window, + &mut App, + ) -> Size, + >, >; struct NodeContext { @@ -22,6 +30,7 @@ pub struct TaffyLayoutEngine { taffy: TaffyTree, absolute_layout_bounds: FxHashMap>, computed_layouts: FxHashSet, + layout_bounds_scratch_space: Vec, } const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by construction if possible"; @@ -29,11 +38,12 @@ const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by constructio impl TaffyLayoutEngine { pub fn new() -> Self { let mut taffy = TaffyTree::new(); - taffy.disable_rounding(); + taffy.enable_rounding(); TaffyLayoutEngine { taffy, absolute_layout_bounds: FxHashMap::default(), computed_layouts: FxHashSet::default(), + layout_bounds_scratch_space: Vec::new(), } } @@ -47,32 +57,30 @@ impl TaffyLayoutEngine { &mut self, style: Style, rem_size: Pixels, + scale_factor: f32, children: &[LayoutId], ) -> LayoutId { - let taffy_style = style.to_taffy(rem_size); - let layout_id = if children.is_empty() { + let taffy_style = style.to_taffy(rem_size, scale_factor); + + if children.is_empty() { self.taffy .new_leaf(taffy_style) .expect(EXPECT_MESSAGE) .into() } else { - let parent_id = self - .taffy + self.taffy // This is safe because LayoutId is repr(transparent) to taffy::tree::NodeId. - .new_with_children(taffy_style, unsafe { - std::mem::transmute::<&[LayoutId], &[taffy::NodeId]>(children) - }) + .new_with_children(taffy_style, LayoutId::to_taffy_slice(children)) .expect(EXPECT_MESSAGE) - .into(); - parent_id - }; - layout_id + .into() + } } pub fn request_measured_layout( &mut self, style: Style, rem_size: Pixels, + scale_factor: f32, measure: impl FnMut( Size>, Size, @@ -81,19 +89,17 @@ impl TaffyLayoutEngine { ) -> Size + 'static, ) -> LayoutId { - let taffy_style = style.to_taffy(rem_size); + let taffy_style = style.to_taffy(rem_size, scale_factor); - let layout_id = self - .taffy + self.taffy .new_leaf_with_context( taffy_style, NodeContext { - measure: Box::new(measure), + measure: StackSafe::new(Box::new(measure)), }, ) .expect(EXPECT_MESSAGE) - .into(); - layout_id + .into() } // Used to understand performance @@ -143,6 +149,7 @@ impl TaffyLayoutEngine { Ok(edges) } + #[stacksafe] pub fn compute_layout( &mut self, id: LayoutId, @@ -159,11 +166,10 @@ impl TaffyLayoutEngine { // for (a, b) in self.get_edges(id)? { // println!("N{} --> N{}", u64::from(a), u64::from(b)); // } - // println!(""); // if !self.computed_layouts.insert(id) { - let mut stack = SmallVec::<[LayoutId; 64]>::new(); + let mut stack = &mut self.layout_bounds_scratch_space; stack.push(id); while let Some(id) = stack.pop() { self.absolute_layout_bounds.remove(&id); @@ -172,12 +178,25 @@ impl TaffyLayoutEngine { .children(id.into()) .expect(EXPECT_MESSAGE) .into_iter() - .map(Into::into), + .map(LayoutId::from), ); } } - // let started_at = std::time::Instant::now(); + let scale_factor = window.scale_factor(); + + let transform = |v: AvailableSpace| match v { + AvailableSpace::Definite(pixels) => { + AvailableSpace::Definite(Pixels(pixels.0 * scale_factor)) + } + AvailableSpace::MinContent => AvailableSpace::MinContent, + AvailableSpace::MaxContent => AvailableSpace::MaxContent, + }; + let available_space = size( + transform(available_space.width), + transform(available_space.height), + ); + self.taffy .compute_layout_with_measure( id.into(), @@ -188,32 +207,50 @@ impl TaffyLayoutEngine { }; let known_dimensions = Size { - width: known_dimensions.width.map(Pixels), - height: known_dimensions.height.map(Pixels), + width: known_dimensions.width.map(|e| Pixels(e / scale_factor)), + height: known_dimensions.height.map(|e| Pixels(e / scale_factor)), }; - (node_context.measure)(known_dimensions, available_space.into(), window, cx) - .into() + let available_space: Size = available_space.into(); + let untransform = |ev: AvailableSpace| match ev { + AvailableSpace::Definite(pixels) => { + AvailableSpace::Definite(Pixels(pixels.0 / scale_factor)) + } + AvailableSpace::MinContent => AvailableSpace::MinContent, + AvailableSpace::MaxContent => AvailableSpace::MaxContent, + }; + let available_space = size( + untransform(available_space.width), + untransform(available_space.height), + ); + + let a: Size = + (node_context.measure)(known_dimensions, available_space, window, cx); + size(a.width.0 * scale_factor, a.height.0 * scale_factor).into() }, ) .expect(EXPECT_MESSAGE); - - // println!("compute_layout took {:?}", started_at.elapsed()); } - pub fn layout_bounds(&mut self, id: LayoutId) -> Bounds { + pub fn layout_bounds(&mut self, id: LayoutId, scale_factor: f32) -> Bounds { if let Some(layout) = self.absolute_layout_bounds.get(&id).cloned() { return layout; } let layout = self.taffy.layout(id.into()).expect(EXPECT_MESSAGE); let mut bounds = Bounds { - origin: layout.location.into(), - size: layout.size.into(), + origin: point( + Pixels(layout.location.x / scale_factor), + Pixels(layout.location.y / scale_factor), + ), + size: size( + Pixels(layout.size.width / scale_factor), + Pixels(layout.size.height / scale_factor), + ), }; if let Some(parent_id) = self.taffy.parent(id.0) { - let parent_bounds = self.layout_bounds(parent_id.into()); + let parent_bounds = self.layout_bounds(parent_id.into(), scale_factor); bounds.origin += parent_bounds.origin; } self.absolute_layout_bounds.insert(id, bounds); @@ -227,6 +264,13 @@ impl TaffyLayoutEngine { #[repr(transparent)] pub struct LayoutId(NodeId); +impl LayoutId { + fn to_taffy_slice(node_ids: &[Self]) -> &[taffy::NodeId] { + // SAFETY: LayoutId is repr(transparent) to taffy::tree::NodeId. + unsafe { std::mem::transmute::<&[LayoutId], &[taffy::NodeId]>(node_ids) } + } +} + impl std::hash::Hash for LayoutId { fn hash(&self, state: &mut H) { u64::from(self.0).hash(state); @@ -246,11 +290,11 @@ impl From for NodeId { } trait ToTaffy { - fn to_taffy(&self, rem_size: Pixels) -> Output; + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> Output; } impl ToTaffy for Style { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Style { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Style { use taffy::style_helpers::{fr, length, minmax, repeat}; fn to_grid_line( @@ -273,24 +317,24 @@ impl ToTaffy for Style { taffy::style::Style { display: self.display.into(), overflow: self.overflow.into(), - scrollbar_width: self.scrollbar_width, + scrollbar_width: self.scrollbar_width.to_taffy(rem_size, scale_factor), position: self.position.into(), - inset: self.inset.to_taffy(rem_size), - size: self.size.to_taffy(rem_size), - min_size: self.min_size.to_taffy(rem_size), - max_size: self.max_size.to_taffy(rem_size), + inset: self.inset.to_taffy(rem_size, scale_factor), + size: self.size.to_taffy(rem_size, scale_factor), + min_size: self.min_size.to_taffy(rem_size, scale_factor), + max_size: self.max_size.to_taffy(rem_size, scale_factor), aspect_ratio: self.aspect_ratio, - margin: self.margin.to_taffy(rem_size), - padding: self.padding.to_taffy(rem_size), - border: self.border_widths.to_taffy(rem_size), + margin: self.margin.to_taffy(rem_size, scale_factor), + padding: self.padding.to_taffy(rem_size, scale_factor), + border: self.border_widths.to_taffy(rem_size, scale_factor), align_items: self.align_items.map(|x| x.into()), align_self: self.align_self.map(|x| x.into()), align_content: self.align_content.map(|x| x.into()), justify_content: self.justify_content.map(|x| x.into()), - gap: self.gap.to_taffy(rem_size), + gap: self.gap.to_taffy(rem_size, scale_factor), flex_direction: self.flex_direction.into(), flex_wrap: self.flex_wrap.into(), - flex_basis: self.flex_basis.to_taffy(rem_size), + flex_basis: self.flex_basis.to_taffy(rem_size, scale_factor), flex_grow: self.flex_grow, flex_shrink: self.flex_shrink, grid_template_rows: to_grid_repeat(&self.grid_rows), @@ -310,33 +354,54 @@ impl ToTaffy for Style { } } +impl ToTaffy for AbsoluteLength { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> f32 { + match self { + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + pixels * scale_factor + } + AbsoluteLength::Rems(rems) => { + let pixels: f32 = (*rems * rem_size).into(); + pixels * scale_factor + } + } + } +} + impl ToTaffy for Length { - fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto { + fn to_taffy( + &self, + rem_size: Pixels, + scale_factor: f32, + ) -> taffy::prelude::LengthPercentageAuto { match self { - Length::Definite(length) => length.to_taffy(rem_size), + Length::Definite(length) => length.to_taffy(rem_size, scale_factor), Length::Auto => taffy::prelude::LengthPercentageAuto::auto(), } } } impl ToTaffy for Length { - fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::Dimension { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::prelude::Dimension { match self { - Length::Definite(length) => length.to_taffy(rem_size), + Length::Definite(length) => length.to_taffy(rem_size, scale_factor), Length::Auto => taffy::prelude::Dimension::auto(), } } } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentage::length(pixels.into()) + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } }, DefiniteLength::Fraction(fraction) => { @@ -347,14 +412,16 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentageAuto { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentageAuto { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentageAuto::length(pixels.into()) + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentageAuto::length(pixels * scale_factor) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentageAuto::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentageAuto::length(pixels * scale_factor) } }, DefiniteLength::Fraction(fraction) => { @@ -365,12 +432,15 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for DefiniteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Dimension { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Dimension { match self { DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::length(pixels.into()), + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + taffy::style::Dimension::length(pixels * scale_factor) + } AbsoluteLength::Rems(rems) => { - taffy::style::Dimension::length((*rems * rem_size).into()) + taffy::style::Dimension::length((*rems * rem_size * scale_factor).into()) } }, DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction), @@ -379,11 +449,15 @@ impl ToTaffy for DefiniteLength { } impl ToTaffy for AbsoluteLength { - fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { match self { - AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::length(pixels.into()), + AbsoluteLength::Pixels(pixels) => { + let pixels: f32 = pixels.into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) + } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::length((*rems * rem_size).into()) + let pixels: f32 = (*rems * rem_size).into(); + taffy::style::LengthPercentage::length(pixels * scale_factor) } } } @@ -418,10 +492,10 @@ impl ToTaffy> for Size where T: ToTaffy + Clone + Debug + Default + PartialEq, { - fn to_taffy(&self, rem_size: Pixels) -> TaffySize { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> TaffySize { TaffySize { - width: self.width.to_taffy(rem_size), - height: self.height.to_taffy(rem_size), + width: self.width.to_taffy(rem_size, scale_factor), + height: self.height.to_taffy(rem_size, scale_factor), } } } @@ -430,12 +504,12 @@ impl ToTaffy> for Edges where T: ToTaffy + Clone + Debug + Default + PartialEq, { - fn to_taffy(&self, rem_size: Pixels) -> TaffyRect { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> TaffyRect { TaffyRect { - top: self.top.to_taffy(rem_size), - right: self.right.to_taffy(rem_size), - bottom: self.bottom.to_taffy(rem_size), - left: self.left.to_taffy(rem_size), + top: self.top.to_taffy(rem_size, scale_factor), + right: self.right.to_taffy(rem_size, scale_factor), + bottom: self.bottom.to_taffy(rem_size, scale_factor), + left: self.left.to_taffy(rem_size, scale_factor), } } } @@ -486,6 +560,7 @@ impl AvailableSpace { /// # Examples /// /// ``` + /// use gpui::AvailableSpace; /// let min_content_size = AvailableSpace::min_size(); /// assert_eq!(min_content_size.width, AvailableSpace::MinContent); /// assert_eq!(min_content_size.height, AvailableSpace::MinContent); diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 4794fd002e28595a5d165ff3ac5876ea31c8ce20..5ae72d2be1688893374e16a55445558b5bc33040 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -64,6 +64,9 @@ pub fn run_test( if attempt < max_retries { println!("attempt {} failed, retrying", attempt); attempt += 1; + // The panic payload might itself trigger an unwind on drop: + // https://doc.rust-lang.org/std/panic/fn.catch_unwind.html#notes + std::mem::forget(error); } else { if is_multiple_runs { eprintln!("failing seed: {}", seed); diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index b48c3a29350ab3c770c5eca765f7019ae1afd8f3..0c8a32b16c5273bdcfd8b5380cf6e0698cffcbcb 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -19,7 +19,7 @@ use crate::{ use anyhow::{Context as _, anyhow}; use collections::FxHashMap; use core::fmt; -use derive_more::Deref; +use derive_more::{Add, Deref, FromStr, Sub}; use itertools::Itertools; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use smallvec::{SmallVec, smallvec}; @@ -41,7 +41,14 @@ pub struct FontId(pub usize); #[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)] pub struct FontFamilyId(pub usize); -pub(crate) const SUBPIXEL_VARIANTS: u8 = 4; +pub(crate) const SUBPIXEL_VARIANTS_X: u8 = 4; + +pub(crate) const SUBPIXEL_VARIANTS_Y: u8 = + if cfg!(target_os = "windows") || cfg!(target_os = "linux") { + 1 + } else { + SUBPIXEL_VARIANTS_X + }; /// The GPUI text rendering sub system. pub struct TextSystem { @@ -66,12 +73,15 @@ impl TextSystem { fallback_font_stack: smallvec![ // TODO: Remove this when Linux have implemented setting fallbacks. font(".ZedMono"), + font(".ZedSans"), font("Helvetica"), - font("Segoe UI"), // Windows - font("Cantarell"), // Gnome - font("Ubuntu"), // Gnome (Ubuntu) - font("Noto Sans"), // KDE - font("DejaVu Sans") + font("Segoe UI"), // Windows + font("Ubuntu"), // Gnome (Ubuntu) + font("Adwaita Sans"), // Gnome 47 + font("Cantarell"), // Gnome + font("Noto Sans"), // KDE + font("DejaVu Sans"), + font("Arial"), // macOS, Windows ], } } @@ -351,7 +361,7 @@ impl WindowTextSystem { /// /// Note that this method can only shape a single line of text. It will panic /// if the text contains newlines. If you need to shape multiple lines of text, - /// use `TextLayout::shape_text` instead. + /// use [`Self::shape_text`] instead. pub fn shape_line( &self, text: SharedString, @@ -366,15 +376,14 @@ impl WindowTextSystem { let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); for run in runs { - if let Some(last_run) = decoration_runs.last_mut() { - if last_run.color == run.color - && last_run.underline == run.underline - && last_run.strikethrough == run.strikethrough - && last_run.background_color == run.background_color - { - last_run.len += run.len as u32; - continue; - } + if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run.len as u32; + continue; } decoration_runs.push(DecorationRun { len: run.len as u32, @@ -409,40 +418,30 @@ impl WindowTextSystem { let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); let mut lines = SmallVec::new(); - let mut line_start = 0; - let mut max_wrap_lines = line_clamp.unwrap_or(usize::MAX); + let mut max_wrap_lines = line_clamp; let mut wrapped_lines = 0; - let mut process_line = |line_text: SharedString| { - let line_end = line_start + line_text.len(); + let mut process_line = |line_text: SharedString, line_start, line_end| { + font_runs.clear(); - let mut last_font: Option = None; let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); let mut run_start = line_start; while run_start < line_end { let Some(run) = runs.peek_mut() else { + log::warn!("`TextRun`s do not cover the entire to be shaped text"); break; }; - let run_len_within_line = cmp::min(line_end, run_start + run.len) - run_start; - - if last_font == Some(run.font.clone()) { - font_runs.last_mut().unwrap().len += run_len_within_line; - } else { - last_font = Some(run.font.clone()); - font_runs.push(FontRun { - len: run_len_within_line, - font_id: self.resolve_font(&run.font), - }); - } + let run_len_within_line = cmp::min(line_end - run_start, run.len); - if decoration_runs.last().map_or(false, |last_run| { - last_run.color == run.color - && last_run.underline == run.underline - && last_run.strikethrough == run.strikethrough - && last_run.background_color == run.background_color - }) { - decoration_runs.last_mut().unwrap().len += run_len_within_line as u32; + let decoration_changed = if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run_len_within_line as u32; + false } else { decoration_runs.push(DecorationRun { len: run_len_within_line as u32, @@ -451,13 +450,26 @@ impl WindowTextSystem { underline: run.underline, strikethrough: run.strikethrough, }); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run_len_within_line; + } else { + font_runs.push(FontRun { + len: run_len_within_line, + font_id, + }); } - if run_len_within_line == run.len { + // Preserve the remainder of the run for the next line + run.len -= run_len_within_line; + if run.len == 0 { runs.next(); - } else { - // Preserve the remainder of the run for the next line - run.len -= run_len_within_line; } run_start += run_len_within_line; } @@ -467,7 +479,7 @@ impl WindowTextSystem { font_size, &font_runs, wrap_width, - Some(max_wrap_lines - wrapped_lines), + max_wrap_lines.map(|max| max.saturating_sub(wrapped_lines)), ); wrapped_lines += layout.wrap_boundaries.len(); @@ -478,33 +490,43 @@ impl WindowTextSystem { }); // Skip `\n` character. - line_start = line_end + 1; if let Some(run) = runs.peek_mut() { run.len -= 1; if run.len == 0 { runs.next(); } } - - font_runs.clear(); }; let mut split_lines = text.split('\n'); - let mut processed = false; - - if let Some(first_line) = split_lines.next() { - if let Some(second_line) = split_lines.next() { - processed = true; - process_line(first_line.to_string().into()); - process_line(second_line.to_string().into()); - for line_text in split_lines { - process_line(line_text.to_string().into()); - } - } - } - if !processed { - process_line(text); + // Special case single lines to prevent allocating a sharedstring + if let Some(first_line) = split_lines.next() + && let Some(second_line) = split_lines.next() + { + let mut line_start = 0; + process_line( + SharedString::new(first_line), + line_start, + line_start + first_line.len(), + ); + line_start += first_line.len() + '\n'.len_utf8(); + process_line( + SharedString::new(second_line), + line_start, + line_start + second_line.len(), + ); + for line_text in split_lines { + line_start += line_text.len() + '\n'.len_utf8(); + process_line( + SharedString::new(line_text), + line_start, + line_start + line_text.len(), + ); + } + } else { + let end = text.len(); + process_line(text, 0, end); } self.font_runs_pool.lock().push(font_runs); @@ -518,39 +540,56 @@ impl WindowTextSystem { /// Layout the given line of text, at the given font_size. /// Subsets of the line can be styled independently with the `runs` parameter. - /// Generally, you should prefer to use `TextLayout::shape_line` instead, which + /// Generally, you should prefer to use [`Self::shape_line`] instead, which /// can be painted directly. - pub fn layout_line( + pub fn layout_line( &self, - text: Text, + text: &str, font_size: Pixels, runs: &[TextRun], force_width: Option, - ) -> Arc - where - Text: AsRef, - SharedString: From, - { + ) -> Arc { + let mut last_run = None::<&TextRun>; + let mut last_font: Option = None; let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + for run in runs.iter() { - let font_id = self.resolve_font(&run.font); - if let Some(last_run) = font_runs.last_mut() { - if last_run.font_id == font_id { - last_run.len += run.len; - continue; - } + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + if let Some(font_run) = font_runs.last_mut() + && Some(font_run.font_id) == last_font + && !decoration_changed + { + font_run.len += run.len; + } else { + let font_id = self.resolve_font(&run.font); + last_font = Some(font_id); + font_runs.push(FontRun { + len: run.len, + font_id, + }); } - font_runs.push(FontRun { - len: run.len, - font_id, - }); } - let layout = - self.line_layout_cache - .layout_line_internal(text, font_size, &font_runs, force_width); + let layout = self.line_layout_cache.layout_line( + &SharedString::new(text), + font_size, + &font_runs, + force_width, + ); - font_runs.clear(); self.font_runs_pool.lock().push(font_runs); layout @@ -599,9 +638,22 @@ impl DerefMut for LineWrapperHandle { /// The degree of blackness or stroke thickness of a font. This value ranges from 100.0 to 900.0, /// with 400.0 as normal. -#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize, Add, Sub, FromStr)] +#[serde(transparent)] pub struct FontWeight(pub f32); +impl Display for FontWeight { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for FontWeight { + fn from(weight: f32) -> Self { + FontWeight(weight) + } +} + impl Default for FontWeight { #[inline] fn default() -> FontWeight { @@ -651,6 +703,23 @@ impl FontWeight { ]; } +impl schemars::JsonSchema for FontWeight { + fn schema_name() -> std::borrow::Cow<'static, str> { + "FontWeight".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + use schemars::json_schema; + json_schema!({ + "type": "number", + "minimum": Self::THIN, + "maximum": Self::BLACK, + "default": Self::default(), + "description": "Font weight value between 100 (thin) and 900 (black)" + }) + } +} + /// Allows italic or oblique faces to be selected. #[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize, JsonSchema)] pub enum FontStyle { @@ -669,7 +738,7 @@ impl Display for FontStyle { } } -/// A styled run of text, for use in [`TextLayout`]. +/// A styled run of text, for use in [`crate::TextLayout`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TextRun { /// A number of utf8 bytes @@ -695,7 +764,7 @@ impl TextRun { } } -/// An identifier for a specific glyph, as returned by [`TextSystem::layout_line`]. +/// An identifier for a specific glyph, as returned by [`WindowTextSystem::layout_line`]. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] #[repr(C)] pub struct GlyphId(pub(crate) u32); diff --git a/crates/gpui/src/text_system/font_fallbacks.rs b/crates/gpui/src/text_system/font_fallbacks.rs index 2be17e0021e42a809547188861ee00641535b110..63dc89ba41ea9cf44bdffb7d81d3015d696f4413 100644 --- a/crates/gpui/src/text_system/font_fallbacks.rs +++ b/crates/gpui/src/text_system/font_fallbacks.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use schemars::JsonSchema; -use serde_derive::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; /// The fallback fonts that can be configured for a given font. /// Fallback fonts family names are stored here. diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 3813393d81deaff4ed9adb1d96e204b75953233f..189a3e85c6b4fed52eddb45d5fa151314830c0e9 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -292,10 +292,10 @@ fn paint_line( } if let Some(style_run) = style_run { - if let Some((_, underline_style)) = &mut current_underline { - if style_run.underline.as_ref() != Some(underline_style) { - finished_underline = current_underline.take(); - } + if let Some((_, underline_style)) = &mut current_underline + && style_run.underline.as_ref() != Some(underline_style) + { + finished_underline = current_underline.take(); } if let Some(run_underline) = style_run.underline.as_ref() { current_underline.get_or_insert(( @@ -310,10 +310,10 @@ fn paint_line( }, )); } - if let Some((_, strikethrough_style)) = &mut current_strikethrough { - if style_run.strikethrough.as_ref() != Some(strikethrough_style) { - finished_strikethrough = current_strikethrough.take(); - } + if let Some((_, strikethrough_style)) = &mut current_strikethrough + && style_run.strikethrough.as_ref() != Some(strikethrough_style) + { + finished_strikethrough = current_strikethrough.take(); } if let Some(run_strikethrough) = style_run.strikethrough.as_ref() { current_strikethrough.get_or_insert(( @@ -509,10 +509,10 @@ fn paint_line_background( } if let Some(style_run) = style_run { - if let Some((_, background_color)) = &mut current_background { - if style_run.background_color.as_ref() != Some(background_color) { - finished_background = current_background.take(); - } + if let Some((_, background_color)) = &mut current_background + && style_run.background_color.as_ref() != Some(background_color) + { + finished_background = current_background.take(); } if let Some(run_background) = style_run.background_color { current_background.get_or_insert(( @@ -585,7 +585,7 @@ fn aligned_origin_x( match align { TextAlign::Left => origin.x, - TextAlign::Center => (2.0 * origin.x + align_width - line_width) / 2.0, + TextAlign::Center => (origin.x * 2.0 + align_width - line_width) / 2.0, TextAlign::Right => origin.x + align_width - line_width, } } diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 9c2dd7f0871e5b67bd15d3a419c1c03496e2afaa..375a9bdc7bccdddb9d34409c5ced138b2d5aebd2 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -185,10 +185,10 @@ impl LineLayout { if width > wrap_width && boundary > last_boundary { // When used line_clamp, we should limit the number of lines. - if let Some(max_lines) = max_lines { - if boundaries.len() >= max_lines - 1 { - break; - } + if let Some(max_lines) = max_lines + && boundaries.len() >= max_lines - 1 + { + break; } if let Some(last_candidate_ix) = last_candidate_ix.take() { @@ -501,7 +501,7 @@ impl LineLayoutCache { } else { drop(current_frame); let text = SharedString::from(text); - let unwrapped_layout = self.layout_line::<&SharedString>(&text, font_size, runs); + let unwrapped_layout = self.layout_line::<&SharedString>(&text, font_size, runs, None); let wrap_boundaries = if let Some(wrap_width) = wrap_width { unwrapped_layout.compute_wrap_boundaries(text.as_ref(), wrap_width, max_lines) } else { @@ -535,19 +535,6 @@ impl LineLayoutCache { text: Text, font_size: Pixels, runs: &[FontRun], - ) -> Arc - where - Text: AsRef, - SharedString: From, - { - self.layout_line_internal(text, font_size, runs, None) - } - - pub fn layout_line_internal( - &self, - text: Text, - font_size: Pixels, - runs: &[FontRun], force_width: Option, ) -> Arc where @@ -634,15 +621,15 @@ struct CacheKeyRef<'a> { force_width: Option, } -impl PartialEq for (dyn AsCacheKeyRef + '_) { +impl PartialEq for dyn AsCacheKeyRef + '_ { fn eq(&self, other: &dyn AsCacheKeyRef) -> bool { self.as_cache_key_ref() == other.as_cache_key_ref() } } -impl Eq for (dyn AsCacheKeyRef + '_) {} +impl Eq for dyn AsCacheKeyRef + '_ {} -impl Hash for (dyn AsCacheKeyRef + '_) { +impl Hash for dyn AsCacheKeyRef + '_ { fn hash(&self, state: &mut H) { self.as_cache_key_ref().hash(state) } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 648d714c89765d09623f154ef55ddd44d9716028..0192a03a3238e8fad1f5f20e2b824755c0ecee4d 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,6 +1,6 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px}; use collections::HashMap; -use std::{iter, sync::Arc}; +use std::{borrow::Cow, iter, sync::Arc}; /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { @@ -44,7 +44,7 @@ impl LineWrapper { let mut prev_c = '\0'; let mut index = 0; let mut candidates = fragments - .into_iter() + .iter() .flat_map(move |fragment| fragment.wrap_boundary_candidates()) .peekable(); iter::from_fn(move || { @@ -129,13 +129,13 @@ impl LineWrapper { } /// Truncate a line of text to the given width with this wrapper's font and font size. - pub fn truncate_line( + pub fn truncate_line<'a>( &mut self, line: SharedString, truncate_width: Pixels, truncation_suffix: &str, - runs: &mut Vec, - ) -> SharedString { + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { let mut width = px(0.); let mut suffix_width = truncation_suffix .chars() @@ -154,15 +154,18 @@ impl LineWrapper { if width.floor() > truncate_width { let result = SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); - update_runs_after_truncation(&result, truncation_suffix, runs); + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_suffix, &mut runs); - return result; + return (result, Cow::Owned(runs)); } } - line + (line, Cow::Borrowed(runs)) } + /// Any character in this list should be treated as a word character, + /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. c.is_ascii_alphanumeric() || @@ -180,10 +183,9 @@ impl LineWrapper { // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode matches!(c, '\u{0400}'..='\u{04FF}') || // Some other known special characters that should be treated as word characters, - // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc. - matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',') || - // Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL. - matches!(c, '/' | ':' | '?' | '&' | '=') || + // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, + // `2^3`, `a~b`, `a=1`, `Self::new`, etc. + matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') || // `⋯` character is special used in Zed, to keep this at the end of the line. matches!(c, '⋯') } @@ -225,19 +227,15 @@ impl LineWrapper { fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) { let mut truncate_at = result.len() - ellipsis.len(); - let mut run_end = None; for (run_index, run) in runs.iter_mut().enumerate() { if run.len <= truncate_at { truncate_at -= run.len; } else { run.len = truncate_at + ellipsis.len(); - run_end = Some(run_index + 1); + runs.truncate(run_index + 1); break; } } - if let Some(run_end) = run_end { - runs.truncate(run_end); - } } /// A fragment of a line that can be wrapped. @@ -496,15 +494,14 @@ mod tests { fn perform_test( wrapper: &mut LineWrapper, text: &'static str, - result: &'static str, + expected: &'static str, ellipsis: &str, ) { let dummy_run_lens = vec![text.len()]; - let mut dummy_runs = generate_test_runs(&dummy_run_lens); - assert_eq!( - wrapper.truncate_line(text.into(), px(220.), ellipsis, &mut dummy_runs), - result - ); + let dummy_runs = generate_test_runs(&dummy_run_lens); + let (result, dummy_runs) = + wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs); + assert_eq!(result, expected); assert_eq!(dummy_runs.first().unwrap().len, result.len()); } @@ -535,16 +532,15 @@ mod tests { fn perform_test( wrapper: &mut LineWrapper, text: &'static str, - result: &str, + expected: &str, run_lens: &[usize], result_run_len: &[usize], line_width: Pixels, ) { - let mut dummy_runs = generate_test_runs(run_lens); - assert_eq!( - wrapper.truncate_line(text.into(), line_width, "…", &mut dummy_runs), - result - ); + let dummy_runs = generate_test_runs(run_lens); + let (result, dummy_runs) = + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs); + assert_eq!(result, expected); for (run, result_len) in dummy_runs.iter().zip(result_run_len) { assert_eq!(run.len, *result_len); } @@ -648,15 +644,19 @@ mod tests { assert_word("@mention"); assert_word("#hashtag"); assert_word("$variable"); + assert_word("a=1"); + assert_word("Self::is_word_char"); assert_word("more⋯"); // Space assert_not_word("foo bar"); // URL case - assert_word("https://github.com/zed-industries/zed/"); assert_word("github.com"); - assert_word("a=1&b=2"); + assert_not_word("zed-industries/zed"); + assert_not_word("zed-industries\\zed"); + assert_not_word("a=1&b=2"); + assert_not_word("foo?b=2"); // Latin-1 Supplement assert_word("ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏ"); diff --git a/crates/gpui/src/util.rs b/crates/gpui/src/util.rs index f357034fbf52eda780447ebd7c0ff32432eaac4a..92c86810c5e30c4c1bc614788b0f16f4966f3b4c 100644 --- a/crates/gpui/src/util.rs +++ b/crates/gpui/src/util.rs @@ -58,13 +58,7 @@ pub trait FluentBuilder { where Self: Sized, { - self.map(|this| { - if let Some(_) = option { - this - } else { - then(this) - } - }) + self.map(|this| if option.is_some() { this } else { then(this) }) } } @@ -89,8 +83,11 @@ impl FutureExt for T { } } +#[pin_project::pin_project] pub struct WithTimeout { + #[pin] future: T, + #[pin] timer: Task<()>, } @@ -103,15 +100,11 @@ impl Future for WithTimeout { type Output = Result; fn poll(self: Pin<&mut Self>, cx: &mut task::Context) -> task::Poll { - // SAFETY: the fields of Timeout are private and we never move the future ourselves - // And its already pinned since we are being polled (all futures need to be pinned to be polled) - let this = unsafe { self.get_unchecked_mut() }; - let future = unsafe { Pin::new_unchecked(&mut this.future) }; - let timer = unsafe { Pin::new_unchecked(&mut this.timer) }; + let this = self.project(); - if let task::Poll::Ready(output) = future.poll(cx) { + if let task::Poll::Ready(output) = this.future.poll(cx) { task::Poll::Ready(Ok(output)) - } else if timer.poll(cx).is_ready() { + } else if this.timer.poll(cx).is_ready() { task::Poll::Ready(Err(Timeout)) } else { task::Poll::Pending @@ -120,6 +113,8 @@ impl Future for WithTimeout { } #[cfg(any(test, feature = "test-support"))] +/// Uses smol executor to run a given future no longer than the timeout specified. +/// Note that this won't "rewind" on `cx.executor().advance_clock` call, truly waiting for the timeout to elapse. pub async fn smol_timeout(timeout: Duration, f: F) -> Result where F: Future, @@ -146,3 +141,35 @@ pub(crate) fn atomic_incr_if_not_zero(counter: &AtomicUsize) -> usize { } } } + +#[cfg(test)] +mod tests { + use crate::TestAppContext; + + use super::*; + + #[gpui::test] + async fn test_with_timeout(cx: &mut TestAppContext) { + Task::ready(()) + .with_timeout(Duration::from_secs(1), &cx.executor()) + .await + .expect("Timeout should be noop"); + + let long_duration = Duration::from_secs(6000); + let short_duration = Duration::from_secs(1); + cx.executor() + .timer(long_duration) + .with_timeout(short_duration, &cx.executor()) + .await + .expect_err("timeout should have triggered"); + + let fut = cx + .executor() + .timer(long_duration) + .with_timeout(short_duration, &cx.executor()); + cx.executor().advance_clock(short_duration * 2); + futures::FutureExt::now_or_never(fut) + .unwrap_or_else(|| panic!("timeout should have triggered")) + .expect_err("timeout"); + } +} diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index f461e2f7d01a1dc2cdc93cda4f5854c8e958feaf..217971792ee978307a19f7e40374cb337e38a625 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -205,22 +205,21 @@ impl Element for AnyView { let content_mask = window.content_mask(); let text_style = window.text_style(); - if let Some(mut element_state) = element_state { - if element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&self.entity_id()) - && !window.refreshing - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&self.entity_id()) + && !window.refreshing + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); } let refreshing = mem::replace(&mut window.refreshing, true); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c0ffd34a0d99f78f9388927ba7857ebb9661baa1..c44b0d642a2970dfb803109591d8dc0e2c6cacc6 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -11,12 +11,12 @@ use crate::{ MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, - SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size, - StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task, - TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, - transparent_black, + SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, + SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, + TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, + WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, + point, prelude::*, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -58,7 +58,14 @@ mod prompts; use crate::util::atomic_incr_if_not_zero; pub use prompts::*; -pub(crate) const DEFAULT_WINDOW_SIZE: Size = size(px(1024.), px(700.)); +pub(crate) const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(864.)); + +/// A 6:5 aspect ratio minimum window size to be used for functional, +/// additional-to-main-Zed windows, like the settings and rules library windows. +pub const DEFAULT_ADDITIONAL_WINDOW_SIZE: Size = Size { + width: Pixels(900.), + height: Pixels(750.), +}; /// Represents the two different phases when dispatching events. #[derive(Default, Copy, Clone, Debug, Eq, PartialEq)] @@ -243,14 +250,14 @@ impl FocusId { pub fn contains_focused(&self, window: &Window, cx: &App) -> bool { window .focused(cx) - .map_or(false, |focused| self.contains(focused.id, window)) + .is_some_and(|focused| self.contains(focused.id, window)) } /// Obtains whether the element associated with this handle is contained within the /// focused element or is itself focused. pub fn within_focused(&self, window: &Window, cx: &App) -> bool { let focused = window.focused(cx); - focused.map_or(false, |focused| focused.id.contains(*self, window)) + focused.is_some_and(|focused| focused.id.contains(*self, window)) } /// Obtains whether this handle contains the given handle in the most recently rendered frame. @@ -504,7 +511,7 @@ impl HitboxId { return true; } } - return false; + false } /// Checks if the hitbox with this ID contains the mouse and should handle scroll events. @@ -580,12 +587,12 @@ pub enum HitboxBehavior { /// For mouse handlers that check those hitboxes, this behaves the same as registering a /// bubble-phase handler for every mouse event type: /// - /// ``` + /// ```ignore /// window.on_mouse_event(move |_: &EveryMouseEventTypeHere, phase, window, cx| { /// if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { /// cx.stop_propagation(); /// } - /// } + /// }) /// ``` /// /// This has effects beyond event handling - any use of hitbox checking, such as hover @@ -604,12 +611,12 @@ pub enum HitboxBehavior { /// For mouse handlers that check those hitboxes, this behaves the same as registering a /// bubble-phase handler for every mouse event type **except** `ScrollWheelEvent`: /// - /// ``` - /// window.on_mouse_event(move |_: &EveryMouseEventTypeExceptScroll, phase, window, _cx| { + /// ```ignore + /// window.on_mouse_event(move |_: &EveryMouseEventTypeExceptScroll, phase, window, cx| { /// if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { /// cx.stop_propagation(); /// } - /// } + /// }) /// ``` /// /// See the documentation of [`Hitbox::is_hovered`] for details of why `ScrollWheelEvent` is @@ -634,7 +641,7 @@ impl TooltipId { window .tooltip_bounds .as_ref() - .map_or(false, |tooltip_bounds| { + .is_some_and(|tooltip_bounds| { tooltip_bounds.id == *self && tooltip_bounds.bounds.contains(&window.mouse_position()) }) @@ -684,7 +691,7 @@ pub(crate) struct Frame { pub(crate) next_inspector_instance_ids: FxHashMap, usize>, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) inspector_hitboxes: FxHashMap, - pub(crate) tab_handles: TabHandles, + pub(crate) tab_stops: TabStopMap, } #[derive(Clone, Default)] @@ -733,7 +740,7 @@ impl Frame { #[cfg(any(feature = "inspector", debug_assertions))] inspector_hitboxes: FxHashMap::default(), - tab_handles: TabHandles::default(), + tab_stops: TabStopMap::default(), } } @@ -749,7 +756,7 @@ impl Frame { self.hitboxes.clear(); self.window_control_hitboxes.clear(); self.deferred_draws.clear(); - self.tab_handles.clear(); + self.tab_stops.clear(); self.focus = None; #[cfg(any(feature = "inspector", debug_assertions))] @@ -815,6 +822,12 @@ impl Frame { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] +enum InputModality { + Mouse, + Keyboard, +} + /// Holds the state for a specific window. pub struct Window { pub(crate) handle: AnyWindowHandle, @@ -837,7 +850,7 @@ pub struct Window { pub(crate) text_style_stack: Vec, pub(crate) rendered_entity_stack: Vec, pub(crate) element_offset_stack: Vec>, - pub(crate) element_opacity: Option, + pub(crate) element_opacity: f32, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, pub(crate) image_cache_stack: Vec, @@ -863,6 +876,7 @@ pub struct Window { hovered: Rc>, pub(crate) needs_present: Rc>, pub(crate) last_input_timestamp: Rc>, + last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -939,11 +953,15 @@ impl Window { show, kind, is_movable, + is_resizable, + is_minimizable, display_id, window_background, app_id, window_min_size, window_decorations, + #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] + tabbing_identifier, } = options; let bounds = window_bounds @@ -956,12 +974,23 @@ impl Window { titlebar, kind, is_movable, + is_resizable, + is_minimizable, focus, show, display_id, window_min_size, + #[cfg(target_os = "macos")] + tabbing_identifier, }, )?; + + let tab_bar_visible = platform_window.tab_bar_visible(); + SystemWindowTabController::init_visible(cx, tab_bar_visible); + if let Some(tabs) = platform_window.tabbed_windows() { + SystemWindowTabController::add_tab(cx, handle.window_id(), tabs); + } + let display_id = platform_window.display().map(|display| display.id()); let sprite_atlas = platform_window.sprite_atlas(); let mouse_position = platform_window.mouse_position(); @@ -991,9 +1020,13 @@ impl Window { } platform_window.on_close(Box::new({ + let window_id = handle.window_id(); let mut cx = cx.to_async(); move || { let _ = handle.update(&mut cx, |_, window, _| window.remove_window()); + let _ = cx.update(|cx| { + SystemWindowTabController::remove_tab(cx, window_id); + }); } })); platform_window.on_request_frame(Box::new({ @@ -1082,7 +1115,11 @@ impl Window { .activation_observers .clone() .retain(&(), |callback| callback(window, cx)); + + window.bounds_changed(cx); window.refresh(); + + SystemWindowTabController::update_last_active(cx, window.handle.id); }) .log_err(); } @@ -1123,6 +1160,57 @@ impl Window { .unwrap_or(None) }) }); + platform_window.on_move_tab_to_new_window({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::move_tab_to_new_window(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_merge_all_windows({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::merge_all_windows(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_next_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_next_tab(cx, handle.window_id()); + }) + .log_err(); + }) + }); + platform_window.on_select_previous_tab({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, _window, cx| { + SystemWindowTabController::select_previous_tab(cx, handle.window_id()) + }) + .log_err(); + }) + }); + platform_window.on_toggle_tab_bar({ + let mut cx = cx.to_async(); + Box::new(move || { + handle + .update(&mut cx, |_, window, cx| { + let tab_bar_visible = window.platform_window.tab_bar_visible(); + SystemWindowTabController::set_visible(cx, tab_bar_visible); + }) + .log_err(); + }) + }); if let Some(app_id) = app_id { platform_window.set_app_id(&app_id); @@ -1148,7 +1236,7 @@ impl Window { rendered_entity_stack: Vec::new(), element_offset_stack: Vec::new(), content_mask_stack: Vec::new(), - element_opacity: None, + element_opacity: 1.0, requested_autoscroll: None, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), @@ -1172,6 +1260,7 @@ impl Window { hovered, needs_present, last_input_timestamp, + last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -1233,9 +1322,7 @@ impl Window { for view_id in self .rendered_frame .dispatch_tree - .view_path(view_id) - .into_iter() - .rev() + .view_path_reversed(view_id) { if !self.dirty_views.insert(view_id) { break; @@ -1341,7 +1428,7 @@ impl Window { return; } - if let Some(handle) = self.rendered_frame.tab_handles.next(self.focus.as_ref()) { + if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) { self.focus(&handle) } } @@ -1352,7 +1439,7 @@ impl Window { return; } - if let Some(handle) = self.rendered_frame.tab_handles.prev(self.focus.as_ref()) { + if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) { self.focus(&handle) } } @@ -1765,7 +1852,8 @@ impl Window { f: impl FnOnce(&GlobalElementId, &mut Self) -> R, ) -> R { self.element_id_stack.push(element_id); - let global_id = GlobalElementId(self.element_id_stack.clone()); + let global_id = GlobalElementId(Arc::from(&*self.element_id_stack)); + let result = f(&global_id, self); self.element_id_stack.pop(); result @@ -1825,6 +1913,12 @@ impl Window { self.modifiers } + /// Returns true if the last input event was keyboard-based (key press, tab navigation, etc.) + /// This is used for focus-visible styling to show focus indicators only for keyboard navigation. + pub fn last_input_was_keyboard(&self) -> bool { + self.last_input_modality == InputModality::Keyboard + } + /// The current state of the keyboard's capslock pub fn capslock(&self) -> Capslock { self.capslock @@ -1835,7 +1929,7 @@ impl Window { } /// Produces a new frame and assigns it to `rendered_frame`. To actually show - /// the contents of the new [Scene], use [present]. + /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { self.invalidate_entities(); @@ -2171,7 +2265,7 @@ impl Window { self.rendered_frame.accessed_element_states[range.start.accessed_element_states_index ..range.end.accessed_element_states_index] .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + .map(|(id, type_id)| (id.clone(), *type_id)), ); self.text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); @@ -2211,7 +2305,7 @@ impl Window { input_handlers_index: self.next_frame.input_handlers.len(), cursor_styles_index: self.next_frame.cursor_styles.len(), accessed_element_states_index: self.next_frame.accessed_element_states.len(), - tab_handle_index: self.next_frame.tab_handles.handles.len(), + tab_handle_index: self.next_frame.tab_stops.paint_index(), line_layout_index: self.text_system.layout_index(), } } @@ -2239,13 +2333,11 @@ impl Window { self.rendered_frame.accessed_element_states[range.start.accessed_element_states_index ..range.end.accessed_element_states_index] .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + .map(|(id, type_id)| (id.clone(), *type_id)), ); - self.next_frame.tab_handles.handles.extend( - self.rendered_frame.tab_handles.handles - [range.start.tab_handle_index..range.end.tab_handle_index] - .iter() - .cloned(), + self.next_frame.tab_stops.replay( + &self.rendered_frame.tab_stops.insertion_history + [range.start.tab_handle_index..range.end.tab_handle_index], ); self.text_system @@ -2363,21 +2455,23 @@ impl Window { opacity: Option, f: impl FnOnce(&mut Self) -> R, ) -> R { - if opacity.is_none() { + self.invalidator.debug_assert_paint_or_prepaint(); + + let Some(opacity) = opacity else { return f(self); - } + }; - self.invalidator.debug_assert_paint_or_prepaint(); - self.element_opacity = opacity; + let previous_opacity = self.element_opacity; + self.element_opacity = previous_opacity * opacity; let result = f(self); - self.element_opacity = None; + self.element_opacity = previous_opacity; result } /// Perform prepaint on child elements in a "retryable" manner, so that any side effects /// of prepaints can be discarded before prepainting again. This is used to support autoscroll /// where we need to prepaint children to detect the autoscroll bounds, then adjust the - /// element offset and prepaint again. See [`List`] for an example. This method should only be + /// element offset and prepaint again. See [`crate::List`] for an example. This method should only be /// called during the prepaint phase of element drawing. pub fn transact(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { self.invalidator.debug_assert_prepaint(); @@ -2402,9 +2496,9 @@ impl Window { result } - /// When you call this method during [`prepaint`], containing elements will attempt to + /// When you call this method during [`Element::prepaint`], containing elements will attempt to /// scroll to cause the specified bounds to become visible. When they decide to autoscroll, they will call - /// [`prepaint`] again with a new set of bounds. See [`List`] for an example of an element + /// [`Element::prepaint`] again with a new set of bounds. See [`crate::List`] for an example of an element /// that supports this method being called on the elements it contains. This method should only be /// called during the prepaint phase of element drawing. pub fn request_autoscroll(&mut self, bounds: Bounds) { @@ -2412,8 +2506,8 @@ impl Window { self.requested_autoscroll = Some(bounds); } - /// This method can be called from a containing element such as [`List`] to support the autoscroll behavior - /// described in [`request_autoscroll`]. + /// This method can be called from a containing element such as [`crate::List`] to support the autoscroll behavior + /// described in [`Self::request_autoscroll`]. pub fn take_autoscroll(&mut self) -> Option> { self.invalidator.debug_assert_prepaint(); self.requested_autoscroll.take() @@ -2453,7 +2547,7 @@ impl Window { /// time. pub fn get_asset(&mut self, source: &A::Source, cx: &mut App) -> Option { let (task, _) = cx.fetch_asset::(source); - task.clone().now_or_never() + task.now_or_never() } /// Obtain the current element offset. This method should only be called during the /// prepaint phase of element drawing. @@ -2467,9 +2561,10 @@ impl Window { /// Obtain the current element opacity. This method should only be called during the /// prepaint phase of element drawing. + #[inline] pub(crate) fn element_opacity(&self) -> f32 { self.invalidator.debug_assert_paint_or_prepaint(); - self.element_opacity.unwrap_or(1.0) + self.element_opacity } /// Obtain the current content mask. This method should only be called during element drawing. @@ -2504,7 +2599,7 @@ impl Window { &mut self, key: impl Into, cx: &mut App, - init: impl FnOnce(&mut Self, &mut App) -> S, + init: impl FnOnce(&mut Self, &mut Context) -> S, ) -> Entity { let current_view = self.current_view(); self.with_global_id(key.into(), |global_id, window| { @@ -2537,7 +2632,7 @@ impl Window { pub fn use_state( &mut self, cx: &mut App, - init: impl FnOnce(&mut Self, &mut App) -> S, + init: impl FnOnce(&mut Self, &mut Context) -> S, ) -> Entity { self.use_keyed_state( ElementId::CodeLocation(*core::panic::Location::caller()), @@ -2560,10 +2655,8 @@ impl Window { { self.invalidator.debug_assert_paint_or_prepaint(); - let key = (GlobalElementId(global_id.0.clone()), TypeId::of::()); - self.next_frame - .accessed_element_states - .push((GlobalElementId(key.0.clone()), TypeId::of::())); + let key = (global_id.clone(), TypeId::of::()); + self.next_frame.accessed_element_states.push(key.clone()); if let Some(any) = self .next_frame @@ -2660,6 +2753,19 @@ impl Window { } } + /// Executes the given closure within the context of a tab group. + #[inline] + pub fn with_tab_group(&mut self, index: Option, f: impl FnOnce(&mut Self) -> R) -> R { + if let Some(index) = index { + self.next_frame.tab_stops.begin_group(index); + let result = f(self); + self.next_frame.tab_stops.end_group(); + result + } else { + f(self) + } + } + /// Defers the drawing of the given element, scheduling it to be painted on top of the currently-drawn tree /// at a later time. The `priority` parameter determines the drawing order relative to other deferred elements, /// with higher values being drawn on top. @@ -2741,7 +2847,7 @@ impl Window { /// Paint one or more quads into the scene for the next frame at the current stacking context. /// Quads are colored rectangular regions with an optional background, border, and corner radius. - /// see [`fill`](crate::fill), [`outline`](crate::outline), and [`quad`](crate::quad) to construct this type. + /// see [`fill`], [`outline`], and [`quad`] to construct this type. /// /// This method should only be called as part of the paint phase of element drawing. /// @@ -2870,9 +2976,10 @@ impl Window { let element_opacity = self.element_opacity(); let scale_factor = self.scale_factor(); let glyph_origin = origin.scale(scale_factor); + let subpixel_variant = Point { - x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, - y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, + x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS_X as f32).floor() as u8, + y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS_Y as f32).floor() as u8, }; let params = RenderGlyphParams { font_id, @@ -2985,6 +3092,7 @@ impl Window { let element_opacity = self.element_opacity(); let scale_factor = self.scale_factor(); + let bounds = bounds.scale(scale_factor); let params = RenderSvgParams { path, @@ -2996,21 +3104,32 @@ impl Window { let Some(tile) = self.sprite_atlas .get_or_insert_with(¶ms.clone().into(), &mut || { - let Some(bytes) = cx.svg_renderer.render(¶ms)? else { + let Some((size, bytes)) = cx.svg_renderer.render_alpha_mask(¶ms)? else { return Ok(None); }; - Ok(Some((params.size, Cow::Owned(bytes)))) + Ok(Some((size, Cow::Owned(bytes)))) })? else { return Ok(()); }; let content_mask = self.content_mask().scale(scale_factor); + let svg_bounds = Bounds { + origin: bounds.center() + - Point::new( + ScaledPixels(tile.bounds.size.width.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.), + ScaledPixels(tile.bounds.size.height.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.), + ), + size: tile + .bounds + .size + .map(|value| ScaledPixels(value.0 as f32 / SMOOTH_SVG_SCALE_FACTOR)), + }; self.next_frame.scene.insert_primitive(MonochromeSprite { order: 0, pad: 0, - bounds: bounds - .map_origin(|origin| origin.floor()) + bounds: svg_bounds + .map_origin(|origin| origin.round()) .map_size(|size| size.ceil()), content_mask, color: color.opacity(element_opacity), @@ -3044,7 +3163,7 @@ impl Window { let tile = self .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { + .get_or_insert_with(¶ms.into(), &mut || { Ok(Some(( data.size(frame_index), Cow::Borrowed( @@ -3124,11 +3243,14 @@ impl Window { cx.layout_id_buffer.clear(); cx.layout_id_buffer.extend(children); let rem_size = self.rem_size(); + let scale_factor = self.scale_factor(); - self.layout_engine - .as_mut() - .unwrap() - .request_layout(style, rem_size, &cx.layout_id_buffer) + self.layout_engine.as_mut().unwrap().request_layout( + style, + rem_size, + scale_factor, + &cx.layout_id_buffer, + ) } /// Add a node to the layout tree for the current frame. Instead of taking a `Style` and children, @@ -3139,21 +3261,19 @@ impl Window { /// returns a `Size`. /// /// This method should only be called as part of the request_layout or prepaint phase of element drawing. - pub fn request_measured_layout< - F: FnMut(Size>, Size, &mut Window, &mut App) -> Size + pub fn request_measured_layout(&mut self, style: Style, measure: F) -> LayoutId + where + F: Fn(Size>, Size, &mut Window, &mut App) -> Size + 'static, - >( - &mut self, - style: Style, - measure: F, - ) -> LayoutId { + { self.invalidator.debug_assert_prepaint(); let rem_size = self.rem_size(); + let scale_factor = self.scale_factor(); self.layout_engine .as_mut() .unwrap() - .request_measured_layout(style, rem_size, measure) + .request_measured_layout(style, rem_size, scale_factor, measure) } /// Compute the layout for the given id within the given available space. @@ -3181,11 +3301,12 @@ impl Window { pub fn layout_bounds(&mut self, layout_id: LayoutId) -> Bounds { self.invalidator.debug_assert_prepaint(); + let scale_factor = self.scale_factor(); let mut bounds = self .layout_engine .as_mut() .unwrap() - .layout_bounds(layout_id) + .layout_bounds(layout_id, scale_factor) .map(Into::into); bounds.origin += self.element_offset(); bounds @@ -3401,16 +3522,16 @@ impl Window { let focus_id = handle.id; let (subscription, activate) = self.new_focus_listener(Box::new(move |event, window, cx| { - if let Some(blurred_id) = event.previous_focus_path.last().copied() { - if event.is_focus_out(focus_id) { - let event = FocusOutEvent { - blurred: WeakFocusHandle { - id: blurred_id, - handles: Arc::downgrade(&cx.focus_handles), - }, - }; - listener(event, window, cx) - } + if let Some(blurred_id) = event.previous_focus_path.last().copied() + && event.is_focus_out(focus_id) + { + let event = FocusOutEvent { + blurred: WeakFocusHandle { + id: blurred_id, + handles: Arc::downgrade(&cx.focus_handles), + }, + }; + listener(event, window, cx) } true })); @@ -3444,12 +3565,12 @@ impl Window { return true; } - if let Some(input) = keystroke.key_char { - if let Some(mut input_handler) = self.platform_window.take_input_handler() { - input_handler.dispatch_input(&input, self, cx); - self.platform_window.set_input_handler(input_handler); - return true; - } + if let Some(input) = keystroke.key_char + && let Some(mut input_handler) = self.platform_window.take_input_handler() + { + input_handler.dispatch_input(&input, self, cx); + self.platform_window.set_input_handler(input_handler); + return true; } false @@ -3474,6 +3595,16 @@ impl Window { #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { self.last_input_timestamp.set(Instant::now()); + + // Track whether this input was keyboard-based for focus-visible styling + self.last_input_modality = match &event { + PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => { + InputModality::Keyboard + } + PlatformInput::MouseDown(e) if e.is_focusing() => InputModality::Mouse, + _ => self.last_input_modality, + }; + // Handlers may set this to false by calling `stop_propagation`. cx.propagate_event = true; // Handlers may set this to true by calling `prevent_default`. @@ -3731,7 +3862,7 @@ impl Window { self.dispatch_keystroke_observers( event, Some(binding.action), - match_result.context_stack.clone(), + match_result.context_stack, cx, ); self.pending_input_changed(cx); @@ -3864,11 +3995,11 @@ impl Window { if !cx.propagate_event { continue 'replay; } - if let Some(input) = replay.keystroke.key_char.as_ref().cloned() { - if let Some(mut input_handler) = self.platform_window.take_input_handler() { - input_handler.dispatch_input(&input, self, cx); - self.platform_window.set_input_handler(input_handler) - } + if let Some(input) = replay.keystroke.key_char.as_ref().cloned() + && let Some(mut input_handler) = self.platform_window.take_input_handler() + { + input_handler.dispatch_input(&input, self, cx); + self.platform_window.set_input_handler(input_handler) } } } @@ -4022,9 +4153,7 @@ impl Window { self.on_next_frame(|window, cx| { if let Some(mut input_handler) = window.platform_window.take_input_handler() { if let Some(bounds) = input_handler.selected_bounds(window, cx) { - window - .platform_window - .update_ime_position(bounds.scale(window.scale_factor())); + window.platform_window.update_ime_position(bounds); } window.platform_window.set_input_handler(input_handler); } @@ -4209,14 +4338,14 @@ impl Window { } /// Returns a generic handler that invokes the given handler with the view and context associated with the given view handle. - pub fn handler_for) + 'static>( + pub fn handler_for) + 'static>( &self, - view: &Entity, + entity: &Entity, f: Callback, - ) -> impl Fn(&mut Window, &mut App) + use { - let view = view.downgrade(); + ) -> impl Fn(&mut Window, &mut App) + 'static { + let entity = entity.downgrade(); move |window: &mut Window, cx: &mut App| { - view.update(cx, |view, cx| f(view, window, cx)).ok(); + entity.update(cx, |entity, cx| f(entity, window, cx)).ok(); } } @@ -4275,11 +4404,54 @@ impl Window { } /// Perform titlebar double-click action. - /// This is MacOS specific. + /// This is macOS specific. pub fn titlebar_double_click(&self) { self.platform_window.titlebar_double_click(); } + /// Gets the window's title at the platform level. + /// This is macOS specific. + pub fn window_title(&self) -> String { + self.platform_window.get_title() + } + + /// Returns a list of all tabbed windows and their titles. + /// This is macOS specific. + pub fn tabbed_windows(&self) -> Option> { + self.platform_window.tabbed_windows() + } + + /// Returns the tab bar visibility. + /// This is macOS specific. + pub fn tab_bar_visible(&self) -> bool { + self.platform_window.tab_bar_visible() + } + + /// Merges all open windows into a single tabbed window. + /// This is macOS specific. + pub fn merge_all_windows(&self) { + self.platform_window.merge_all_windows() + } + + /// Moves the tab to a new containing window. + /// This is macOS specific. + pub fn move_tab_to_new_window(&self) { + self.platform_window.move_tab_to_new_window() + } + + /// Shows or hides the window tab overview. + /// This is macOS specific. + pub fn toggle_window_tab_overview(&self) { + self.platform_window.toggle_window_tab_overview() + } + + /// Sets the tabbing identifier for the window. + /// This is macOS specific. + pub fn set_tabbing_identifier(&self, tabbing_identifier: Option) { + self.platform_window + .set_tabbing_identifier(tabbing_identifier) + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { @@ -4309,15 +4481,15 @@ impl Window { cx: &mut App, f: impl FnOnce(&mut Option, &mut Self) -> R, ) -> R { - if let Some(inspector_id) = _inspector_id { - if let Some(inspector) = &self.inspector { - let inspector = inspector.clone(); - let active_element_id = inspector.read(cx).active_element_id(); - if Some(inspector_id) == active_element_id { - return inspector.update(cx, |inspector, _cx| { - inspector.with_active_element_state(self, f) - }); - } + if let Some(inspector_id) = _inspector_id + && let Some(inspector) = &self.inspector + { + let inspector = inspector.clone(); + let active_element_id = inspector.read(cx).active_element_id(); + if Some(inspector_id) == active_element_id { + return inspector.update(cx, |inspector, _cx| { + inspector.with_active_element_state(self, f) + }); } } f(&mut None, self) @@ -4389,15 +4561,13 @@ impl Window { if let Some(inspector) = self.inspector.as_ref() { let inspector = inspector.read(cx); if let Some((hitbox_id, _)) = self.hovered_inspector_hitbox(inspector, &self.next_frame) - { - if let Some(hitbox) = self + && let Some(hitbox) = self .next_frame .hitboxes .iter() .find(|hitbox| hitbox.id == hitbox_id) - { - self.paint_quad(crate::fill(hitbox.bounds, crate::rgba(0x61afef4d))); - } + { + self.paint_quad(crate::fill(hitbox.bounds, crate::rgba(0x61afef4d))); } } } @@ -4434,7 +4604,7 @@ impl Window { if let Some(inspector) = self.inspector.clone() { inspector.update(cx, |inspector, _cx| { if let Some(depth) = inspector.pick_depth.as_mut() { - *depth += delta_y.0 / SCROLL_PIXELS_PER_LAYER; + *depth += f32::from(delta_y) / SCROLL_PIXELS_PER_LAYER; let max_depth = self.mouse_hit_test.ids.len() as f32 - 0.5; if *depth < 0.0 { *depth = 0.0; @@ -4444,7 +4614,7 @@ impl Window { if let Some((_, inspector_id)) = self.hovered_inspector_hitbox(inspector, &self.rendered_frame) { - inspector.set_active_element_id(inspector_id.clone(), self); + inspector.set_active_element_id(inspector_id, self); } } }); @@ -4468,7 +4638,14 @@ impl Window { } } } - return None; + None + } + + /// For testing: set the current modifier keys state. + /// This does not generate any events. + #[cfg(any(test, feature = "test-support"))] + pub fn set_modifiers(&mut self, modifiers: Modifiers) { + self.modifiers = modifiers; } } @@ -4498,7 +4675,15 @@ pub struct WindowHandle { #[deref] #[deref_mut] pub(crate) any_handle: AnyWindowHandle, - state_type: PhantomData, + state_type: PhantomData V>, +} + +impl Debug for WindowHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowHandle") + .field("any_handle", &self.any_handle.id.as_u64()) + .finish() + } } impl WindowHandle { @@ -4558,7 +4743,7 @@ impl WindowHandle { .get(self.id) .and_then(|window| { window - .as_ref() + .as_deref() .and_then(|window| window.root.clone()) .map(|root_view| root_view.downcast::()) }) @@ -4585,7 +4770,7 @@ impl WindowHandle { where C: AppContext, { - cx.read_window(self, |root_view, _cx| root_view.clone()) + cx.read_window(self, |root_view, _cx| root_view) } /// Check if this window is 'active'. @@ -4626,9 +4811,6 @@ impl From> for AnyWindowHandle { } } -unsafe impl Send for WindowHandle {} -unsafe impl Sync for WindowHandle {} - /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. #[derive(Copy, Clone, PartialEq, Eq, Hash)] pub struct AnyWindowHandle { @@ -4699,7 +4881,7 @@ impl HasDisplayHandle for Window { } } -/// An identifier for an [`Element`](crate::Element). +/// An identifier for an [`Element`]. /// /// Can be constructed with a string, a number, or both, as well /// as other internal representations. @@ -4722,7 +4904,7 @@ pub enum ElementId { /// A code location. CodeLocation(core::panic::Location<'static>), /// A labeled child of an element. - NamedChild(Box, SharedString), + NamedChild(Arc, SharedString), } impl ElementId { @@ -4836,7 +5018,13 @@ impl From<(&'static str, u32)> for ElementId { impl> From<(ElementId, T)> for ElementId { fn from((id, name): (ElementId, T)) -> Self { - ElementId::NamedChild(Box::new(id), name.into()) + ElementId::NamedChild(Arc::new(id), name.into()) + } +} + +impl From<&'static core::panic::Location<'static>> for ElementId { + fn from(location: &'static core::panic::Location<'static>) -> Self { + ElementId::CodeLocation(*location) } } diff --git a/crates/gpui/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs index 778ee1dab0eb8312161dcbca0ddf8964afe0c6bb..63ad1668bec298a6b59d218bf7d4ca7cdce11e8c 100644 --- a/crates/gpui/src/window/prompts.rs +++ b/crates/gpui/src/window/prompts.rs @@ -142,6 +142,7 @@ impl Render for FallbackPromptRenderer { .id(ix) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(PromptResponse(ix)); + cx.stop_propagation(); })) })); diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs index 7bff3a97b1c3d22e6c0e9841a26f2adf5d7f3a70..66ef6fba2c9b980e27150bb3bf8d9d07f35ab030 100644 --- a/crates/gpui/tests/action_macros.rs +++ b/crates/gpui/tests/action_macros.rs @@ -1,7 +1,7 @@ use gpui::{Action, actions}; use gpui_macros::register_action; use schemars::JsonSchema; -use serde_derive::Deserialize; +use serde::Deserialize; #[test] fn test_action_macros() { @@ -19,7 +19,7 @@ fn test_action_macros() { #[serde(deny_unknown_fields)] struct AnotherAction; - #[derive(PartialEq, Clone, gpui::private::serde_derive::Deserialize)] + #[derive(PartialEq, Clone, gpui::private::serde::Deserialize)] #[serde(deny_unknown_fields)] struct RegisterableAction {} diff --git a/crates/gpui_macros/Cargo.toml b/crates/gpui_macros/Cargo.toml index 6dad698177af0bed634fc70f296bf52285f851a7..2ee8da52fb7a013cefdd5fe79520a5d18f1e5b3f 100644 --- a/crates/gpui_macros/Cargo.toml +++ b/crates/gpui_macros/Cargo.toml @@ -2,8 +2,9 @@ name = "gpui_macros" version = "0.1.0" edition.workspace = true -publish.workspace = true +publish = false license = "Apache-2.0" +description = "Macros used by gpui" [lints] workspace = true @@ -21,7 +22,6 @@ heck.workspace = true proc-macro2.workspace = true quote.workspace = true syn.workspace = true -workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["inspector"] } diff --git a/crates/gpui_macros/src/derive_action.rs b/crates/gpui_macros/src/derive_action.rs index 9c7f97371d86eecc29dc16902ba9e392d53b8660..4e6c6277e452189657b4725b4027780a54cfed1d 100644 --- a/crates/gpui_macros/src/derive_action.rs +++ b/crates/gpui_macros/src/derive_action.rs @@ -16,6 +16,13 @@ pub(crate) fn derive_action(input: TokenStream) -> TokenStream { let mut deprecated = None; let mut doc_str: Option = None; + /* + * + * #[action()] + * Struct Foo { + * bar: bool // is bar considered an attribute + } + */ for attr in &input.attrs { if attr.path().is_ident("action") { attr.parse_nested_meta(|meta| { diff --git a/crates/gpui_macros/src/derive_inspector_reflection.rs b/crates/gpui_macros/src/derive_inspector_reflection.rs index fa22f95f9a1c274d193a6985a84bf3cdecfcc17f..9c1cb503a87e5f726ba27d1868a6c053b36c6731 100644 --- a/crates/gpui_macros/src/derive_inspector_reflection.rs +++ b/crates/gpui_macros/src/derive_inspector_reflection.rs @@ -160,16 +160,14 @@ fn extract_doc_comment(attrs: &[Attribute]) -> Option { let mut doc_lines = Vec::new(); for attr in attrs { - if attr.path().is_ident("doc") { - if let Meta::NameValue(meta) = &attr.meta { - if let Expr::Lit(expr_lit) = &meta.value { - if let Lit::Str(lit_str) = &expr_lit.lit { - let line = lit_str.value(); - let line = line.strip_prefix(' ').unwrap_or(&line); - doc_lines.push(line.to_string()); - } - } - } + if attr.path().is_ident("doc") + && let Meta::NameValue(meta) = &attr.meta + && let Expr::Lit(expr_lit) = &meta.value + && let Lit::Str(lit_str) = &expr_lit.lit + { + let line = lit_str.value(); + let line = line.strip_prefix(' ').unwrap_or(&line); + doc_lines.push(line.to_string()); } } @@ -191,7 +189,7 @@ fn extract_cfg_attributes(attrs: &[Attribute]) -> Vec { fn is_called_from_gpui_crate(_span: Span) -> bool { // Check if we're being called from within the gpui crate by examining the call site // This is a heuristic approach - we check if the current crate name is "gpui" - std::env::var("CARGO_PKG_NAME").map_or(false, |name| name == "gpui") + std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "gpui") } struct MacroExpander; diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 3a58af67052d06f108b4b9c87d52fc358405466e..0f1365be77ec221d9061f588f84ff6acab3c32ab 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -172,7 +172,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// - `#[gpui::test(iterations = 5)]` runs five times, providing as seed the values in the range `0..5`. /// - `#[gpui::test(retries = 3)]` runs up to four times if it fails to try and make it pass. /// - `#[gpui::test(on_failure = "crate::test::report_failure")]` will call the specified function after the -/// tests fail so that you can write out more detail about the failure. +/// tests fail so that you can write out more detail about the failure. /// /// You can combine `iterations = ...` with `seeds(...)`: /// - `#[gpui::test(iterations = 5, seed = 10)]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10))]`. diff --git a/crates/gpui_macros/src/test.rs b/crates/gpui_macros/src/test.rs index adb27f42ea2c7689d19290d17b78299ff149fdd2..42ce304b97a2708bac8dc081b22a561162bdbb1a 100644 --- a/crates/gpui_macros/src/test.rs +++ b/crates/gpui_macros/src/test.rs @@ -73,7 +73,7 @@ impl Parse for Args { (Meta::NameValue(meta), "seed") => { seeds = vec![parse_usize_from_expr(&meta.value)? as u64] } - (Meta::List(list), "seeds") => seeds = parse_u64_array(&list)?, + (Meta::List(list), "seeds") => seeds = parse_u64_array(list)?, (Meta::Path(_), _) => { return Err(syn::Error::new(meta.span(), "invalid path argument")); } @@ -86,7 +86,7 @@ impl Parse for Args { Ok(Args { seeds, max_retries, - max_iterations: max_iterations, + max_iterations, on_failure_fn_name, }) } @@ -100,7 +100,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { }; let inner_fn_attributes = mem::take(&mut inner_fn.attrs); - let inner_fn_name = format_ident!("_{}", inner_fn.sig.ident); + let inner_fn_name = format_ident!("__{}", inner_fn.sig.ident); let outer_fn_name = mem::replace(&mut inner_fn.sig.ident, inner_fn_name.clone()); let result = generate_test_function( @@ -152,28 +152,28 @@ fn generate_test_function( } _ => {} } - } else if let Type::Reference(ty) = &*arg.ty { - if let Type::Path(ty) = &*ty.elem { - let last_segment = ty.path.segments.last(); - if let Some("TestAppContext") = - last_segment.map(|s| s.ident.to_string()).as_deref() - { - let cx_varname = format_ident!("cx_{}", ix); - cx_vars.extend(quote!( - let mut #cx_varname = gpui::TestAppContext::build( - dispatcher.clone(), - Some(stringify!(#outer_fn_name)), - ); - )); - cx_teardowns.extend(quote!( - dispatcher.run_until_parked(); - #cx_varname.executor().forbid_parking(); - #cx_varname.quit(); - dispatcher.run_until_parked(); - )); - inner_fn_args.extend(quote!(&mut #cx_varname,)); - continue; - } + } else if let Type::Reference(ty) = &*arg.ty + && let Type::Path(ty) = &*ty.elem + { + let last_segment = ty.path.segments.last(); + if let Some("TestAppContext") = + last_segment.map(|s| s.ident.to_string()).as_deref() + { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)), + ); + )); + cx_teardowns.extend(quote!( + dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); + #cx_varname.quit(); + dispatcher.run_until_parked(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; } } } @@ -215,48 +215,48 @@ fn generate_test_function( inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(_seed),)); continue; } - } else if let Type::Reference(ty) = &*arg.ty { - if let Type::Path(ty) = &*ty.elem { - let last_segment = ty.path.segments.last(); - match last_segment.map(|s| s.ident.to_string()).as_deref() { - Some("App") => { - let cx_varname = format_ident!("cx_{}", ix); - let cx_varname_lock = format_ident!("cx_{}_lock", ix); - cx_vars.extend(quote!( - let mut #cx_varname = gpui::TestAppContext::build( - dispatcher.clone(), - Some(stringify!(#outer_fn_name)) - ); - let mut #cx_varname_lock = #cx_varname.app.borrow_mut(); - )); - inner_fn_args.extend(quote!(&mut #cx_varname_lock,)); - cx_teardowns.extend(quote!( + } else if let Type::Reference(ty) = &*arg.ty + && let Type::Path(ty) = &*ty.elem + { + let last_segment = ty.path.segments.last(); + match last_segment.map(|s| s.ident.to_string()).as_deref() { + Some("App") => { + let cx_varname = format_ident!("cx_{}", ix); + let cx_varname_lock = format_ident!("cx_{}_lock", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) + ); + let mut #cx_varname_lock = #cx_varname.app.borrow_mut(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname_lock,)); + cx_teardowns.extend(quote!( drop(#cx_varname_lock); dispatcher.run_until_parked(); #cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); }); dispatcher.run_until_parked(); )); - continue; - } - Some("TestAppContext") => { - let cx_varname = format_ident!("cx_{}", ix); - cx_vars.extend(quote!( - let mut #cx_varname = gpui::TestAppContext::build( - dispatcher.clone(), - Some(stringify!(#outer_fn_name)) - ); - )); - cx_teardowns.extend(quote!( - dispatcher.run_until_parked(); - #cx_varname.executor().forbid_parking(); - #cx_varname.quit(); - dispatcher.run_until_parked(); - )); - inner_fn_args.extend(quote!(&mut #cx_varname,)); - continue; - } - _ => {} + continue; + } + Some("TestAppContext") => { + let cx_varname = format_ident!("cx_{}", ix); + cx_vars.extend(quote!( + let mut #cx_varname = gpui::TestAppContext::build( + dispatcher.clone(), + Some(stringify!(#outer_fn_name)) + ); + )); + cx_teardowns.extend(quote!( + dispatcher.run_until_parked(); + #cx_varname.executor().forbid_parking(); + #cx_varname.quit(); + dispatcher.run_until_parked(); + )); + inner_fn_args.extend(quote!(&mut #cx_varname,)); + continue; } + _ => {} } } } diff --git a/crates/gpui_macros/tests/derive_inspector_reflection.rs b/crates/gpui_macros/tests/derive_inspector_reflection.rs index 522c0a62c469cd181c44c465547a8c19c4d04f69..a0adcb7801e55d7272191a1e4e831b2c9c6b115c 100644 --- a/crates/gpui_macros/tests/derive_inspector_reflection.rs +++ b/crates/gpui_macros/tests/derive_inspector_reflection.rs @@ -34,13 +34,6 @@ trait Transform: Clone { /// Adds one to the value fn add_one(self) -> Self; - - /// cfg attributes are respected - #[cfg(all())] - fn cfg_included(self) -> Self; - - #[cfg(any())] - fn cfg_omitted(self) -> Self; } #[derive(Debug, Clone, PartialEq)] @@ -70,10 +63,6 @@ impl Transform for Number { fn add_one(self) -> Self { Number(self.0 + 1) } - - fn cfg_included(self) -> Self { - Number(self.0) - } } #[test] @@ -83,14 +72,13 @@ fn test_derive_inspector_reflection() { // Get all methods that match the pattern fn(self) -> Self or fn(mut self) -> Self let methods = methods::(); - assert_eq!(methods.len(), 6); + assert_eq!(methods.len(), 5); let method_names: Vec<_> = methods.iter().map(|m| m.name).collect(); assert!(method_names.contains(&"double")); assert!(method_names.contains(&"triple")); assert!(method_names.contains(&"increment")); assert!(method_names.contains(&"quadruple")); assert!(method_names.contains(&"add_one")); - assert!(method_names.contains(&"cfg_included")); // Invoke methods by name let num = Number(5); @@ -106,9 +94,7 @@ fn test_derive_inspector_reflection() { .invoke(num.clone()); assert_eq!(incremented, Number(6)); - let quadrupled = find_method::("quadruple") - .unwrap() - .invoke(num.clone()); + let quadrupled = find_method::("quadruple").unwrap().invoke(num); assert_eq!(quadrupled, Number(20)); // Try to invoke a non-existent method diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml index 46d5eafd5adceadadf5fbd942d104ee4249aa941..e9d72b8ec25c1464e622ec1e531298cbd2df8c37 100644 --- a/crates/gpui_tokio/Cargo.toml +++ b/crates/gpui_tokio/Cargo.toml @@ -13,7 +13,7 @@ path = "src/gpui_tokio.rs" doctest = false [dependencies] +anyhow.workspace = true util.workspace = true gpui.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } -workspace-hack.workspace = true diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index fffe18a616d9b597c9f5ed25b68df2911c8f3886..61dcfc48efb1dfecc04c4a131ddc32691e01e255 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -1,9 +1,10 @@ use std::future::Future; use gpui::{App, AppContext, Global, ReadGlobal, Task}; -use tokio::task::JoinError; use util::defer; +pub use tokio::task::JoinError; + pub fn init(cx: &mut App) { cx.set_global(GlobalTokio::new()); } @@ -52,6 +53,28 @@ impl Tokio { }) } + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn_result(cx: &C, f: Fut) -> C::Result>> + where + C: AppContext, + Fut: Future> + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.runtime.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await?; + drop(cancel); + result + }) + }) + } + pub fn handle(cx: &App) -> tokio::runtime::Handle { GlobalTokio::global(cx).runtime.handle().clone() } diff --git a/crates/html_to_markdown/Cargo.toml b/crates/html_to_markdown/Cargo.toml index 16f10d0cbc1343c0ce7bc439fa860b8002e3d94a..70ff3b3555ee3a2e03debe6aaa24f68ddbc4196a 100644 --- a/crates/html_to_markdown/Cargo.toml +++ b/crates/html_to_markdown/Cargo.toml @@ -20,7 +20,6 @@ anyhow.workspace = true html5ever.workspace = true markup5ever_rcdom.workspace = true regex.workspace = true -workspace-hack.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/html_to_markdown/src/markdown.rs b/crates/html_to_markdown/src/markdown.rs index b9ffbac79c6b6af64222e6447392aa3a75440dda..bb3b3563bcdff8692c80b1b79e7c94d4184bf1cb 100644 --- a/crates/html_to_markdown/src/markdown.rs +++ b/crates/html_to_markdown/src/markdown.rs @@ -34,15 +34,14 @@ impl HandleTag for ParagraphHandler { tag: &HtmlElement, writer: &mut MarkdownWriter, ) -> StartTagOutcome { - if tag.is_inline() && writer.is_inside("p") { - if let Some(parent) = writer.current_element_stack().iter().last() { - if !(parent.is_inline() - || writer.markdown.ends_with(' ') - || writer.markdown.ends_with('\n')) - { - writer.push_str(" "); - } - } + if tag.is_inline() + && writer.is_inside("p") + && let Some(parent) = writer.current_element_stack().iter().last() + && !(parent.is_inline() + || writer.markdown.ends_with(' ') + || writer.markdown.ends_with('\n')) + { + writer.push_str(" "); } if tag.tag() == "p" { diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index f63bff295e22c36512dbc6285e68d4686714f411..f4ce028b1c650ba3c85081d7737c99e9d1434e44 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -2,8 +2,9 @@ name = "http_client" version = "0.1.0" edition.workspace = true -publish.workspace = true +publish = false license = "Apache-2.0" +description = "A HTTP client library for Zed and GPUI" [lints] workspace = true @@ -16,16 +17,21 @@ path = "src/http_client.rs" doctest = true [dependencies] -bytes.workspace = true anyhow.workspace = true +async-compression.workspace = true +async-fs.workspace = true +async-tar.workspace = true +bytes.workspace = true derive_more.workspace = true futures.workspace = true -http.workspace = true http-body.workspace = true +http.workspace = true log.workspace = true parking_lot.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true url.workspace = true -workspace-hack.workspace = true +util.workspace = true diff --git a/crates/http_client/src/async_body.rs b/crates/http_client/src/async_body.rs index 473849f3cdca785a802590a60cce922c9ee0b5f9..6b99a54a7d941c290f2680bc2a599bc63251e24b 100644 --- a/crates/http_client/src/async_body.rs +++ b/crates/http_client/src/async_body.rs @@ -40,7 +40,7 @@ impl AsyncBody { } pub fn from_bytes(bytes: Bytes) -> Self { - Self(Inner::Bytes(Cursor::new(bytes.clone()))) + Self(Inner::Bytes(Cursor::new(bytes))) } } diff --git a/crates/http_client/src/github.rs b/crates/http_client/src/github.rs index 89309ff344c2a64127ee8b2603d10d029a82f6bf..32efed8e727330d3ac1c2fb6d8ea5d57fdd66dd4 100644 --- a/crates/http_client/src/github.rs +++ b/crates/http_client/src/github.rs @@ -77,10 +77,10 @@ pub async fn latest_github_release( .find(|release| release.pre_release == pre_release) .context("finding a prerelease")?; release.assets.iter_mut().for_each(|asset| { - if let Some(digest) = &mut asset.digest { - if let Some(stripped) = digest.strip_prefix("sha256:") { - *digest = stripped.to_owned(); - } + if let Some(digest) = &mut asset.digest + && let Some(stripped) = digest.strip_prefix("sha256:") + { + *digest = stripped.to_owned(); } }); Ok(release) diff --git a/crates/languages/src/github_download.rs b/crates/http_client/src/github_download.rs similarity index 87% rename from crates/languages/src/github_download.rs rename to crates/http_client/src/github_download.rs index 5b0f1d0729c6ca620c6983ce3c3d64c5d7274314..02dee08b215e547d632caaf5f94b0872aa6aa20d 100644 --- a/crates/languages/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -3,18 +3,18 @@ use std::{path::Path, pin::Pin, task::Poll}; use anyhow::{Context, Result}; use async_compression::futures::bufread::GzipDecoder; use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader}; -use http_client::github::AssetKind; -use language::LspAdapterDelegate; use sha2::{Digest, Sha256}; +use crate::{HttpClient, github::AssetKind}; + #[derive(serde::Deserialize, serde::Serialize, Debug)] -pub(crate) struct GithubBinaryMetadata { - pub(crate) metadata_version: u64, - pub(crate) digest: Option, +pub struct GithubBinaryMetadata { + pub metadata_version: u64, + pub digest: Option, } impl GithubBinaryMetadata { - pub(crate) async fn read_from_file(metadata_path: &Path) -> Result { + pub async fn read_from_file(metadata_path: &Path) -> Result { let metadata_content = async_fs::read_to_string(metadata_path) .await .with_context(|| format!("reading metadata file at {metadata_path:?}"))?; @@ -22,7 +22,7 @@ impl GithubBinaryMetadata { .with_context(|| format!("parsing metadata file at {metadata_path:?}")) } - pub(crate) async fn write_to_file(&self, metadata_path: &Path) -> Result<()> { + pub async fn write_to_file(&self, metadata_path: &Path) -> Result<()> { let metadata_content = serde_json::to_string(self) .with_context(|| format!("serializing metadata for {metadata_path:?}"))?; async_fs::write(metadata_path, metadata_content.as_bytes()) @@ -32,16 +32,15 @@ impl GithubBinaryMetadata { } } -pub(crate) async fn download_server_binary( - delegate: &dyn LspAdapterDelegate, +pub async fn download_server_binary( + http_client: &dyn HttpClient, url: &str, digest: Option<&str>, destination_path: &Path, asset_kind: AssetKind, ) -> Result<(), anyhow::Error> { log::info!("downloading github artifact from {url}"); - let mut response = delegate - .http_client() + let mut response = http_client .get(url, Default::default(), true) .await .with_context(|| format!("downloading release from {url}"))?; @@ -96,7 +95,7 @@ async fn stream_response_archive( AssetKind::TarGz => extract_tar_gz(destination_path, url, response).await?, AssetKind::Gz => extract_gz(destination_path, url, response).await?, AssetKind::Zip => { - util::archive::extract_zip(&destination_path, response).await?; + util::archive::extract_zip(destination_path, response).await?; } }; Ok(()) @@ -113,11 +112,11 @@ async fn stream_file_archive( AssetKind::Gz => extract_gz(destination_path, url, file_archive).await?, #[cfg(not(windows))] AssetKind::Zip => { - util::archive::extract_seekable_zip(&destination_path, file_archive).await?; + util::archive::extract_seekable_zip(destination_path, file_archive).await?; } #[cfg(windows)] AssetKind::Zip => { - util::archive::extract_zip(&destination_path, file_archive).await?; + util::archive::extract_zip(destination_path, file_archive).await?; } }; Ok(()) @@ -143,7 +142,7 @@ async fn extract_gz( from: impl AsyncRead + Unpin, ) -> Result<(), anyhow::Error> { let mut decompressed_bytes = GzipDecoder::new(BufReader::new(from)); - let mut file = smol::fs::File::create(&destination_path) + let mut file = async_fs::File::create(&destination_path) .await .with_context(|| { format!("creating a file {destination_path:?} for a download from {url}") diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index a7f75b0962561ac713e57f9ad26cb64ed82f8003..056cee4e346e34b5689a0dfe3278c880b7297986 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -1,17 +1,17 @@ mod async_body; pub mod github; +pub mod github_download; pub use anyhow::{Result, anyhow}; pub use async_body::{AsyncBody, Inner}; use derive_more::Deref; use http::HeaderValue; -pub use http::{self, Method, Request, Response, StatusCode, Uri}; +pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; use futures::{ FutureExt as _, future::{self, BoxFuture}, }; -use http::request::Builder; use parking_lot::Mutex; #[cfg(feature = "test-support")] use std::fmt; @@ -28,6 +28,25 @@ pub enum RedirectPolicy { pub struct FollowRedirects(pub bool); pub trait HttpRequestExt { + /// Conditionally modify self with the given closure. + fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + if condition { then(self) } else { self } + } + + /// Conditionally unwrap and modify self with the given closure, if the given option is Some. + fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self + where + Self: Sized, + { + match option { + Some(value) => then(self, value), + None => self, + } + } + /// Whether or not to follow redirects fn follow_redirects(self, follow: RedirectPolicy) -> Self; } @@ -48,12 +67,12 @@ pub trait HttpClient: 'static + Send + Sync { req: http::Request, ) -> BoxFuture<'static, anyhow::Result>>; - fn get<'a>( - &'a self, + fn get( + &self, uri: &str, body: AsyncBody, follow_redirects: bool, - ) -> BoxFuture<'a, anyhow::Result>> { + ) -> BoxFuture<'static, anyhow::Result>> { let request = Builder::new() .uri(uri) .follow_redirects(if follow_redirects { @@ -64,16 +83,16 @@ pub trait HttpClient: 'static + Send + Sync { .body(body); match request { - Ok(request) => Box::pin(async move { self.send(request).await }), + Ok(request) => self.send(request), Err(e) => Box::pin(async move { Err(e.into()) }), } } - fn post_json<'a>( - &'a self, + fn post_json( + &self, uri: &str, body: AsyncBody, - ) -> BoxFuture<'a, anyhow::Result>> { + ) -> BoxFuture<'static, anyhow::Result>> { let request = Builder::new() .uri(uri) .method(Method::POST) @@ -81,7 +100,7 @@ pub trait HttpClient: 'static + Send + Sync { .body(body); match request { - Ok(request) => Box::pin(async move { self.send(request).await }), + Ok(request) => self.send(request), Err(e) => Box::pin(async move { Err(e.into()) }), } } @@ -318,6 +337,12 @@ pub fn read_proxy_from_env() -> Option { .and_then(|env| env.parse().ok()) } +pub fn read_no_proxy_from_env() -> Option { + const ENV_VARS: &[&str] = &["NO_PROXY", "no_proxy"]; + + ENV_VARS.iter().find_map(|var| std::env::var(var).ok()) +} + pub struct BlockedHttpClient; impl BlockedHttpClient { @@ -435,8 +460,7 @@ impl HttpClient for FakeHttpClient { &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { - let future = (self.handler.lock().as_ref().unwrap())(req); - future + ((self.handler.lock().as_ref().unwrap())(req)) as _ } fn user_agent(&self) -> Option<&HeaderValue> { diff --git a/crates/http_client_tls/Cargo.toml b/crates/http_client_tls/Cargo.toml index d0b45d70346de1b0ff5e3a0f5a62d643622778ba..a55268ac314ebe4a45d2aaa53c6281f8ebac6aa2 100644 --- a/crates/http_client_tls/Cargo.toml +++ b/crates/http_client_tls/Cargo.toml @@ -18,4 +18,3 @@ doctest = true [dependencies] rustls.workspace = true rustls-platform-verifier.workspace = true -workspace-hack.workspace = true diff --git a/crates/icons/Cargo.toml b/crates/icons/Cargo.toml index c2574014eabef20017fae91cfc0d35bbfeb38ee8..fc00165843a84d7948c7bbcc1b83a9d7c43b67a1 100644 --- a/crates/icons/Cargo.toml +++ b/crates/icons/Cargo.toml @@ -14,4 +14,3 @@ path = "src/icons.rs" [dependencies] serde.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 8bd76cbecf59a8c515118bfe473386e2b05efac4..1442c482d89f0c46e45ccd280e678021e6ba63c7 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -34,6 +34,7 @@ pub enum IconName { ArrowRightLeft, ArrowUp, ArrowUpRight, + Attach, AudioOff, AudioOn, Backspace, @@ -144,7 +145,9 @@ pub enum IconName { Keyboard, Library, LineHeight, + Link, ListCollapse, + ListFilter, ListTodo, ListTree, ListX, @@ -155,6 +158,7 @@ pub enum IconName { Maximize, Menu, MenuAlt, + MenuAltTemp, Mic, MicMute, Minimize, @@ -162,7 +166,9 @@ pub enum IconName { Option, PageDown, PageUp, + Paperclip, Pencil, + PencilUnavailable, Person, Pin, PlayOutlined, @@ -212,6 +218,7 @@ pub enum IconName { Tab, Terminal, TerminalAlt, + TerminalGhost, TextSnippet, TextThread, Thread, @@ -245,6 +252,8 @@ pub enum IconName { Warning, WholeWord, XCircle, + XCircleFilled, + ZedAgent, ZedAssistant, ZedBurnMode, ZedBurnModeOn, @@ -256,6 +265,7 @@ pub enum IconName { ZedPredictError, ZedPredictUp, ZedXCopilot, + Linux, } impl IconName { diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 254c916789df2cdfa3e3458ed30572e84153ad61..92386e8ba8a38f79711ee50343a6e7cf4a393cbd 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -24,14 +24,12 @@ gpui.workspace = true language.workspace = true log.workspace = true project.workspace = true -schemars.workspace = true serde.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index 70a92736aa3d8715a3974ddc5709743e001d9fe8..6e8956abc67868457f071e04f3c2a1957ff6c19c 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -47,7 +47,7 @@ impl Render for ImageInfo { let settings = ImageViewerSettings::get_global(cx); let Some(metadata) = self.metadata.as_ref() else { - return div(); + return div().hidden(); }; let mut components = Vec::new(); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index b96557b391f5941283b67b7b798ee177ab383cb2..f9a2cc9e045ae67ac7d993250f87cf7ee23789c0 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1,7 +1,7 @@ mod image_info; mod image_viewer_settings; -use std::path::PathBuf; +use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; @@ -15,11 +15,12 @@ use language::{DiskState, File as _}; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; -use theme::Theme; +use theme::{Theme, ThemeSettings}; use ui::prelude::*; use util::paths::PathExt; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, + invalid_item_view::InvalidItemView, item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, }; @@ -100,13 +101,9 @@ impl Item for ImageView { f(self.image_item.entity_id(), self.image_item.read(cx)) } - fn is_singleton(&self, _cx: &App) -> bool { - true - } - fn tab_tooltip_text(&self, cx: &App) -> Option { let abs_path = self.image_item.read(cx).abs_path(cx)?; - let file_path = abs_path.compact().to_string_lossy().to_string(); + let file_path = abs_path.compact().to_string_lossy().into_owned(); Some(file_path.into()) } @@ -144,7 +141,6 @@ impl Item for ImageView { .read(cx) .file .file_name(cx) - .to_string_lossy() .to_string() .into() } @@ -169,49 +165,52 @@ impl Item for ImageView { fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); + let settings = ThemeSettings::get_global(cx); + Some(vec![BreadcrumbText { text, highlights: None, - font: None, + font: Some(settings.buffer_font.clone()), }]) } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| Self { + Task::ready(Some(cx.new(|cx| Self { image_item: self.image_item.clone(), project: self.project.clone(), focus_handle: cx.focus_handle(), - })) + }))) } fn has_deleted_file(&self, cx: &App) -> bool { self.image_item.read(cx).file.disk_state() == DiskState::Deleted } + fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Singleton + } } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { - let path = image.file.file_name(cx); - if project.visible_worktrees(cx).count() <= 1 { - return path.to_string_lossy().to_string(); + let mut path = image.file.path().clone(); + if project.visible_worktrees(cx).count() > 1 + && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx) + { + path = worktree.read(cx).root_name().join(&path); } - project - .worktree_for_id(image.project_path(cx).worktree_id, cx) - .map(|worktree| { - PathBuf::from(worktree.read(cx).root_name()) - .join(path) - .to_string_lossy() - .to_string() - }) - .unwrap_or_else(|| path.to_string_lossy().to_string()) + path.display(project.path_style(cx)).to_string() } impl SerializableItem for ImageView { @@ -242,7 +241,7 @@ impl SerializableItem for ImageView { let project_path = ProjectPath { worktree_id, - path: relative_path.into(), + path: relative_path, }; let image_item = project @@ -306,72 +305,79 @@ impl Focusable for ImageView { impl Render for ImageView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let image = self.image_item.read(cx).image.clone(); - let checkered_background = |bounds: Bounds, - _, - window: &mut Window, - _cx: &mut App| { - let square_size = 32.0; - - let start_y = bounds.origin.y.0; - let height = bounds.size.height.0; - let start_x = bounds.origin.x.0; - let width = bounds.size.width.0; - - let mut y = start_y; - let mut x = start_x; - let mut color_swapper = true; - // draw checkerboard pattern - while y <= start_y + height { - // Keeping track of the grid in order to be resilient to resizing - let start_swap = color_swapper; - while x <= start_x + width { - let rect = - Bounds::new(point(px(x), px(y)), size(px(square_size), px(square_size))); - - let color = if color_swapper { - opaque_grey(0.6, 0.4) - } else { - opaque_grey(0.7, 0.4) - }; - - window.paint_quad(fill(rect, color)); - color_swapper = !color_swapper; - x += square_size; + let checkered_background = + |bounds: Bounds, _, window: &mut Window, _cx: &mut App| { + let square_size: f32 = 32.0; + + let start_y = bounds.origin.y.into(); + let height: f32 = bounds.size.height.into(); + let start_x = bounds.origin.x.into(); + let width: f32 = bounds.size.width.into(); + + let mut y = start_y; + let mut x = start_x; + let mut color_swapper = true; + // draw checkerboard pattern + while y < start_y + height { + // Keeping track of the grid in order to be resilient to resizing + let start_swap = color_swapper; + while x < start_x + width { + // Clamp square dimensions to not exceed bounds + let square_width = square_size.min(start_x + width - x); + let square_height = square_size.min(start_y + height - y); + + let rect = Bounds::new( + point(px(x), px(y)), + size(px(square_width), px(square_height)), + ); + + let color = if color_swapper { + opaque_grey(0.6, 0.4) + } else { + opaque_grey(0.7, 0.4) + }; + + window.paint_quad(fill(rect, color)); + color_swapper = !color_swapper; + x += square_size; + } + x = start_x; + color_swapper = !start_swap; + y += square_size; } - x = start_x; - color_swapper = !start_swap; - y += square_size; - } - }; + }; - let checkered_background = canvas(|_, _, _| (), checkered_background) - .border_2() - .border_color(cx.theme().styles.colors.border) - .size_full() - .absolute() - .top_0() - .left_0(); - - div() - .track_focus(&self.focus_handle(cx)) - .size_full() - .child(checkered_background) - .child( - div() - .flex() - .justify_center() - .items_center() - .w_full() - // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full - .h_full() - .child( - img(image) - .object_fit(ObjectFit::ScaleDown) - .max_w_full() - .max_h_full() - .id("img"), - ), - ) + div().track_focus(&self.focus_handle(cx)).size_full().child( + div() + .flex() + .justify_center() + .items_center() + .w_full() + // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full + .h_full() + .child( + div() + .relative() + .max_w_full() + .max_h_full() + .child( + canvas(|_, _, _| (), checkered_background) + .border_2() + .border_color(cx.theme().styles.colors.border) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .child( + img(image) + .object_fit(ObjectFit::ScaleDown) + .max_w_full() + .max_h_full() + .id("img"), + ), + ), + ) } } @@ -390,6 +396,19 @@ impl ProjectItem for ImageView { { Self::new(item, project, window, cx) } + + fn for_broken_project_item( + abs_path: &Path, + is_local: bool, + e: &anyhow::Error, + window: &mut Window, + cx: &mut App, + ) -> Option + where + Self: Sized, + { + Some(InvalidItemView::new(abs_path, is_local, e, window, cx)) + } } pub fn init(cx: &mut App) { @@ -401,12 +420,19 @@ pub fn init(cx: &mut App) { mod persistence { use std::path::PathBuf; - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; - define_connection! { - pub static ref IMAGE_VIEWER: ImageViewerDb = - &[sql!( + pub struct ImageViewerDb(ThreadSafeConnection); + + impl Domain for ImageViewerDb { + const NAME: &str = stringify!(ImageViewerDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE image_viewers ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -417,9 +443,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]); + impl ImageViewerDb { query! { pub async fn save_image_path( diff --git a/crates/image_viewer/src/image_viewer_settings.rs b/crates/image_viewer/src/image_viewer_settings.rs index 1dcf99c0afcb3f69f48e2e1a82351852a4bf1c22..839d5fbfe44fc624351953018c1437e9fa2c32e0 100644 --- a/crates/image_viewer/src/image_viewer_settings.rs +++ b/crates/image_viewer/src/image_viewer_settings.rs @@ -1,41 +1,19 @@ -use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +pub use settings::ImageFileSizeUnit; +use settings::Settings; /// The settings for the image viewer. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)] +#[derive(Clone, Debug, Default)] pub struct ImageViewerSettings { /// The unit to use for displaying image file sizes. /// /// Default: "binary" - #[serde(default)] pub unit: ImageFileSizeUnit, } -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -pub enum ImageFileSizeUnit { - /// Displays file size in binary units (e.g., KiB, MiB). - #[default] - Binary, - /// Displays file size in decimal units (e.g., KB, MB). - Decimal, -} - impl Settings for ImageViewerSettings { - const KEY: Option<&'static str> = Some("image_viewer"); - - type FileContent = Self; - - fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { - SettingsSources::::json_merge_with( - [sources.default] - .into_iter() - .chain(sources.user) - .chain(sources.server), - ) + fn from_settings(content: &settings::SettingsContent) -> Self { + Self { + unit: content.image_viewer.clone().unwrap().unit.unwrap(), + } } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index 8e55a8a477e5346bd12ec594b36ac04e197dfc8e..aaf40b2f8d11aa324f2f76e71988ada87415237b 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -22,8 +22,9 @@ project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true theme.workspace = true +title_bar.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true +util_macros.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/inspector_ui/README.md b/crates/inspector_ui/README.md index 5c720dfea2df3ff2ddf75112fec8793ba1851ed1..74886e611108fd4fd3f5f5015746f913e2697cae 100644 --- a/crates/inspector_ui/README.md +++ b/crates/inspector_ui/README.md @@ -68,7 +68,7 @@ With both approaches, would need to record the buffer version and use that when * Mode to navigate to source code on every element change while picking. -* Tracking of more source locations - currently the source location is often in a ui compoenent. Ideally this would have a way for the components to indicate that they are probably not the source location the user is looking for. +* Tracking of more source locations - currently the source location is often in a ui component. Ideally this would have a way for the components to indicate that they are probably not the source location the user is looking for. - Could have `InspectorElementId` be `Vec<(ElementId, Option)>`, but if there are multiple code paths that construct the same element this would cause them to be considered different. diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index bd395aa01bca42ce923073ee6f80472abc7820eb..da99c5b92c1e6ad4d8a3e92ed2e565bcb518e227 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -14,18 +14,22 @@ use language::{ DiagnosticSeverity, LanguageServerId, Point, ToOffset as _, ToPoint as _, }; use project::lsp_store::CompletionDocumentation; -use project::{Completion, CompletionResponse, CompletionSource, Project, ProjectPath}; +use project::{ + Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, Project, + ProjectPath, +}; use std::fmt::Write as _; use std::ops::Range; use std::path::Path; use std::rc::Rc; use std::sync::LazyLock; use ui::{Label, LabelSize, Tooltip, prelude::*, styled_ext_reflection, v_flex}; +use util::rel_path::RelPath; use util::split_str_with_ranges; /// Path used for unsaved buffer that contains style json. To support the json language server, this /// matches the name used in the generated schemas. -const ZED_INSPECTOR_STYLE_JSON: &str = "/zed-inspector-style.json"; +const ZED_INSPECTOR_STYLE_JSON: &str = util_macros::path!("/zed-inspector-style.json"); pub(crate) struct DivInspector { state: State, @@ -93,8 +97,8 @@ impl DivInspector { Ok((json_style_buffer, rust_style_buffer)) => { this.update_in(cx, |this, window, cx| { this.state = State::BuffersLoaded { - json_style_buffer: json_style_buffer, - rust_style_buffer: rust_style_buffer, + json_style_buffer, + rust_style_buffer, }; // Initialize editors immediately instead of waiting for @@ -200,8 +204,8 @@ impl DivInspector { cx.subscribe_in(&json_style_editor, window, { let id = id.clone(); let rust_style_buffer = rust_style_buffer.clone(); - move |this, editor, event: &EditorEvent, window, cx| match event { - EditorEvent::BufferEdited => { + move |this, editor, event: &EditorEvent, window, cx| { + if event == &EditorEvent::BufferEdited { let style_json = editor.read(cx).text(cx); match serde_json_lenient::from_str_lenient::(&style_json) { Ok(new_style) => { @@ -243,7 +247,6 @@ impl DivInspector { Err(err) => this.json_style_error = Some(err.to_string().into()), } } - _ => {} } }) .detach(); @@ -251,11 +254,10 @@ impl DivInspector { cx.subscribe(&rust_style_editor, { let json_style_buffer = json_style_buffer.clone(); let rust_style_buffer = rust_style_buffer.clone(); - move |this, _editor, event: &EditorEvent, cx| match event { - EditorEvent::BufferEdited => { + move |this, _editor, event: &EditorEvent, cx| { + if let EditorEvent::BufferEdited = event { this.update_json_style_from_rust(&json_style_buffer, &rust_style_buffer, cx); } - _ => {} } }) .detach(); @@ -271,23 +273,19 @@ impl DivInspector { } fn reset_style(&mut self, cx: &mut App) { - match &self.state { - State::Ready { - rust_style_buffer, - json_style_buffer, - .. - } => { - if let Err(err) = self.reset_style_editors( - &rust_style_buffer.clone(), - &json_style_buffer.clone(), - cx, - ) { - self.json_style_error = Some(format!("{err}").into()); - } else { - self.json_style_error = None; - } + if let State::Ready { + rust_style_buffer, + json_style_buffer, + .. + } = &self.state + { + if let Err(err) = + self.reset_style_editors(&rust_style_buffer.clone(), &json_style_buffer.clone(), cx) + { + self.json_style_error = Some(format!("{err}").into()); + } else { + self.json_style_error = None; } - _ => {} } } @@ -395,11 +393,11 @@ impl DivInspector { .zip(self.rust_completion_replace_range.as_ref()) { let before_text = snapshot - .text_for_range(0..completion_range.start.to_offset(&snapshot)) + .text_for_range(0..completion_range.start.to_offset(snapshot)) .collect::(); let after_text = snapshot .text_for_range( - completion_range.end.to_offset(&snapshot) + completion_range.end.to_offset(snapshot) ..snapshot.clip_offset(usize::MAX, Bias::Left), ) .collect::(); @@ -469,7 +467,7 @@ impl DivInspector { let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath { worktree_id: worktree.id(), - path: Path::new("").into(), + path: RelPath::empty().into(), })?; let buffer = project @@ -578,7 +576,12 @@ fn render_layout_state(inspector_state: &DivInspectorState, cx: &App) -> Div { .child( div() .text_ui(cx) - .child(format!("Bounds: {}", inspector_state.bounds)), + .child(format!( + "Bounds: ⌜{} - {}⌟", + inspector_state.bounds.origin, + inspector_state.bounds.bottom_right() + )) + .child(format!("Size: {}", inspector_state.bounds.size)), ) .child( div() @@ -670,6 +673,7 @@ impl CompletionProvider for RustStyleCompletionProvider { confirm: None, }) .collect(), + display_options: CompletionDisplayOptions::default(), is_incomplete: false, }])) } @@ -702,10 +706,10 @@ impl CompletionProvider for RustStyleCompletionProvider { } fn completion_replace_range(snapshot: &BufferSnapshot, anchor: &Anchor) -> Option> { - let point = anchor.to_point(&snapshot); - let offset = point.to_offset(&snapshot); - let line_start = Point::new(point.row, 0).to_offset(&snapshot); - let line_end = Point::new(point.row, snapshot.line_len(point.row)).to_offset(&snapshot); + let point = anchor.to_point(snapshot); + let offset = point.to_offset(snapshot); + let line_start = Point::new(point.row, 0).to_offset(snapshot); + let line_end = Point::new(point.row, snapshot.line_len(point.row)).to_offset(snapshot); let mut lines = snapshot.text_for_range(line_start..line_end).lines(); let line = lines.next()?; diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 8d24b93fa9265be44e871c1a825d4ce17316392a..7f7985df9b98ee286c79e18a665802b1f73fbc1e 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, anyhow}; use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window}; use std::{cell::OnceCell, path::Path, sync::Arc}; +use title_bar::platform_title_bar::PlatformTitleBar; use ui::{Label, Tooltip, prelude::*}; use util::{ResultExt as _, command::new_smol_command}; use workspace::AppState; @@ -56,6 +57,8 @@ fn render_inspector( let ui_font = theme::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); + let toolbar_height = PlatformTitleBar::height(window); + v_flex() .size_full() .bg(colors.panel_background) @@ -65,7 +68,11 @@ fn render_inspector( .border_color(colors.border) .child( h_flex() - .p_2() + .justify_between() + .pr_2() + .pl_1() + .mt_px() + .h(toolbar_height) .border_b_1() .border_color(colors.border_variant) .child( @@ -78,18 +85,14 @@ fn render_inspector( window.refresh(); })), ) - .child( - h_flex() - .w_full() - .justify_end() - .child(Label::new("GPUI Inspector").size(LabelSize::Large)), - ), + .child(h_flex().justify_end().child(Label::new("GPUI Inspector"))), ) .child( v_flex() .id("gpui-inspector-content") .overflow_y_scroll() - .p_2() + .px_2() + .py_0p5() .gap_2() .when_some(inspector_id, |this, inspector_id| { this.child(render_inspector_id(inspector_id, cx)) @@ -110,15 +113,19 @@ fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div { .unwrap_or(source_location_string); v_flex() - .child(Label::new("Element ID").size(LabelSize::Large)) .child( - div() - .id("instance-id") - .text_ui(cx) - .tooltip(Tooltip::text( - "Disambiguates elements from the same source location", - )) - .child(format!("Instance {}", inspector_id.instance_id)), + h_flex() + .justify_between() + .child(Label::new("Element ID").size(LabelSize::Large)) + .child( + div() + .id("instance-id") + .text_ui(cx) + .tooltip(Tooltip::text( + "Disambiguates elements from the same source location", + )) + .child(format!("Instance {}", inspector_id.instance_id)), + ), ) .child( div() @@ -126,8 +133,10 @@ fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div { .text_ui(cx) .bg(cx.theme().colors().editor_foreground.opacity(0.025)) .underline() + .font_buffer(cx) + .text_xs() .child(source_location_string) - .tooltip(Tooltip::text("Click to open by running zed cli")) + .tooltip(Tooltip::text("Click to open by running Zed CLI")) .on_click(move |_, _window, cx| { cx.background_spawn(open_zed_source_location(source_location)) .detach_and_log_err(cx); diff --git a/crates/install_cli/Cargo.toml b/crates/install_cli/Cargo.toml index 4679f9e54fc8139b6d91a32f897e5b5c1802aa04..1eede025e50a236523b35137a56c02887436c257 100644 --- a/crates/install_cli/Cargo.toml +++ b/crates/install_cli/Cargo.toml @@ -21,5 +21,4 @@ gpui.workspace = true release_channel.workspace = true smol.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true diff --git a/crates/install_cli/src/install_cli.rs b/crates/install_cli/src/install_cli.rs index 12c094448b8362c8d638ac62da5838544b4fcc6d..281069020af37c3de6bf0df4465c495353ad82e9 100644 --- a/crates/install_cli/src/install_cli.rs +++ b/crates/install_cli/src/install_cli.rs @@ -1,112 +1,7 @@ -use anyhow::{Context as _, Result}; -use client::ZED_URL_SCHEME; -use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions}; -use release_channel::ReleaseChannel; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use util::ResultExt; -use workspace::notifications::{DetachAndPromptErr, NotificationId}; -use workspace::{Toast, Workspace}; +#[cfg(not(target_os = "windows"))] +mod install_cli_binary; +mod register_zed_scheme; -actions!( - cli, - [ - /// Installs the Zed CLI tool to the system PATH. - Install, - /// Registers the zed:// URL scheme handler. - RegisterZedScheme - ] -); - -async fn install_script(cx: &AsyncApp) -> Result { - let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))??; - let link_path = Path::new("/usr/local/bin/zed"); - let bin_dir_path = link_path.parent().unwrap(); - - // Don't re-create symlink if it points to the same CLI binary. - if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { - return Ok(link_path.into()); - } - - // If the symlink is not there or is outdated, first try replacing it - // without escalating. - smol::fs::remove_file(link_path).await.log_err(); - // todo("windows") - #[cfg(not(windows))] - { - if smol::fs::unix::symlink(&cli_path, link_path) - .await - .log_err() - .is_some() - { - return Ok(link_path.into()); - } - } - - // The symlink could not be created, so use osascript with admin privileges - // to create it. - let status = smol::process::Command::new("/usr/bin/osascript") - .args([ - "-e", - &format!( - "do shell script \" \ - mkdir -p \'{}\' && \ - ln -sf \'{}\' \'{}\' \ - \" with administrator privileges", - bin_dir_path.to_string_lossy(), - cli_path.to_string_lossy(), - link_path.to_string_lossy(), - ), - ]) - .stdout(smol::process::Stdio::inherit()) - .stderr(smol::process::Stdio::inherit()) - .output() - .await? - .status; - anyhow::ensure!(status.success(), "error running osascript"); - Ok(link_path.into()) -} - -pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> { - cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME))? - .await -} - -pub fn install_cli(window: &mut Window, cx: &mut Context) { - const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else."; - - cx.spawn_in(window, async move |workspace, cx| { - if cfg!(any(target_os = "linux", target_os = "freebsd")) { - let prompt = cx.prompt( - PromptLevel::Warning, - "CLI should already be installed", - Some(LINUX_PROMPT_DETAIL), - &["Ok"], - ); - cx.background_spawn(prompt).detach(); - return Ok(()); - } - let path = install_script(cx.deref()) - .await - .context("error creating CLI symlink")?; - - workspace.update_in(cx, |workspace, _, cx| { - struct InstalledZedCli; - - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - format!( - "Installed `zed` to {}. You can launch {} from your terminal.", - path.to_string_lossy(), - ReleaseChannel::global(cx).display_name() - ), - ), - cx, - ) - })?; - register_zed_scheme(&cx).await.log_err(); - Ok(()) - }) - .detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None); -} +#[cfg(not(target_os = "windows"))] +pub use install_cli_binary::{InstallCliBinary, install_cli_binary}; +pub use register_zed_scheme::{RegisterZedScheme, register_zed_scheme}; diff --git a/crates/install_cli/src/install_cli_binary.rs b/crates/install_cli/src/install_cli_binary.rs new file mode 100644 index 0000000000000000000000000000000000000000..414bdabc7090be4372ff984949809839bbd3ee05 --- /dev/null +++ b/crates/install_cli/src/install_cli_binary.rs @@ -0,0 +1,101 @@ +use super::register_zed_scheme; +use anyhow::{Context as _, Result}; +use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions}; +use release_channel::ReleaseChannel; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use util::ResultExt; +use workspace::notifications::{DetachAndPromptErr, NotificationId}; +use workspace::{Toast, Workspace}; + +actions!( + cli, + [ + /// Installs the Zed CLI tool to the system PATH. + InstallCliBinary, + ] +); + +async fn install_script(cx: &AsyncApp) -> Result { + let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))??; + let link_path = Path::new("/usr/local/bin/zed"); + let bin_dir_path = link_path.parent().unwrap(); + + // Don't re-create symlink if it points to the same CLI binary. + if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { + return Ok(link_path.into()); + } + + // If the symlink is not there or is outdated, first try replacing it + // without escalating. + smol::fs::remove_file(link_path).await.log_err(); + if smol::fs::unix::symlink(&cli_path, link_path) + .await + .log_err() + .is_some() + { + return Ok(link_path.into()); + } + + // The symlink could not be created, so use osascript with admin privileges + // to create it. + let status = smol::process::Command::new("/usr/bin/osascript") + .args([ + "-e", + &format!( + "do shell script \" \ + mkdir -p \'{}\' && \ + ln -sf \'{}\' \'{}\' \ + \" with administrator privileges", + bin_dir_path.to_string_lossy(), + cli_path.to_string_lossy(), + link_path.to_string_lossy(), + ), + ]) + .stdout(smol::process::Stdio::inherit()) + .stderr(smol::process::Stdio::inherit()) + .output() + .await? + .status; + anyhow::ensure!(status.success(), "error running osascript"); + Ok(link_path.into()) +} + +pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { + const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else."; + + cx.spawn_in(window, async move |workspace, cx| { + if cfg!(any(target_os = "linux", target_os = "freebsd")) { + let prompt = cx.prompt( + PromptLevel::Warning, + "CLI should already be installed", + Some(LINUX_PROMPT_DETAIL), + &["Ok"], + ); + cx.background_spawn(prompt).detach(); + return Ok(()); + } + let path = install_script(cx.deref()) + .await + .context("error creating CLI symlink")?; + + workspace.update_in(cx, |workspace, _, cx| { + struct InstalledZedCli; + + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!( + "Installed `zed` to {}. You can launch {} from your terminal.", + path.to_string_lossy(), + ReleaseChannel::global(cx).display_name() + ), + ), + cx, + ) + })?; + register_zed_scheme(cx).await.log_err(); + Ok(()) + }) + .detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None); +} diff --git a/crates/install_cli/src/register_zed_scheme.rs b/crates/install_cli/src/register_zed_scheme.rs new file mode 100644 index 0000000000000000000000000000000000000000..819287c5d0bcd15e531e21b417c7e5d4a4b4ece5 --- /dev/null +++ b/crates/install_cli/src/register_zed_scheme.rs @@ -0,0 +1,15 @@ +use client::ZED_URL_SCHEME; +use gpui::{AsyncApp, actions}; + +actions!( + cli, + [ + /// Registers the zed:// URL scheme handler. + RegisterZedScheme + ] +); + +pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> { + cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME))? + .await +} diff --git a/crates/jj/src/jj.rs b/crates/jj/src/jj.rs deleted file mode 100644 index 45fa2b07e14e8bcfb3693df0f88405a5f52516e8..0000000000000000000000000000000000000000 --- a/crates/jj/src/jj.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod jj_repository; -mod jj_store; - -pub use jj_repository::*; -pub use jj_store::*; diff --git a/crates/jj/src/jj_repository.rs b/crates/jj/src/jj_repository.rs deleted file mode 100644 index 93ae79eb90992a8fc71804788325683eae800cb4..0000000000000000000000000000000000000000 --- a/crates/jj/src/jj_repository.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -use anyhow::Result; -use gpui::SharedString; -use jj_lib::config::StackedConfig; -use jj_lib::repo::StoreFactories; -use jj_lib::settings::UserSettings; -use jj_lib::workspace::{self, DefaultWorkspaceLoaderFactory, WorkspaceLoaderFactory}; - -#[derive(Debug, Clone)] -pub struct Bookmark { - pub ref_name: SharedString, -} - -pub trait JujutsuRepository: Send + Sync { - fn list_bookmarks(&self) -> Vec; -} - -pub struct RealJujutsuRepository { - repository: Arc, -} - -impl RealJujutsuRepository { - pub fn new(cwd: &Path) -> Result { - let workspace_loader_factory = DefaultWorkspaceLoaderFactory; - let workspace_loader = workspace_loader_factory.create(Self::find_workspace_dir(cwd))?; - - let config = StackedConfig::with_defaults(); - let settings = UserSettings::from_config(config)?; - - let workspace = workspace_loader.load( - &settings, - &StoreFactories::default(), - &workspace::default_working_copy_factories(), - )?; - - let repo_loader = workspace.repo_loader(); - let repository = repo_loader.load_at_head()?; - - Ok(Self { repository }) - } - - fn find_workspace_dir(cwd: &Path) -> &Path { - cwd.ancestors() - .find(|path| path.join(".jj").is_dir()) - .unwrap_or(cwd) - } -} - -impl JujutsuRepository for RealJujutsuRepository { - fn list_bookmarks(&self) -> Vec { - let bookmarks = self - .repository - .view() - .bookmarks() - .map(|(ref_name, _target)| Bookmark { - ref_name: ref_name.as_str().to_string().into(), - }) - .collect(); - - bookmarks - } -} - -pub struct FakeJujutsuRepository {} - -impl JujutsuRepository for FakeJujutsuRepository { - fn list_bookmarks(&self) -> Vec { - Vec::new() - } -} diff --git a/crates/jj/src/jj_store.rs b/crates/jj/src/jj_store.rs deleted file mode 100644 index a10f06fad48a3867ce6e19ffb5fc721c931ae6e4..0000000000000000000000000000000000000000 --- a/crates/jj/src/jj_store.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::path::Path; -use std::sync::Arc; - -use gpui::{App, Entity, Global, prelude::*}; - -use crate::{JujutsuRepository, RealJujutsuRepository}; - -/// Note: We won't ultimately be storing the jj store in a global, we're just doing this for exploration purposes. -struct GlobalJujutsuStore(Entity); - -impl Global for GlobalJujutsuStore {} - -pub struct JujutsuStore { - repository: Arc, -} - -impl JujutsuStore { - pub fn init_global(cx: &mut App) { - let Some(repository) = RealJujutsuRepository::new(&Path::new(".")).ok() else { - return; - }; - - let repository = Arc::new(repository); - let jj_store = cx.new(|cx| JujutsuStore::new(repository, cx)); - - cx.set_global(GlobalJujutsuStore(jj_store)); - } - - pub fn try_global(cx: &App) -> Option> { - cx.try_global::() - .map(|global| global.0.clone()) - } - - pub fn new(repository: Arc, _cx: &mut Context) -> Self { - Self { repository } - } - - pub fn repository(&self) -> &Arc { - &self.repository - } -} diff --git a/crates/jj_ui/src/bookmark_picker.rs b/crates/jj_ui/src/bookmark_picker.rs deleted file mode 100644 index f6121fb9fc4cf40eaee2fa0f759e34e67d60429d..0000000000000000000000000000000000000000 --- a/crates/jj_ui/src/bookmark_picker.rs +++ /dev/null @@ -1,198 +0,0 @@ -use std::sync::Arc; - -use fuzzy::{StringMatchCandidate, match_strings}; -use gpui::{ - App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, - prelude::*, -}; -use jj::{Bookmark, JujutsuStore}; -use picker::{Picker, PickerDelegate}; -use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; -use util::ResultExt as _; -use workspace::{ModalView, Workspace}; - -pub fn register(workspace: &mut Workspace) { - workspace.register_action(open); -} - -fn open( - workspace: &mut Workspace, - _: &zed_actions::jj::BookmarkList, - window: &mut Window, - cx: &mut Context, -) { - let Some(jj_store) = JujutsuStore::try_global(cx) else { - return; - }; - - workspace.toggle_modal(window, cx, |window, cx| { - let delegate = BookmarkPickerDelegate::new(cx.entity().downgrade(), jj_store, cx); - BookmarkPicker::new(delegate, window, cx) - }); -} - -pub struct BookmarkPicker { - picker: Entity>, -} - -impl BookmarkPicker { - pub fn new( - delegate: BookmarkPickerDelegate, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - Self { picker } - } -} - -impl ModalView for BookmarkPicker {} - -impl EventEmitter for BookmarkPicker {} - -impl Focusable for BookmarkPicker { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.focus_handle(cx) - } -} - -impl Render for BookmarkPicker { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) - } -} - -#[derive(Debug, Clone)] -struct BookmarkEntry { - bookmark: Bookmark, - positions: Vec, -} - -pub struct BookmarkPickerDelegate { - picker: WeakEntity, - matches: Vec, - all_bookmarks: Vec, - selected_index: usize, -} - -impl BookmarkPickerDelegate { - fn new( - picker: WeakEntity, - jj_store: Entity, - cx: &mut Context, - ) -> Self { - let bookmarks = jj_store.read(cx).repository().list_bookmarks(); - - Self { - picker, - matches: Vec::new(), - all_bookmarks: bookmarks, - selected_index: 0, - } - } -} - -impl PickerDelegate for BookmarkPickerDelegate { - type ListItem = ListItem; - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select Bookmark…".into() - } - - fn match_count(&self) -> usize { - self.matches.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let background = cx.background_executor().clone(); - let all_bookmarks = self.all_bookmarks.clone(); - - cx.spawn_in(window, async move |this, cx| { - let matches = if query.is_empty() { - all_bookmarks - .into_iter() - .map(|bookmark| BookmarkEntry { - bookmark, - positions: Vec::new(), - }) - .collect() - } else { - let candidates = all_bookmarks - .iter() - .enumerate() - .map(|(ix, bookmark)| StringMatchCandidate::new(ix, &bookmark.ref_name)) - .collect::>(); - match_strings( - &candidates, - &query, - false, - true, - 100, - &Default::default(), - background, - ) - .await - .into_iter() - .map(|mat| BookmarkEntry { - bookmark: all_bookmarks[mat.candidate_id].clone(), - positions: mat.positions, - }) - .collect() - }; - - this.update(cx, |this, _cx| { - this.delegate.matches = matches; - }) - .log_err(); - }) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, _cx: &mut Context>) { - // - } - - fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - self.picker - .update(cx, |_, cx| cx.emit(DismissEvent)) - .log_err(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - _cx: &mut Context>, - ) -> Option { - let entry = &self.matches[ix]; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child(HighlightedLabel::new( - entry.bookmark.ref_name.clone(), - entry.positions.clone(), - )), - ) - } -} diff --git a/crates/jj_ui/src/jj_ui.rs b/crates/jj_ui/src/jj_ui.rs deleted file mode 100644 index 5a2ecb78b1102ea27d6a661b4ab736206ad3151d..0000000000000000000000000000000000000000 --- a/crates/jj_ui/src/jj_ui.rs +++ /dev/null @@ -1,39 +0,0 @@ -mod bookmark_picker; - -use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; -use gpui::App; -use jj::JujutsuStore; -use workspace::Workspace; - -pub fn init(cx: &mut App) { - JujutsuStore::init_global(cx); - - cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - bookmark_picker::register(workspace); - }) - .detach(); - - feature_gate_jj_ui_actions(cx); -} - -fn feature_gate_jj_ui_actions(cx: &mut App) { - const JJ_ACTION_NAMESPACE: &str = "jj"; - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_namespace(JJ_ACTION_NAMESPACE); - }); - - cx.observe_flag::({ - move |is_enabled, cx| { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - if is_enabled { - filter.show_namespace(JJ_ACTION_NAMESPACE); - } else { - filter.hide_namespace(JJ_ACTION_NAMESPACE); - } - }); - } - }) - .detach(); -} diff --git a/crates/journal/Cargo.toml b/crates/journal/Cargo.toml index 041badd10490a8ea876eb43975a911ca6811fa05..a78a2cc3b2ef465c38367255019e0bda104b5ef2 100644 --- a/crates/journal/Cargo.toml +++ b/crates/journal/Cargo.toml @@ -18,12 +18,10 @@ chrono.workspace = true editor.workspace = true gpui.workspace = true log.workspace = true -schemars.workspace = true serde.workspace = true settings.workspace = true shellexpand.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index 0335a746cd23eb2654dac7f8960a649aa3c269ff..9062081f66da0e920b99af8816432b4f006d2295 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -1,11 +1,9 @@ -use anyhow::Result; use chrono::{Datelike, Local, NaiveTime, Timelike}; use editor::scroll::Autoscroll; use editor::{Editor, SelectionEffects}; use gpui::{App, AppContext as _, Context, Window, actions}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +pub use settings::HourFormat; +use settings::Settings; use std::{ fs::OpenOptions, path::{Path, PathBuf}, @@ -22,45 +20,27 @@ actions!( ); /// Settings specific to journaling -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Debug)] pub struct JournalSettings { /// The path of the directory where journal entries are stored. /// /// Default: `~` - pub path: Option, + pub path: String, /// What format to display the hours in. /// /// Default: hour12 - pub hour_format: Option, -} - -impl Default for JournalSettings { - fn default() -> Self { - Self { - path: Some("~".into()), - hour_format: Some(Default::default()), - } - } -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum HourFormat { - #[default] - Hour12, - Hour24, + pub hour_format: HourFormat, } impl settings::Settings for JournalSettings { - const KEY: Option<&'static str> = Some("journal"); + fn from_settings(content: &settings::SettingsContent) -> Self { + let journal = content.journal.clone().unwrap(); - type FileContent = Self; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - sources.json_merge() + Self { + path: journal.path.unwrap(), + hour_format: journal.hour_format.unwrap(), + } } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } pub fn init(_: Arc, cx: &mut App) { @@ -78,7 +58,7 @@ pub fn init(_: Arc, cx: &mut App) { pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut App) { let settings = JournalSettings::get_global(cx); - let journal_dir = match journal_dir(settings.path.as_ref().unwrap()) { + let journal_dir = match journal_dir(&settings.path) { Some(journal_dir) => journal_dir, None => { log::error!("Can't determine journal directory"); @@ -114,7 +94,7 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap break; } for directory in worktree.read(cx).directories(true, 1) { - let full_directory_path = worktree_root.join(&directory.path); + let full_directory_path = worktree_root.join(directory.path.as_std_path()); if full_directory_path.ends_with(&journal_dir_clone) { open_new_workspace = false; break 'outer; @@ -123,7 +103,7 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap } let app_state = workspace.app_state().clone(); - let view_snapshot = workspace.weak_handle().clone(); + let view_snapshot = workspace.weak_handle(); window .spawn(cx, async move |cx| { @@ -170,23 +150,23 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap .await }; - if let Some(Some(Ok(item))) = opened.first() { - if let Some(editor) = item.downcast::().map(|editor| editor.downgrade()) { - editor.update_in(cx, |editor, window, cx| { - let len = editor.buffer().read(cx).len(cx); - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |s| s.select_ranges([len..len]), - ); - if len > 0 { - editor.insert("\n\n", window, cx); - } - editor.insert(&entry_heading, window, cx); + if let Some(Some(Ok(item))) = opened.first() + && let Some(editor) = item.downcast::().map(|editor| editor.downgrade()) + { + editor.update_in(cx, |editor, window, cx| { + let len = editor.buffer().read(cx).len(cx); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |s| s.select_ranges([len..len]), + ); + if len > 0 { editor.insert("\n\n", window, cx); - })?; - } + } + editor.insert(&entry_heading, window, cx); + editor.insert("\n\n", window, cx); + })?; } anyhow::Ok(()) @@ -195,20 +175,18 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap } fn journal_dir(path: &str) -> Option { - let expanded_journal_dir = shellexpand::full(path) //TODO handle this better + shellexpand::full(path) //TODO handle this better .ok() - .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal")); - - expanded_journal_dir + .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal")) } -fn heading_entry(now: NaiveTime, hour_format: &Option) -> String { +fn heading_entry(now: NaiveTime, hour_format: &HourFormat) -> String { match hour_format { - Some(HourFormat::Hour24) => { + HourFormat::Hour24 => { let hour = now.hour(); format!("# {}:{:02}", hour, now.minute()) } - _ => { + HourFormat::Hour12 => { let (pm, hour) = now.hour12(); let am_or_pm = if pm { "PM" } else { "AM" }; format!("# {}:{:02} {}", hour, now.minute(), am_or_pm) @@ -224,7 +202,7 @@ mod tests { #[test] fn test_heading_entry_defaults_to_hour_12() { let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap(); - let actual_heading_entry = heading_entry(naive_time, &None); + let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12); let expected_heading_entry = "# 3:00 PM"; assert_eq!(actual_heading_entry, expected_heading_entry); @@ -233,7 +211,7 @@ mod tests { #[test] fn test_heading_entry_is_hour_12() { let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap(); - let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour12)); + let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour12); let expected_heading_entry = "# 3:00 PM"; assert_eq!(actual_heading_entry, expected_heading_entry); @@ -242,7 +220,7 @@ mod tests { #[test] fn test_heading_entry_is_hour_24() { let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap(); - let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour24)); + let actual_heading_entry = heading_entry(naive_time, &HourFormat::Hour24); let expected_heading_entry = "# 15:00"; assert_eq!(actual_heading_entry, expected_heading_entry); diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..efb1b36e7978805ec9c5a07baf9339f66a9d2f9f --- /dev/null +++ b/crates/json_schema_store/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "json_schema_store" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/json_schema_store.rs" + +[features] +default = [] + +[dependencies] +anyhow.workspace = true +dap.workspace = true +extension.workspace = true +gpui.workspace = true +language.workspace = true +paths.workspace = true +project.workspace = true +schemars.workspace = true +serde_json.workspace = true +serde.workspace = true +settings.workspace = true +snippet_provider.workspace = true +task.workspace = true +theme.workspace = true +util.workspace = true + + + +# Uncomment other workspace dependencies as needed +# assistant.workspace = true +# client.workspace = true +# project.workspace = true +# settings.workspace = true diff --git a/tooling/workspace-hack/LICENSE-GPL b/crates/json_schema_store/LICENSE-GPL similarity index 100% rename from tooling/workspace-hack/LICENSE-GPL rename to crates/json_schema_store/LICENSE-GPL diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..b44efb8b1b135850ab78460a428b5088e5fa0928 --- /dev/null +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -0,0 +1,310 @@ +//! # json_schema_store +use std::{str::FromStr, sync::Arc}; + +use anyhow::{Context as _, Result}; +use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, WeakEntity}; +use language::LanguageRegistry; +use project::LspStore; + +// Origin: https://github.com/SchemaStore/schemastore +const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json"); +const PACKAGE_JSON_SCHEMA: &str = include_str!("schemas/package.json"); + +pub fn init(cx: &mut App) { + cx.set_global(SchemaStore::default()); + project::lsp_store::json_language_server_ext::register_schema_handler( + handle_schema_request, + cx, + ); + + cx.observe_new(|_, _, cx| { + let lsp_store = cx.weak_entity(); + cx.global_mut::().lsp_stores.push(lsp_store); + }) + .detach(); + + if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) { + cx.subscribe(&extension_events, |_, evt, cx| { + match evt { + extension::Event::ExtensionInstalled(_) + | extension::Event::ExtensionUninstalled(_) + | extension::Event::ConfigureExtensionRequested(_) => return, + extension::Event::ExtensionsInstalledChanged => {} + } + cx.update_global::(|schema_store, cx| { + schema_store.notify_schema_changed("zed://schemas/settings", cx); + }); + }) + .detach(); + } + + cx.observe_global::(|cx| { + cx.update_global::(|schema_store, cx| { + schema_store.notify_schema_changed("zed://schemas/debug_tasks", cx); + }); + }) + .detach(); +} + +#[derive(Default)] +pub struct SchemaStore { + lsp_stores: Vec>, +} + +impl gpui::Global for SchemaStore {} + +impl SchemaStore { + fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) { + let uri = uri.to_string(); + self.lsp_stores.retain(|lsp_store| { + let Some(lsp_store) = lsp_store.upgrade() else { + return false; + }; + project::lsp_store::json_language_server_ext::notify_schema_changed( + lsp_store, + uri.clone(), + cx, + ); + true + }) + } +} + +fn handle_schema_request( + lsp_store: Entity, + uri: String, + cx: &mut AsyncApp, +) -> Result { + let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone())?; + let schema = resolve_schema_request(&languages, uri, cx)?; + serde_json::to_string(&schema).context("Failed to serialize schema") +} + +pub fn resolve_schema_request( + languages: &Arc, + uri: String, + cx: &mut AsyncApp, +) -> Result { + let path = uri.strip_prefix("zed://schemas/").context("Invalid URI")?; + resolve_schema_request_inner(languages, path, cx) +} + +pub fn resolve_schema_request_inner( + languages: &Arc, + path: &str, + cx: &mut AsyncApp, +) -> Result { + let (schema_name, rest) = path.split_once('/').unzip(); + let schema_name = schema_name.unwrap_or(path); + + let schema = match schema_name { + "settings" => cx.update(|cx| { + let font_names = &cx.text_system().all_font_names(); + let language_names = &languages + .language_names() + .into_iter() + .map(|name| name.to_string()) + .collect::>(); + + let mut icon_theme_names = vec![]; + let mut theme_names = vec![]; + if let Some(registry) = theme::ThemeRegistry::try_global(cx) { + icon_theme_names.extend( + registry + .list_icon_themes() + .into_iter() + .map(|icon_theme| icon_theme.name), + ); + theme_names.extend(registry.list_names()); + } + let icon_theme_names = icon_theme_names.as_slice(); + let theme_names = theme_names.as_slice(); + + cx.global::().json_schema( + &settings::SettingsJsonSchemaParams { + language_names, + font_names, + theme_names, + icon_theme_names, + }, + ) + })?, + "keymap" => cx.update(settings::KeymapFile::generate_json_schema_for_registered_actions)?, + "action" => { + let normalized_action_name = rest.context("No Action name provided")?; + let action_name = denormalize_action_name(normalized_action_name); + let mut generator = settings::KeymapFile::action_schema_generator(); + let schema = cx + // PERF: cx.action_schema_by_name(action_name, &mut generator) + .update(|cx| cx.action_schemas(&mut generator))? + .into_iter() + .find_map(|(name, schema)| (name == action_name).then_some(schema)) + .flatten(); + root_schema_from_action_schema(schema, &mut generator).to_value() + } + "tasks" => task::TaskTemplates::generate_json_schema(), + "debug_tasks" => { + let adapter_schemas = cx.read_global::(|dap_registry, _| { + dap_registry.adapters_schema() + })?; + task::DebugTaskFile::generate_json_schema(&adapter_schemas) + } + "package_json" => package_json_schema(), + "tsconfig" => tsconfig_schema(), + "zed_inspector_style" => { + if cfg!(debug_assertions) { + generate_inspector_style_schema() + } else { + schemars::json_schema!(true).to_value() + } + } + "snippets" => snippet_provider::format::VsSnippetsFile::generate_json_schema(), + _ => { + anyhow::bail!("Unrecognized builtin JSON schema: {}", schema_name); + } + }; + Ok(schema) +} + +pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { + let mut file_associations = serde_json::json!([ + { + "fileMatch": [ + schema_file_match(paths::settings_file()), + paths::local_settings_file_relative_path() + ], + "url": "zed://schemas/settings", + }, + { + "fileMatch": [schema_file_match(paths::keymap_file())], + "url": "zed://schemas/keymap", + }, + { + "fileMatch": [ + schema_file_match(paths::tasks_file()), + paths::local_tasks_file_relative_path() + ], + "url": "zed://schemas/tasks", + }, + { + "fileMatch": [ + schema_file_match(paths::debug_scenarios_file()), + paths::local_debug_file_relative_path() + ], + "url": "zed://schemas/debug_tasks", + }, + { + "fileMatch": [ + schema_file_match( + paths::snippets_dir() + .join("*.json") + .as_path() + ) + ], + "url": "zed://schemas/snippets", + }, + { + "fileMatch": ["tsconfig.json"], + "url": "zed://schemas/tsconfig" + }, + { + "fileMatch": ["package.json"], + "url": "zed://schemas/package_json" + }, + ]); + + #[cfg(debug_assertions)] + { + file_associations + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "fileMatch": [ + "zed-inspector-style.json" + ], + "url": "zed://schemas/zed_inspector_style" + })); + } + + file_associations.as_array_mut().unwrap().extend( + // ?PERF: use all_action_schemas() and don't include action schemas with no arguments + cx.all_action_names().into_iter().map(|&name| { + let normalized_name = normalize_action_name(name); + let file_name = normalized_action_name_to_file_name(normalized_name.clone()); + serde_json::json!({ + "fileMatch": [file_name], + "url": format!("zed://schemas/action/{}", normalized_name) + }) + }), + ); + + file_associations +} + +fn tsconfig_schema() -> serde_json::Value { + serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap() +} + +fn package_json_schema() -> serde_json::Value { + serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap() +} + +fn generate_inspector_style_schema() -> serde_json::Value { + let schema = schemars::generate::SchemaSettings::draft2019_09() + .with_transform(util::schemars::DefaultDenyUnknownFields) + .into_generator() + .root_schema_for::(); + + serde_json::to_value(schema).unwrap() +} + +pub fn normalize_action_name(action_name: &str) -> String { + action_name.replace("::", "__") +} + +pub fn denormalize_action_name(action_name: &str) -> String { + action_name.replace("__", "::") +} + +pub fn normalized_action_file_name(action_name: &str) -> String { + normalized_action_name_to_file_name(normalize_action_name(action_name)) +} + +pub fn normalized_action_name_to_file_name(mut normalized_action_name: String) -> String { + normalized_action_name.push_str(".json"); + normalized_action_name +} + +fn root_schema_from_action_schema( + action_schema: Option, + generator: &mut schemars::SchemaGenerator, +) -> schemars::Schema { + let Some(mut action_schema) = action_schema else { + return schemars::json_schema!(false); + }; + let meta_schema = generator + .settings() + .meta_schema + .as_ref() + .expect("meta_schema should be present in schemars settings") + .to_string(); + let defs = generator.definitions(); + let mut schema = schemars::json_schema!({ + "$schema": meta_schema, + "allowTrailingCommas": true, + "$defs": defs, + }); + schema + .ensure_object() + .extend(std::mem::take(action_schema.ensure_object())); + schema +} + +#[inline] +fn schema_file_match(path: &std::path::Path) -> String { + path.strip_prefix(path.parent().unwrap().parent().unwrap()) + .unwrap() + .display() + .to_string() + .replace('\\', "/") +} diff --git a/crates/languages/src/json/schemas/package.json b/crates/json_schema_store/src/schemas/package.json similarity index 88% rename from crates/languages/src/json/schemas/package.json rename to crates/json_schema_store/src/schemas/package.json index 664149eca92b81946420c98405219440c7be7c08..a24583fa8848891d661114291951d4df28f463fd 100644 --- a/crates/languages/src/json/schemas/package.json +++ b/crates/json_schema_store/src/schemas/package.json @@ -160,6 +160,11 @@ "$ref": "#/definitions/packageExportsEntryOrFallback", "description": "The module path that is resolved when this specifier is imported as an ECMAScript module using an `import` declaration or the dynamic `import(...)` function." }, + "module-sync": { + "$ref": "#/definitions/packageExportsEntryOrFallback", + "$comment": "https://nodejs.org/api/packages.html#conditional-exports#:~:text=%22module-sync%22", + "description": "The same as `import`, but can be used with require(esm) in Node 20+. This requires the files to not use any top-level awaits." + }, "node": { "$ref": "#/definitions/packageExportsEntryOrFallback", "description": "The module path that is resolved when this environment is Node.js." @@ -304,6 +309,33 @@ "required": [ "url" ] + }, + "devEngineDependency": { + "description": "Specifies requirements for development environment components such as operating systems, runtimes, or package managers. Used to ensure consistent development environments across the team.", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the dependency, with allowed values depending on the parent field" + }, + "version": { + "type": "string", + "description": "The version range for the dependency" + }, + "onFail": { + "type": "string", + "enum": [ + "ignore", + "warn", + "error", + "download" + ], + "description": "What action to take if validation fails" + } + } } }, "type": "object", @@ -755,7 +787,7 @@ ] }, "resolutions": { - "description": "Resolutions is used to support selective version resolutions using yarn, which lets you define custom package versions or ranges inside your dependencies. For npm, use overrides instead. See: https://classic.yarnpkg.com/en/docs/selective-version-resolutions", + "description": "Resolutions is used to support selective version resolutions using yarn, which lets you define custom package versions or ranges inside your dependencies. For npm, use overrides instead. See: https://yarnpkg.com/configuration/manifest#resolutions", "type": "object" }, "overrides": { @@ -810,6 +842,82 @@ "type": "string" } }, + "devEngines": { + "description": "Define the runtime and package manager for developing the current project.", + "type": "object", + "properties": { + "os": { + "oneOf": [ + { + "$ref": "#/definitions/devEngineDependency" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/devEngineDependency" + } + } + ], + "description": "Specifies which operating systems are supported for development" + }, + "cpu": { + "oneOf": [ + { + "$ref": "#/definitions/devEngineDependency" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/devEngineDependency" + } + } + ], + "description": "Specifies which CPU architectures are supported for development" + }, + "libc": { + "oneOf": [ + { + "$ref": "#/definitions/devEngineDependency" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/devEngineDependency" + } + } + ], + "description": "Specifies which C standard libraries are supported for development" + }, + "runtime": { + "oneOf": [ + { + "$ref": "#/definitions/devEngineDependency" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/devEngineDependency" + } + } + ], + "description": "Specifies which JavaScript runtimes (like Node.js, Deno, Bun) are supported for development. Values should use WinterCG Runtime Keys (see https://runtime-keys.proposal.wintercg.org/)" + }, + "packageManager": { + "oneOf": [ + { + "$ref": "#/definitions/devEngineDependency" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/devEngineDependency" + } + } + ], + "description": "Specifies which package managers are supported for development" + } + } + }, "preferGlobal": { "type": "boolean", "description": "DEPRECATED: This option used to trigger an npm warning, but it will no longer warn. It is purely there for informational purposes. It is now recommended that you install any binaries as local devDependencies wherever possible." @@ -973,6 +1081,7 @@ "additionalProperties": false }, "peerDependencyRules": { + "type": "object", "properties": { "ignoreMissing": { "description": "pnpm will not print warnings about missing peer dependencies from this list.", @@ -1032,6 +1141,10 @@ "description": "When true, installation won't fail if some of the patches from the \"patchedDependencies\" field were not applied.", "type": "boolean" }, + "allowUnusedPatches": { + "description": "When true, installation won't fail if some of the patches from the \"patchedDependencies\" field were not applied.", + "type": "boolean" + }, "updateConfig": { "type": "object", "properties": { @@ -1122,6 +1235,41 @@ } }, "additionalProperties": false + }, + "stackblitz": { + "description": "Defines the StackBlitz configuration for the project.", + "type": "object", + "properties": { + "installDependencies": { + "description": "StackBlitz automatically installs npm dependencies when opening a project.", + "type": "boolean" + }, + "startCommand": { + "description": "A terminal command to be executed when opening the project, after installing npm dependencies.", + "type": [ + "string", + "boolean" + ] + }, + "compileTrigger": { + "description": "The compileTrigger option controls how file changes in the editor are written to the WebContainers in-memory filesystem. ", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "keystroke", + "save" + ] + } + ] + }, + "env": { + "description": "A map of default environment variables that will be set in each top-level shell process.", + "type": "object" + } + }, + "additionalProperties": false } }, "anyOf": [ diff --git a/crates/languages/src/json/schemas/tsconfig.json b/crates/json_schema_store/src/schemas/tsconfig.json similarity index 100% rename from crates/languages/src/json/schemas/tsconfig.json rename to crates/json_schema_store/src/schemas/tsconfig.json diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..b6086566c3be01b60527d497b836fc53d101e467 --- /dev/null +++ b/crates/keymap_editor/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "keymap_editor" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/keymap_editor.rs" + +[dependencies] +anyhow.workspace = true +collections.workspace = true +command_palette.workspace = true +component.workspace = true +db.workspace = true +editor.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +itertools.workspace = true +json_schema_store.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +notifications.workspace = true +paths.workspace = true +project.workspace = true +search.workspace = true +serde_json.workspace = true +serde.workspace = true +settings.workspace = true +telemetry.workspace = true +tempfile.workspace = true +theme.workspace = true +tree-sitter-json.workspace = true +tree-sitter-rust.workspace = true +ui_input.workspace = true +ui.workspace = true +util.workspace = true +vim.workspace = true +workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +db = {"workspace"= true, "features" = ["test-support"]} +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/keymap_editor/LICENSE-GPL b/crates/keymap_editor/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/keymap_editor/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/settings_ui/src/keybindings.rs b/crates/keymap_editor/src/keymap_editor.rs similarity index 74% rename from crates/settings_ui/src/keybindings.rs rename to crates/keymap_editor/src/keymap_editor.rs index b4e871c617461ce7d760c4d9374b6ad3dacb2f23..e3fb30d46eb57059afc53682c57be392ec8254ed 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1,55 +1,58 @@ use std::{ + cell::RefCell, cmp::{self}, ops::{Not as _, Range}, + rc::Rc, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; +mod ui_components; + use anyhow::{Context as _, anyhow}; use collections::{HashMap, HashSet}; use editor::{CompletionProvider, Editor, EditorEvent}; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, - EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, Keystroke, MouseButton, - Point, ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task, - TextStyleRefinement, WeakEntity, actions, anchored, deferred, div, + Action, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, Global, IsZero, + KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or}, + KeyContext, KeybindingKeystroke, MouseButton, PlatformKeyboardMapper, Point, ScrollStrategy, + ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, + actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; -use project::Project; -use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets}; +use project::{CompletionDisplayOptions, Project}; +use settings::{ + BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size, +}; use ui::{ ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator, - Modal, ModalFooter, ModalHeader, ParentElement as _, Render, Section, SharedString, - Styled as _, Tooltip, Window, prelude::*, right_click_menu, + Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section, + SharedString, Styled as _, Table, TableColumnWidths, TableInteractionState, + TableResizeBehavior, Tooltip, Window, prelude::*, }; -use ui_input::SingleLineInput; +use ui_input::InputField; use util::ResultExt; use workspace::{ Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _, register_serializable_item, }; +pub use ui_components::*; +use zed_actions::{ChangeKeybinding, OpenKeymap}; + use crate::{ - keybindings::persistence::KEYBINDING_EDITORS, - ui_components::{ - keystroke_input::{ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording}, - table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, + persistence::KEYBINDING_EDITORS, + ui_components::keystroke_input::{ + ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording, }, }; const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static(""); -actions!( - zed, - [ - /// Opens the keymap editor. - OpenKeymapEditor - ] -); - actions!( keymap_editor, [ @@ -78,37 +81,77 @@ pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); - cx.on_action(|_: &OpenKeymapEditor, cx| { + fn common(filter: Option, cx: &mut App) { workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { workspace - .with_local_workspace(window, cx, |workspace, window, cx| { + .with_local_workspace(window, cx, move |workspace, window, cx| { let existing = workspace .active_pane() .read(cx) .items() .find_map(|item| item.downcast::()); - if let Some(existing) = existing { + let keymap_editor = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); + existing } else { let keymap_editor = cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); workspace.add_item_to_active_pane( - Box::new(keymap_editor), + Box::new(keymap_editor.clone()), None, true, window, cx, ); + keymap_editor + }; + + if let Some(filter) = filter { + keymap_editor.update(cx, |editor, cx| { + editor.filter_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.insert(&filter, window, cx); + }); + if !editor.has_binding_for(&filter) { + open_binding_modal_after_loading(cx) + } + }) } }) .detach(); }) - }); + } + + cx.on_action(|_: &OpenKeymap, cx| common(None, cx)); + cx.on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); register_serializable_item::(cx); } +fn open_binding_modal_after_loading(cx: &mut Context) { + let started_at = Instant::now(); + let observer = Rc::new(RefCell::new(None)); + let handle = { + let observer = Rc::clone(&observer); + cx.observe(&cx.entity(), move |editor, _, cx| { + let subscription = observer.borrow_mut().take(); + + if started_at.elapsed().as_secs() > 10 { + return; + } + if !editor.matches.is_empty() { + editor.selected_index = Some(0); + cx.dispatch_action(&CreateBinding); + return; + } + + *observer.borrow_mut() = subscription; + }) + }; + *observer.borrow_mut() = Some(handle); +} + pub struct KeymapEventChannel {} impl Global for KeymapEventChannel {} @@ -172,7 +215,7 @@ impl FilterState { #[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] struct ActionMapping { - keystrokes: Vec, + keystrokes: Rc<[KeybindingKeystroke]>, context: Option, } @@ -182,15 +225,6 @@ struct KeybindConflict { remaining_conflict_amount: usize, } -impl KeybindConflict { - fn from_iter<'a>(mut indices: impl Iterator) -> Option { - indices.next().map(|origin| Self { - first_conflict_index: origin.index, - remaining_conflict_amount: indices.count(), - }) - } -} - #[derive(Clone, Copy, PartialEq)] struct ConflictOrigin { override_source: KeybindSource, @@ -238,13 +272,21 @@ impl ConflictOrigin { #[derive(Default)] struct ConflictState { conflicts: Vec>, - keybind_mapping: HashMap>, + keybind_mapping: ConflictKeybindMapping, has_user_conflicts: bool, } +type ConflictKeybindMapping = HashMap< + Rc<[KeybindingKeystroke]>, + Vec<( + Option, + Vec, + )>, +>; + impl ConflictState { fn new(key_bindings: &[ProcessedBinding]) -> Self { - let mut action_keybind_mapping: HashMap<_, Vec> = HashMap::default(); + let mut action_keybind_mapping = ConflictKeybindMapping::default(); let mut largest_index = 0; for (index, binding) in key_bindings @@ -252,29 +294,48 @@ impl ConflictState { .enumerate() .flat_map(|(index, binding)| Some(index).zip(binding.keybind_information())) { - action_keybind_mapping - .entry(binding.get_action_mapping()) - .or_default() - .push(ConflictOrigin::new(binding.source, index)); + let mapping = binding.get_action_mapping(); + let predicate = mapping + .context + .and_then(|ctx| gpui::KeyBindingContextPredicate::parse(&ctx).ok()); + let entry = action_keybind_mapping + .entry(mapping.keystrokes.clone()) + .or_default(); + let origin = ConflictOrigin::new(binding.source, index); + if let Some((_, origins)) = + entry + .iter_mut() + .find(|(other_predicate, _)| match (&predicate, other_predicate) { + (None, None) => true, + (Some(a), Some(b)) => normalized_ctx_eq(a, b), + _ => false, + }) + { + origins.push(origin); + } else { + entry.push((predicate, vec![origin])); + } largest_index = index; } let mut conflicts = vec![None; largest_index + 1]; let mut has_user_conflicts = false; - for indices in action_keybind_mapping.values_mut() { - indices.sort_unstable_by_key(|origin| origin.override_source); - let Some((fst, snd)) = indices.get(0).zip(indices.get(1)) else { - continue; - }; + for entries in action_keybind_mapping.values_mut() { + for (_, indices) in entries.iter_mut() { + indices.sort_unstable_by_key(|origin| origin.override_source); + let Some((fst, snd)) = indices.get(0).zip(indices.get(1)) else { + continue; + }; - for origin in indices.iter() { - conflicts[origin.index] = - origin.get_conflict_with(if origin == fst { &snd } else { &fst }) - } + for origin in indices.iter() { + conflicts[origin.index] = + origin.get_conflict_with(if origin == fst { snd } else { fst }) + } - has_user_conflicts |= fst.override_source == KeybindSource::User - && snd.override_source == KeybindSource::User; + has_user_conflicts |= fst.override_source == KeybindSource::User + && snd.override_source == KeybindSource::User; + } } Self { @@ -289,15 +350,34 @@ impl ConflictState { action_mapping: &ActionMapping, keybind_idx: Option, ) -> Option { - self.keybind_mapping - .get(action_mapping) - .and_then(|indices| { - KeybindConflict::from_iter( - indices + let ActionMapping { + keystrokes, + context, + } = action_mapping; + let predicate = context + .as_deref() + .and_then(|ctx| gpui::KeyBindingContextPredicate::parse(&ctx).ok()); + self.keybind_mapping.get(keystrokes).and_then(|entries| { + entries + .iter() + .find_map(|(other_predicate, indices)| { + match (&predicate, other_predicate) { + (None, None) => true, + (Some(pred), Some(other)) => normalized_ctx_eq(pred, other), + _ => false, + } + .then_some(indices) + }) + .and_then(|indices| { + let mut indices = indices .iter() - .filter(|&conflict| Some(conflict.index) != keybind_idx), - ) - }) + .filter(|&conflict| Some(conflict.index) != keybind_idx); + indices.next().map(|origin| KeybindConflict { + first_conflict_index: origin.index, + remaining_conflict_amount: indices.count(), + }) + }) + }) } fn conflict_for_idx(&self, idx: usize) -> Option { @@ -333,7 +413,7 @@ struct KeymapEditor { context_menu: Option<(Entity, Point, Subscription)>, previous_edit: Option, humanized_action_names: HumanizedActionNameCache, - current_widths: Entity>, + current_widths: Entity>, show_hover_menus: bool, /// In order for the JSON LSP to run in the actions arguments editor, we /// require a backing file In order to avoid issues (primarily log spam) @@ -375,19 +455,24 @@ impl Focusable for KeymapEditor { } } /// Helper function to check if two keystroke sequences match exactly -fn keystrokes_match_exactly(keystrokes1: &[Keystroke], keystrokes2: &[Keystroke]) -> bool { +fn keystrokes_match_exactly( + keystrokes1: &[KeybindingKeystroke], + keystrokes2: &[KeybindingKeystroke], +) -> bool { keystrokes1.len() == keystrokes2.len() - && keystrokes1 - .iter() - .zip(keystrokes2) - .all(|(k1, k2)| k1.key == k2.key && k1.modifiers == k2.modifiers) + && keystrokes1.iter().zip(keystrokes2).all(|(k1, k2)| { + k1.inner().key == k2.inner().key && k1.inner().modifiers == k2.inner().modifiers + }) } impl KeymapEditor { fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let _keymap_subscription = cx.observe_global_in::(window, Self::on_keymap_changed); - let table_interaction_state = TableInteractionState::new(window, cx); + let table_interaction_state = cx.new(|cx| { + TableInteractionState::new(cx) + .with_custom_scrollbar(ui::Scrollbars::for_settings::()) + }); let keystroke_editor = cx.new(|cx| { let mut keystroke_editor = KeystrokeInput::new(None, window, cx); @@ -397,7 +482,7 @@ impl KeymapEditor { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Filter action names…", cx); + editor.set_placeholder_text("Filter action names…", window, cx); editor }); @@ -458,7 +543,7 @@ impl KeymapEditor { show_hover_menus: true, action_args_temp_dir: None, action_args_temp_dir_worktree: None, - current_widths: cx.new(|cx| ColumnWidths::new(cx)), + current_widths: cx.new(|cx| TableColumnWidths::new(cx)), }; this.on_keymap_changed(window, cx); @@ -470,19 +555,18 @@ impl KeymapEditor { self.filter_editor.read(cx).text(cx) } - fn current_keystroke_query(&self, cx: &App) -> Vec { + fn current_keystroke_query(&self, cx: &App) -> Vec { match self.search_mode { - SearchMode::KeyStroke { .. } => self - .keystroke_editor - .read(cx) - .keystrokes() - .iter() - .cloned() - .collect(), + SearchMode::KeyStroke { .. } => self.keystroke_editor.read(cx).keystrokes().to_vec(), SearchMode::Normal => Default::default(), } } + fn clear_action_query(&self, window: &mut Window, cx: &mut Context) { + self.filter_editor + .update(cx, |editor, cx| editor.clear(window, cx)) + } + fn on_query_changed(&mut self, cx: &mut Context) { let action_query = self.current_action_query(cx); let keystroke_query = self.current_keystroke_query(cx); @@ -497,7 +581,7 @@ impl KeymapEditor { let keystroke_query = keystroke_query .into_iter() - .map(|keystroke| keystroke.unparse()) + .map(|keystroke| keystroke.inner().unparse()) .collect::>() .join(" "); @@ -521,7 +605,7 @@ impl KeymapEditor { async fn update_matches( this: WeakEntity, action_query: String, - keystroke_query: Vec, + keystroke_query: Vec, cx: &mut AsyncApp, ) -> anyhow::Result<()> { let action_query = command_palette::normalize_action_query(&action_query); @@ -559,7 +643,7 @@ impl KeymapEditor { if exact_match { keystrokes_match_exactly(&keystroke_query, keystrokes) } else if keystroke_query.len() > keystrokes.len() { - return false; + false } else { for keystroke_offset in 0..keystrokes.len() { let mut found_count = 0; @@ -570,16 +654,15 @@ impl KeymapEditor { { let query = &keystroke_query[query_cursor]; let keystroke = &keystrokes[keystroke_cursor]; - let matches = - query.modifiers.is_subset_of(&keystroke.modifiers) - && ((query.key.is_empty() - || query.key == keystroke.key) - && query - .key_char - .as_ref() - .map_or(true, |q_kc| { - q_kc == &keystroke.key - })); + let matches = query + .inner() + .modifiers + .is_subset_of(&keystroke.inner().modifiers) + && ((query.inner().key.is_empty() + || query.inner().key == keystroke.inner().key) + && query.inner().key_char.as_ref().is_none_or( + |q_kc| q_kc == &keystroke.inner().key, + )); if matches { found_count += 1; query_cursor += 1; @@ -591,7 +674,7 @@ impl KeymapEditor { return true; } } - return false; + false } }) }); @@ -630,8 +713,7 @@ impl KeymapEditor { let key_bindings_ptr = cx.key_bindings(); let lock = key_bindings_ptr.borrow(); let key_bindings = lock.bindings(); - let mut unmapped_action_names = - HashSet::from_iter(cx.all_action_names().into_iter().copied()); + let mut unmapped_action_names = HashSet::from_iter(cx.all_action_names().iter().copied()); let action_documentation = cx.action_documentation(); let mut generator = KeymapFile::action_schema_generator(); let actions_with_schemas = HashSet::from_iter( @@ -649,9 +731,8 @@ impl KeymapEditor { .map(KeybindSource::from_meta) .unwrap_or(KeybindSource::Unknown); - let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx); - let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) - .vim_mode(source == KeybindSource::Vim); + let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx); + let binding = KeyBinding::new(key_binding, source); let context = key_binding .predicate() @@ -673,8 +754,8 @@ impl KeymapEditor { action_name, action_arguments, &actions_with_schemas, - &action_documentation, - &humanized_action_names, + action_documentation, + humanized_action_names, ); let index = processed_bindings.len(); @@ -682,7 +763,7 @@ impl KeymapEditor { StringMatchCandidate::new(index, &action_information.humanized_name); processed_bindings.push(ProcessedBinding::new_mapped( keystroke_text, - ui_key_binding, + binding, context, source, action_information, @@ -696,8 +777,8 @@ impl KeymapEditor { action_name, None, &actions_with_schemas, - &action_documentation, - &humanized_action_names, + action_documentation, + humanized_action_names, ); let string_match_candidate = StringMatchCandidate::new(index, &action_information.humanized_name); @@ -751,9 +832,8 @@ impl KeymapEditor { match previous_edit { // should remove scroll from process_query PreviousEdit::ScrollBarOffset(offset) => { - this.table_interaction_state.update(cx, |table, _| { - table.set_scrollbar_offset(Axis::Vertical, offset) - }) + this.table_interaction_state + .update(cx, |table, _| table.set_scroll_offset(offset)) // set selected index and scroll } PreviousEdit::Keybinding { @@ -782,9 +862,8 @@ impl KeymapEditor { cx, ); } else { - this.table_interaction_state.update(cx, |table, _| { - table.set_scrollbar_offset(Axis::Vertical, fallback) - }); + this.table_interaction_state + .update(cx, |table, _| table.set_scroll_offset(fallback)); } cx.notify(); } @@ -942,12 +1021,11 @@ impl KeymapEditor { if conflict.is_user_keybind_conflict() { base_button_style(index, IconName::Warning) .icon_color(Color::Warning) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "View conflicts", Some(&ToggleConflictFilter), "Use alt+click to show all conflicts", - window, cx, ) }) @@ -962,12 +1040,11 @@ impl KeymapEditor { })) } else if self.search_mode.exact_match() { base_button_style(index, IconName::Info) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Edit this binding", Some(&ShowMatchingKeybinds), "This binding is overridden by other bindings.", - window, cx, ) }) @@ -978,12 +1055,11 @@ impl KeymapEditor { })) } else { base_button_style(index, IconName::Info) - .tooltip(|window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Show matching keybinds", Some(&ShowMatchingKeybinds), "This binding is overridden by other bindings.\nUse alt+click to edit this binding", - window, cx, ) }) @@ -1167,14 +1243,14 @@ impl KeymapEditor { else { return; }; - let tab_size = cx.global::().json_tab_size(); self.previous_edit = Some(PreviousEdit::ScrollBarOffset( - self.table_interaction_state - .read(cx) - .get_scrollbar_offset(Axis::Vertical), + self.table_interaction_state.read(cx).scroll_offset(), )); - cx.spawn(async move |_, _| remove_keybinding(to_remove, &fs, tab_size).await) - .detach_and_notify_err(window, cx); + let keyboard_mapper = cx.keyboard_mapper().clone(); + cx.spawn(async move |_, _| { + remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await + }) + .detach_and_notify_err(window, cx); } fn copy_context_to_clipboard( @@ -1192,8 +1268,8 @@ impl KeymapEditor { return; }; - telemetry::event!("Keybinding Context Copied", context = context.clone()); - cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone())); + telemetry::event!("Keybinding Context Copied", context = context); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(context)); } fn copy_action_to_clipboard( @@ -1209,8 +1285,8 @@ impl KeymapEditor { return; }; - telemetry::event!("Keybinding Action Copied", action = action.clone()); - cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone())); + telemetry::event!("Keybinding Action Copied", action = action); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(action)); } fn toggle_conflict_filter( @@ -1290,6 +1366,13 @@ impl KeymapEditor { editor.set_keystrokes(keystrokes, cx); }); } + + fn has_binding_for(&self, action_name: &str) -> bool { + self.keybindings + .iter() + .filter(|kb| kb.keystrokes().is_some()) + .any(|kb| kb.action().name == action_name) + } } struct HumanizedActionNameCache { @@ -1298,7 +1381,7 @@ struct HumanizedActionNameCache { impl HumanizedActionNameCache { fn new(cx: &App) -> Self { - let cache = HashMap::from_iter(cx.all_action_names().into_iter().map(|&action_name| { + let cache = HashMap::from_iter(cx.all_action_names().iter().map(|&action_name| { ( action_name, command_palette::humanize_action_name(action_name).into(), @@ -1315,10 +1398,25 @@ impl HumanizedActionNameCache { } } +#[derive(Clone)] +struct KeyBinding { + keystrokes: Rc<[KeybindingKeystroke]>, + source: KeybindSource, +} + +impl KeyBinding { + fn new(binding: &gpui::KeyBinding, source: KeybindSource) -> Self { + Self { + keystrokes: Rc::from(binding.keystrokes()), + source, + } + } +} + #[derive(Clone)] struct KeybindInformation { keystroke_text: SharedString, - ui_binding: ui::KeyBinding, + binding: KeyBinding, context: KeybindContextString, source: KeybindSource, } @@ -1326,7 +1424,7 @@ struct KeybindInformation { impl KeybindInformation { fn get_action_mapping(&self) -> ActionMapping { ActionMapping { - keystrokes: self.ui_binding.keystrokes.clone(), + keystrokes: self.binding.keystrokes.clone(), context: self.context.local().cloned(), } } @@ -1368,7 +1466,7 @@ enum ProcessedBinding { impl ProcessedBinding { fn new_mapped( keystroke_text: impl Into, - ui_key_binding: ui::KeyBinding, + binding: KeyBinding, context: KeybindContextString, source: KeybindSource, action_information: ActionInformation, @@ -1376,7 +1474,7 @@ impl ProcessedBinding { Self::Mapped( KeybindInformation { keystroke_text: keystroke_text.into(), - ui_binding: ui_key_binding, + binding, context, source, }, @@ -1393,9 +1491,9 @@ impl ProcessedBinding { .map(|keybind| keybind.get_action_mapping()) } - fn keystrokes(&self) -> Option<&[Keystroke]> { - self.ui_key_binding() - .map(|binding| binding.keystrokes.as_slice()) + fn keystrokes(&self) -> Option<&[KeybindingKeystroke]> { + self.key_binding() + .map(|binding| binding.keystrokes.as_ref()) } fn keybind_information(&self) -> Option<&KeybindInformation> { @@ -1413,9 +1511,8 @@ impl ProcessedBinding { self.keybind_information().map(|keybind| &keybind.context) } - fn ui_key_binding(&self) -> Option<&ui::KeyBinding> { - self.keybind_information() - .map(|keybind| &keybind.ui_binding) + fn key_binding(&self) -> Option<&KeyBinding> { + self.keybind_information().map(|keybind| &keybind.binding) } fn keystroke_text(&self) -> Option<&SharedString> { @@ -1474,7 +1571,7 @@ impl RenderOnce for KeybindContextString { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { match self { KeybindContextString::Global => { - muted_styled_text(KeybindContextString::GLOBAL.clone(), cx).into_any_element() + muted_styled_text(KeybindContextString::GLOBAL, cx).into_any_element() } KeybindContextString::Local(name, language) => { SyntaxHighlightedText::new(name, language).into_any_element() @@ -1537,34 +1634,7 @@ impl Render for KeymapEditor { h_flex() .gap_2() .child( - right_click_menu("open-keymap-menu") - .menu(|window, cx| { - ContextMenu::build(window, cx, |menu, _, _| { - menu.header("Open Keymap JSON") - .action("User", zed_actions::OpenKeymap.boxed_clone()) - .action("Zed Default", zed_actions::OpenDefaultKeymap.boxed_clone()) - .action("Vim Default", vim::OpenDefaultKeymap.boxed_clone()) - }) - }) - .anchor(gpui::Corner::TopLeft) - .trigger(|open, _, _| - IconButton::new( - "OpenKeymapJsonButton", - IconName::Json - ) - .shape(ui::IconButtonShape::Square) - .when(!open, |this| - this.tooltip(move |window, cx| { - Tooltip::with_meta("Open Keymap JSON", Some(&zed_actions::OpenKeymap),"Right click to view more options", window, cx) - }) - ) - .on_click(|_, window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); - }) - ) - ) - .child( - div() + h_flex() .key_context({ let mut context = KeyContext::new_with_defaults(); context.add("BufferSearchBar"); @@ -1577,73 +1647,141 @@ impl Render for KeymapEditor { .py_1() .border_1() .border_color(theme.colors().border) - .rounded_lg() + .rounded_md() .child(self.filter_editor.clone()), ) .child( - IconButton::new( - "KeymapEditorToggleFiltersIcon", - IconName::Keyboard, - ) - .shape(ui::IconButtonShape::Square) - .tooltip({ - let focus_handle = focus_handle.clone(); - - move |window, cx| { - Tooltip::for_action_in( - "Search by Keystroke", - &ToggleKeystrokeSearch, - &focus_handle.clone(), - window, - cx, + h_flex() + .gap_1() + .min_w_64() + .child( + IconButton::new( + "KeymapEditorToggleFiltersIcon", + IconName::Keyboard, ) - } - }) - .toggle_state(matches!( - self.search_mode, - SearchMode::KeyStroke { .. } - )) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx); - }), - ) - .child( - IconButton::new("KeymapEditorConflictIcon", IconName::Warning) - .shape(ui::IconButtonShape::Square) - .when( - self.keybinding_conflict_state.any_user_binding_conflicts(), - |this| { - this.indicator(Indicator::dot().color(Color::Warning)) - }, + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + + move |_window, cx| { + Tooltip::for_action_in( + "Search by Keystroke", + &ToggleKeystrokeSearch, + &focus_handle.clone(), + cx, + ) + } + }) + .toggle_state(matches!( + self.search_mode, + SearchMode::KeyStroke { .. } + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleKeystrokeSearch.boxed_clone(), + cx, + ); + }), ) - .tooltip({ - let filter_state = self.filter_state; - let focus_handle = focus_handle.clone(); - - move |window, cx| { - Tooltip::for_action_in( - match filter_state { - FilterState::All => "Show Conflicts", - FilterState::Conflicts => "Hide Conflicts", + .child( + IconButton::new("KeymapEditorConflictIcon", IconName::Warning) + .icon_size(IconSize::Small) + .when( + self.keybinding_conflict_state + .any_user_binding_conflicts(), + |this| { + this.indicator( + Indicator::dot().color(Color::Warning), + ) }, - &ToggleConflictFilter, - &focus_handle.clone(), - window, - cx, ) - } - }) - .selected_icon_color(Color::Warning) - .toggle_state(matches!( - self.filter_state, - FilterState::Conflicts - )) - .on_click(|_, window, cx| { - window.dispatch_action( - ToggleConflictFilter.boxed_clone(), - cx, - ); - }), + .tooltip({ + let filter_state = self.filter_state; + let focus_handle = focus_handle.clone(); + + move |_window, cx| { + Tooltip::for_action_in( + match filter_state { + FilterState::All => "Show Conflicts", + FilterState::Conflicts => { + "Hide Conflicts" + } + }, + &ToggleConflictFilter, + &focus_handle.clone(), + cx, + ) + } + }) + .selected_icon_color(Color::Warning) + .toggle_state(matches!( + self.filter_state, + FilterState::Conflicts + )) + .on_click(|_, window, cx| { + window.dispatch_action( + ToggleConflictFilter.boxed_clone(), + cx, + ); + }), + ) + .child( + h_flex() + .w_full() + .pl_2() + .gap_1() + .justify_end() + .child( + PopoverMenu::new("open-keymap-menu") + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _, _| { + menu.header("View Default...") + .action( + "Zed Key Bindings", + zed_actions::OpenDefaultKeymap + .boxed_clone(), + ) + .action( + "Vim Bindings", + vim::OpenDefaultKeymap.boxed_clone(), + ) + })) + }) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.0), + y: px(2.0), + }) + .trigger_with_tooltip( + IconButton::new( + "OpenKeymapJsonButton", + IconName::Ellipsis, + ) + .icon_size(IconSize::Small), + { + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "View Default...", + &zed_actions::OpenKeymapFile, + &focus_handle, + cx, + ) + } + }, + ), + ) + .child( + Button::new("edit-in-json", "Edit in keymap.json") + .style(ButtonStyle::Outlined) + .on_click(|_, window, cx| { + window.dispatch_action( + zed_actions::OpenKeymapFile.boxed_clone(), + cx, + ); + }) + ), + ) ), ) .when_some( @@ -1654,48 +1792,41 @@ impl Render for KeymapEditor { |this, exact_match| { this.child( h_flex() - .map(|this| { - if self - .keybinding_conflict_state - .any_user_binding_conflicts() - { - this.pr(rems_from_px(54.)) - } else { - this.pr_7() - } - }) .gap_2() .child(self.keystroke_editor.clone()) .child( - IconButton::new( - "keystrokes-exact-match", - IconName::CaseSensitive, - ) - .tooltip({ - let keystroke_focus_handle = - self.keystroke_editor.read(cx).focus_handle(cx); - - move |window, cx| { - Tooltip::for_action_in( - "Toggle Exact Match Mode", - &ToggleExactKeystrokeMatching, - &keystroke_focus_handle, - window, - cx, + h_flex() + .min_w_64() + .child( + IconButton::new( + "keystrokes-exact-match", + IconName::CaseSensitive, ) - } - }) - .shape(IconButtonShape::Square) - .toggle_state(exact_match) - .on_click( - cx.listener(|_, _, window, cx| { - window.dispatch_action( - ToggleExactKeystrokeMatching.boxed_clone(), - cx, - ); - }), - ), - ), + .tooltip({ + let keystroke_focus_handle = + self.keystroke_editor.read(cx).focus_handle(cx); + + move |_window, cx| { + Tooltip::for_action_in( + "Toggle Exact Match Mode", + &ToggleExactKeystrokeMatching, + &keystroke_focus_handle, + cx, + ) + } + }) + .shape(IconButtonShape::Square) + .toggle_state(exact_match) + .on_click( + cx.listener(|_, _, window, cx| { + window.dispatch_action( + ToggleExactKeystrokeMatching.boxed_clone(), + cx, + ); + }), + ), + ), + ) ) }, ), @@ -1718,12 +1849,12 @@ impl Render for KeymapEditor { ]) .resizable_columns( [ - ResizeBehavior::None, - ResizeBehavior::Resizable, - ResizeBehavior::Resizable, - ResizeBehavior::Resizable, - ResizeBehavior::Resizable, - ResizeBehavior::Resizable, // this column doesn't matter + TableResizeBehavior::None, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, + TableResizeBehavior::Resizable, // this column doesn't matter ], &self.current_widths, cx, @@ -1758,7 +1889,7 @@ impl Render for KeymapEditor { } else { const NULL: SharedString = SharedString::new_static(""); - muted_styled_text(NULL.clone(), cx) + muted_styled_text(NULL, cx) .into_any_element() } }) @@ -1785,13 +1916,13 @@ impl Render for KeymapEditor { ) .into_any_element(); - let keystrokes = binding.ui_key_binding().cloned().map_or( + let keystrokes = binding.key_binding().map_or( binding .keystroke_text() .cloned() .unwrap_or_default() .into_any_element(), - IntoElement::into_any_element, + |binding| ui::KeyBinding::from_keystrokes(binding.keystrokes.clone(), binding.source).into_any_element() ); let action_arguments = match binding.action().arguments.clone() @@ -1866,18 +1997,15 @@ impl Render for KeymapEditor { mouse_down_event: &gpui::MouseDownEvent, window, cx| { - match mouse_down_event.button { - MouseButton::Right => { - this.select_index( - row_index, None, window, cx, - ); - this.create_context_menu( - mouse_down_event.position, - window, - cx, - ); - } - _ => {} + if mouse_down_event.button == MouseButton::Right { + this.select_index( + row_index, None, window, cx, + ); + this.create_context_menu( + mouse_down_event.position, + window, + cx, + ); } }, )) @@ -2021,21 +2149,21 @@ impl RenderOnce for SyntaxHighlightedText { #[derive(PartialEq)] struct InputError { - severity: ui::Severity, + severity: Severity, content: SharedString, } impl InputError { fn warning(message: impl Into) -> Self { Self { - severity: ui::Severity::Warning, + severity: Severity::Warning, content: message.into(), } } fn error(message: anyhow::Error) -> Self { Self { - severity: ui::Severity::Error, + severity: Severity::Error, content: message.to_string().into(), } } @@ -2046,7 +2174,7 @@ struct KeybindingEditorModal { editing_keybind: ProcessedBinding, editing_keybind_idx: usize, keybind_editor: Entity, - context_editor: Entity, + context_editor: Entity, action_arguments_editor: Option>, fs: Arc, error: Option, @@ -2080,8 +2208,8 @@ impl KeybindingEditorModal { let keybind_editor = cx .new(|cx| KeystrokeInput::new(editing_keybind.keystrokes().map(Vec::from), window, cx)); - let context_editor: Entity = cx.new(|cx| { - let input = SingleLineInput::new(window, cx, "Keybinding Context") + let context_editor: Entity = cx.new(|cx| { + let input = InputField::new(window, cx, "Keybinding Context") .label("Edit Context") .label_size(LabelSize::Default); @@ -2162,9 +2290,11 @@ impl KeybindingEditorModal { } fn set_error(&mut self, error: InputError, cx: &mut Context) -> bool { - if self.error.as_ref().is_some_and(|old_error| { - old_error.severity == ui::Severity::Warning && *old_error == error - }) { + if self + .error + .as_ref() + .is_some_and(|old_error| old_error.severity == Severity::Warning && *old_error == error) + { false } else { self.error = Some(error); @@ -2177,22 +2307,22 @@ impl KeybindingEditorModal { let action_arguments = self .action_arguments_editor .as_ref() - .map(|editor| editor.read(cx).editor.read(cx).text(cx)); + .map(|arguments_editor| arguments_editor.read(cx).editor.read(cx).text(cx)) + .filter(|args| !args.is_empty()); let value = action_arguments .as_ref() - .filter(|args| !args.is_empty()) .map(|args| { serde_json::from_str(args).context("Failed to parse action arguments as JSON") }) .transpose()?; - cx.build_action(&self.editing_keybind.action().name, value) + cx.build_action(self.editing_keybind.action().name, value) .context("Failed to validate action arguments")?; Ok(action_arguments) } - fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { + fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { let new_keystrokes = self .keybind_editor .read_with(cx, |editor, _| editor.keystrokes().to_vec()); @@ -2219,14 +2349,11 @@ impl KeybindingEditorModal { fn save(&mut self, cx: &mut Context) -> Result<(), InputError> { let existing_keybind = self.editing_keybind.clone(); let fs = self.fs.clone(); - let tab_size = cx.global::().json_tab_size(); - let new_keystrokes = self - .validate_keystrokes(cx) - .map_err(InputError::error)? - .into_iter() - .map(remove_key_char) - .collect::>(); + let mut new_keystrokes = self.validate_keystrokes(cx).map_err(InputError::error)?; + new_keystrokes + .iter_mut() + .for_each(|ks| ks.remove_key_char()); let new_context = self.validate_context(cx).map_err(InputError::error)?; let new_action_args = self @@ -2234,7 +2361,7 @@ impl KeybindingEditorModal { .map_err(InputError::error)?; let action_mapping = ActionMapping { - keystrokes: new_keystrokes, + keystrokes: Rc::from(new_keystrokes.as_slice()), context: new_context.map(SharedString::from), }; @@ -2288,58 +2415,56 @@ impl KeybindingEditorModal { }).unwrap_or(Ok(()))?; let create = self.creating; - - let status_toast = StatusToast::new( - format!( - "Saved edits to the {} action.", - &self.editing_keybind.action().humanized_name - ), - cx, - move |this, _cx| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) - .dismiss_button(true) - // .action("Undo", f) todo: wire the undo functionality - }, - ); - - self.workspace - .update(cx, |workspace, cx| { - workspace.toggle_status_toast(status_toast, cx); - }) - .log_err(); + let keyboard_mapper = cx.keyboard_mapper().clone(); cx.spawn(async move |this, cx| { let action_name = existing_keybind.action().name; + let humanized_action_name = existing_keybind.action().humanized_name.clone(); - if let Err(err) = save_keybinding_update( + match save_keybinding_update( create, existing_keybind, &action_mapping, new_action_args.as_deref(), &fs, - tab_size, + keyboard_mapper.as_ref(), ) .await { - this.update(cx, |this, cx| { - this.set_error(InputError::error(err), cx); - }) - .log_err(); - } else { - this.update(cx, |this, cx| { - this.keymap_editor.update(cx, |keymap, cx| { - keymap.previous_edit = Some(PreviousEdit::Keybinding { - action_mapping, - action_name, - fallback: keymap - .table_interaction_state - .read(cx) - .get_scrollbar_offset(Axis::Vertical), - }) - }); - cx.emit(DismissEvent); - }) - .ok(); + Ok(_) => { + this.update(cx, |this, cx| { + this.keymap_editor.update(cx, |keymap, cx| { + keymap.previous_edit = Some(PreviousEdit::Keybinding { + action_mapping, + action_name, + fallback: keymap.table_interaction_state.read(cx).scroll_offset(), + }); + let status_toast = StatusToast::new( + format!("Saved edits to the {} action.", humanized_action_name), + cx, + move |this, _cx| { + this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + .dismiss_button(true) + // .action("Undo", f) todo: wire the undo functionality + }, + ); + + this.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(status_toast, cx); + }) + .log_err(); + }); + cx.emit(DismissEvent); + }) + .ok(); + } + Err(err) => { + this.update(cx, |this, cx| { + this.set_error(InputError::error(err), cx); + }) + .log_err(); + } } }) .detach(); @@ -2375,7 +2500,7 @@ impl KeybindingEditorModal { } fn get_matching_bindings_count(&self, cx: &Context) -> usize { - let current_keystrokes = self.keybind_editor.read(cx).keystrokes().to_vec(); + let current_keystrokes = self.keybind_editor.read(cx).keystrokes(); if current_keystrokes.is_empty() { return 0; @@ -2392,17 +2517,20 @@ impl KeybindingEditorModal { return false; } - binding - .keystrokes() - .map(|keystrokes| keystrokes_match_exactly(keystrokes, ¤t_keystrokes)) - .unwrap_or(false) + binding.keystrokes().is_some_and(|keystrokes| { + keystrokes_match_exactly(keystrokes, current_keystrokes) + }) }) .count() } - fn show_matching_bindings(&mut self, _window: &mut Window, cx: &mut Context) { + fn show_matching_bindings(&mut self, window: &mut Window, cx: &mut Context) { let keystrokes = self.keybind_editor.read(cx).keystrokes().to_vec(); + self.keymap_editor.update(cx, |keymap_editor, cx| { + keymap_editor.clear_action_query(window, cx) + }); + // Dismiss the modal cx.emit(DismissEvent); @@ -2417,14 +2545,6 @@ impl KeybindingEditorModal { } } -fn remove_key_char(Keystroke { modifiers, key, .. }: Keystroke) -> Keystroke { - Keystroke { - modifiers, - key, - ..Default::default() - } -} - impl Render for KeybindingEditorModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().colors(); @@ -2655,10 +2775,7 @@ impl ActionArgumentsEditor { ) })?; - let file_name = - project::lsp_store::json_language_server_ext::normalized_action_file_name( - action_name, - ); + let file_name = json_schema_store::normalized_action_file_name(action_name); let (buffer, backup_temp_dir) = Self::create_temp_buffer(temp_dir, file_name.clone(), project.clone(), fs, cx) @@ -2719,7 +2836,7 @@ impl ActionArgumentsEditor { }) .ok(); } - return result; + result }) .detach_and_log_err(cx); Self { @@ -2740,7 +2857,7 @@ impl ActionArgumentsEditor { editor.set_text(arguments, window, cx); } else { // TODO: default value from schema? - editor.set_placeholder_text("Action Arguments", cx); + editor.set_placeholder_text("Action Arguments", window, cx); } } @@ -2822,7 +2939,7 @@ impl Render for ActionArgumentsEditor { self.editor .update(cx, |editor, _| editor.set_text_style_refinement(text_style)); - return v_flex().w_full().child( + v_flex().w_full().child( h_flex() .min_h_8() .min_w_48() @@ -2835,7 +2952,7 @@ impl Render for ActionArgumentsEditor { .border_color(border_color) .track_focus(&self.focus_handle) .child(self.editor.clone()), - ); + ) } } @@ -2862,11 +2979,8 @@ impl CompletionProvider for KeyContextCompletionProvider { break; } } - let start_anchor = buffer.anchor_before( - buffer_position - .to_offset(&buffer) - .saturating_sub(count_back), - ); + let start_anchor = + buffer.anchor_before(buffer_position.to_offset(buffer).saturating_sub(count_back)); let replace_range = start_anchor..buffer_position; gpui::Task::ready(Ok(vec![project::CompletionResponse { completions: self @@ -2883,6 +2997,7 @@ impl CompletionProvider for KeyContextCompletionProvider { confirm: None, }) .collect(), + display_options: CompletionDisplayOptions::default(), is_incomplete: false, }])) } @@ -2896,9 +3011,9 @@ impl CompletionProvider for KeyContextCompletionProvider { _menu_is_open: bool, _cx: &mut Context, ) -> bool { - text.chars().last().map_or(false, |last_char| { - last_char.is_ascii_alphanumeric() || last_char == '_' - }) + text.chars() + .last() + .is_some_and(|last_char| last_char.is_ascii_alphanumeric() || last_char == '_') } } @@ -2917,7 +3032,7 @@ async fn load_json_language(workspace: WeakEntity, cx: &mut AsyncApp) Some(task) => task.await.context("Failed to load JSON language").log_err(), None => None, }; - return json_language.unwrap_or_else(|| { + json_language.unwrap_or_else(|| { Arc::new(Language::new( LanguageConfig { name: "JSON".into(), @@ -2925,7 +3040,7 @@ async fn load_json_language(workspace: WeakEntity, cx: &mut AsyncApp) }, Some(tree_sitter_json::LANGUAGE.into()), )) - }); + }) } async fn load_keybind_context_language( @@ -2949,7 +3064,7 @@ async fn load_keybind_context_language( .log_err(), None => None, }; - return language.unwrap_or_else(|| { + language.unwrap_or_else(|| { Arc::new(Language::new( LanguageConfig { name: "Zed Keybind Context".into(), @@ -2957,7 +3072,7 @@ async fn load_keybind_context_language( }, Some(tree_sitter_rust::LANGUAGE.into()), )) - }); + }) } async fn save_keybinding_update( @@ -2966,12 +3081,14 @@ async fn save_keybinding_update( action_mapping: &ActionMapping, new_args: Option<&str>, fs: &Arc, - tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await .context("Failed to load keymap file")?; + let tab_size = infer_json_indent_size(&keymap_contents); + let existing_keystrokes = existing.keystrokes().unwrap_or_default(); let existing_context = existing.context().and_then(KeybindContextString::local_str); let existing_args = existing @@ -2983,14 +3100,14 @@ async fn save_keybinding_update( let target = settings::KeybindUpdateTarget { context: existing_context, keystrokes: existing_keystrokes, - action_name: &existing.action().name, + action_name: existing.action().name, action_arguments: existing_args, }; let source = settings::KeybindUpdateTarget { context: action_mapping.context.as_ref().map(|a| &***a), keystrokes: &action_mapping.keystrokes, - action_name: &existing.action().name, + action_name: existing.action().name, action_arguments: new_args, }; @@ -3009,9 +3126,13 @@ async fn save_keybinding_update( let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .context("Failed to update keybinding")?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3031,7 +3152,7 @@ async fn save_keybinding_update( async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, - tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { anyhow::bail!("Cannot remove a keybinding that does not exist"); @@ -3039,12 +3160,13 @@ async fn remove_keybinding( let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await .context("Failed to load keymap file")?; + let tab_size = infer_json_indent_size(&keymap_contents); let operation = settings::KeybindUpdateOperation::Remove { target: settings::KeybindUpdateTarget { context: existing.context().and_then(KeybindContextString::local_str), keystrokes, - action_name: &existing.action().name, + action_name: existing.action().name, action_arguments: existing .action() .arguments @@ -3055,9 +3177,13 @@ async fn remove_keybinding( }; let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .context("Failed to update keybinding")?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .context("Failed to update keybinding")?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3103,29 +3229,29 @@ fn collect_contexts_from_assets() -> Vec { queue.push(root_context); while let Some(context) = queue.pop() { match context { - gpui::KeyBindingContextPredicate::Identifier(ident) => { + Identifier(ident) => { contexts.insert(ident); } - gpui::KeyBindingContextPredicate::Equal(ident_a, ident_b) => { + Equal(ident_a, ident_b) => { contexts.insert(ident_a); contexts.insert(ident_b); } - gpui::KeyBindingContextPredicate::NotEqual(ident_a, ident_b) => { + NotEqual(ident_a, ident_b) => { contexts.insert(ident_a); contexts.insert(ident_b); } - gpui::KeyBindingContextPredicate::Descendant(ctx_a, ctx_b) => { + Descendant(ctx_a, ctx_b) => { queue.push(*ctx_a); queue.push(*ctx_b); } - gpui::KeyBindingContextPredicate::Not(ctx) => { + Not(ctx) => { queue.push(*ctx); } - gpui::KeyBindingContextPredicate::And(ctx_a, ctx_b) => { + And(ctx_a, ctx_b) => { queue.push(*ctx_a); queue.push(*ctx_b); } - gpui::KeyBindingContextPredicate::Or(ctx_a, ctx_b) => { + Or(ctx_a, ctx_b) => { queue.push(*ctx_a); queue.push(*ctx_b); } @@ -3137,7 +3263,128 @@ fn collect_contexts_from_assets() -> Vec { let mut contexts = contexts.into_iter().collect::>(); contexts.sort(); - return contexts; + contexts +} + +fn normalized_ctx_eq( + a: &gpui::KeyBindingContextPredicate, + b: &gpui::KeyBindingContextPredicate, +) -> bool { + use gpui::KeyBindingContextPredicate::*; + return match (a, b) { + (Identifier(_), Identifier(_)) => a == b, + (Equal(a_left, a_right), Equal(b_left, b_right)) => { + (a_left == b_left && a_right == b_right) || (a_left == b_right && a_right == b_left) + } + (NotEqual(a_left, a_right), NotEqual(b_left, b_right)) => { + (a_left == b_left && a_right == b_right) || (a_left == b_right && a_right == b_left) + } + (Descendant(a_parent, a_child), Descendant(b_parent, b_child)) => { + normalized_ctx_eq(a_parent, b_parent) && normalized_ctx_eq(a_child, b_child) + } + (Not(a_expr), Not(b_expr)) => normalized_ctx_eq(a_expr, b_expr), + // Handle double negation: !(!a) == a + (Not(a_expr), b) if matches!(a_expr.as_ref(), Not(_)) => { + let Not(a_inner) = a_expr.as_ref() else { + unreachable!(); + }; + normalized_ctx_eq(b, a_inner) + } + (a, Not(b_expr)) if matches!(b_expr.as_ref(), Not(_)) => { + let Not(b_inner) = b_expr.as_ref() else { + unreachable!(); + }; + normalized_ctx_eq(a, b_inner) + } + (And(a_left, a_right), And(b_left, b_right)) + if matches!(a_left.as_ref(), And(_, _)) + || matches!(a_right.as_ref(), And(_, _)) + || matches!(b_left.as_ref(), And(_, _)) + || matches!(b_right.as_ref(), And(_, _)) => + { + let mut a_operands = Vec::new(); + flatten_and(a, &mut a_operands); + let mut b_operands = Vec::new(); + flatten_and(b, &mut b_operands); + compare_operand_sets(&a_operands, &b_operands) + } + (And(a_left, a_right), And(b_left, b_right)) => { + (normalized_ctx_eq(a_left, b_left) && normalized_ctx_eq(a_right, b_right)) + || (normalized_ctx_eq(a_left, b_right) && normalized_ctx_eq(a_right, b_left)) + } + (Or(a_left, a_right), Or(b_left, b_right)) + if matches!(a_left.as_ref(), Or(_, _)) + || matches!(a_right.as_ref(), Or(_, _)) + || matches!(b_left.as_ref(), Or(_, _)) + || matches!(b_right.as_ref(), Or(_, _)) => + { + let mut a_operands = Vec::new(); + flatten_or(a, &mut a_operands); + let mut b_operands = Vec::new(); + flatten_or(b, &mut b_operands); + compare_operand_sets(&a_operands, &b_operands) + } + (Or(a_left, a_right), Or(b_left, b_right)) => { + (normalized_ctx_eq(a_left, b_left) && normalized_ctx_eq(a_right, b_right)) + || (normalized_ctx_eq(a_left, b_right) && normalized_ctx_eq(a_right, b_left)) + } + _ => false, + }; + + fn flatten_and<'a>( + pred: &'a gpui::KeyBindingContextPredicate, + operands: &mut Vec<&'a gpui::KeyBindingContextPredicate>, + ) { + use gpui::KeyBindingContextPredicate::*; + match pred { + And(left, right) => { + flatten_and(left, operands); + flatten_and(right, operands); + } + _ => operands.push(pred), + } + } + + fn flatten_or<'a>( + pred: &'a gpui::KeyBindingContextPredicate, + operands: &mut Vec<&'a gpui::KeyBindingContextPredicate>, + ) { + use gpui::KeyBindingContextPredicate::*; + match pred { + Or(left, right) => { + flatten_or(left, operands); + flatten_or(right, operands); + } + _ => operands.push(pred), + } + } + + fn compare_operand_sets( + a: &[&gpui::KeyBindingContextPredicate], + b: &[&gpui::KeyBindingContextPredicate], + ) -> bool { + if a.len() != b.len() { + return false; + } + + // For each operand in a, find a matching operand in b + let mut b_matched = vec![false; b.len()]; + for a_operand in a { + let mut found = false; + for (b_idx, b_operand) in b.iter().enumerate() { + if !b_matched[b_idx] && normalized_ctx_eq(a_operand, b_operand) { + b_matched[b_idx] = true; + found = true; + break; + } + } + if !found { + return false; + } + } + + true + } } impl SerializableItem for KeymapEditor { @@ -3202,12 +3449,15 @@ impl SerializableItem for KeymapEditor { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{query, sqlez::domain::Domain, sqlez_macros::sql}; use workspace::WorkspaceDb; - define_connection! { - pub static ref KEYBINDING_EDITORS: KeybindingEditorDb = - &[sql!( + pub struct KeybindingEditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + + impl Domain for KeybindingEditorDb { + const NAME: &str = stringify!(KeybindingEditorDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE keybinding_editors ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -3216,9 +3466,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(KEYBINDING_EDITORS, KeybindingEditorDb, [WorkspaceDb]); + impl KeybindingEditorDb { query! { pub async fn save_keybinding_editor( @@ -3242,3 +3494,152 @@ mod persistence { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalized_ctx_cmp() { + #[track_caller] + fn cmp(a: &str, b: &str) -> bool { + let a = gpui::KeyBindingContextPredicate::parse(a) + .expect("Failed to parse keybinding context a"); + let b = gpui::KeyBindingContextPredicate::parse(b) + .expect("Failed to parse keybinding context b"); + normalized_ctx_eq(&a, &b) + } + + // Basic equality - identical expressions + assert!(cmp("a && b", "a && b")); + assert!(cmp("a || b", "a || b")); + assert!(cmp("a == b", "a == b")); + assert!(cmp("a != b", "a != b")); + assert!(cmp("a > b", "a > b")); + assert!(cmp("!a", "!a")); + + // AND operator - associative/commutative + assert!(cmp("a && b", "b && a")); + assert!(cmp("a && b && c", "c && b && a")); + assert!(cmp("a && b && c", "b && a && c")); + assert!(cmp("a && b && c && d", "d && c && b && a")); + + // OR operator - associative/commutative + assert!(cmp("a || b", "b || a")); + assert!(cmp("a || b || c", "c || b || a")); + assert!(cmp("a || b || c", "b || a || c")); + assert!(cmp("a || b || c || d", "d || c || b || a")); + + // Equality operator - associative/commutative + assert!(cmp("a == b", "b == a")); + assert!(cmp("x == y", "y == x")); + + // Inequality operator - associative/commutative + assert!(cmp("a != b", "b != a")); + assert!(cmp("x != y", "y != x")); + + // Complex nested expressions with associative operators + assert!(cmp("(a && b) || c", "c || (a && b)")); + assert!(cmp("(a && b) || c", "c || (b && a)")); + assert!(cmp("(a || b) && c", "c && (a || b)")); + assert!(cmp("(a || b) && c", "c && (b || a)")); + assert!(cmp("(a && b) || (c && d)", "(c && d) || (a && b)")); + assert!(cmp("(a && b) || (c && d)", "(d && c) || (b && a)")); + + // Multiple levels of nesting + assert!(cmp("((a && b) || c) && d", "d && ((a && b) || c)")); + assert!(cmp("((a && b) || c) && d", "d && (c || (b && a))")); + assert!(cmp("a && (b || (c && d))", "(b || (c && d)) && a")); + assert!(cmp("a && (b || (c && d))", "(b || (d && c)) && a")); + + // Negation with associative operators + assert!(cmp("!a && b", "b && !a")); + assert!(cmp("!a || b", "b || !a")); + assert!(cmp("!(a && b) || c", "c || !(a && b)")); + assert!(cmp("!(a && b) || c", "c || !(b && a)")); + + // Descendant operator (>) - NOT associative/commutative + assert!(cmp("a > b", "a > b")); + assert!(!cmp("a > b", "b > a")); + assert!(!cmp("a > b > c", "c > b > a")); + assert!(!cmp("a > b > c", "a > c > b")); + + // Mixed operators with descendant + assert!(cmp("(a > b) && c", "c && (a > b)")); + assert!(!cmp("(a > b) && c", "c && (b > a)")); + assert!(cmp("(a > b) || (c > d)", "(c > d) || (a > b)")); + assert!(!cmp("(a > b) || (c > d)", "(b > a) || (d > c)")); + + // Negative cases - different operators + assert!(!cmp("a && b", "a || b")); + assert!(!cmp("a == b", "a != b")); + assert!(!cmp("a && b", "a > b")); + assert!(!cmp("a || b", "a > b")); + assert!(!cmp("a == b", "a && b")); + assert!(!cmp("a != b", "a || b")); + + // Negative cases - different operands + assert!(!cmp("a && b", "a && c")); + assert!(!cmp("a && b", "c && d")); + assert!(!cmp("a || b", "a || c")); + assert!(!cmp("a || b", "c || d")); + assert!(!cmp("a == b", "a == c")); + assert!(!cmp("a != b", "a != c")); + assert!(!cmp("a > b", "a > c")); + assert!(!cmp("a > b", "c > b")); + + // Negative cases - with negation + assert!(!cmp("!a", "a")); + assert!(!cmp("!a && b", "a && b")); + assert!(!cmp("!(a && b)", "a && b")); + assert!(!cmp("!a || b", "a || b")); + assert!(!cmp("!(a || b)", "a || b")); + + // Negative cases - complex expressions + assert!(!cmp("(a && b) || c", "(a || b) && c")); + assert!(!cmp("a && (b || c)", "a || (b && c)")); + assert!(!cmp("(a && b) || (c && d)", "(a || b) && (c || d)")); + assert!(!cmp("a > b && c", "a && b > c")); + + // Edge cases - multiple same operands + assert!(cmp("a && a", "a && a")); + assert!(cmp("a || a", "a || a")); + assert!(cmp("a && a && b", "b && a && a")); + assert!(cmp("a || a || b", "b || a || a")); + + // Edge cases - deeply nested + assert!(cmp( + "((a && b) || (c && d)) && ((e || f) && g)", + "((e || f) && g) && ((c && d) || (a && b))" + )); + assert!(cmp( + "((a && b) || (c && d)) && ((e || f) && g)", + "(g && (f || e)) && ((d && c) || (b && a))" + )); + + // Edge cases - repeated patterns + assert!(cmp("(a && b) || (a && b)", "(b && a) || (b && a)")); + assert!(cmp("(a || b) && (a || b)", "(b || a) && (b || a)")); + + // Negative cases - subtle differences + assert!(!cmp("a && b && c", "a && b")); + assert!(!cmp("a || b || c", "a || b")); + assert!(!cmp("(a && b) || c", "a && (b || c)")); + + // a > b > c is not the same as a > c, should not be equal + assert!(!cmp("a > b > c", "a > c")); + + // Double negation with complex expressions + assert!(cmp("!(!(a && b))", "a && b")); + assert!(cmp("!(!(a || b))", "a || b")); + assert!(cmp("!(!(a > b))", "a > b")); + assert!(cmp("!(!a) && b", "a && b")); + assert!(cmp("!(!a) || b", "a || b")); + assert!(cmp("!(!(a && b)) || c", "(a && b) || c")); + assert!(cmp("!(!(a && b)) || c", "(b && a) || c")); + assert!(cmp("!(!a)", "a")); + assert!(cmp("a", "!(!a)")); + assert!(cmp("!(!(!a))", "!a")); + assert!(cmp("!(!(!(!a)))", "a")); + } +} diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs similarity index 85% rename from crates/settings_ui/src/ui_components/keystroke_input.rs rename to crates/keymap_editor/src/ui_components/keystroke_input.rs index f23d80931c4e3e509731836bd1230df1f64e4423..e264df3b62bc3c5c78acc38ed906e81837dfbf94 100644 --- a/crates/settings_ui/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -1,6 +1,6 @@ use gpui::{ Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, - Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, + KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, }; use ui::{ ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, @@ -19,7 +19,7 @@ actions!( ] ); -const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput"; +const KEY_CONTEXT_VALUE: &str = "KeystrokeInput"; const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); @@ -42,8 +42,8 @@ impl PartialEq for CloseKeystrokeResult { } pub struct KeystrokeInput { - keystrokes: Vec, - placeholder_keystrokes: Option>, + keystrokes: Vec, + placeholder_keystrokes: Option>, outer_focus_handle: FocusHandle, inner_focus_handle: FocusHandle, intercept_subscription: Option, @@ -70,7 +70,7 @@ impl KeystrokeInput { const KEYSTROKE_COUNT_MAX: usize = 3; pub fn new( - placeholder_keystrokes: Option>, + placeholder_keystrokes: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -97,7 +97,7 @@ impl KeystrokeInput { } } - pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { + pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { self.keystrokes = keystrokes; self.keystrokes_changed(cx); } @@ -106,7 +106,7 @@ impl KeystrokeInput { self.search = search; } - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { if let Some(placeholders) = self.placeholder_keystrokes.as_ref() && self.keystrokes.is_empty() { @@ -116,19 +116,19 @@ impl KeystrokeInput { && self .keystrokes .last() - .map_or(false, |last| last.key.is_empty()) + .is_some_and(|last| last.key().is_empty()) { return &self.keystrokes[..self.keystrokes.len() - 1]; } - return &self.keystrokes; + &self.keystrokes } - fn dummy(modifiers: Modifiers) -> Keystroke { - return Keystroke { + fn dummy(modifiers: Modifiers) -> KeybindingKeystroke { + KeybindingKeystroke::from_keystroke(Keystroke { modifiers, key: "".to_string(), key_char: None, - }; + }) } fn keystrokes_changed(&self, cx: &mut Context) { @@ -182,7 +182,7 @@ impl KeystrokeInput { fn end_close_keystrokes_capture(&mut self) -> Option { self.close_keystrokes.take(); self.clear_close_keystrokes_timer.take(); - return self.close_keystrokes_start.take(); + self.close_keystrokes_start.take() } fn handle_possible_close_keystroke( @@ -233,7 +233,7 @@ impl KeystrokeInput { return CloseKeystrokeResult::Partial; } self.end_close_keystrokes_capture(); - return CloseKeystrokeResult::None; + CloseKeystrokeResult::None } fn on_modifiers_changed( @@ -254,7 +254,7 @@ impl KeystrokeInput { self.keystrokes_changed(cx); if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() + && last.key().is_empty() && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if !self.search && !event.modifiers.modified() { @@ -263,13 +263,14 @@ impl KeystrokeInput { } if self.search { if self.previous_modifiers.modified() { - last.modifiers |= event.modifiers; + let modifiers = *last.modifiers() | event.modifiers; + last.set_modifiers(modifiers); } else { self.keystrokes.push(Self::dummy(event.modifiers)); } self.previous_modifiers |= event.modifiers; } else { - last.modifiers = event.modifiers; + last.set_modifiers(event.modifiers); return; } } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { @@ -297,14 +298,15 @@ impl KeystrokeInput { return; } - let mut keystroke = keystroke.clone(); + let keystroke = KeybindingKeystroke::new_with_mapper( + keystroke.clone(), + false, + cx.keyboard_mapper().as_ref(), + ); if let Some(last) = self.keystrokes.last() - && last.key.is_empty() + && last.key().is_empty() && (!self.search || self.previous_modifiers.modified()) { - let key = keystroke.key.clone(); - keystroke = last.clone(); - keystroke.key = key; self.keystrokes.pop(); } @@ -320,15 +322,19 @@ impl KeystrokeInput { return; } - self.keystrokes.push(keystroke.clone()); + self.keystrokes.push(keystroke); self.keystrokes_changed(cx); + // The reason we use the real modifiers from the window instead of the keystroke's modifiers + // is that for keystrokes like `ctrl-$` the modifiers reported by keystroke is `ctrl` which + // is wrong, it should be `ctrl-shift`. The window's modifiers are always correct. + let real_modifiers = window.modifiers(); if self.search { - self.previous_modifiers = keystroke.modifiers; + self.previous_modifiers = real_modifiers; return; } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && real_modifiers.modified() { + self.keystrokes.push(Self::dummy(real_modifiers)); } } @@ -364,7 +370,7 @@ impl KeystrokeInput { &self.keystrokes }; keystrokes.iter().map(move |keystroke| { - h_flex().children(ui::render_keystroke( + h_flex().children(ui::render_keybinding_keystroke( keystroke, Some(Color::Default), Some(rems(0.875).into()), @@ -437,7 +443,7 @@ impl KeystrokeInput { // is a much more reliable check, as the intercept keystroke handlers are installed // on focus of the inner focus handle, thereby ensuring our recording state does // not get de-synced - return self.inner_focus_handle.is_focused(window); + self.inner_focus_handle.is_focused(window) } } @@ -455,7 +461,7 @@ impl Render for KeystrokeInput { let is_focused = self.outer_focus_handle.contains_focused(window, cx); let is_recording = self.is_recording(window); - let horizontal_padding = rems_from_px(64.); + let width = rems_from_px(64.); let recording_bg_color = colors .editor_background @@ -522,6 +528,9 @@ impl Render for KeystrokeInput { h_flex() .id("keystroke-input") .track_focus(&self.outer_focus_handle) + .key_context(Self::key_context()) + .on_action(cx.listener(Self::start_recording)) + .on_action(cx.listener(Self::clear_keystrokes)) .py_2() .px_3() .gap_2() @@ -529,7 +538,7 @@ impl Render for KeystrokeInput { .w_full() .flex_1() .justify_between() - .rounded_sm() + .rounded_md() .overflow_hidden() .map(|this| { if is_recording { @@ -539,16 +548,16 @@ impl Render for KeystrokeInput { } }) .border_1() - .border_color(colors.border_variant) - .when(is_focused, |parent| { - parent.border_color(colors.border_focused) + .map(|this| { + if is_focused { + this.border_color(colors.border_focused) + } else { + this.border_color(colors.border_variant) + } }) - .key_context(Self::key_context()) - .on_action(cx.listener(Self::start_recording)) - .on_action(cx.listener(Self::clear_keystrokes)) .child( h_flex() - .w(horizontal_padding) + .w(width) .gap_0p5() .justify_start() .flex_none() @@ -567,14 +576,13 @@ impl Render for KeystrokeInput { .id("keystroke-input-inner") .track_focus(&self.inner_focus_handle) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) - .size_full() .when(!self.search, |this| { this.focus(|mut style| { style.border_color = Some(colors.border_focused); style }) }) - .w_full() + .size_full() .min_w_0() .justify_center() .flex_wrap() @@ -583,7 +591,7 @@ impl Render for KeystrokeInput { ) .child( h_flex() - .w(horizontal_padding) + .w(width) .gap_0p5() .justify_end() .flex_none() @@ -635,9 +643,7 @@ impl Render for KeystrokeInput { "Clear Keystrokes", &ClearKeystrokes, )) - .when(!is_recording || !is_focused, |this| { - this.icon_color(Color::Muted) - }) + .when(!is_focused, |this| this.icon_color(Color::Muted)) .on_click(cx.listener(|this, _event, window, cx| { this.clear_keystrokes(&ClearKeystrokes, window, cx); })), @@ -706,8 +712,11 @@ mod tests { // Combine current modifiers with keystroke modifiers keystroke.modifiers |= self.current_modifiers; + let real_modifiers = keystroke.modifiers; + keystroke = to_gpui_keystroke(keystroke); self.update_input(|input, window, cx| { + window.set_modifiers(real_modifiers); input.handle_keystroke(&keystroke, window, cx); }); @@ -735,6 +744,7 @@ mod tests { }; self.update_input(|input, window, cx| { + window.set_modifiers(new_modifiers); input.on_modifiers_changed(&event, window, cx); }); @@ -809,9 +819,13 @@ mod tests { /// Verifies that the keystrokes match the expected strings #[track_caller] pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self { - let actual = self - .input - .read_with(&mut self.cx, |input, _| input.keystrokes.clone()); + let actual: Vec = self.input.read_with(&self.cx, |input, _| { + input + .keystrokes + .iter() + .map(|keystroke| keystroke.inner().clone()) + .collect() + }); Self::expect_keystrokes_equal(&actual, expected); self } @@ -820,7 +834,7 @@ mod tests { pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self { let actual = self .input - .read_with(&mut self.cx, |input, _| input.close_keystrokes.clone()) + .read_with(&self.cx, |input, _| input.close_keystrokes.clone()) .unwrap_or_default(); Self::expect_keystrokes_equal(&actual, expected); self @@ -934,12 +948,106 @@ mod tests { let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx); let result = self.input.update_in(&mut self.cx, cb); KeystrokeUpdateTracker::finish(change_tracker, &self.cx); - return result; + result } } + /// For GPUI, when you press `ctrl-shift-2`, it produces `ctrl-@` without the shift modifier. + fn to_gpui_keystroke(mut keystroke: Keystroke) -> Keystroke { + if keystroke.modifiers.shift { + match keystroke.key.as_str() { + "`" => { + keystroke.key = "~".into(); + keystroke.modifiers.shift = false; + } + "1" => { + keystroke.key = "!".into(); + keystroke.modifiers.shift = false; + } + "2" => { + keystroke.key = "@".into(); + keystroke.modifiers.shift = false; + } + "3" => { + keystroke.key = "#".into(); + keystroke.modifiers.shift = false; + } + "4" => { + keystroke.key = "$".into(); + keystroke.modifiers.shift = false; + } + "5" => { + keystroke.key = "%".into(); + keystroke.modifiers.shift = false; + } + "6" => { + keystroke.key = "^".into(); + keystroke.modifiers.shift = false; + } + "7" => { + keystroke.key = "&".into(); + keystroke.modifiers.shift = false; + } + "8" => { + keystroke.key = "*".into(); + keystroke.modifiers.shift = false; + } + "9" => { + keystroke.key = "(".into(); + keystroke.modifiers.shift = false; + } + "0" => { + keystroke.key = ")".into(); + keystroke.modifiers.shift = false; + } + "-" => { + keystroke.key = "_".into(); + keystroke.modifiers.shift = false; + } + "=" => { + keystroke.key = "+".into(); + keystroke.modifiers.shift = false; + } + "[" => { + keystroke.key = "{".into(); + keystroke.modifiers.shift = false; + } + "]" => { + keystroke.key = "}".into(); + keystroke.modifiers.shift = false; + } + "\\" => { + keystroke.key = "|".into(); + keystroke.modifiers.shift = false; + } + ";" => { + keystroke.key = ":".into(); + keystroke.modifiers.shift = false; + } + "'" => { + keystroke.key = "\"".into(); + keystroke.modifiers.shift = false; + } + "," => { + keystroke.key = "<".into(); + keystroke.modifiers.shift = false; + } + "." => { + keystroke.key = ">".into(); + keystroke.modifiers.shift = false; + } + "/" => { + keystroke.key = "?".into(); + keystroke.modifiers.shift = false; + } + _ => {} + } + } + keystroke + } + struct KeystrokeUpdateTracker { - initial_keystrokes: Vec, + initial_keystrokes: Vec, _subscription: Subscription, input: Entity, received_keystrokes_updated: bool, @@ -983,8 +1091,8 @@ mod tests { ); } - fn keystrokes_str(ks: &[Keystroke]) -> String { - ks.iter().map(|ks| ks.unparse()).join(" ") + fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String { + ks.iter().map(|ks| ks.inner().unparse()).join(" ") } } } @@ -1041,7 +1149,15 @@ mod tests { .send_events(&["+cmd", "shift-f", "-cmd"]) // In search mode, when completing a modifier-only keystroke with a key, // only the original modifiers are preserved, not the keystroke's modifiers - .expect_keystrokes(&["cmd-f"]); + // + // Update: + // This behavior was changed to preserve all modifiers in search mode, this is now reflected in the expected keystrokes. + // Specifically, considering the sequence: `+cmd +shift -shift 2`, we expect it to produce the same result as `+cmd +shift 2` + // which is `cmd-@`. But in the case of `+cmd +shift -shift 2`, the keystroke we receive is `cmd-2`, which means that + // we need to dynamically map the key from `2` to `@` when the shift modifier is not present, which is not possible. + // Therefore, we now preserve all modifiers in search mode to ensure consistent behavior. + // And also, VSCode seems to preserve all modifiers in search mode as well. + .expect_keystrokes(&["cmd-shift-f"]); } #[gpui::test] @@ -1218,7 +1334,7 @@ mod tests { .await .with_search_mode(true) .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"]) - .expect_keystrokes(&["ctrl-shift-a"]); + .expect_keystrokes(&["ctrl-a"]); } #[gpui::test] @@ -1326,7 +1442,7 @@ mod tests { .await .with_search_mode(true) .send_events(&["+ctrl+alt", "-ctrl", "j"]) - .expect_keystrokes(&["ctrl-alt-j"]); + .expect_keystrokes(&["alt-j"]); } #[gpui::test] @@ -1348,11 +1464,11 @@ mod tests { .send_events(&["+ctrl+alt", "-ctrl", "+shift"]) .expect_keystrokes(&["ctrl-shift-alt-"]) .send_keystroke("j") - .expect_keystrokes(&["ctrl-shift-alt-j"]) + .expect_keystrokes(&["shift-alt-j"]) .send_keystroke("i") - .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"]) + .expect_keystrokes(&["shift-alt-j", "shift-alt-i"]) .send_events(&["-shift-alt", "+cmd"]) - .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]); + .expect_keystrokes(&["shift-alt-j", "shift-alt-i", "cmd-"]); } #[gpui::test] @@ -1385,4 +1501,13 @@ mod tests { .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"]) .expect_empty(); } + + #[gpui::test] + async fn test_not_search_shifted_keys(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "+shift", "4", "-all"]) + .expect_keystrokes(&["ctrl-$"]); + } } diff --git a/crates/settings_ui/src/ui_components/mod.rs b/crates/keymap_editor/src/ui_components/mod.rs similarity index 62% rename from crates/settings_ui/src/ui_components/mod.rs rename to crates/keymap_editor/src/ui_components/mod.rs index 5d6463a61a21afd5208b75af0362f6f7956f5e56..c093bab554d9e4f3f2f74818d5016a0480053903 100644 --- a/crates/settings_ui/src/ui_components/mod.rs +++ b/crates/keymap_editor/src/ui_components/mod.rs @@ -1,2 +1 @@ pub mod keystroke_input; -pub mod table; diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 4ab56d6647db5246bf0af7343c8485d946c8b156..ffc5ad85d14c293eeeaff9172b21ef58cf9a1cf0 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -20,7 +20,6 @@ test-support = [ "text/test-support", "tree-sitter-rust", "tree-sitter-python", - "tree-sitter-rust", "tree-sitter-typescript", "settings/test-support", "util/test-support", @@ -39,7 +38,6 @@ globset.workspace = true gpui.workspace = true http_client.workspace = true imara-diff.workspace = true -inventory.workspace = true itertools.workspace = true log.workspace = true lsp.workspace = true @@ -68,7 +66,7 @@ tree-sitter.workspace = true unicase = "2.6" util.workspace = true watch.workspace = true -workspace-hack.workspace = true +zlog.workspace = true diffy = "0.4.2" [dev-dependencies] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 83517accc239ecf9d2196f124fc5695a8545ef17..c72350f38561e7aea62b7d3402eaa24bbdb08044 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,7 +1,7 @@ use crate::{ DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, TextObject, TreeSitterOptions, - diagnostic_set::{DiagnosticEntry, DiagnosticGroup}, + diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{LanguageSettings, language_settings}, outline::OutlineItem, syntax_map::{ @@ -18,8 +18,8 @@ pub use crate::{ proto, }; use anyhow::{Context as _, Result}; +use clock::Lamport; pub use clock::ReplicaId; -use clock::{AGENT_REPLICA_ID, Lamport}; use collections::HashMap; use fs::MTime; use futures::channel::oneshot; @@ -27,9 +27,9 @@ use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, SharedString, StyledText, Task, TaskLabel, TextStyle, }; + use lsp::{LanguageServerId, NumberOrString}; use parking_lot::Mutex; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use settings::WorktreeId; @@ -41,13 +41,12 @@ use std::{ cell::Cell, cmp::{self, Ordering, Reverse}, collections::{BTreeMap, BTreeSet}, - ffi::OsStr, future::Future, iter::{self, Iterator, Peekable}, mem, num::NonZeroU32, ops::{Deref, Range}, - path::{Path, PathBuf}, + path::PathBuf, rc, sync::{Arc, LazyLock}, time::{Duration, Instant}, @@ -65,7 +64,7 @@ pub use text::{ use theme::{ActiveTheme as _, SyntaxTheme}; #[cfg(any(test, feature = "test-support"))] use util::RandomCharIter; -use util::{RangeExt, debug_panic, maybe}; +use util::{RangeExt, debug_panic, maybe, paths::PathStyle, rel_path::RelPath}; #[cfg(any(test, feature = "test-support"))] pub use {tree_sitter_python, tree_sitter_rust, tree_sitter_typescript}; @@ -144,7 +143,7 @@ struct BufferBranchState { /// state of a buffer. pub struct BufferSnapshot { pub text: text::BufferSnapshot, - pub(crate) syntax: SyntaxSnapshot, + pub syntax: SyntaxSnapshot, file: Option>, diagnostics: SmallVec<[(LanguageServerId, DiagnosticSet); 2]>, remote_selections: TreeMap, @@ -173,8 +172,7 @@ pub enum IndentKind { } /// The shape of a selection cursor. -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum CursorShape { /// A vertical bar #[default] @@ -187,6 +185,17 @@ pub enum CursorShape { Hollow, } +impl From for CursorShape { + fn from(shape: settings::CursorShape) -> Self { + match shape { + settings::CursorShape::Bar => CursorShape::Bar, + settings::CursorShape::Block => CursorShape::Block, + settings::CursorShape::Underline => CursorShape::Underline, + settings::CursorShape::Hollow => CursorShape::Hollow, + } + } +} + #[derive(Clone, Debug)] struct SelectionSet { line_mode: bool, @@ -202,7 +211,7 @@ pub struct Diagnostic { pub source: Option, /// A machine-readable code that identifies this diagnostic. pub code: Option, - pub code_description: Option, + pub code_description: Option, /// Whether this diagnostic is a hint, warning, or error. pub severity: DiagnosticSeverity, /// The human-readable message associated with this diagnostic. @@ -282,6 +291,14 @@ pub enum Operation { /// The language server ID. server_id: LanguageServerId, }, + + /// An update to the line ending type of this buffer. + UpdateLineEnding { + /// The line ending type. + line_ending: LineEnding, + /// The buffer's lamport timestamp. + lamport_timestamp: clock::Lamport, + }, } /// An event that occurs in a buffer. @@ -313,10 +330,6 @@ pub enum BufferEvent { DiagnosticsUpdated, /// The buffer gained or lost editing capabilities. CapabilityChanged, - /// The buffer was explicitly requested to close. - Closed, - /// The buffer was discarded when closing. - Discarded, } /// The file associated with a buffer. @@ -335,15 +348,18 @@ pub trait File: Send + Sync + Any { fn disk_state(&self) -> DiskState; /// Returns the path of this file relative to the worktree's root directory. - fn path(&self) -> &Arc; + fn path(&self) -> &Arc; /// Returns the path of this file relative to the worktree's parent directory (this means it /// includes the name of the worktree's root folder). fn full_path(&self, cx: &App) -> PathBuf; + /// Returns the path style of this file. + fn path_style(&self, cx: &App) -> PathStyle; + /// Returns the last component of this handle's absolute path. If this handle refers to the root /// of its worktree, then this method will return the name of the worktree itself. - fn file_name<'a>(&'a self, cx: &'a App) -> &'a OsStr; + fn file_name<'a>(&'a self, cx: &'a App) -> &'a str; /// Returns the id of the worktree to which this file belongs. /// @@ -490,11 +506,15 @@ pub struct Chunk<'a> { pub highlight_style: Option, /// The severity of diagnostic associated with this chunk, if any. pub diagnostic_severity: Option, + /// A bitset of which characters are tabs in this string. + pub tabs: u128, + /// Bitmap of character indices in this chunk + pub chars: u128, /// Whether this chunk of text is marked as unnecessary. pub is_unnecessary: bool, /// Whether this chunk of text was originally a tab character. pub is_tab: bool, - /// Whether this chunk of text was originally a tab character. + /// Whether this chunk of text was originally an inlay. pub is_inlay: bool, /// Whether to underline the corresponding text range in the editor. pub underline: bool, @@ -528,6 +548,23 @@ pub enum CharKind { Word, } +/// Context for character classification within a specific scope. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum CharScopeContext { + /// Character classification for completion queries. + /// + /// This context treats certain characters as word constituents that would + /// normally be considered punctuation, such as '-' in Tailwind classes + /// ("bg-yellow-100") or '.' in import paths ("foo.ts"). + Completion, + /// Character classification for linked edits. + /// + /// This context handles characters that should be treated as part of + /// identifiers during linked editing operations, such as '.' in JSX + /// component names like ``. + LinkedEdit, +} + /// A runnable is a set of data about a region that could be resolved into a task pub struct Runnable { pub tags: SmallVec<[RunnableTag; 1]>, @@ -544,7 +581,7 @@ pub struct HighlightedText { #[derive(Default, Debug)] struct HighlightedTextBuilder { pub text: String, - pub highlights: Vec<(Range, HighlightStyle)>, + highlights: Vec<(Range, HighlightStyle)>, } impl HighlightedText { @@ -587,10 +624,11 @@ impl HighlightedText { let preview_highlights = self .highlights .into_iter() + .skip_while(|(range, _)| range.end <= preview_start_ix) .take_while(|(range, _)| range.start < newline_ix) .filter_map(|(mut range, highlight)| { range.start = range.start.saturating_sub(preview_start_ix); - range.end = range.end.saturating_sub(preview_start_ix).min(newline_ix); + range.end = range.end.min(newline_ix).saturating_sub(preview_start_ix); if range.is_empty() { None } else { @@ -629,13 +667,13 @@ impl HighlightedTextBuilder { self.text.push_str(chunk.text); let end = self.text.len(); - if let Some(mut highlight_style) = chunk + if let Some(highlight_style) = chunk .syntax_highlight_id .and_then(|id| id.style(syntax_theme)) { - if let Some(override_style) = override_style { - highlight_style.highlight(override_style); - } + let highlight_style = override_style.map_or(highlight_style, |override_style| { + highlight_style.highlight(override_style) + }); self.highlights.push((start..end, highlight_style)); } else if let Some(override_style) = override_style { self.highlights.push((start..end, override_style)); @@ -649,7 +687,10 @@ impl HighlightedTextBuilder { syntax_snapshot: &'a SyntaxSnapshot, ) -> BufferChunks<'a> { let captures = syntax_snapshot.captures(range.clone(), snapshot, |grammar| { - grammar.highlights_query.as_ref() + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) }); let highlight_maps = captures @@ -716,7 +757,7 @@ impl EditPreview { &self.applied_edits_snapshot, &self.syntax_snapshot, None, - &syntax_theme, + syntax_theme, ); } @@ -727,7 +768,7 @@ impl EditPreview { ¤t_snapshot.text, ¤t_snapshot.syntax, Some(deletion_highlight_style), - &syntax_theme, + syntax_theme, ); } @@ -737,7 +778,7 @@ impl EditPreview { &self.applied_edits_snapshot, &self.syntax_snapshot, Some(insertion_highlight_style), - &syntax_theme, + syntax_theme, ); } @@ -749,7 +790,7 @@ impl EditPreview { &self.applied_edits_snapshot, &self.syntax_snapshot, None, - &syntax_theme, + syntax_theme, ); highlighted_text.build() @@ -787,7 +828,11 @@ impl Buffer { /// Create a new buffer with the given base text. pub fn local>(base_text: T, cx: &Context) -> Self { Self::build( - TextBuffer::new(0, cx.entity_id().as_non_zero_u64().into(), base_text.into()), + TextBuffer::new( + ReplicaId::LOCAL, + cx.entity_id().as_non_zero_u64().into(), + base_text.into(), + ), None, Capability::ReadWrite, ) @@ -801,7 +846,7 @@ impl Buffer { ) -> Self { Self::build( TextBuffer::new_normalized( - 0, + ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), line_ending, base_text_normalized, @@ -950,10 +995,10 @@ impl Buffer { language: None, remote_selections: Default::default(), diagnostics: Default::default(), - diagnostics_timestamp: Default::default(), + diagnostics_timestamp: Lamport::MIN, completion_triggers: Default::default(), completion_triggers_per_language_server: Default::default(), - completion_triggers_timestamp: Default::default(), + completion_triggers_timestamp: Lamport::MIN, deferred_ops: OperationQueue::new(), has_conflict: false, change_bits: Default::default(), @@ -971,11 +1016,10 @@ impl Buffer { let buffer_id = entity_id.as_non_zero_u64().into(); async move { let text = - TextBuffer::new_normalized(0, buffer_id, Default::default(), text).snapshot(); + TextBuffer::new_normalized(ReplicaId::LOCAL, buffer_id, Default::default(), text) + .snapshot(); let mut syntax = SyntaxMap::new(&text).snapshot(); if let Some(language) = language.clone() { - let text = text.clone(); - let language = language.clone(); let language_registry = language_registry.clone(); syntax.reparse(&text, language_registry, language); } @@ -994,8 +1038,13 @@ impl Buffer { pub fn build_empty_snapshot(cx: &mut App) -> BufferSnapshot { let entity_id = cx.reserve_entity::().entity_id(); let buffer_id = entity_id.as_non_zero_u64().into(); - let text = - TextBuffer::new_normalized(0, buffer_id, Default::default(), Rope::new()).snapshot(); + let text = TextBuffer::new_normalized( + ReplicaId::LOCAL, + buffer_id, + Default::default(), + Rope::new(), + ) + .snapshot(); let syntax = SyntaxMap::new(&text).snapshot(); BufferSnapshot { text, @@ -1017,12 +1066,11 @@ impl Buffer { ) -> BufferSnapshot { let entity_id = cx.reserve_entity::().entity_id(); let buffer_id = entity_id.as_non_zero_u64().into(); - let text = TextBuffer::new_normalized(0, buffer_id, Default::default(), text).snapshot(); + let text = + TextBuffer::new_normalized(ReplicaId::LOCAL, buffer_id, Default::default(), text) + .snapshot(); let mut syntax = SyntaxMap::new(&text).snapshot(); if let Some(language) = language.clone() { - let text = text.clone(); - let language = language.clone(); - let language_registry = language_registry.clone(); syntax.reparse(&text, language_registry, language); } BufferSnapshot { @@ -1128,7 +1176,7 @@ impl Buffer { } else { ranges.as_slice() } - .into_iter() + .iter() .peekable(); let mut edits = Vec::new(); @@ -1158,13 +1206,12 @@ impl Buffer { base_buffer.edit(edits, None, cx) }); - if let Some(operation) = operation { - if let Some(BufferBranchState { + if let Some(operation) = operation + && let Some(BufferBranchState { merged_operations, .. }) = &mut self.branch_state - { - merged_operations.push(operation); - } + { + merged_operations.push(operation); } } @@ -1185,11 +1232,11 @@ impl Buffer { }; let mut operation_to_undo = None; - if let Operation::Buffer(text::Operation::Edit(operation)) = &operation { - if let Ok(ix) = merged_operations.binary_search(&operation.timestamp) { - merged_operations.remove(ix); - operation_to_undo = Some(operation.timestamp); - } + if let Operation::Buffer(text::Operation::Edit(operation)) = &operation + && let Ok(ix) = merged_operations.binary_search(&operation.timestamp) + { + merged_operations.remove(ix); + operation_to_undo = Some(operation.timestamp); } self.apply_ops([operation.clone()], cx); @@ -1248,10 +1295,27 @@ impl Buffer { self.syntax_map.lock().language_registry() } + /// Assign the line ending type to the buffer. + pub fn set_line_ending(&mut self, line_ending: LineEnding, cx: &mut Context) { + self.text.set_line_ending(line_ending); + + let lamport_timestamp = self.text.lamport_clock.tick(); + self.send_operation( + Operation::UpdateLineEnding { + line_ending, + lamport_timestamp, + }, + true, + cx, + ); + } + /// Assign the buffer a new [`Capability`]. pub fn set_capability(&mut self, capability: Capability, cx: &mut Context) { - self.capability = capability; - cx.emit(BufferEvent::CapabilityChanged) + if self.capability != capability { + self.capability = capability; + cx.emit(BufferEvent::CapabilityChanged) + } } /// This method is called to signal that the buffer has been saved. @@ -1261,9 +1325,8 @@ impl Buffer { mtime: Option, cx: &mut Context, ) { - self.saved_version = version; - self.has_unsaved_edits - .set((self.saved_version().clone(), false)); + self.saved_version = version.clone(); + self.has_unsaved_edits.set((version, false)); self.has_conflict = false; self.saved_mtime = mtime; self.was_changed(); @@ -1271,12 +1334,6 @@ impl Buffer { cx.notify(); } - /// This method is called to signal that the buffer has been discarded. - pub fn discarded(&self, cx: &mut Context) { - cx.emit(BufferEvent::Discarded); - cx.notify(); - } - /// Reloads the contents of the buffer from disk. pub fn reload(&mut self, cx: &Context) -> oneshot::Receiver> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -1396,7 +1453,8 @@ impl Buffer { is_first = false; return true; } - let any_sub_ranges_contain_range = layer + + layer .included_sub_ranges .map(|sub_ranges| { sub_ranges.iter().any(|sub_range| { @@ -1405,9 +1463,7 @@ impl Buffer { !is_before_start && !is_after_end }) }) - .unwrap_or(true); - let result = any_sub_ranges_contain_range; - return result; + .unwrap_or(true) }) .last() .map(|info| info.language.clone()) @@ -1424,10 +1480,10 @@ impl Buffer { .map(|info| info.language.clone()) .collect(); - if languages.is_empty() { - if let Some(buffer_language) = self.language() { - languages.push(buffer_language.clone()); - } + if languages.is_empty() + && let Some(buffer_language) = self.language() + { + languages.push(buffer_language.clone()); } languages @@ -1517,21 +1573,24 @@ impl Buffer { self.reparse = None; } Err(parse_task) => { + // todo(lw): hot foreground spawn self.reparse = Some(cx.spawn(async move |this, cx| { - let new_syntax_map = parse_task.await; + let new_syntax_map = cx.background_spawn(parse_task).await; this.update(cx, move |this, cx| { - let grammar_changed = - this.language.as_ref().map_or(true, |current_language| { + let grammar_changed = || { + this.language.as_ref().is_none_or(|current_language| { !Arc::ptr_eq(&language, current_language) - }); - let language_registry_changed = new_syntax_map - .contains_unknown_injections() - && language_registry.map_or(false, |registry| { - registry.version() != new_syntax_map.language_registry_version() - }); - let parse_again = language_registry_changed - || grammar_changed - || this.version.changed_since(&parsed_version); + }) + }; + let language_registry_changed = || { + new_syntax_map.contains_unknown_injections() + && language_registry.is_some_and(|registry| { + registry.version() != new_syntax_map.language_registry_version() + }) + }; + let parse_again = this.version.changed_since(&parsed_version) + || language_registry_changed() + || grammar_changed(); this.did_finish_parsing(new_syntax_map, cx); this.reparse = None; if parse_again { @@ -1571,15 +1630,26 @@ impl Buffer { diagnostics: diagnostics.iter().cloned().collect(), lamport_timestamp, }; + self.apply_diagnostic_update(server_id, diagnostics, lamport_timestamp, cx); self.send_operation(op, true, cx); } - pub fn get_diagnostics(&self, server_id: LanguageServerId) -> Option<&DiagnosticSet> { - let Ok(idx) = self.diagnostics.binary_search_by_key(&server_id, |v| v.0) else { - return None; - }; - Some(&self.diagnostics[idx].1) + pub fn buffer_diagnostics( + &self, + for_server: Option, + ) -> Vec<&DiagnosticEntry> { + match for_server { + Some(server_id) => match self.diagnostics.binary_search_by_key(&server_id, |v| v.0) { + Ok(idx) => self.diagnostics[idx].1.iter().collect(), + Err(_) => Vec::new(), + }, + None => self + .diagnostics + .iter() + .flat_map(|(_, diagnostic_set)| diagnostic_set.iter()) + .collect(), + } } fn request_autoindent(&mut self, cx: &mut Context) { @@ -1719,8 +1789,7 @@ impl Buffer { }) .with_delta(suggestion.delta, language_indent_size); - if old_suggestions.get(&new_row).map_or( - true, + if old_suggestions.get(&new_row).is_none_or( |(old_indentation, was_within_error)| { suggested_indent != *old_indentation && (!suggestion.within_error || *was_within_error) @@ -1942,7 +2011,7 @@ impl Buffer { self.end_transaction(cx) } - fn has_unsaved_edits(&self) -> bool { + pub fn has_unsaved_edits(&self) -> bool { let (last_version, has_unsaved_edits) = self.has_unsaved_edits.take(); if last_version == self.version { @@ -2012,12 +2081,15 @@ impl Buffer { } } + /// Set the change bit for all "listeners". fn was_changed(&mut self) { self.change_bits.retain(|change_bit| { - change_bit.upgrade().map_or(false, |bit| { - bit.replace(true); - true - }) + change_bit + .upgrade() + .inspect(|bit| { + _ = bit.replace(true); + }) + .is_some() }); } @@ -2191,7 +2263,7 @@ impl Buffer { if self .remote_selections .get(&self.text.replica_id()) - .map_or(true, |set| !set.selections.is_empty()) + .is_none_or(|set| !set.selections.is_empty()) { self.set_active_selections(Arc::default(), false, Default::default(), cx); } @@ -2206,9 +2278,9 @@ impl Buffer { ) { let lamport_timestamp = self.text.lamport_clock.tick(); self.remote_selections.insert( - AGENT_REPLICA_ID, + ReplicaId::AGENT, SelectionSet { - selections: selections.clone(), + selections, lamport_timestamp, line_mode, cursor_shape, @@ -2270,13 +2342,11 @@ impl Buffer { } let new_text = new_text.into(); if !new_text.is_empty() || !range.is_empty() { - if let Some((prev_range, prev_text)) = edits.last_mut() { - if prev_range.end >= range.start { - prev_range.end = cmp::max(prev_range.end, range.end); - *prev_text = format!("{prev_text}{new_text}").into(); - } else { - edits.push((range, new_text)); - } + if let Some((prev_range, prev_text)) = edits.last_mut() + && prev_range.end >= range.start + { + prev_range.end = cmp::max(prev_range.end, range.end); + *prev_text = format!("{prev_text}{new_text}").into(); } else { edits.push((range, new_text)); } @@ -2296,10 +2366,27 @@ impl Buffer { if let Some((before_edit, mode)) = autoindent_request { let mut delta = 0isize; - let entries = edits + let mut previous_setting = None; + let entries: Vec<_> = edits .into_iter() .enumerate() .zip(&edit_operation.as_edit().unwrap().new_text) + .filter(|((_, (range, _)), _)| { + let language = before_edit.language_at(range.start); + let language_id = language.map(|l| l.id()); + if let Some((cached_language_id, auto_indent)) = previous_setting + && cached_language_id == language_id + { + auto_indent + } else { + // The auto-indent setting is not present in editorconfigs, hence + // we can avoid passing the file here. + let auto_indent = + language_settings(language.map(|l| l.name()), None, cx).auto_indent; + previous_setting = Some((language_id, auto_indent)); + auto_indent + } + }) .map(|((ix, (range, _)), new_text)| { let new_text_length = new_text.len(); let old_start = range.start.to_point(&before_edit); @@ -2373,12 +2460,14 @@ impl Buffer { }) .collect(); - self.autoindent_requests.push(Arc::new(AutoindentRequest { - before_edit, - entries, - is_block_mode: matches!(mode, AutoindentMode::Block { .. }), - ignore_empty_lines: false, - })); + if !entries.is_empty() { + self.autoindent_requests.push(Arc::new(AutoindentRequest { + before_edit, + entries, + is_block_mode: matches!(mode, AutoindentMode::Block { .. }), + ignore_empty_lines: false, + })); + } } self.end_transaction(cx); @@ -2543,7 +2632,7 @@ impl Buffer { Operation::UpdateSelections { selections, .. } => selections .iter() .all(|s| self.can_resolve(&s.start) && self.can_resolve(&s.end)), - Operation::UpdateCompletionTriggers { .. } => true, + Operation::UpdateCompletionTriggers { .. } | Operation::UpdateLineEnding { .. } => true, } } @@ -2571,10 +2660,10 @@ impl Buffer { line_mode, cursor_shape, } => { - if let Some(set) = self.remote_selections.get(&lamport_timestamp.replica_id) { - if set.lamport_timestamp > lamport_timestamp { - return; - } + if let Some(set) = self.remote_selections.get(&lamport_timestamp.replica_id) + && set.lamport_timestamp > lamport_timestamp + { + return; } self.remote_selections.insert( @@ -2600,7 +2689,7 @@ impl Buffer { self.completion_triggers = self .completion_triggers_per_language_server .values() - .flat_map(|triggers| triggers.into_iter().cloned()) + .flat_map(|triggers| triggers.iter().cloned()) .collect(); } else { self.completion_triggers_per_language_server @@ -2609,6 +2698,13 @@ impl Buffer { } self.text.lamport_clock.observe(lamport_timestamp); } + Operation::UpdateLineEnding { + line_ending, + lamport_timestamp, + } => { + self.text.set_line_ending(line_ending); + self.text.lamport_clock.observe(lamport_timestamp); + } } } @@ -2760,7 +2856,7 @@ impl Buffer { self.completion_triggers = self .completion_triggers_per_language_server .values() - .flat_map(|triggers| triggers.into_iter().cloned()) + .flat_map(|triggers| triggers.iter().cloned()) .collect(); } else { self.completion_triggers_per_language_server @@ -2822,24 +2918,24 @@ impl Buffer { let mut edits: Vec<(Range, String)> = Vec::new(); let mut last_end = None; for _ in 0..old_range_count { - if last_end.map_or(false, |last_end| last_end >= self.len()) { + if last_end.is_some_and(|last_end| last_end >= self.len()) { break; } let new_start = last_end.map_or(0, |last_end| last_end + 1); let mut range = self.random_byte_range(new_start, rng); - if rng.gen_bool(0.2) { + if rng.random_bool(0.2) { mem::swap(&mut range.start, &mut range.end); } last_end = Some(range.end); - let new_text_len = rng.gen_range(0..10); + let new_text_len = rng.random_range(0..10); let mut new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect(); new_text = new_text.to_uppercase(); edits.push((range, new_text)); } - log::info!("mutating buffer {} with {:?}", self.replica_id(), edits); + log::info!("mutating buffer {:?} with {:?}", self.replica_id(), edits); self.edit(edits, None, cx); } @@ -2991,9 +3087,9 @@ impl BufferSnapshot { } let mut error_ranges = Vec::>::new(); - let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { - grammar.error_query.as_ref() - }); + let mut matches = self + .syntax + .matches(range, &self.text, |grammar| grammar.error_query.as_ref()); while let Some(mat) = matches.peek() { let node = mat.captures[0].node; let start = Point::from_ts_point(node.start_position()); @@ -3042,14 +3138,14 @@ impl BufferSnapshot { if config .decrease_indent_pattern .as_ref() - .map_or(false, |regex| regex.is_match(line)) + .is_some_and(|regex| regex.is_match(line)) { indent_change_rows.push((row, Ordering::Less)); } if config .increase_indent_pattern .as_ref() - .map_or(false, |regex| regex.is_match(line)) + .is_some_and(|regex| regex.is_match(line)) { indent_change_rows.push((row + 1, Ordering::Greater)); } @@ -3065,7 +3161,7 @@ impl BufferSnapshot { } } for rule in &config.decrease_indent_patterns { - if rule.pattern.as_ref().map_or(false, |r| r.is_match(line)) { + if rule.pattern.as_ref().is_some_and(|r| r.is_match(line)) { let row_start_column = self.indent_size_for_line(row).len; let basis_row = rule .valid_after @@ -3197,7 +3293,10 @@ impl BufferSnapshot { fn get_highlights(&self, range: Range) -> (SyntaxMapCaptures<'_>, Vec) { let captures = self.syntax.captures(range, &self.text, |grammar| { - grammar.highlights_query.as_ref() + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) }); let highlight_maps = captures .grammars() @@ -3261,25 +3360,31 @@ impl BufferSnapshot { /// Iterates over every [`SyntaxLayer`] in the buffer. pub fn syntax_layers(&self) -> impl Iterator> + '_ { - self.syntax - .layers_for_range(0..self.len(), &self.text, true) + self.syntax_layers_for_range(0..self.len(), true) } pub fn syntax_layer_at(&self, position: D) -> Option> { let offset = position.to_offset(self); - self.syntax - .layers_for_range(offset..offset, &self.text, false) + self.syntax_layers_for_range(offset..offset, false) .filter(|l| l.node().end_byte() > offset) .last() } + pub fn syntax_layers_for_range( + &self, + range: Range, + include_hidden: bool, + ) -> impl Iterator> + '_ { + self.syntax + .layers_for_range(range, &self.text, include_hidden) + } + pub fn smallest_syntax_layer_containing( &self, range: Range, ) -> Option> { let range = range.to_offset(self); - return self - .syntax + self.syntax .layers_for_range(range, &self.text, false) .max_by(|a, b| { if a.depth != b.depth { @@ -3289,7 +3394,7 @@ impl BufferSnapshot { } else { a.node().end_byte().cmp(&b.node().end_byte()).reverse() } - }); + }) } /// Returns the main [`Language`]. @@ -3347,9 +3452,8 @@ impl BufferSnapshot { } } - if let Some(range) = range { - if smallest_range_and_depth.as_ref().map_or( - true, + if let Some(range) = range + && smallest_range_and_depth.as_ref().is_none_or( |(smallest_range, smallest_range_depth)| { if layer.depth > *smallest_range_depth { true @@ -3359,13 +3463,13 @@ impl BufferSnapshot { false } }, - ) { - smallest_range_and_depth = Some((range, layer.depth)); - scope = Some(LanguageScope { - language: layer.language.clone(), - override_id: layer.override_id(offset, &self.text), - }); - } + ) + { + smallest_range_and_depth = Some((range, layer.depth)); + scope = Some(LanguageScope { + language: layer.language.clone(), + override_id: layer.override_id(offset, &self.text), + }); } } @@ -3382,16 +3486,14 @@ impl BufferSnapshot { pub fn surrounding_word( &self, start: T, - for_completion: bool, + scope_context: Option, ) -> (Range, Option) { let mut start = start.to_offset(self); let mut end = start; let mut next_chars = self.chars_at(start).take(128).peekable(); let mut prev_chars = self.reversed_chars_at(start).take(128).peekable(); - let classifier = self - .char_classifier_at(start) - .for_completion(for_completion); + let classifier = self.char_classifier_at(start).scope_context(scope_context); let word_kind = cmp::max( prev_chars.peek().copied().map(|c| classifier.kind(c)), next_chars.peek().copied().map(|c| classifier.kind(c)), @@ -3416,47 +3518,72 @@ impl BufferSnapshot { (start..end, word_kind) } - /// Returns the closest syntax node enclosing the given range. + /// Moves the TreeCursor to the smallest descendant or ancestor syntax node enclosing the given + /// range. When `require_larger` is true, the node found must be larger than the query range. + /// + /// Returns true if a node was found, and false otherwise. In the `false` case the cursor will + /// be moved to the root of the tree. + fn goto_node_enclosing_range( + cursor: &mut tree_sitter::TreeCursor, + query_range: &Range, + require_larger: bool, + ) -> bool { + let mut ascending = false; + loop { + let mut range = cursor.node().byte_range(); + if query_range.is_empty() { + // When the query range is empty and the current node starts after it, move to the + // previous sibling to find the node the containing node. + if range.start > query_range.start { + cursor.goto_previous_sibling(); + range = cursor.node().byte_range(); + } + } else { + // When the query range is non-empty and the current node ends exactly at the start, + // move to the next sibling to find a node that extends beyond the start. + if range.end == query_range.start { + cursor.goto_next_sibling(); + range = cursor.node().byte_range(); + } + } + + let encloses = range.contains_inclusive(query_range) + && (!require_larger || range.len() > query_range.len()); + if !encloses { + ascending = true; + if !cursor.goto_parent() { + return false; + } + continue; + } else if ascending { + return true; + } + + // Descend into the current node. + if cursor + .goto_first_child_for_byte(query_range.start) + .is_none() + { + return true; + } + } + } + pub fn syntax_ancestor<'a, T: ToOffset>( &'a self, range: Range, ) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut result: Option> = None; - 'outer: for layer in self + for layer in self .syntax .layers_for_range(range.clone(), &self.text, true) { let mut cursor = layer.node().walk(); - // Descend to the first leaf that touches the start of the range. - // - // If the range is non-empty and the current node ends exactly at the start, - // move to the next sibling to find a node that extends beyond the start. - // - // If the range is empty and the current node starts after the range position, - // move to the previous sibling to find the node that contains the position. - while cursor.goto_first_child_for_byte(range.start).is_some() { - if !range.is_empty() && cursor.node().end_byte() == range.start { - cursor.goto_next_sibling(); - } - if range.is_empty() && cursor.node().start_byte() > range.start { - cursor.goto_previous_sibling(); - } - } - - // Ascend to the smallest ancestor that strictly contains the range. - loop { - let node_range = cursor.node().byte_range(); - if node_range.start <= range.start - && node_range.end >= range.end - && node_range.len() > range.len() - { - break; - } - if !cursor.goto_parent() { - continue 'outer; - } + // Find the node that both contains the range and is larger than it. + if !Self::goto_node_enclosing_range(&mut cursor, &range, true) { + continue; } let left_node = cursor.node(); @@ -3481,19 +3608,121 @@ impl BufferSnapshot { // If there is a candidate node on both sides of the (empty) range, then // decide between the two by favoring a named node over an anonymous token. // If both nodes are the same in that regard, favor the right one. - if let Some(right_node) = right_node { - if right_node.is_named() || !left_node.is_named() { - layer_result = right_node; + if let Some(right_node) = right_node + && (right_node.is_named() || !left_node.is_named()) + { + layer_result = right_node; + } + } + + if let Some(previous_result) = &result + && previous_result.byte_range().len() < layer_result.byte_range().len() + { + continue; + } + result = Some(layer_result); + } + + result + } + + /// Find the previous sibling syntax node at the given range. + /// + /// This function locates the syntax node that precedes the node containing + /// the given range. It searches hierarchically by: + /// 1. Finding the node that contains the given range + /// 2. Looking for the previous sibling at the same tree level + /// 3. If no sibling is found, moving up to parent levels and searching for siblings + /// + /// Returns `None` if there is no previous sibling at any ancestor level. + pub fn syntax_prev_sibling<'a, T: ToOffset>( + &'a self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut result: Option> = None; + + for layer in self + .syntax + .layers_for_range(range.clone(), &self.text, true) + { + let mut cursor = layer.node().walk(); + + // Find the node that contains the range + if !Self::goto_node_enclosing_range(&mut cursor, &range, false) { + continue; + } + + // Look for the previous sibling, moving up ancestor levels if needed + loop { + if cursor.goto_previous_sibling() { + let layer_result = cursor.node(); + + if let Some(previous_result) = &result { + if previous_result.byte_range().end < layer_result.byte_range().end { + continue; + } } + result = Some(layer_result); + break; + } + + // No sibling found at this level, try moving up to parent + if !cursor.goto_parent() { + break; } } + } - if let Some(previous_result) = &result { - if previous_result.byte_range().len() < layer_result.byte_range().len() { - continue; + result + } + + /// Find the next sibling syntax node at the given range. + /// + /// This function locates the syntax node that follows the node containing + /// the given range. It searches hierarchically by: + /// 1. Finding the node that contains the given range + /// 2. Looking for the next sibling at the same tree level + /// 3. If no sibling is found, moving up to parent levels and searching for siblings + /// + /// Returns `None` if there is no next sibling at any ancestor level. + pub fn syntax_next_sibling<'a, T: ToOffset>( + &'a self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut result: Option> = None; + + for layer in self + .syntax + .layers_for_range(range.clone(), &self.text, true) + { + let mut cursor = layer.node().walk(); + + // Find the node that contains the range + if !Self::goto_node_enclosing_range(&mut cursor, &range, false) { + continue; + } + + // Look for the next sibling, moving up ancestor levels if needed + loop { + if cursor.goto_next_sibling() { + let layer_result = cursor.node(); + + if let Some(previous_result) = &result { + if previous_result.byte_range().start > layer_result.byte_range().start { + continue; + } + } + result = Some(layer_result); + break; + } + + // No sibling found at this level, try moving up to parent + if !cursor.goto_parent() { + break; } } - result = Some(layer_result); } result @@ -3526,16 +3755,15 @@ impl BufferSnapshot { } } - return Some(cursor.node()); + Some(cursor.node()) } /// Returns the outline for the buffer. /// /// This method allows passing an optional [`SyntaxTheme`] to /// syntax-highlight the returned symbols. - pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option> { - self.outline_items_containing(0..self.len(), true, theme) - .map(Outline::new) + pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Outline { + Outline::new(self.outline_items_containing(0..self.len(), true, theme)) } /// Returns all the symbols that contain the given position. @@ -3546,20 +3774,18 @@ impl BufferSnapshot { &self, position: T, theme: Option<&SyntaxTheme>, - ) -> Option>> { + ) -> Vec> { let position = position.to_offset(self); - let mut items = self.outline_items_containing( - position.saturating_sub(1)..self.len().min(position + 1), - false, - theme, - )?; + let start = self.clip_offset(position.saturating_sub(1), Bias::Left); + let end = self.clip_offset(position + 1, Bias::Right); + let mut items = self.outline_items_containing(start..end, false, theme); let mut prev_depth = None; items.retain(|item| { - let result = prev_depth.map_or(true, |prev_depth| item.depth > prev_depth); + let result = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth); prev_depth = Some(item.depth); result }); - Some(items) + items } pub fn outline_range_containing(&self, range: Range) -> Option> { @@ -3609,21 +3835,45 @@ impl BufferSnapshot { range: Range, include_extra_context: bool, theme: Option<&SyntaxTheme>, - ) -> Option>> { + ) -> Vec> { + self.outline_items_containing_internal( + range, + include_extra_context, + theme, + |this, range| this.anchor_after(range.start)..this.anchor_before(range.end), + ) + } + + pub fn outline_items_as_points_containing( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + ) -> Vec> { + self.outline_items_containing_internal(range, include_extra_context, theme, |_, range| { + range + }) + } + + fn outline_items_containing_internal( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + range_callback: fn(&Self, Range) -> Range, + ) -> Vec> { let range = range.to_offset(self); let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { grammar.outline_config.as_ref().map(|c| &c.query) }); - let configs = matches - .grammars() - .iter() - .map(|g| g.outline_config.as_ref().unwrap()) - .collect::>(); let mut items = Vec::new(); let mut annotation_row_ranges: Vec> = Vec::new(); while let Some(mat) = matches.peek() { - let config = &configs[mat.grammar_index]; + let config = matches.grammars()[mat.grammar_index] + .outline_config + .as_ref() + .unwrap(); if let Some(item) = self.next_outline_item(config, &mat, &range, include_extra_context, theme) { @@ -3684,25 +3934,22 @@ impl BufferSnapshot { anchor_items.push(OutlineItem { depth: item_ends_stack.len(), - range: self.anchor_after(item.range.start)..self.anchor_before(item.range.end), + range: range_callback(self, item.range.clone()), + source_range_for_text: range_callback(self, item.source_range_for_text.clone()), text: item.text, highlight_ranges: item.highlight_ranges, name_ranges: item.name_ranges, - body_range: item.body_range.map(|body_range| { - self.anchor_after(body_range.start)..self.anchor_before(body_range.end) - }), + body_range: item.body_range.map(|r| range_callback(self, r)), annotation_range: annotation_row_range.map(|annotation_range| { - self.anchor_after(Point::new(annotation_range.start, 0)) - ..self.anchor_before(Point::new( - annotation_range.end, - self.line_len(annotation_range.end), - )) + let point_range = Point::new(annotation_range.start, 0) + ..Point::new(annotation_range.end, self.line_len(annotation_range.end)); + range_callback(self, point_range) }), }); item_ends_stack.push(item.range.end); } - Some(anchor_items) + anchor_items } fn next_outline_item( @@ -3730,47 +3977,47 @@ impl BufferSnapshot { let mut open_point = None; let mut close_point = None; + let mut buffer_ranges = Vec::new(); + let mut add_to_buffer_ranges = |node: tree_sitter::Node, node_is_name| { + let mut range = node.start_byte()..node.end_byte(); + let start = node.start_position(); + if node.end_position().row > start.row { + range.end = range.start + self.line_len(start.row as u32) as usize - start.column; + } + + if !range.is_empty() { + buffer_ranges.push((range, node_is_name)); + } + }; + for capture in mat.captures { - let node_is_name; if capture.index == config.name_capture_ix { - node_is_name = true; + add_to_buffer_ranges(capture.node, true); } else if Some(capture.index) == config.context_capture_ix || (Some(capture.index) == config.extra_context_capture_ix && include_extra_context) { - node_is_name = false; + add_to_buffer_ranges(capture.node, false); } else { if Some(capture.index) == config.open_capture_ix { open_point = Some(Point::from_ts_point(capture.node.end_position())); } else if Some(capture.index) == config.close_capture_ix { close_point = Some(Point::from_ts_point(capture.node.start_position())); } - - continue; - } - - let mut range = capture.node.start_byte()..capture.node.end_byte(); - let start = capture.node.start_position(); - if capture.node.end_position().row > start.row { - range.end = range.start + self.line_len(start.row as u32) as usize - start.column; - } - - if !range.is_empty() { - buffer_ranges.push((range, node_is_name)); } } + if buffer_ranges.is_empty() { return None; } + let source_range_for_text = + buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end; + let mut text = String::new(); let mut highlight_ranges = Vec::new(); let mut name_ranges = Vec::new(); - let mut chunks = self.chunks( - buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end, - true, - ); + let mut chunks = self.chunks(source_range_for_text.clone(), true); let mut last_buffer_range_end = 0; - for (buffer_range, is_name) in buffer_ranges { let space_added = !text.is_empty() && buffer_range.start > last_buffer_range_end; if space_added { @@ -3815,6 +4062,7 @@ impl BufferSnapshot { Some(OutlineItem { depth: 0, // We'll calculate the depth later range: item_point_range, + source_range_for_text: source_range_for_text.to_point(self), text, highlight_ranges, name_ranges, @@ -3895,8 +4143,7 @@ impl BufferSnapshot { range: Range, ) -> impl Iterator + '_ { // Find bracket pairs that *inclusively* contain the given range. - let range = range.start.to_offset(self).saturating_sub(1) - ..self.len().min(range.end.to_offset(self) + 1); + let range = range.start.to_previous_offset(self)..range.end.to_next_offset(self); self.all_bracket_ranges(range) .filter(|pair| !pair.newline_only) } @@ -3905,8 +4152,7 @@ impl BufferSnapshot { &self, range: Range, ) -> impl Iterator, DebuggerTextObject)> + '_ { - let range = range.start.to_offset(self).saturating_sub(1) - ..self.len().min(range.end.to_offset(self) + 1); + let range = range.start.to_previous_offset(self)..range.end.to_next_offset(self); let mut matches = self.syntax.matches_with_options( range.clone(), @@ -3974,8 +4220,8 @@ impl BufferSnapshot { range: Range, options: TreeSitterOptions, ) -> impl Iterator, TextObject)> + '_ { - let range = range.start.to_offset(self).saturating_sub(1) - ..self.len().min(range.end.to_offset(self) + 1); + let range = + range.start.to_previous_offset(self)..self.len().min(range.end.to_next_offset(self)); let mut matches = self.syntax @@ -4062,11 +4308,11 @@ impl BufferSnapshot { // Get the ranges of the innermost pair of brackets. let mut result: Option<(Range, Range)> = None; - for pair in self.enclosing_bracket_ranges(range.clone()) { - if let Some(range_filter) = range_filter { - if !range_filter(pair.open_range.clone(), pair.close_range.clone()) { - continue; - } + for pair in self.enclosing_bracket_ranges(range) { + if let Some(range_filter) = range_filter + && !range_filter(pair.open_range.clone(), pair.close_range.clone()) + { + continue; } let len = pair.close_range.end - pair.open_range.start; @@ -4235,7 +4481,7 @@ impl BufferSnapshot { .map(|(range, name)| { ( name.to_string(), - self.text_for_range(range.clone()).collect::(), + self.text_for_range(range).collect::(), ) }) .collect(); @@ -4314,7 +4560,7 @@ impl BufferSnapshot { &'a self, search_range: Range, reversed: bool, - ) -> impl 'a + Iterator> + ) -> impl 'a + Iterator> where T: 'a + Clone + ToOffset, O: 'a + FromAnchor, @@ -4347,21 +4593,29 @@ impl BufferSnapshot { })?; iterators[next_ix] .next() - .map(|DiagnosticEntry { range, diagnostic }| DiagnosticEntry { - diagnostic, - range: FromAnchor::from_anchor(&range.start, self) - ..FromAnchor::from_anchor(&range.end, self), - }) + .map( + |DiagnosticEntryRef { range, diagnostic }| DiagnosticEntryRef { + diagnostic, + range: FromAnchor::from_anchor(&range.start, self) + ..FromAnchor::from_anchor(&range.end, self), + }, + ) }) } + /// Raw access to the diagnostic sets. Typically `diagnostic_groups` or `diagnostic_group` + /// should be used instead. + pub fn diagnostic_sets(&self) -> &SmallVec<[(LanguageServerId, DiagnosticSet); 2]> { + &self.diagnostics + } + /// Returns all the diagnostic groups associated with the given /// language server ID. If no language server ID is provided, /// all diagnostics groups are returned. pub fn diagnostic_groups( &self, language_server_id: Option, - ) -> Vec<(LanguageServerId, DiagnosticGroup)> { + ) -> Vec<(LanguageServerId, DiagnosticGroup<'_, Anchor>)> { let mut groups = Vec::new(); if let Some(language_server_id) = language_server_id { @@ -4392,7 +4646,7 @@ impl BufferSnapshot { pub fn diagnostic_group( &self, group_id: usize, - ) -> impl Iterator> + '_ + ) -> impl Iterator> + use<'_, O> where O: FromAnchor + 'static, { @@ -4417,13 +4671,12 @@ impl BufferSnapshot { self.file.as_ref() } - /// Resolves the file path (relative to the worktree root) associated with the underlying file. - pub fn resolve_file_path(&self, cx: &App, include_root: bool) -> Option { + pub fn resolve_file_path(&self, include_root: bool, cx: &App) -> Option { if let Some(file) = self.file() { if file.path().file_name().is_none() || include_root { - Some(file.full_path(cx)) + Some(file.full_path(cx).to_string_lossy().into_owned()) } else { - Some(file.path().to_path_buf()) + Some(file.path().display(file.path_style(cx)).to_string()) } } else { None @@ -4432,7 +4685,7 @@ impl BufferSnapshot { pub fn words_in_range(&self, query: WordsQuery) -> BTreeMap> { let query_str = query.fuzzy_contents; - if query_str.map_or(false, |query| query.is_empty()) { + if query_str.is_some_and(|query| query.is_empty()) { return BTreeMap::default(); } @@ -4456,27 +4709,26 @@ impl BufferSnapshot { current_word_start_ix = Some(ix); } - if let Some(query_chars) = &query_chars { - if query_ix < query_len { - if c.to_lowercase().eq(query_chars[query_ix].to_lowercase()) { - query_ix += 1; - } - } + if let Some(query_chars) = &query_chars + && query_ix < query_len + && c.to_lowercase().eq(query_chars[query_ix].to_lowercase()) + { + query_ix += 1; } continue; - } else if let Some(word_start) = current_word_start_ix.take() { - if query_ix == query_len { - let word_range = self.anchor_before(word_start)..self.anchor_after(ix); - let mut word_text = self.text_for_range(word_start..ix).peekable(); - let first_char = word_text - .peek() - .and_then(|first_chunk| first_chunk.chars().next()); - // Skip empty and "words" starting with digits as a heuristic to reduce useless completions - if !query.skip_digits - || first_char.map_or(true, |first_char| !first_char.is_digit(10)) - { - words.insert(word_text.collect(), word_range); - } + } else if let Some(word_start) = current_word_start_ix.take() + && query_ix == query_len + { + let word_range = self.anchor_before(word_start)..self.anchor_after(ix); + let mut word_text = self.text_for_range(word_start..ix).peekable(); + let first_char = word_text + .peek() + .and_then(|first_chunk| first_chunk.chars().next()); + // Skip empty and "words" starting with digits as a heuristic to reduce useless completions + if !query.skip_digits + || first_char.is_none_or(|first_char| !first_char.is_digit(10)) + { + words.insert(word_text.collect(), word_range); } } query_ix = 0; @@ -4589,17 +4841,17 @@ impl<'a> BufferChunks<'a> { highlights .stack .retain(|(end_offset, _)| *end_offset > range.start); - if let Some(capture) = &highlights.next_capture { - if range.start >= capture.node.start_byte() { - let next_capture_end = capture.node.end_byte(); - if range.start < next_capture_end { - highlights.stack.push(( - next_capture_end, - highlights.highlight_maps[capture.grammar_index].get(capture.index), - )); - } - highlights.next_capture.take(); + if let Some(capture) = &highlights.next_capture + && range.start >= capture.node.start_byte() + { + let next_capture_end = capture.node.end_byte(); + if range.start < next_capture_end { + highlights.stack.push(( + next_capture_end, + highlights.highlight_maps[capture.grammar_index].get(capture.index), + )); } + highlights.next_capture.take(); } } else if let Some(snapshot) = self.buffer_snapshot { let (captures, highlight_maps) = snapshot.get_highlights(self.range.clone()); @@ -4624,33 +4876,33 @@ impl<'a> BufferChunks<'a> { } fn initialize_diagnostic_endpoints(&mut self) { - if let Some(diagnostics) = self.diagnostic_endpoints.as_mut() { - if let Some(buffer) = self.buffer_snapshot { - let mut diagnostic_endpoints = Vec::new(); - for entry in buffer.diagnostics_in_range::<_, usize>(self.range.clone(), false) { - diagnostic_endpoints.push(DiagnosticEndpoint { - offset: entry.range.start, - is_start: true, - severity: entry.diagnostic.severity, - is_unnecessary: entry.diagnostic.is_unnecessary, - underline: entry.diagnostic.underline, - }); - diagnostic_endpoints.push(DiagnosticEndpoint { - offset: entry.range.end, - is_start: false, - severity: entry.diagnostic.severity, - is_unnecessary: entry.diagnostic.is_unnecessary, - underline: entry.diagnostic.underline, - }); - } - diagnostic_endpoints - .sort_unstable_by_key(|endpoint| (endpoint.offset, !endpoint.is_start)); - *diagnostics = diagnostic_endpoints.into_iter().peekable(); - self.hint_depth = 0; - self.error_depth = 0; - self.warning_depth = 0; - self.information_depth = 0; + if let Some(diagnostics) = self.diagnostic_endpoints.as_mut() + && let Some(buffer) = self.buffer_snapshot + { + let mut diagnostic_endpoints = Vec::new(); + for entry in buffer.diagnostics_in_range::<_, usize>(self.range.clone(), false) { + diagnostic_endpoints.push(DiagnosticEndpoint { + offset: entry.range.start, + is_start: true, + severity: entry.diagnostic.severity, + is_unnecessary: entry.diagnostic.is_unnecessary, + underline: entry.diagnostic.underline, + }); + diagnostic_endpoints.push(DiagnosticEndpoint { + offset: entry.range.end, + is_start: false, + severity: entry.diagnostic.severity, + is_unnecessary: entry.diagnostic.is_unnecessary, + underline: entry.diagnostic.underline, + }); } + diagnostic_endpoints + .sort_unstable_by_key(|endpoint| (endpoint.offset, !endpoint.is_start)); + *diagnostics = diagnostic_endpoints.into_iter().peekable(); + self.hint_depth = 0; + self.error_depth = 0; + self.warning_depth = 0; + self.information_depth = 0; } } @@ -4755,21 +5007,32 @@ impl<'a> Iterator for BufferChunks<'a> { } self.diagnostic_endpoints = diagnostic_endpoints; - if let Some(chunk) = self.chunks.peek() { + if let Some(ChunkBitmaps { + text: chunk, + chars: chars_map, + tabs, + }) = self.chunks.peek_with_bitmaps() + { let chunk_start = self.range.start; let mut chunk_end = (self.chunks.offset() + chunk.len()) .min(next_capture_start) .min(next_diagnostic_endpoint); let mut highlight_id = None; - if let Some(highlights) = self.highlights.as_ref() { - if let Some((parent_capture_end, parent_highlight_id)) = highlights.stack.last() { - chunk_end = chunk_end.min(*parent_capture_end); - highlight_id = Some(*parent_highlight_id); - } + if let Some(highlights) = self.highlights.as_ref() + && let Some((parent_capture_end, parent_highlight_id)) = highlights.stack.last() + { + chunk_end = chunk_end.min(*parent_capture_end); + highlight_id = Some(*parent_highlight_id); } + let bit_start = chunk_start - self.chunks.offset(); + let bit_end = chunk_end - self.chunks.offset(); + + let slice = &chunk[bit_start..bit_end]; + + let mask = 1u128.unbounded_shl(bit_end as u32).wrapping_sub(1); + let tabs = (tabs >> bit_start) & mask; + let chars = (chars_map >> bit_start) & mask; - let slice = - &chunk[chunk_start - self.chunks.offset()..chunk_end - self.chunks.offset()]; self.range.start = chunk_end; if self.range.start == self.chunks.offset() + chunk.len() { self.chunks.next().unwrap(); @@ -4781,6 +5044,8 @@ impl<'a> Iterator for BufferChunks<'a> { underline: self.underline, diagnostic_severity: self.current_diagnostic_severity(), is_unnecessary: self.current_code_is_unnecessary(), + tabs, + chars, ..Chunk::default() }) } else { @@ -4803,6 +5068,9 @@ impl operation_queue::Operation for Operation { } | Operation::UpdateCompletionTriggers { lamport_timestamp, .. + } + | Operation::UpdateLineEnding { + lamport_timestamp, .. } => *lamport_timestamp, } } @@ -4889,19 +5157,19 @@ impl IndentSize { #[cfg(any(test, feature = "test-support"))] pub struct TestFile { - pub path: Arc, + pub path: Arc, pub root_name: String, pub local_root: Option, } #[cfg(any(test, feature = "test-support"))] impl File for TestFile { - fn path(&self) -> &Arc { + fn path(&self) -> &Arc { &self.path } fn full_path(&self, _: &gpui::App) -> PathBuf { - PathBuf::from(&self.root_name).join(self.path.as_ref()) + PathBuf::from(self.root_name.clone()).join(self.path.as_std_path()) } fn as_local(&self) -> Option<&dyn LocalFile> { @@ -4916,7 +5184,7 @@ impl File for TestFile { unimplemented!() } - fn file_name<'a>(&'a self, _: &'a gpui::App) -> &'a std::ffi::OsStr { + fn file_name<'a>(&'a self, _: &'a gpui::App) -> &'a str { self.path().file_name().unwrap_or(self.root_name.as_ref()) } @@ -4931,6 +5199,10 @@ impl File for TestFile { fn is_private(&self) -> bool { false } + + fn path_style(&self, _cx: &App) -> PathStyle { + PathStyle::local() + } } #[cfg(any(test, feature = "test-support"))] @@ -4938,7 +5210,7 @@ impl LocalFile for TestFile { fn abs_path(&self, _cx: &App) -> PathBuf { PathBuf::from(self.local_root.as_ref().unwrap()) .join(&self.root_name) - .join(self.path.as_ref()) + .join(self.path.as_std_path()) } fn load(&self, _cx: &App) -> Task> { @@ -4959,11 +5231,12 @@ pub(crate) fn contiguous_ranges( std::iter::from_fn(move || { loop { if let Some(value) = values.next() { - if let Some(range) = &mut current_range { - if value == range.end && range.len() < max_len { - range.end += 1; - continue; - } + if let Some(range) = &mut current_range + && value == range.end + && range.len() < max_len + { + range.end += 1; + continue; } let prev_range = current_range.clone(); @@ -4981,7 +5254,7 @@ pub(crate) fn contiguous_ranges( #[derive(Default, Debug)] pub struct CharClassifier { scope: Option, - for_completion: bool, + scope_context: Option, ignore_punctuation: bool, } @@ -4989,14 +5262,14 @@ impl CharClassifier { pub fn new(scope: Option) -> Self { Self { scope, - for_completion: false, + scope_context: None, ignore_punctuation: false, } } - pub fn for_completion(self, for_completion: bool) -> Self { + pub fn scope_context(self, scope_context: Option) -> Self { Self { - for_completion, + scope_context, ..self } } @@ -5026,15 +5299,15 @@ impl CharClassifier { } if let Some(scope) = &self.scope { - let characters = if self.for_completion { - scope.completion_query_characters() - } else { - scope.word_characters() + let characters = match self.scope_context { + Some(CharScopeContext::Completion) => scope.completion_query_characters(), + Some(CharScopeContext::LinkedEdit) => scope.linked_edit_characters(), + None => scope.word_characters(), }; - if let Some(characters) = characters { - if characters.contains(&c) { - return CharKind::Word; - } + if let Some(characters) = characters + && characters.contains(&c) + { + return CharKind::Word; } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 2e2df7e658596daaca3b338ef830794fd0d3bef8..f824639ad762191f4168586551af51fb4e37c8dc 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1,8 +1,5 @@ use super::*; use crate::Buffer; -use crate::language_settings::{ - AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent, -}; use clock::ReplicaId; use collections::BTreeMap; use futures::FutureExt as _; @@ -13,6 +10,7 @@ use proto::deserialize_operation; use rand::prelude::*; use regex::RegexBuilder; use settings::SettingsStore; +use settings::{AllLanguageSettingsContent, LanguageSettingsContent}; use std::collections::BTreeSet; use std::{ env, @@ -26,6 +24,7 @@ use text::{BufferId, LineEnding}; use text::{Point, ToPoint}; use theme::ActiveTheme; use unindent::Unindent as _; +use util::rel_path::rel_path; use util::test::marked_text_offsets; use util::{RandomCharIter, assert_set_eq, post_inc, test::marked_text_ranges}; @@ -67,6 +66,84 @@ fn test_line_endings(cx: &mut gpui::App) { }); } +#[gpui::test] +fn test_set_line_ending(cx: &mut TestAppContext) { + let base = cx.new(|cx| Buffer::local("one\ntwo\nthree\n", cx)); + let base_replica = cx.new(|cx| { + Buffer::from_proto( + ReplicaId::new(1), + Capability::ReadWrite, + base.read(cx).to_proto(cx), + None, + ) + .unwrap() + }); + base.update(cx, |_buffer, cx| { + cx.subscribe(&base_replica, |this, _, event, cx| { + if let BufferEvent::Operation { + operation, + is_local: true, + } = event + { + this.apply_ops([operation.clone()], cx); + } + }) + .detach(); + }); + base_replica.update(cx, |_buffer, cx| { + cx.subscribe(&base, |this, _, event, cx| { + if let BufferEvent::Operation { + operation, + is_local: true, + } = event + { + this.apply_ops([operation.clone()], cx); + } + }) + .detach(); + }); + + // Base + base_replica.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); + base.update(cx, |buffer, cx| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + buffer.set_line_ending(LineEnding::Windows, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); + base_replica.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); + base.update(cx, |buffer, cx| { + buffer.set_line_ending(LineEnding::Unix, cx); + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); + base_replica.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); + + // Replica + base.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); + base_replica.update(cx, |buffer, cx| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + buffer.set_line_ending(LineEnding::Windows, cx); + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); + base.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Windows); + }); + base_replica.update(cx, |buffer, cx| { + buffer.set_line_ending(LineEnding::Unix, cx); + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); + base.read_with(cx, |buffer, _| { + assert_eq!(buffer.line_ending(), LineEnding::Unix); + }); +} + #[gpui::test] fn test_select_language(cx: &mut App) { init_settings(cx, |_| {}); @@ -198,16 +275,16 @@ async fn test_first_line_pattern(cx: &mut TestAppContext) { async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext) { cx.update(|cx| { init_settings(cx, |settings| { - settings.file_types.extend([ - ("TypeScript".into(), vec!["js".into()]), + settings.file_types.get_or_insert_default().extend([ + ("TypeScript".into(), vec!["js".into()].into()), ( "JavaScript".into(), - vec!["*longer.ts".into(), "ecmascript".into()], + vec!["*longer.ts".into(), "ecmascript".into()].into(), ), - ("C++".into(), vec!["c".into(), "*.dev".into()]), + ("C++".into(), vec!["c".into(), "*.dev".into()].into()), ( "Dockerfile".into(), - vec!["Dockerfile".into(), "Dockerfile.*".into()], + vec!["Dockerfile".into(), "Dockerfile.*".into()].into(), ), ]); }) @@ -310,7 +387,7 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext) fn file(path: &str) -> Arc { Arc::new(TestFile { - path: Path::new(path).into(), + path: Arc::from(rel_path(path)), root_name: "zed".into(), local_root: None, }) @@ -326,7 +403,7 @@ fn test_edit_events(cx: &mut gpui::App) { let buffer2 = cx.new(|cx| { Buffer::remote( BufferId::from(cx.entity_id().as_non_zero_u64()), - 1, + ReplicaId::new(1), Capability::ReadWrite, "abcdef", ) @@ -707,9 +784,7 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let outline = buffer - .update(cx, |buffer, _| buffer.snapshot().outline(None)) - .unwrap(); + let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline @@ -791,9 +866,7 @@ async fn test_outline_nodes_with_newlines(cx: &mut gpui::TestAppContext) { .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let outline = buffer - .update(cx, |buffer, _| buffer.snapshot().outline(None)) - .unwrap(); + let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline @@ -830,7 +903,7 @@ async fn test_outline_with_extra_context(cx: &mut gpui::TestAppContext) { let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); // extra context nodes are included in the outline. - let outline = snapshot.outline(None).unwrap(); + let outline = snapshot.outline(None); assert_eq!( outline .items @@ -841,7 +914,7 @@ async fn test_outline_with_extra_context(cx: &mut gpui::TestAppContext) { ); // extra context nodes do not appear in breadcrumbs. - let symbols = snapshot.symbols_containing(3, None).unwrap(); + let symbols = snapshot.symbols_containing(3, None); assert_eq!( symbols .iter() @@ -873,9 +946,7 @@ fn test_outline_annotations(cx: &mut App) { .unindent(); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let outline = buffer - .update(cx, |buffer, _| buffer.snapshot().outline(None)) - .unwrap(); + let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline @@ -979,7 +1050,6 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { ) -> Vec<(String, Range)> { snapshot .symbols_containing(position, None) - .unwrap() .into_iter() .map(|item| { ( @@ -989,6 +1059,21 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { }) .collect() } + + let (text, offsets) = marked_text_offsets( + &" + // ˇ😅 // + fn test() { + } + " + .unindent(), + ); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + // note, it would be nice to actually return the method test in this + // case, but primarily asserting we don't crash because of the multibyte character. + assert_eq!(snapshot.symbols_containing(offsets[0], None), vec![]); } #[gpui::test] @@ -1744,7 +1829,7 @@ fn test_autoindent_block_mode(cx: &mut App) { buffer.edit( [(Point::new(2, 8)..Point::new(2, 8), inserted_text)], Some(AutoindentMode::Block { - original_indent_columns: original_indent_columns.clone(), + original_indent_columns, }), cx, ); @@ -1790,9 +1875,9 @@ fn test_autoindent_block_mode_with_newline(cx: &mut App) { "# .unindent(); buffer.edit( - [(Point::new(2, 0)..Point::new(2, 0), inserted_text.clone())], + [(Point::new(2, 0)..Point::new(2, 0), inserted_text)], Some(AutoindentMode::Block { - original_indent_columns: original_indent_columns.clone(), + original_indent_columns, }), cx, ); @@ -1843,7 +1928,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) { buffer.edit( [(Point::new(2, 0)..Point::new(2, 0), inserted_text)], Some(AutoindentMode::Block { - original_indent_columns: original_indent_columns.clone(), + original_indent_columns, }), cx, ); @@ -2030,7 +2115,7 @@ fn test_autoindent_with_injected_languages(cx: &mut App) { let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); language_registry.add(html_language.clone()); - language_registry.add(javascript_language.clone()); + language_registry.add(javascript_language); cx.new(|cx| { let (text, ranges) = marked_text_ranges( @@ -2696,7 +2781,8 @@ fn test_serialization(cx: &mut gpui::App) { .background_executor() .block(buffer1.read(cx).serialize_ops(None, cx)); let buffer2 = cx.new(|cx| { - let mut buffer = Buffer::from_proto(1, Capability::ReadWrite, state, None).unwrap(); + let mut buffer = + Buffer::from_proto(ReplicaId::new(1), Capability::ReadWrite, state, None).unwrap(); buffer.apply_ops( ops.into_iter() .map(|op| proto::deserialize_operation(op).unwrap()), @@ -2715,7 +2801,13 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { // Create a remote replica of the base buffer. let base_replica = cx.new(|cx| { - Buffer::from_proto(1, Capability::ReadWrite, base.read(cx).to_proto(cx), None).unwrap() + Buffer::from_proto( + ReplicaId::new(1), + Capability::ReadWrite, + base.read(cx).to_proto(cx), + None, + ) + .unwrap() }); base.update(cx, |_buffer, cx| { cx.subscribe(&base_replica, |this, _, event, cx| { @@ -3013,7 +3105,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let base_text_len = rng.gen_range(0..10); + let base_text_len = rng.random_range(0..10); let base_text = RandomCharIter::new(&mut rng) .take(base_text_len) .collect::(); @@ -3022,20 +3114,21 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { let network = Arc::new(Mutex::new(Network::new(rng.clone()))); let base_buffer = cx.new(|cx| Buffer::local(base_text.as_str(), cx)); - for i in 0..rng.gen_range(min_peers..=max_peers) { + for i in 0..rng.random_range(min_peers..=max_peers) { let buffer = cx.new(|cx| { let state = base_buffer.read(cx).to_proto(cx); let ops = cx .background_executor() .block(base_buffer.read(cx).serialize_ops(None, cx)); let mut buffer = - Buffer::from_proto(i as ReplicaId, Capability::ReadWrite, state, None).unwrap(); + Buffer::from_proto(ReplicaId::new(i as u16), Capability::ReadWrite, state, None) + .unwrap(); buffer.apply_ops( ops.into_iter() .map(|op| proto::deserialize_operation(op).unwrap()), cx, ); - buffer.set_group_interval(Duration::from_millis(rng.gen_range(0..=200))); + buffer.set_group_interval(Duration::from_millis(rng.random_range(0..=200))); let network = network.clone(); cx.subscribe(&cx.entity(), move |buffer, _, event, _| { if let BufferEvent::Operation { @@ -3054,9 +3147,9 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); buffers.push(buffer); - replica_ids.push(i as ReplicaId); - network.lock().add_peer(i as ReplicaId); - log::info!("Adding initial peer with replica id {}", i); + replica_ids.push(ReplicaId::new(i as u16)); + network.lock().add_peer(ReplicaId::new(i as u16)); + log::info!("Adding initial peer with replica id {:?}", replica_ids[i]); } log::info!("initial text: {:?}", base_text); @@ -3066,29 +3159,29 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { let mut next_diagnostic_id = 0; let mut active_selections = BTreeMap::default(); loop { - let replica_index = rng.gen_range(0..replica_ids.len()); + let replica_index = rng.random_range(0..replica_ids.len()); let replica_id = replica_ids[replica_index]; let buffer = &mut buffers[replica_index]; let mut new_buffer = None; - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..=29 if mutation_count != 0 => { buffer.update(cx, |buffer, cx| { buffer.start_transaction_at(now); buffer.randomly_edit(&mut rng, 5, cx); buffer.end_transaction_at(now, cx); - log::info!("buffer {} text: {:?}", buffer.replica_id(), buffer.text()); + log::info!("buffer {:?} text: {:?}", buffer.replica_id(), buffer.text()); }); mutation_count -= 1; } 30..=39 if mutation_count != 0 => { buffer.update(cx, |buffer, cx| { - if rng.gen_bool(0.2) { - log::info!("peer {} clearing active selections", replica_id); + if rng.random_bool(0.2) { + log::info!("peer {:?} clearing active selections", replica_id); active_selections.remove(&replica_id); buffer.remove_active_selections(cx); } else { let mut selections = Vec::new(); - for id in 0..rng.gen_range(1..=5) { + for id in 0..rng.random_range(1..=5) { let range = buffer.random_byte_range(0, &mut rng); selections.push(Selection { id, @@ -3100,7 +3193,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { } let selections: Arc<[Selection]> = selections.into(); log::info!( - "peer {} setting active selections: {:?}", + "peer {:?} setting active selections: {:?}", replica_id, selections ); @@ -3110,8 +3203,8 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); mutation_count -= 1; } - 40..=49 if mutation_count != 0 && replica_id == 0 => { - let entry_count = rng.gen_range(1..=5); + 40..=49 if mutation_count != 0 && replica_id == ReplicaId::REMOTE_SERVER => { + let entry_count = rng.random_range(1..=5); buffer.update(cx, |buffer, cx| { let diagnostics = DiagnosticSet::new( (0..entry_count).map(|_| { @@ -3128,7 +3221,11 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }), buffer, ); - log::info!("peer {} setting diagnostics: {:?}", replica_id, diagnostics); + log::info!( + "peer {:?} setting diagnostics: {:?}", + replica_id, + diagnostics + ); buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); }); mutation_count -= 1; @@ -3138,12 +3235,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { let old_buffer_ops = cx .background_executor() .block(buffer.read(cx).serialize_ops(None, cx)); - let new_replica_id = (0..=replica_ids.len() as ReplicaId) + let new_replica_id = (0..=replica_ids.len() as u16) + .map(ReplicaId::new) .filter(|replica_id| *replica_id != buffer.read(cx).replica_id()) .choose(&mut rng) .unwrap(); log::info!( - "Adding new replica {} (replicating from {})", + "Adding new replica {:?} (replicating from {:?})", new_replica_id, replica_id ); @@ -3162,11 +3260,11 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { cx, ); log::info!( - "New replica {} text: {:?}", + "New replica {:?} text: {:?}", new_buffer.replica_id(), new_buffer.text() ); - new_buffer.set_group_interval(Duration::from_millis(rng.gen_range(0..=200))); + new_buffer.set_group_interval(Duration::from_millis(rng.random_range(0..=200))); let network = network.clone(); cx.subscribe(&cx.entity(), move |buffer, _, event, _| { if let BufferEvent::Operation { @@ -3185,7 +3283,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { })); network.lock().replicate(replica_id, new_replica_id); - if new_replica_id as usize == replica_ids.len() { + if new_replica_id.as_u16() as usize == replica_ids.len() { replica_ids.push(new_replica_id); } else { let new_buffer = new_buffer.take().unwrap(); @@ -3197,7 +3295,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .map(|op| proto::deserialize_operation(op).unwrap()); if ops.len() > 0 { log::info!( - "peer {} (version: {:?}) applying {} ops from the network. {:?}", + "peer {:?} (version: {:?}) applying {} ops from the network. {:?}", new_replica_id, buffer.read(cx).version(), ops.len(), @@ -3208,13 +3306,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { }); } } - buffers[new_replica_id as usize] = new_buffer; + buffers[new_replica_id.as_u16() as usize] = new_buffer; } } 60..=69 if mutation_count != 0 => { buffer.update(cx, |buffer, cx| { buffer.randomly_undo_redo(&mut rng, cx); - log::info!("buffer {} text: {:?}", buffer.replica_id(), buffer.text()); + log::info!("buffer {:?} text: {:?}", buffer.replica_id(), buffer.text()); }); mutation_count -= 1; } @@ -3226,7 +3324,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { .map(|op| proto::deserialize_operation(op).unwrap()); if ops.len() > 0 { log::info!( - "peer {} (version: {:?}) applying {} ops from the network. {:?}", + "peer {:?} (version: {:?}) applying {} ops from the network. {:?}", replica_id, buffer.read(cx).version(), ops.len(), @@ -3238,7 +3336,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { _ => {} } - now += Duration::from_millis(rng.gen_range(0..=200)); + now += Duration::from_millis(rng.random_range(0..=200)); buffers.extend(new_buffer); for buffer in &buffers { @@ -3256,13 +3354,13 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { assert_eq!( buffer.version(), first_buffer.version(), - "Replica {} version != Replica 0 version", + "Replica {:?} version != Replica 0 version", buffer.replica_id() ); assert_eq!( buffer.text(), first_buffer.text(), - "Replica {} text != Replica 0 text", + "Replica {:?} text != Replica 0 text", buffer.replica_id() ); assert_eq!( @@ -3272,7 +3370,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { first_buffer .diagnostics_in_range::<_, usize>(0..first_buffer.len(), false) .collect::>(), - "Replica {} diagnostics != Replica 0 diagnostics", + "Replica {:?} diagnostics != Replica 0 diagnostics", buffer.replica_id() ); } @@ -3291,7 +3389,7 @@ fn test_random_collaboration(cx: &mut App, mut rng: StdRng) { assert_eq!( actual_remote_selections, expected_remote_selections, - "Replica {} remote selections != expected selections", + "Replica {:?} remote selections != expected selections", buffer.replica_id() ); } @@ -3320,23 +3418,23 @@ fn test_trailing_whitespace_ranges(mut rng: StdRng) { // Generate a random multi-line string containing // some lines with trailing whitespace. let mut text = String::new(); - for _ in 0..rng.gen_range(0..16) { - for _ in 0..rng.gen_range(0..36) { - text.push(match rng.gen_range(0..10) { + for _ in 0..rng.random_range(0..16) { + for _ in 0..rng.random_range(0..36) { + text.push(match rng.random_range(0..10) { 0..=1 => ' ', 3 => '\t', - _ => rng.gen_range('a'..='z'), + _ => rng.random_range('a'..='z'), }); } text.push('\n'); } - match rng.gen_range(0..10) { + match rng.random_range(0..10) { // sometimes remove the last newline 0..=1 => drop(text.pop()), // // sometimes add extra newlines - 2..=3 => text.push_str(&"\n".repeat(rng.gen_range(1..5))), + 2..=3 => text.push_str(&"\n".repeat(rng.random_range(1..5))), _ => {} } @@ -3784,6 +3882,83 @@ fn init_settings(cx: &mut App, f: fn(&mut AllLanguageSettingsContent)) { cx.set_global(settings_store); crate::init(cx); cx.update_global::(|settings, cx| { - settings.update_user_settings::(cx, f); + settings.update_user_settings(cx, |content| f(&mut content.project.all_languages)); }); } + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + use util::RandomCharIter; + + // Generate random text + let len = rng.random_range(0..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Get all chunks and verify their bitmaps + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + // Check empty chunks have empty bitmaps + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + // Verify that chunk text doesn't exceed 128 bytes + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + + // Verify tabs bitmap + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} diff --git a/crates/language/src/diagnostic_set.rs b/crates/language/src/diagnostic_set.rs index 613c445652fbcfe87232afec559480ce943b15e3..fa3263df48ff773b32332980e7341fa8a453ba4f 100644 --- a/crates/language/src/diagnostic_set.rs +++ b/crates/language/src/diagnostic_set.rs @@ -34,19 +34,66 @@ pub struct DiagnosticEntry { pub diagnostic: Diagnostic, } +/// A single diagnostic in a set. Generic over its range type, because +/// the diagnostics are stored internally as [`Anchor`]s, but can be +/// resolved to different coordinates types like [`usize`] byte offsets or +/// [`Point`](gpui::Point)s. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct DiagnosticEntryRef<'a, T> { + /// The range of the buffer where the diagnostic applies. + pub range: Range, + /// The information about the diagnostic. + pub diagnostic: &'a Diagnostic, +} + +impl PartialEq> for DiagnosticEntryRef<'_, T> { + fn eq(&self, other: &DiagnosticEntry) -> bool { + self.range == other.range && *self.diagnostic == other.diagnostic + } +} + +impl PartialEq> for DiagnosticEntry { + fn eq(&self, other: &DiagnosticEntryRef<'_, T>) -> bool { + self.range == other.range && self.diagnostic == *other.diagnostic + } +} + +impl DiagnosticEntryRef<'_, T> { + pub fn to_owned(&self) -> DiagnosticEntry { + DiagnosticEntry { + range: self.range.clone(), + diagnostic: self.diagnostic.clone(), + } + } +} + +impl<'a> DiagnosticEntryRef<'a, Anchor> { + /// Converts the [DiagnosticEntry] to a different buffer coordinate type. + pub fn resolve( + &self, + buffer: &text::BufferSnapshot, + ) -> DiagnosticEntryRef<'a, O> { + DiagnosticEntryRef { + range: O::from_anchor(&self.range.start, buffer) + ..O::from_anchor(&self.range.end, buffer), + diagnostic: &self.diagnostic, + } + } +} + /// A group of related diagnostics, ordered by their start position /// in the buffer. #[derive(Debug, Serialize)] -pub struct DiagnosticGroup { +pub struct DiagnosticGroup<'a, T> { /// The diagnostics. - pub entries: Vec>, + pub entries: Vec>, /// The index into `entries` where the primary diagnostic is stored. pub primary_ix: usize, } -impl DiagnosticGroup { +impl<'a> DiagnosticGroup<'a, Anchor> { /// Converts the entries in this [`DiagnosticGroup`] to a different buffer coordinate type. - pub fn resolve(&self, buffer: &text::BufferSnapshot) -> DiagnosticGroup { + pub fn resolve(&self, buffer: &text::BufferSnapshot) -> DiagnosticGroup<'a, O> { DiagnosticGroup { entries: self .entries @@ -84,6 +131,23 @@ impl DiagnosticEntry { }) } } +impl DiagnosticEntryRef<'_, PointUtf16> { + /// Returns a raw LSP diagnostic used to provide diagnostic context to LSP + /// codeAction request + pub fn to_lsp_diagnostic_stub(&self) -> Result { + let range = range_to_lsp(self.range.clone())?; + + Ok(lsp::Diagnostic { + range, + code: self.diagnostic.code.clone(), + severity: Some(self.diagnostic.severity), + source: self.diagnostic.source.clone(), + message: self.diagnostic.message.clone(), + data: self.diagnostic.data.clone(), + ..Default::default() + }) + } +} impl DiagnosticSet { /// Constructs a [DiagnosticSet] from a sequence of entries, ordered by @@ -138,7 +202,7 @@ impl DiagnosticSet { buffer: &'a text::BufferSnapshot, inclusive: bool, reversed: bool, - ) -> impl 'a + Iterator> + ) -> impl 'a + Iterator> where T: 'a + ToOffset, O: FromAnchor, @@ -179,10 +243,10 @@ impl DiagnosticSet { } /// Adds all of this set's diagnostic groups to the given output vector. - pub fn groups( - &self, + pub fn groups<'a>( + &'a self, language_server_id: LanguageServerId, - output: &mut Vec<(LanguageServerId, DiagnosticGroup)>, + output: &mut Vec<(LanguageServerId, DiagnosticGroup<'a, Anchor>)>, buffer: &text::BufferSnapshot, ) { let mut groups = HashMap::default(); @@ -190,7 +254,10 @@ impl DiagnosticSet { groups .entry(entry.diagnostic.group_id) .or_insert(Vec::new()) - .push(entry.clone()); + .push(DiagnosticEntryRef { + range: entry.range.clone(), + diagnostic: &entry.diagnostic, + }); } let start_ix = output.len(); @@ -224,7 +291,7 @@ impl DiagnosticSet { &'a self, group_id: usize, buffer: &'a text::BufferSnapshot, - ) -> impl 'a + Iterator> { + ) -> impl 'a + Iterator> { self.iter() .filter(move |entry| entry.diagnostic.group_id == group_id) .map(|entry| entry.resolve(buffer)) @@ -247,11 +314,14 @@ impl sum_tree::Item for DiagnosticEntry { impl DiagnosticEntry { /// Converts the [DiagnosticEntry] to a different buffer coordinate type. - pub fn resolve(&self, buffer: &text::BufferSnapshot) -> DiagnosticEntry { - DiagnosticEntry { + pub fn resolve<'a, O: FromAnchor>( + &'a self, + buffer: &text::BufferSnapshot, + ) -> DiagnosticEntryRef<'a, O> { + DiagnosticEntryRef { range: O::from_anchor(&self.range.start, buffer) ..O::from_anchor(&self.range.end, buffer), - diagnostic: self.diagnostic.clone(), + diagnostic: &self.diagnostic, } } } @@ -269,13 +339,13 @@ impl Default for Summary { } impl sum_tree::Summary for Summary { - type Context = text::BufferSnapshot; + type Context<'a> = &'a text::BufferSnapshot; - fn zero(_cx: &Self::Context) -> Self { + fn zero(_cx: Self::Context<'_>) -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, buffer: &Self::Context) { + fn add_summary(&mut self, other: &Self, buffer: Self::Context<'_>) { if other.min_start.cmp(&self.min_start, buffer).is_lt() { self.min_start = other.min_start; } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index b9933dfcec36f1e8c5cb31271668a25b60020c8a..e3fb6733dd5176906f0a9a9d208305d67470ba15 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -22,14 +22,13 @@ mod toolchain; #[cfg(test)] pub mod buffer_tests; -pub use crate::language_settings::EditPredictionsMode; use crate::language_settings::SoftWrap; +pub use crate::language_settings::{EditPredictionsMode, IndentGuideSettings}; use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::{HashMap, HashSet, IndexSet}; -use fs::Fs; use futures::Future; -use gpui::{App, AsyncApp, Entity, SharedString, Task}; +use gpui::{App, AsyncApp, Entity, SharedString}; pub use highlight_map::HighlightMap; use http_client::HttpClient; pub use language_registry::{ @@ -44,8 +43,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Value; use settings::WorktreeId; use smol::future::FutureExt as _; +use std::num::NonZeroU32; use std::{ - any::Any, ffi::OsStr, fmt::Debug, hash::Hash, @@ -56,10 +55,9 @@ use std::{ str, sync::{ Arc, LazyLock, - atomic::{AtomicU64, AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicUsize, Ordering::SeqCst}, }, }; -use std::{num::NonZeroU32, sync::OnceLock}; use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; use task::RunnableTag; pub use task_context::{ContextLocation, ContextProvider, RunnableRange}; @@ -67,20 +65,26 @@ pub use text_diff::{ DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff, }; use theme::SyntaxTheme; -pub use toolchain::{LanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister}; +pub use toolchain::{ + LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister, + ToolchainMetadata, ToolchainScope, +}; use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime}; +use util::rel_path::RelPath; use util::serde::default_true; pub use buffer::Operation; pub use buffer::*; -pub use diagnostic_set::{DiagnosticEntry, DiagnosticGroup}; +pub use diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}; pub use language_registry::{ AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry, QUERY_FILENAME_PREFIXES, }; pub use lsp::{LanguageServerId, LanguageServerName}; pub use outline::*; -pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer, ToTreeSitterPoint, TreeSitterOptions}; +pub use syntax_map::{ + OwnedSyntaxLayer, SyntaxLayer, SyntaxMapMatches, ToTreeSitterPoint, TreeSitterOptions, +}; pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; @@ -119,8 +123,8 @@ where func(cursor.deref_mut()) } -static NEXT_LANGUAGE_ID: LazyLock = LazyLock::new(Default::default); -static NEXT_GRAMMAR_ID: LazyLock = LazyLock::new(Default::default); +static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); +static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); static WASM_ENGINE: LazyLock = LazyLock::new(|| { wasmtime::Engine::new(&wasmtime::Config::new()).expect("Failed to create Wasmtime engine") }); @@ -154,6 +158,8 @@ pub struct Location { pub range: Range, } +type ServerBinaryCache = futures::lock::Mutex>; + /// Represents a Language Server, with certain cached sync properties. /// Uses [`LspAdapter`] under the hood, but calls all 'static' methods /// once at startup, and caches the results. @@ -163,9 +169,7 @@ pub struct CachedLspAdapter { pub disk_based_diagnostics_progress_token: Option, language_ids: HashMap, pub adapter: Arc, - pub reinstall_attempt_count: AtomicU64, - cached_binary: futures::lock::Mutex>, - manifest_name: OnceLock>, + cached_binary: ServerBinaryCache, } impl Debug for CachedLspAdapter { @@ -181,7 +185,6 @@ impl Debug for CachedLspAdapter { &self.disk_based_diagnostics_progress_token, ) .field("language_ids", &self.language_ids) - .field("reinstall_attempt_count", &self.reinstall_attempt_count) .finish_non_exhaustive() } } @@ -200,26 +203,30 @@ impl CachedLspAdapter { language_ids, adapter, cached_binary: Default::default(), - reinstall_attempt_count: AtomicU64::new(0), - manifest_name: Default::default(), }) } pub fn name(&self) -> LanguageServerName { - self.adapter.name().clone() + self.adapter.name() } pub async fn get_language_server_command( self: Arc, delegate: Arc, - toolchains: Arc, + toolchains: Option, binary_options: LanguageServerBinaryOptions, cx: &mut AsyncApp, ) -> Result { - let cached_binary = self.cached_binary.lock().await; + let mut cached_binary = self.cached_binary.lock().await; self.adapter .clone() - .get_language_server_command(delegate, toolchains, binary_options, cached_binary, cx) + .get_language_server_command( + delegate, + toolchains, + binary_options, + &mut cached_binary, + cx, + ) .await } @@ -281,21 +288,6 @@ impl CachedLspAdapter { .cloned() .unwrap_or_else(|| language_name.lsp_id()) } - - pub fn manifest_name(&self) -> Option { - self.manifest_name - .get_or_init(|| self.adapter.manifest_name()) - .clone() - } -} - -/// Determines what gets sent out as a workspace folders content -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum WorkspaceFoldersContent { - /// Send out a single entry with the root of the workspace. - WorktreeRoot, - /// Send out a list of subproject roots. - SubprojectRoots, } /// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application @@ -316,133 +308,14 @@ pub trait LspAdapterDelegate: Send + Sync { ) -> Result>; async fn which(&self, command: &OsStr) -> Option; async fn shell_env(&self) -> HashMap; - async fn read_text_file(&self, path: PathBuf) -> Result; + async fn read_text_file(&self, path: &RelPath) -> Result; async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>; } #[async_trait(?Send)] -pub trait LspAdapter: 'static + Send + Sync { +pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { fn name(&self) -> LanguageServerName; - fn get_language_server_command<'a>( - self: Arc, - delegate: Arc, - toolchains: Arc, - binary_options: LanguageServerBinaryOptions, - mut cached_binary: futures::lock::MutexGuard<'a, Option>, - cx: &'a mut AsyncApp, - ) -> Pin>>> { - async move { - // First we check whether the adapter can give us a user-installed binary. - // If so, we do *not* want to cache that, because each worktree might give us a different - // binary: - // - // worktree 1: user-installed at `.bin/gopls` - // worktree 2: user-installed at `~/bin/gopls` - // worktree 3: no gopls found in PATH -> fallback to Zed installation - // - // We only want to cache when we fall back to the global one, - // because we don't want to download and overwrite our global one - // for each worktree we might have open. - if binary_options.allow_path_lookup { - if let Some(binary) = self.check_if_user_installed(delegate.as_ref(), toolchains, cx).await { - log::info!( - "found user-installed language server for {}. path: {:?}, arguments: {:?}", - self.name().0, - binary.path, - binary.arguments - ); - return Ok(binary); - } - } - - anyhow::ensure!(binary_options.allow_binary_download, "downloading language servers disabled"); - - if let Some(cached_binary) = cached_binary.as_ref() { - return Ok(cached_binary.clone()); - } - - let Some(container_dir) = delegate.language_server_download_dir(&self.name()).await else { - anyhow::bail!("no language server download dir defined") - }; - - let mut binary = try_fetch_server_binary(self.as_ref(), &delegate, container_dir.to_path_buf(), cx).await; - - if let Err(error) = binary.as_ref() { - if let Some(prev_downloaded_binary) = self - .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref()) - .await - { - log::info!( - "failed to fetch newest version of language server {:?}. error: {:?}, falling back to using {:?}", - self.name(), - error, - prev_downloaded_binary.path - ); - binary = Ok(prev_downloaded_binary); - } else { - delegate.update_status( - self.name(), - BinaryStatus::Failed { - error: format!("{error:?}"), - }, - ); - } - } - - if let Ok(binary) = &binary { - *cached_binary = Some(binary.clone()); - } - - binary - } - .boxed_local() - } - - async fn check_if_user_installed( - &self, - _: &dyn LspAdapterDelegate, - _: Arc, - _: &AsyncApp, - ) -> Option { - None - } - - async fn fetch_latest_server_version( - &self, - delegate: &dyn LspAdapterDelegate, - ) -> Result>; - - fn will_fetch_server( - &self, - _: &Arc, - _: &mut AsyncApp, - ) -> Option>> { - None - } - - async fn check_if_version_installed( - &self, - _version: &(dyn 'static + Send + Any), - _container_dir: &PathBuf, - _delegate: &dyn LspAdapterDelegate, - ) -> Option { - None - } - - async fn fetch_server_binary( - &self, - latest_version: Box, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result; - - async fn cached_server_binary( - &self, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option; - fn process_diagnostics( &self, _: &mut lsp::PublishDiagnosticsParams, @@ -525,7 +398,6 @@ pub trait LspAdapter: 'static + Send + Sync { /// Returns initialization options that are going to be sent to a LSP server as a part of [`lsp::InitializeParams`] async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(None) @@ -533,9 +405,8 @@ pub trait LspAdapter: 'static + Send + Sync { async fn workspace_configuration( self: Arc, - _: &dyn Fs, _: &Arc, - _: Arc, + _: Option, _cx: &mut AsyncApp, ) -> Result { Ok(serde_json::json!({})) @@ -544,7 +415,6 @@ pub trait LspAdapter: 'static + Send + Sync { async fn additional_initialization_options( self: Arc, _target_language_server_id: LanguageServerName, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(None) @@ -553,9 +423,7 @@ pub trait LspAdapter: 'static + Send + Sync { async fn additional_workspace_configuration( self: Arc, _target_language_server_id: LanguageServerName, - _: &dyn Fs, _: &Arc, - _: Arc, _cx: &mut AsyncApp, ) -> Result> { Ok(None) @@ -587,17 +455,6 @@ pub trait LspAdapter: 'static + Send + Sync { Ok(original) } - /// Determines whether a language server supports workspace folders. - /// - /// And does not trip over itself in the process. - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::SubprojectRoots - } - - fn manifest_name(&self) -> Option { - None - } - /// Method only implemented by the default JSON language server adapter. /// Used to provide dynamic reloading of the JSON schemas used to /// provide autocompletion and diagnostics in Zed setting and keybind @@ -606,52 +463,200 @@ pub trait LspAdapter: 'static + Send + Sync { false } - /// Method only implemented by the default JSON language server adapter. - /// Used to clear the cache of JSON schemas that are used to provide - /// autocompletion and diagnostics in Zed settings and keybinds files. - /// Should not be called unless the callee is sure that - /// `Self::is_primary_zed_json_schema_adapter` returns `true` - async fn clear_zed_json_schema_cache(&self) { - unreachable!( - "Not implemented for this adapter. This method should only be called on the default JSON language server adapter" - ); + /// True for the extension adapter and false otherwise. + fn is_extension(&self) -> bool { + false } } -async fn try_fetch_server_binary( - adapter: &L, - delegate: &Arc, - container_dir: PathBuf, - cx: &mut AsyncApp, -) -> Result { - if let Some(task) = adapter.will_fetch_server(delegate, cx) { - task.await?; +pub trait LspInstaller { + type BinaryVersion; + fn check_if_user_installed( + &self, + _: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> impl Future> { + async { None } } - let name = adapter.name(); - log::info!("fetching latest version of language server {:?}", name.0); - delegate.update_status(name.clone(), BinaryStatus::CheckingForUpdate); + fn fetch_latest_server_version( + &self, + delegate: &dyn LspAdapterDelegate, + pre_release: bool, + cx: &mut AsyncApp, + ) -> impl Future>; + + fn check_if_version_installed( + &self, + _version: &Self::BinaryVersion, + _container_dir: &PathBuf, + _delegate: &dyn LspAdapterDelegate, + ) -> impl Future> { + async { None } + } - let latest_version = adapter - .fetch_latest_server_version(delegate.as_ref()) - .await?; + fn fetch_server_binary( + &self, + latest_version: Self::BinaryVersion, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> impl Future>; - if let Some(binary) = adapter - .check_if_version_installed(latest_version.as_ref(), &container_dir, delegate.as_ref()) - .await - { - log::info!("language server {:?} is already installed", name.0); - delegate.update_status(name.clone(), BinaryStatus::None); - Ok(binary) - } else { - log::info!("downloading language server {:?}", name.0); - delegate.update_status(adapter.name(), BinaryStatus::Downloading); - let binary = adapter - .fetch_server_binary(latest_version, container_dir, delegate.as_ref()) - .await; + fn cached_server_binary( + &self, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> impl Future>; +} - delegate.update_status(name.clone(), BinaryStatus::None); - binary +#[async_trait(?Send)] +pub trait DynLspInstaller { + async fn try_fetch_server_binary( + &self, + delegate: &Arc, + container_dir: PathBuf, + pre_release: bool, + cx: &mut AsyncApp, + ) -> Result; + fn get_language_server_command<'a>( + self: Arc, + delegate: Arc, + toolchains: Option, + binary_options: LanguageServerBinaryOptions, + cached_binary: &'a mut Option<(bool, LanguageServerBinary)>, + cx: &'a mut AsyncApp, + ) -> Pin>>>; +} + +#[async_trait(?Send)] +impl DynLspInstaller for LI +where + LI: LspInstaller + LspAdapter, +{ + async fn try_fetch_server_binary( + &self, + delegate: &Arc, + container_dir: PathBuf, + pre_release: bool, + cx: &mut AsyncApp, + ) -> Result { + let name = self.name(); + + log::debug!("fetching latest version of language server {:?}", name.0); + delegate.update_status(name.clone(), BinaryStatus::CheckingForUpdate); + + let latest_version = self + .fetch_latest_server_version(delegate.as_ref(), pre_release, cx) + .await?; + + if let Some(binary) = self + .check_if_version_installed(&latest_version, &container_dir, delegate.as_ref()) + .await + { + log::debug!("language server {:?} is already installed", name.0); + delegate.update_status(name.clone(), BinaryStatus::None); + Ok(binary) + } else { + log::debug!("downloading language server {:?}", name.0); + delegate.update_status(name.clone(), BinaryStatus::Downloading); + let binary = self + .fetch_server_binary(latest_version, container_dir, delegate.as_ref()) + .await; + + delegate.update_status(name.clone(), BinaryStatus::None); + binary + } + } + fn get_language_server_command<'a>( + self: Arc, + delegate: Arc, + toolchain: Option, + binary_options: LanguageServerBinaryOptions, + cached_binary: &'a mut Option<(bool, LanguageServerBinary)>, + cx: &'a mut AsyncApp, + ) -> Pin>>> { + async move { + // First we check whether the adapter can give us a user-installed binary. + // If so, we do *not* want to cache that, because each worktree might give us a different + // binary: + // + // worktree 1: user-installed at `.bin/gopls` + // worktree 2: user-installed at `~/bin/gopls` + // worktree 3: no gopls found in PATH -> fallback to Zed installation + // + // We only want to cache when we fall back to the global one, + // because we don't want to download and overwrite our global one + // for each worktree we might have open. + if binary_options.allow_path_lookup + && let Some(binary) = self + .check_if_user_installed(delegate.as_ref(), toolchain, cx) + .await + { + log::info!( + "found user-installed language server for {}. path: {:?}, arguments: {:?}", + self.name().0, + binary.path, + binary.arguments + ); + return Ok(binary); + } + + anyhow::ensure!( + binary_options.allow_binary_download, + "downloading language servers disabled" + ); + + if let Some((pre_release, cached_binary)) = cached_binary + && *pre_release == binary_options.pre_release + { + return Ok(cached_binary.clone()); + } + + let Some(container_dir) = delegate.language_server_download_dir(&self.name()).await + else { + anyhow::bail!("no language server download dir defined") + }; + + let mut binary = self + .try_fetch_server_binary( + &delegate, + container_dir.to_path_buf(), + binary_options.pre_release, + cx, + ) + .await; + + if let Err(error) = binary.as_ref() { + if let Some(prev_downloaded_binary) = self + .cached_server_binary(container_dir.to_path_buf(), delegate.as_ref()) + .await + { + log::info!( + "failed to fetch newest version of language server {:?}. \ + error: {:?}, falling back to using {:?}", + self.name(), + error, + prev_downloaded_binary.path + ); + binary = Ok(prev_downloaded_binary); + } else { + delegate.update_status( + self.name(), + BinaryStatus::Failed { + error: format!("{error:?}"), + }, + ); + } + } + + if let Ok(binary) = &binary { + *cached_binary = Some((binary_options.pre_release, binary.clone())); + } + + binary + } + .boxed_local() } } @@ -665,6 +670,16 @@ pub struct CodeLabel { pub filter_range: Range, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeLabelBuilder { + /// The text to display. + text: String, + /// Syntax highlighting runs. + runs: Vec<(Range, HighlightId)>, + /// The portion of the text that should be used in fuzzy filtering. + filter_range: Range, +} + #[derive(Clone, Deserialize, JsonSchema)] pub struct LanguageConfig { /// Human-readable name of the language. @@ -744,10 +759,14 @@ pub struct LanguageConfig { pub hard_tabs: Option, /// How many columns a tab should occupy. #[serde(default)] + #[schemars(range(min = 1, max = 128))] pub tab_size: Option, /// How to soft-wrap long lines of text. #[serde(default)] pub soft_wrap: Option, + /// When set, selections can be wrapped using prefix/suffix pairs on both sides. + #[serde(default)] + pub wrap_characters: Option, /// The name of a Prettier parser that will be used for this language when no file path is available. /// If there's a parser name in the language settings, that will be used instead. #[serde(default)] @@ -762,9 +781,21 @@ pub struct LanguageConfig { /// A list of characters that Zed should treat as word characters for completion queries. #[serde(default)] pub completion_query_characters: HashSet, + /// A list of characters that Zed should treat as word characters for linked edit operations. + #[serde(default)] + pub linked_edit_characters: HashSet, /// A list of preferred debuggers for this language. #[serde(default)] pub debuggers: IndexSet, + /// A list of import namespace segments that aren't expected to appear in file paths. For + /// example, "super" and "crate" in Rust. + #[serde(default)] + pub ignored_import_segments: HashSet>, + /// Regular expression that matches substrings to omit from import paths, to make the paths more + /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub import_path_strip_regex: Option, } #[derive(Clone, Debug, Deserialize, Default, JsonSchema)] @@ -834,6 +865,7 @@ pub struct BlockCommentConfig { /// A character to add as a prefix when a new line is added to a block comment. pub prefix: Arc, /// A indent to add for prefix and end line upon new line. + #[schemars(range(min = 1, max = 128))] pub tab_size: u32, } @@ -898,6 +930,8 @@ pub struct LanguageConfigOverride { #[serde(default)] pub completion_query_characters: Override>, #[serde(default)] + pub linked_edit_characters: Override>, + #[serde(default)] pub opt_into_language_servers: Vec, #[serde(default)] pub prefer_label_for_snippet: Option, @@ -951,15 +985,31 @@ impl Default for LanguageConfig { hard_tabs: None, tab_size: None, soft_wrap: None, + wrap_characters: None, prettier_parser_name: None, hidden: false, jsx_tag_auto_close: None, completion_query_characters: Default::default(), + linked_edit_characters: Default::default(), debuggers: Default::default(), + ignored_import_segments: Default::default(), + import_path_strip_regex: None, } } } +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct WrapCharactersConfig { + /// Opening token split into a prefix and suffix. The first caret goes + /// after the prefix (i.e., between prefix and suffix). + pub start_prefix: String, + pub start_suffix: String, + /// Closing token split into a prefix and suffix. The second caret goes + /// after the prefix (i.e., between prefix and suffix). + pub end_prefix: String, + pub end_suffix: String, +} + fn auto_indent_using_last_non_empty_line_default() -> bool { true } @@ -991,11 +1041,11 @@ where fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { let sources = Vec::::deserialize(d)?; - let mut regexes = Vec::new(); - for source in sources { - regexes.push(regex::Regex::new(&source).map_err(de::Error::custom)?); - } - Ok(regexes) + sources + .into_iter() + .map(|source| regex::Regex::new(&source)) + .collect::>() + .map_err(de::Error::custom) } fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { @@ -1061,12 +1111,10 @@ impl<'de> Deserialize<'de> for BracketPairConfig { D: Deserializer<'de>, { let result = Vec::::deserialize(deserializer)?; - let mut brackets = Vec::with_capacity(result.len()); - let mut disabled_scopes_by_bracket_ix = Vec::with_capacity(result.len()); - for entry in result { - brackets.push(entry.bracket_pair); - disabled_scopes_by_bracket_ix.push(entry.not_in); - } + let (brackets, disabled_scopes_by_bracket_ix) = result + .into_iter() + .map(|entry| (entry.bracket_pair, entry.not_in)) + .unzip(); Ok(BracketPairConfig { pairs: brackets, @@ -1108,6 +1156,7 @@ pub struct Language { pub(crate) grammar: Option>, pub(crate) context_provider: Option>, pub(crate) toolchain: Option>, + pub(crate) manifest_name: Option, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -1123,7 +1172,7 @@ pub struct Grammar { id: GrammarId, pub ts_language: tree_sitter::Language, pub(crate) error_query: Option, - pub(crate) highlights_query: Option, + pub highlights_config: Option, pub(crate) brackets_config: Option, pub(crate) redactions_config: Option, pub(crate) runnable_config: Option, @@ -1134,9 +1183,15 @@ pub struct Grammar { pub(crate) injection_config: Option, pub(crate) override_config: Option, pub(crate) debug_variables_config: Option, + pub(crate) imports_config: Option, pub(crate) highlight_map: Mutex, } +pub struct HighlightsConfig { + pub query: Query, + pub identifier_capture_indices: Vec, +} + struct IndentConfig { query: Query, indent_capture_ix: u32, @@ -1263,6 +1318,7 @@ struct InjectionPatternConfig { combined: bool, } +#[derive(Debug)] struct BracketsConfig { query: Query, open_capture_ix: u32, @@ -1280,6 +1336,17 @@ pub struct DebugVariablesConfig { pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, } +pub struct ImportsConfig { + pub query: Query, + pub import_ix: u32, + pub name_ix: Option, + pub namespace_ix: Option, + pub source_ix: Option, + pub list_ix: Option, + pub wildcard_ix: Option, + pub alias_ix: Option, +} + impl Language { pub fn new(config: LanguageConfig, ts_language: Option) -> Self { Self::new_with_id(LanguageId::new(), config, ts_language) @@ -1300,7 +1367,7 @@ impl Language { grammar: ts_language.map(|ts_language| { Arc::new(Grammar { id: GrammarId::new(), - highlights_query: None, + highlights_config: None, brackets_config: None, outline_config: None, text_object_config: None, @@ -1312,12 +1379,14 @@ impl Language { runnable_config: None, error_query: Query::new(&ts_language, "(ERROR) @error").ok(), debug_variables_config: None, + imports_config: None, ts_language, highlight_map: Default::default(), }) }), context_provider: None, toolchain: None, + manifest_name: None, } } @@ -1331,6 +1400,11 @@ impl Language { self } + pub fn with_manifest(mut self, name: Option) -> Self { + self.manifest_name = name; + self + } + pub fn with_queries(mut self, queries: LanguageQueries) -> Result { if let Some(query) = queries.highlights { self = self @@ -1387,29 +1461,54 @@ impl Language { .with_debug_variables_query(query.as_ref()) .context("Error loading debug variables query")?; } + if let Some(query) = queries.imports { + self = self + .with_imports_query(query.as_ref()) + .context("Error loading imports query")?; + } Ok(self) } pub fn with_highlights_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - grammar.highlights_query = Some(Query::new(&grammar.ts_language, source)?); + let grammar = self.grammar_mut()?; + let query = Query::new(&grammar.ts_language, source)?; + + let mut identifier_capture_indices = Vec::new(); + for name in [ + "variable", + "constant", + "constructor", + "function", + "function.method", + "function.method.call", + "function.special", + "property", + "type", + "type.interface", + ] { + identifier_capture_indices.extend(query.capture_index_for_name(name)); + } + + grammar.highlights_config = Some(HighlightsConfig { + query, + identifier_capture_indices, + }); + Ok(self) } pub fn with_runnable_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; + let grammar = self.grammar_mut()?; let query = Query::new(&grammar.ts_language, source)?; - let mut extra_captures = Vec::with_capacity(query.capture_names().len()); - - for name in query.capture_names().iter() { - let kind = if *name == "run" { - RunnableCapture::Run - } else { - RunnableCapture::Named(name.to_string().into()) - }; - extra_captures.push(kind); - } + let extra_captures: Vec<_> = query + .capture_names() + .iter() + .map(|&name| match name { + "run" => RunnableCapture::Run, + name => RunnableCapture::Named(name.to_string().into()), + }) + .collect(); grammar.runnable_config = Some(RunnableConfig { extra_captures, @@ -1420,29 +1519,30 @@ impl Language { } pub fn with_outline_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; - let mut item_capture_ix = None; - let mut name_capture_ix = None; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + let mut item_capture_ix = 0; + let mut name_capture_ix = 0; let mut context_capture_ix = None; let mut extra_context_capture_ix = None; let mut open_capture_ix = None; let mut close_capture_ix = None; let mut annotation_capture_ix = None; - get_capture_indices( + if populate_capture_indices( &query, + &self.config.name, + "outline", + &[], &mut [ - ("item", &mut item_capture_ix), - ("name", &mut name_capture_ix), - ("context", &mut context_capture_ix), - ("context.extra", &mut extra_context_capture_ix), - ("open", &mut open_capture_ix), - ("close", &mut close_capture_ix), - ("annotation", &mut annotation_capture_ix), + Capture::Required("item", &mut item_capture_ix), + Capture::Required("name", &mut name_capture_ix), + Capture::Optional("context", &mut context_capture_ix), + Capture::Optional("context.extra", &mut extra_context_capture_ix), + Capture::Optional("open", &mut open_capture_ix), + Capture::Optional("close", &mut close_capture_ix), + Capture::Optional("annotation", &mut annotation_capture_ix), ], - ); - if let Some((item_capture_ix, name_capture_ix)) = item_capture_ix.zip(name_capture_ix) { - grammar.outline_config = Some(OutlineConfig { + ) { + self.grammar_mut()?.outline_config = Some(OutlineConfig { query, item_capture_ix, name_capture_ix, @@ -1457,17 +1557,22 @@ impl Language { } pub fn with_text_object_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; let mut text_objects_by_capture_ix = Vec::new(); for (ix, name) in query.capture_names().iter().enumerate() { if let Some(text_object) = TextObject::from_capture_name(name) { text_objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} textobjects TreeSitter query", + name, + self.config.name, + ); } } - grammar.text_object_config = Some(TextObjectConfig { + self.grammar_mut()?.text_object_config = Some(TextObjectConfig { query, text_objects_by_capture_ix, }); @@ -1475,25 +1580,26 @@ impl Language { } pub fn with_embedding_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; - let mut item_capture_ix = None; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + let mut item_capture_ix = 0; let mut name_capture_ix = None; let mut context_capture_ix = None; let mut collapse_capture_ix = None; let mut keep_capture_ix = None; - get_capture_indices( + if populate_capture_indices( &query, + &self.config.name, + "embedding", + &[], &mut [ - ("item", &mut item_capture_ix), - ("name", &mut name_capture_ix), - ("context", &mut context_capture_ix), - ("keep", &mut keep_capture_ix), - ("collapse", &mut collapse_capture_ix), + Capture::Required("item", &mut item_capture_ix), + Capture::Optional("name", &mut name_capture_ix), + Capture::Optional("context", &mut context_capture_ix), + Capture::Optional("keep", &mut keep_capture_ix), + Capture::Optional("collapse", &mut collapse_capture_ix), ], - ); - if let Some(item_capture_ix) = item_capture_ix { - grammar.embedding_config = Some(EmbeddingConfig { + ) { + self.grammar_mut()?.embedding_config = Some(EmbeddingConfig { query, item_capture_ix, name_capture_ix, @@ -1506,49 +1612,93 @@ impl Language { } pub fn with_debug_variables_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; let mut objects_by_capture_ix = Vec::new(); for (ix, name) in query.capture_names().iter().enumerate() { if let Some(text_object) = DebuggerTextObject::from_capture_name(name) { objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} debugger TreeSitter query", + name, + self.config.name, + ); } } - grammar.debug_variables_config = Some(DebugVariablesConfig { + self.grammar_mut()?.debug_variables_config = Some(DebugVariablesConfig { query, objects_by_capture_ix, }); Ok(self) } + pub fn with_imports_query(mut self, source: &str) -> Result { + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + + let mut import_ix = 0; + let mut name_ix = None; + let mut namespace_ix = None; + let mut source_ix = None; + let mut list_ix = None; + let mut wildcard_ix = None; + let mut alias_ix = None; + if populate_capture_indices( + &query, + &self.config.name, + "imports", + &[], + &mut [ + Capture::Required("import", &mut import_ix), + Capture::Optional("name", &mut name_ix), + Capture::Optional("namespace", &mut namespace_ix), + Capture::Optional("source", &mut source_ix), + Capture::Optional("list", &mut list_ix), + Capture::Optional("wildcard", &mut wildcard_ix), + Capture::Optional("alias", &mut alias_ix), + ], + ) { + self.grammar_mut()?.imports_config = Some(ImportsConfig { + query, + import_ix, + name_ix, + namespace_ix, + source_ix, + list_ix, + wildcard_ix, + alias_ix, + }); + } + return Ok(self); + } + pub fn with_brackets_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; - let mut open_capture_ix = None; - let mut close_capture_ix = None; - get_capture_indices( + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + let mut open_capture_ix = 0; + let mut close_capture_ix = 0; + if populate_capture_indices( &query, + &self.config.name, + "brackets", + &[], &mut [ - ("open", &mut open_capture_ix), - ("close", &mut close_capture_ix), + Capture::Required("open", &mut open_capture_ix), + Capture::Required("close", &mut close_capture_ix), ], - ); - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = BracketsPatternConfig::default(); - for setting in query.property_settings(ix) { - match setting.key.as_ref() { - "newline.only" => config.newline_only = true, - _ => {} + ) { + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = BracketsPatternConfig::default(); + for setting in query.property_settings(ix) { + if setting.key.as_ref() == "newline.only" { + config.newline_only = true + } } - } - config - }) - .collect(); - if let Some((open_capture_ix, close_capture_ix)) = open_capture_ix.zip(close_capture_ix) { - grammar.brackets_config = Some(BracketsConfig { + config + }) + .collect(); + self.grammar_mut()?.brackets_config = Some(BracketsConfig { query, open_capture_ix, close_capture_ix, @@ -1559,31 +1709,31 @@ impl Language { } pub fn with_indents_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; - let mut indent_capture_ix = None; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + let mut indent_capture_ix = 0; let mut start_capture_ix = None; let mut end_capture_ix = None; let mut outdent_capture_ix = None; - get_capture_indices( + if populate_capture_indices( &query, + &self.config.name, + "indents", + &["start."], &mut [ - ("indent", &mut indent_capture_ix), - ("start", &mut start_capture_ix), - ("end", &mut end_capture_ix), - ("outdent", &mut outdent_capture_ix), + Capture::Required("indent", &mut indent_capture_ix), + Capture::Optional("start", &mut start_capture_ix), + Capture::Optional("end", &mut end_capture_ix), + Capture::Optional("outdent", &mut outdent_capture_ix), ], - ); - - let mut suffixed_start_captures = HashMap::default(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(suffix) = name.strip_prefix("start.") { - suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); + ) { + let mut suffixed_start_captures = HashMap::default(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(suffix) = name.strip_prefix("start.") { + suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); + } } - } - if let Some(indent_capture_ix) = indent_capture_ix { - grammar.indents_config = Some(IndentConfig { + self.grammar_mut()?.indents_config = Some(IndentConfig { query, indent_capture_ix, start_capture_ix, @@ -1596,68 +1746,74 @@ impl Language { } pub fn with_injection_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - let query = Query::new(&grammar.ts_language, source)?; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; let mut language_capture_ix = None; let mut injection_language_capture_ix = None; let mut content_capture_ix = None; let mut injection_content_capture_ix = None; - get_capture_indices( + if populate_capture_indices( &query, + &self.config.name, + "injections", + &[], &mut [ - ("language", &mut language_capture_ix), - ("injection.language", &mut injection_language_capture_ix), - ("content", &mut content_capture_ix), - ("injection.content", &mut injection_content_capture_ix), + Capture::Optional("language", &mut language_capture_ix), + Capture::Optional("injection.language", &mut injection_language_capture_ix), + Capture::Optional("content", &mut content_capture_ix), + Capture::Optional("injection.content", &mut injection_content_capture_ix), ], - ); - language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both language and injection.language captures are present"); - } - _ => language_capture_ix, - }; - content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both content and injection.content captures are present") - } - _ => content_capture_ix, - }; - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = InjectionPatternConfig::default(); - for setting in query.property_settings(ix) { - match setting.key.as_ref() { - "language" | "injection.language" => { - config.language.clone_from(&setting.value); - } - "combined" | "injection.combined" => { - config.combined = true; + ) { + language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both language and injection.language captures are present"); + } + _ => language_capture_ix, + }; + content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both content and injection.content captures are present") + } + _ => content_capture_ix, + }; + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = InjectionPatternConfig::default(); + for setting in query.property_settings(ix) { + match setting.key.as_ref() { + "language" | "injection.language" => { + config.language.clone_from(&setting.value); + } + "combined" | "injection.combined" => { + config.combined = true; + } + _ => {} } - _ => {} } - } - config - }) - .collect(); - if let Some(content_capture_ix) = content_capture_ix { - grammar.injection_config = Some(InjectionConfig { - query, - language_capture_ix, - content_capture_ix, - patterns, - }); + config + }) + .collect(); + if let Some(content_capture_ix) = content_capture_ix { + self.grammar_mut()?.injection_config = Some(InjectionConfig { + query, + language_capture_ix, + content_capture_ix, + patterns, + }); + } else { + log::error!( + "missing required capture in injections {} TreeSitter query: \ + content or injection.content", + &self.config.name, + ); + } } Ok(self) } pub fn with_override_query(mut self, source: &str) -> anyhow::Result { - let query = { - let grammar = self.grammar.as_ref().context("no grammar for language")?; - Query::new(&grammar.ts_language, source)? - }; + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; let mut override_configs_by_id = HashMap::default(); for (ix, mut name) in query.capture_names().iter().copied().enumerate() { @@ -1732,7 +1888,7 @@ impl Language { self.config.brackets.disabled_scopes_by_bracket_ix.clear(); - let grammar = self.grammar_mut().context("cannot mutate grammar")?; + let grammar = self.grammar_mut()?; grammar.override_config = Some(OverrideConfig { query, values: override_configs_by_id, @@ -1741,29 +1897,41 @@ impl Language { } pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result { - let grammar = self.grammar_mut().context("cannot mutate grammar")?; - - let query = Query::new(&grammar.ts_language, source)?; - let mut redaction_capture_ix = None; - get_capture_indices(&query, &mut [("redact", &mut redaction_capture_ix)]); - - if let Some(redaction_capture_ix) = redaction_capture_ix { - grammar.redactions_config = Some(RedactionConfig { + let query = Query::new(&self.expect_grammar()?.ts_language, source)?; + let mut redaction_capture_ix = 0; + if populate_capture_indices( + &query, + &self.config.name, + "redactions", + &[], + &mut [Capture::Required("redact", &mut redaction_capture_ix)], + ) { + self.grammar_mut()?.redactions_config = Some(RedactionConfig { query, redaction_capture_ix, }); } - Ok(self) } - fn grammar_mut(&mut self) -> Option<&mut Grammar> { - Arc::get_mut(self.grammar.as_mut()?) + fn expect_grammar(&self) -> Result<&Grammar> { + self.grammar + .as_ref() + .map(|grammar| grammar.as_ref()) + .context("no grammar for language") + } + + fn grammar_mut(&mut self) -> Result<&mut Grammar> { + Arc::get_mut(self.grammar.as_mut().context("no grammar for language")?) + .context("cannot mutate grammar") } pub fn name(&self) -> LanguageName { self.config.name.clone() } + pub fn manifest(&self) -> Option<&ManifestName> { + self.manifest_name.as_ref() + } pub fn code_fence_block_name(&self) -> Arc { self.config @@ -1790,7 +1958,10 @@ impl Language { let tree = grammar.parse_text(text, None); let captures = SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { - grammar.highlights_query.as_ref() + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) }); let highlight_maps = vec![grammar.highlight_map()]; let mut offset = 0; @@ -1798,10 +1969,10 @@ impl Language { BufferChunks::new(text, range, Some((captures, highlight_maps)), false, None) { let end_offset = offset + chunk.text.len(); - if let Some(highlight_id) = chunk.syntax_highlight_id { - if !highlight_id.is_default() { - result.push((offset..end_offset, highlight_id)); - } + if let Some(highlight_id) = chunk.syntax_highlight_id + && !highlight_id.is_default() + { + result.push((offset..end_offset, highlight_id)); } offset = end_offset; } @@ -1818,11 +1989,11 @@ impl Language { } pub fn set_theme(&self, theme: &SyntaxTheme) { - if let Some(grammar) = self.grammar.as_ref() { - if let Some(highlights_query) = &grammar.highlights_query { - *grammar.highlight_map.lock() = - HighlightMap::new(highlights_query.capture_names(), theme); - } + if let Some(grammar) = self.grammar.as_ref() + && let Some(highlights_config) = &grammar.highlights_config + { + *grammar.highlight_map.lock() = + HighlightMap::new(highlights_config.query.capture_names(), theme); } } @@ -1852,7 +2023,7 @@ impl Language { impl LanguageScope { pub fn path_suffixes(&self) -> &[String] { - &self.language.path_suffixes() + self.language.path_suffixes() } pub fn language_name(&self) -> LanguageName { @@ -1916,6 +2087,15 @@ impl LanguageScope { ) } + /// Returns a list of language-specific characters that are considered part of + /// identifiers during linked editing operations. + pub fn linked_edit_characters(&self) -> Option<&HashSet> { + Override::as_option( + self.config_override().map(|o| &o.linked_edit_characters), + Some(&self.language.config.linked_edit_characters), + ) + } + /// Returns whether to prefer snippet `label` over `new_text` to replace text when /// completion is accepted. /// @@ -1942,11 +2122,11 @@ impl LanguageScope { .enumerate() .map(move |(ix, bracket)| { let mut is_enabled = true; - if let Some(next_disabled_ix) = disabled_ids.first() { - if ix == *next_disabled_ix as usize { - disabled_ids = &disabled_ids[1..]; - is_enabled = false; - } + if let Some(next_disabled_ix) = disabled_ids.first() + && ix == *next_disabled_ix as usize + { + disabled_ids = &disabled_ids[1..]; + is_enabled = false; } (bracket, is_enabled) }) @@ -2037,8 +2217,9 @@ impl Grammar { pub fn highlight_id_for_name(&self, name: &str) -> Option { let capture_id = self - .highlights_query + .highlights_config .as_ref()? + .query .capture_index_for_name(name)?; Some(self.highlight_map.lock().get(capture_id)) } @@ -2046,6 +2227,38 @@ impl Grammar { pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { self.debug_variables_config.as_ref() } + + pub fn imports_config(&self) -> Option<&ImportsConfig> { + self.imports_config.as_ref() + } +} + +impl CodeLabelBuilder { + pub fn respan_filter_range(&mut self, filter_text: Option<&str>) { + self.filter_range = filter_text + .and_then(|filter| self.text.find(filter).map(|ix| ix..ix + filter.len())) + .unwrap_or(0..self.text.len()); + } + + pub fn push_str(&mut self, text: &str, highlight: Option) { + let start_ix = self.text.len(); + self.text.push_str(text); + if let Some(highlight) = highlight { + let end_ix = self.text.len(); + self.runs.push((start_ix..end_ix, highlight)); + } + } + + pub fn build(mut self) -> CodeLabel { + if self.filter_range.end == 0 { + self.respan_filter_range(None); + } + CodeLabel { + text: self.text, + runs: self.runs, + filter_range: self.filter_range, + } + } } impl CodeLabel { @@ -2111,25 +2324,39 @@ impl CodeLabel { } pub fn plain(text: String, filter_text: Option<&str>) -> Self { + Self::filtered(text, filter_text, Vec::new()) + } + + pub fn filtered( + text: String, + filter_text: Option<&str>, + runs: Vec<(Range, HighlightId)>, + ) -> Self { let filter_range = filter_text .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) .unwrap_or(0..text.len()); + Self::new(text, filter_range, runs) + } + + pub fn new( + text: String, + filter_range: Range, + runs: Vec<(Range, HighlightId)>, + ) -> Self { + assert!( + text.get(filter_range.clone()).is_some(), + "invalid filter range" + ); + runs.iter().for_each(|(range, _)| { + assert!(text.get(range.clone()).is_some(), "invalid run range"); + }); Self { - runs: Vec::new(), + runs, filter_range, text, } } - pub fn push_str(&mut self, text: &str, highlight: Option) { - let start_ix = self.text.len(); - self.text.push_str(text); - let end_ix = self.text.len(); - if let Some(highlight) = highlight { - self.runs.push((start_ix..end_ix, highlight)); - } - } - pub fn text(&self) -> &str { self.text.as_str() } @@ -2200,42 +2427,30 @@ impl Default for FakeLspAdapter { } #[cfg(any(test, feature = "test-support"))] -#[async_trait(?Send)] -impl LspAdapter for FakeLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName(self.name.into()) +impl LspInstaller for FakeLspAdapter { + type BinaryVersion = (); + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + unreachable!() } async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { Some(self.language_server_binary.clone()) } - fn get_language_server_command<'a>( - self: Arc, - _: Arc, - _: Arc, - _: LanguageServerBinaryOptions, - _: futures::lock::MutexGuard<'a, Option>, - _: &'a mut AsyncApp, - ) -> Pin>>> { - async move { Ok(self.language_server_binary.clone()) }.boxed_local() - } - - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - unreachable!(); - } - async fn fetch_server_binary( &self, - _: Box, + _: (), _: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { @@ -2249,6 +2464,14 @@ impl LspAdapter for FakeLspAdapter { ) -> Option { unreachable!(); } +} + +#[cfg(any(test, feature = "test-support"))] +#[async_trait(?Send)] +impl LspAdapter for FakeLspAdapter { + fn name(&self) -> LanguageServerName { + LanguageServerName(self.name.into()) + } fn disk_based_diagnostic_sources(&self) -> Vec { self.disk_based_diagnostics_sources.clone() @@ -2260,7 +2483,6 @@ impl LspAdapter for FakeLspAdapter { async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(self.initialization_options.clone()) @@ -2274,17 +2496,72 @@ impl LspAdapter for FakeLspAdapter { let label_for_completion = self.label_for_completion.as_ref()?; label_for_completion(item, language) } + + fn is_extension(&self) -> bool { + false + } +} + +enum Capture<'a> { + Required(&'static str, &'a mut u32), + Optional(&'static str, &'a mut Option), } -fn get_capture_indices(query: &Query, captures: &mut [(&str, &mut Option)]) { - for (ix, name) in query.capture_names().iter().enumerate() { - for (capture_name, index) in captures.iter_mut() { - if capture_name == name { - **index = Some(ix as u32); - break; +fn populate_capture_indices( + query: &Query, + language_name: &LanguageName, + query_type: &str, + expected_prefixes: &[&str], + captures: &mut [Capture<'_>], +) -> bool { + let mut found_required_indices = Vec::new(); + 'outer: for (ix, name) in query.capture_names().iter().enumerate() { + for (required_ix, capture) in captures.iter_mut().enumerate() { + match capture { + Capture::Required(capture_name, index) if capture_name == name => { + **index = ix as u32; + found_required_indices.push(required_ix); + continue 'outer; + } + Capture::Optional(capture_name, index) if capture_name == name => { + **index = Some(ix as u32); + continue 'outer; + } + _ => {} } } + if !name.starts_with("_") + && !expected_prefixes + .iter() + .any(|&prefix| name.starts_with(prefix)) + { + log::warn!( + "unrecognized capture name '{}' in {} {} TreeSitter query \ + (suppress this warning by prefixing with '_')", + name, + language_name, + query_type + ); + } + } + let mut missing_required_captures = Vec::new(); + for (capture_ix, capture) in captures.iter().enumerate() { + if let Capture::Required(capture_name, _) = capture + && !found_required_indices.contains(&capture_ix) + { + missing_required_captures.push(*capture_name); + } + } + let success = missing_required_captures.is_empty(); + if !success { + log::error!( + "missing required capture(s) in {} {} TreeSitter query: {}", + language_name, + query_type, + missing_required_captures.join(", ") + ); } + success } pub fn point_to_lsp(point: PointUtf16) -> lsp::Position { @@ -2312,12 +2589,76 @@ pub fn range_from_lsp(range: lsp::Range) -> Range> { let mut start = point_from_lsp(range.start); let mut end = point_from_lsp(range.end); if start > end { - log::warn!("range_from_lsp called with inverted range {start:?}-{end:?}"); + // We debug instead of warn so that this is not logged by default unless explicitly requested. + // Using warn would write to the log file, and since we receive an enormous amount of + // range_from_lsp calls (especially during completions), that can hang the main thread. + // + // See issue #36223. + zlog::debug!("range_from_lsp called with inverted range {start:?}-{end:?}"); mem::swap(&mut start, &mut end); } start..end } +#[doc(hidden)] +#[cfg(any(test, feature = "test-support"))] +pub fn rust_lang() -> Arc { + use std::borrow::Cow; + + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + indents: Some(Cow::from( + r#" +[ + ((where_clause) _ @end) + (field_expression) + (call_expression) + (assignment_expression) + (let_declaration) + (let_chain) + (await_expression) +] @indent + +(_ "[" "]" @end) @indent +(_ "<" ">" @end) @indent +(_ "{" "}" @end) @indent +(_ "(" ")" @end) @indent"#, + )), + brackets: Some(Cow::from( + r#" +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) +("<" @open ">" @close) +("\"" @open "\"" @close) +(closure_parameters "|" @open "|" @close)"#, + )), + text_objects: Some(Cow::from( + r#" +(function_item + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + "#, + )), + ..LanguageQueries::default() + }) + .expect("Could not parse queries"); + Arc::new(language) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index ea988e8098ec2a795e8c0a386b4e162ecd5c89ca..022eb89e6d2b378b8c4305c81887060d776bb411 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -1,14 +1,11 @@ use crate::{ CachedLspAdapter, File, Language, LanguageConfig, LanguageId, LanguageMatcher, - LanguageServerName, LspAdapter, PLAIN_TEXT, ToolchainLister, - language_settings::{ - AllLanguageSettingsContent, LanguageSettingsContent, all_language_settings, - }, - task_context::ContextProvider, - with_parser, + LanguageServerName, LspAdapter, ManifestName, PLAIN_TEXT, ToolchainLister, + language_settings::all_language_settings, task_context::ContextProvider, with_parser, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, HashMap, HashSet, hash_map}; +use settings::{AllLanguageSettingsContent, LanguageSettingsContent}; use futures::{ Future, @@ -49,7 +46,7 @@ impl LanguageName { pub fn from_proto(s: String) -> Self { Self(SharedString::from(s)) } - pub fn to_proto(self) -> String { + pub fn to_proto(&self) -> String { self.0.to_string() } pub fn lsp_id(&self) -> String { @@ -172,6 +169,7 @@ pub struct AvailableLanguage { hidden: bool, load: Arc Result + 'static + Send + Sync>, loaded: bool, + manifest_name: Option, } impl AvailableLanguage { @@ -231,6 +229,7 @@ pub const QUERY_FILENAME_PREFIXES: &[( ("runnables", |q| &mut q.runnables), ("debugger", |q| &mut q.debugger), ("textobjects", |q| &mut q.text_objects), + ("imports", |q| &mut q.imports), ]; /// Tree-sitter language queries for a given language. @@ -247,6 +246,7 @@ pub struct LanguageQueries { pub runnables: Option>, pub text_objects: Option>, pub debugger: Option>, + pub imports: Option>, } #[derive(Clone, Default)] @@ -259,6 +259,7 @@ pub struct LoadedLanguage { pub queries: LanguageQueries, pub context_provider: Option>, pub toolchain_provider: Option>, + pub manifest_name: Option, } impl LanguageRegistry { @@ -349,12 +350,14 @@ impl LanguageRegistry { config.grammar.clone(), config.matcher.clone(), config.hidden, + None, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), queries: Default::default(), toolchain_provider: None, context_provider: None, + manifest_name: None, }) }), ) @@ -370,14 +373,23 @@ impl LanguageRegistry { pub fn register_available_lsp_adapter( &self, name: LanguageServerName, - load: impl Fn() -> Arc + 'static + Send + Sync, + adapter: Arc, ) { - self.state.write().available_lsp_adapters.insert( + let mut state = self.state.write(); + + if adapter.is_extension() + && let Some(existing_adapter) = state.all_lsp_adapters.get(&name) + && !existing_adapter.adapter.is_extension() + { + log::warn!( + "not registering extension-provided language server {name:?}, since a builtin language server exists with that name", + ); + return; + } + + state.available_lsp_adapters.insert( name, - Arc::new(move || { - let lsp_adapter = load(); - CachedLspAdapter::new(lsp_adapter) - }), + Arc::new(move || CachedLspAdapter::new(adapter.clone())), ); } @@ -392,13 +404,21 @@ impl LanguageRegistry { Some(load_lsp_adapter()) } - pub fn register_lsp_adapter( - &self, - language_name: LanguageName, - adapter: Arc, - ) -> Arc { - let cached = CachedLspAdapter::new(adapter); + pub fn register_lsp_adapter(&self, language_name: LanguageName, adapter: Arc) { let mut state = self.state.write(); + + if adapter.is_extension() + && let Some(existing_adapter) = state.all_lsp_adapters.get(&adapter.name()) + && !existing_adapter.adapter.is_extension() + { + log::warn!( + "not registering extension-provided language server {:?} for language {language_name:?}, since a builtin language server exists with that name", + adapter.name(), + ); + return; + } + + let cached = CachedLspAdapter::new(adapter); state .lsp_adapters .entry(language_name) @@ -407,8 +427,6 @@ impl LanguageRegistry { state .all_lsp_adapters .insert(cached.name.clone(), cached.clone()); - - cached } /// Register a fake language server and adapter @@ -428,7 +446,7 @@ impl LanguageRegistry { let mut state = self.state.write(); state .lsp_adapters - .entry(language_name.clone()) + .entry(language_name) .or_default() .push(adapter.clone()); state.all_lsp_adapters.insert(adapter.name(), adapter); @@ -450,7 +468,7 @@ impl LanguageRegistry { let cached_adapter = CachedLspAdapter::new(Arc::new(adapter)); state .lsp_adapters - .entry(language_name.clone()) + .entry(language_name) .or_default() .push(cached_adapter.clone()); state @@ -487,6 +505,7 @@ impl LanguageRegistry { grammar_name: Option>, matcher: LanguageMatcher, hidden: bool, + manifest_name: Option, load: Arc Result + 'static + Send + Sync>, ) { let state = &mut *self.state.write(); @@ -496,6 +515,7 @@ impl LanguageRegistry { existing_language.grammar = grammar_name; existing_language.matcher = matcher; existing_language.load = load; + existing_language.manifest_name = manifest_name; return; } } @@ -508,6 +528,7 @@ impl LanguageRegistry { load, hidden, loaded: false, + manifest_name, }); state.version += 1; state.reload_count += 1; @@ -575,6 +596,7 @@ impl LanguageRegistry { grammar: language.config.grammar.clone(), matcher: language.config.matcher.clone(), hidden: language.config.hidden, + manifest_name: None, load: Arc::new(|| Err(anyhow!("already loaded"))), loaded: true, }); @@ -626,6 +648,24 @@ impl LanguageRegistry { async move { rx.await? } } + pub async fn language_for_id(self: &Arc, id: LanguageId) -> Result> { + let available_language = { + let state = self.state.read(); + + let Some(available_language) = state + .available_languages + .iter() + .find(|lang| lang.id == id) + .cloned() + else { + anyhow::bail!(LanguageNotFound); + }; + available_language + }; + + self.load_language(&available_language).await? + } + pub fn language_name_for_extension(self: &Arc, extension: &str) -> Option { self.state.try_read().and_then(|state| { state @@ -693,15 +733,19 @@ impl LanguageRegistry { ) } - pub fn language_for_file_path<'a>( + pub fn language_for_file_path(self: &Arc, path: &Path) -> Option { + self.language_for_file_internal(path, None, None) + } + + pub fn load_language_for_file_path<'a>( self: &Arc, path: &'a Path, ) -> impl Future>> + 'a { - let available_language = self.language_for_file_internal(path, None, None); + let language = self.language_for_file_path(path); let this = self.clone(); async move { - if let Some(language) = available_language { + if let Some(language) = language { this.load_language(&language).await? } else { Err(anyhow!(LanguageNotFound)) @@ -715,7 +759,7 @@ impl LanguageRegistry { content: Option<&Rope>, user_file_types: Option<&FxHashMap, GlobSet>>, ) -> Option { - let filename = path.file_name().and_then(|name| name.to_str()); + let filename = path.file_name().and_then(|filename| filename.to_str()); // `Path.extension()` returns None for files with a leading '.' // and no other extension which is not the desired behavior here, // as we want `.zshrc` to result in extension being `Some("zshrc")` @@ -765,7 +809,7 @@ impl LanguageRegistry { }; let content_matches = || { - config.first_line_pattern.as_ref().map_or(false, |pattern| { + config.first_line_pattern.as_ref().is_some_and(|pattern| { content .as_ref() .is_some_and(|content| pattern.is_match(content)) @@ -914,10 +958,12 @@ impl LanguageRegistry { Language::new_with_id(id, loaded_language.config, grammar) .with_context_provider(loaded_language.context_provider) .with_toolchain_lister(loaded_language.toolchain_provider) + .with_manifest(loaded_language.manifest_name) .with_queries(loaded_language.queries) } else { Ok(Language::new_with_id(id, loaded_language.config, None) .with_context_provider(loaded_language.context_provider) + .with_manifest(loaded_language.manifest_name) .with_toolchain_lister(loaded_language.toolchain_provider)) } } @@ -1092,7 +1138,7 @@ impl LanguageRegistry { use gpui::AppContext as _; let mut state = self.state.write(); - let fake_entry = state.fake_server_entries.get_mut(&name)?; + let fake_entry = state.fake_server_entries.get_mut(name)?; let (server, mut fake_server) = lsp::FakeLanguageServer::new( server_id, binary, @@ -1150,15 +1196,14 @@ impl LanguageRegistryState { language.set_theme(theme.syntax()); } self.language_settings.languages.0.insert( - language.name(), + language.name().0, LanguageSettingsContent { tab_size: language.config.tab_size, hard_tabs: language.config.hard_tabs, soft_wrap: language.config.soft_wrap, auto_indent_on_paste: language.config.auto_indent_on_paste, ..Default::default() - } - .clone(), + }, ); self.languages.push(language); self.version += 1; diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 1aae0b2f7e23cc87cdd2f13e55805b566a20b5bb..b485065689832995cdb100ae47a4f1f197ad1a70 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -1,28 +1,23 @@ //! Provides `language`-related settings. use crate::{File, Language, LanguageName, LanguageServerName}; -use anyhow::Result; use collections::{FxHashMap, HashMap, HashSet}; use ec4rs::{ Properties as EditorconfigProperties, - property::{FinalNewline, IndentSize, IndentStyle, TabWidth, TrimTrailingWs}, + property::{FinalNewline, IndentSize, IndentStyle, MaxLineLen, TabWidth, TrimTrailingWs}, }; use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; -use gpui::{App, Modifiers}; +use gpui::{App, Modifiers, SharedString}; use itertools::{Either, Itertools}; -use schemars::{JsonSchema, json_schema}; -use serde::{ - Deserialize, Deserializer, Serialize, - de::{self, IntoDeserializer, MapAccess, SeqAccess, Visitor}, -}; -use settings::{ - ParameterizedJsonSchema, Settings, SettingsLocation, SettingsSources, SettingsStore, +pub use settings::{ + CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, + Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LspInsertMode, + RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, }; +use settings::{Settings, SettingsLocation, SettingsStore}; use shellexpand; -use std::{borrow::Cow, num::NonZeroU32, path::Path, slice, sync::Arc}; -use util::schemars::replace_subschema; -use util::serde::default_true; +use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc}; /// Initializes the language settings. pub fn init(cx: &mut App) { @@ -64,8 +59,14 @@ pub struct AllLanguageSettings { pub(crate) file_types: FxHashMap, GlobSet>, } +#[derive(Debug, Clone)] +pub struct WhitespaceMap { + pub space: SharedString, + pub tab: SharedString, +} + /// The settings for a particular language. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct LanguageSettings { /// How many columns a tab should occupy. pub tab_size: NonZeroU32, @@ -73,7 +74,7 @@ pub struct LanguageSettings { /// spaces. pub hard_tabs: bool, /// How to soft-wrap long lines of text. - pub soft_wrap: SoftWrap, + pub soft_wrap: settings::SoftWrap, /// The column at which to soft-wrap lines, for buffers where soft-wrap /// is enabled. pub preferred_line_length: u32, @@ -95,11 +96,11 @@ pub struct LanguageSettings { /// when saving it. pub ensure_final_newline_on_save: bool, /// How to perform a buffer format. - pub formatter: SelectedFormatter, + pub formatter: settings::FormatterList, /// Zed's Prettier integration settings. pub prettier: PrettierSettings, /// Whether to automatically close JSX tags. - pub jsx_tag_auto_close: JsxTagAutoCloseSettings, + pub jsx_tag_auto_close: bool, /// Whether to use language servers to provide code intelligence. pub enable_language_server: bool, /// The list of language servers to use (or disable) for this language. @@ -121,7 +122,9 @@ pub struct LanguageSettings { /// scopes. pub edit_predictions_disabled_in: Vec, /// Whether to show tabs and spaces in the editor. - pub show_whitespaces: ShowWhitespaceSetting, + pub show_whitespaces: settings::ShowWhitespaceSetting, + /// Visible characters used to render whitespace when show_whitespaces is enabled. + pub whitespace_map: WhitespaceMap, /// Whether to start a new line with a comment when a previous line is a comment as well. pub extend_comment_on_newline: bool, /// Inlay hint related settings. @@ -133,6 +136,8 @@ pub struct LanguageSettings { /// Whether to use additional LSP queries to format (and amend) the code after /// every "trigger" symbol input, defined by LSP server capabilities. pub use_on_type_format: bool, + /// Whether indentation should be adjusted based on the context whilst typing. + pub auto_indent: bool, /// Whether indentation of pasted content should be adjusted based on the context. pub auto_indent_on_paste: bool, /// Controls how the editor handles the autoclosed characters. @@ -142,7 +147,7 @@ pub struct LanguageSettings { /// Whether to perform linked edits pub linked_edits: bool, /// Task configuration for this language. - pub tasks: LanguageTaskConfig, + pub tasks: LanguageTaskSettings, /// Whether to pop the completions menu while typing in an editor without /// explicitly requesting it. pub show_completions_on_input: bool, @@ -155,6 +160,93 @@ pub struct LanguageSettings { pub debuggers: Vec, } +#[derive(Debug, Clone)] +pub struct CompletionSettings { + /// Controls how words are completed. + /// For large documents, not all words may be fetched for completion. + /// + /// Default: `fallback` + pub words: WordsCompletionMode, + /// How many characters has to be in the completions query to automatically show the words-based completions. + /// Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command. + /// + /// Default: 3 + pub words_min_length: usize, + /// Whether to fetch LSP completions or not. + /// + /// Default: true + pub lsp: bool, + /// When fetching LSP completions, determines how long to wait for a response of a particular server. + /// When set to 0, waits indefinitely. + /// + /// Default: 0 + pub lsp_fetch_timeout_ms: u64, + /// Controls how LSP completions are inserted. + /// + /// Default: "replace_suffix" + pub lsp_insert_mode: LspInsertMode, +} + +/// The settings for indent guides. +#[derive(Debug, Clone, PartialEq)] +pub struct IndentGuideSettings { + /// Whether to display indent guides in the editor. + /// + /// Default: true + pub enabled: bool, + /// The width of the indent guides in pixels, between 1 and 10. + /// + /// Default: 1 + pub line_width: u32, + /// The width of the active indent guide in pixels, between 1 and 10. + /// + /// Default: 1 + pub active_line_width: u32, + /// Determines how indent guides are colored. + /// + /// Default: Fixed + pub coloring: settings::IndentGuideColoring, + /// Determines how indent guide backgrounds are colored. + /// + /// Default: Disabled + pub background_coloring: settings::IndentGuideBackgroundColoring, +} + +#[derive(Debug, Clone)] +pub struct LanguageTaskSettings { + /// Extra task variables to set for a particular language. + pub variables: HashMap, + pub enabled: bool, + /// Use LSP tasks over Zed language extension ones. + /// If no LSP tasks are returned due to error/timeout or regular execution, + /// Zed language extension tasks will be used instead. + /// + /// Other Zed tasks will still be shown: + /// * Zed task from either of the task config file + /// * Zed task from history (e.g. one-off task was spawned before) + pub prefer_lsp: bool, +} + +/// Allows to enable/disable formatting with Prettier +/// and configure default Prettier, used when no project-level Prettier installation is found. +/// Prettier formatting is disabled by default. +#[derive(Debug, Clone)] +pub struct PrettierSettings { + /// Enables or disables formatting with Prettier for a given language. + pub allowed: bool, + + /// Forces Prettier integration to use a specific parser name when formatting files with the language. + pub parser: Option, + + /// Forces Prettier integration to use specific plugins when formatting files with the language. + /// The default Prettier will be installed with these plugins. + pub plugins: HashSet, + + /// Default Prettier options, in the format as in package.json section for Prettier. + /// If project installs Prettier via its package.json, these options will be ignored. + pub options: HashMap, +} + impl LanguageSettings { /// A token representing the rest of the available language servers. const REST_OF_LANGUAGE_SERVERS: &'static str = "..."; @@ -185,8 +277,8 @@ impl LanguageSettings { let rest = available_language_servers .iter() .filter(|&available_language_server| { - !disabled_language_servers.contains(&available_language_server) - && !enabled_language_servers.contains(&available_language_server) + !disabled_language_servers.contains(available_language_server) + && !enabled_language_servers.contains(available_language_server) }) .cloned() .collect::>(); @@ -197,32 +289,77 @@ impl LanguageSettings { if language_server.0.as_ref() == Self::REST_OF_LANGUAGE_SERVERS { rest.clone() } else { - vec![language_server.clone()] + vec![language_server] } }) .collect::>() } } -/// The provider that supplies edit predictions. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum EditPredictionProvider { - None, - #[default] - Copilot, - Supermaven, - Zed, +// The settings for inlay hints. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct InlayHintSettings { + /// Global switch to toggle hints on and off. + /// + /// Default: false + pub enabled: bool, + /// Global switch to toggle inline values on and off when debugging. + /// + /// Default: true + pub show_value_hints: bool, + /// Whether type hints should be shown. + /// + /// Default: true + pub show_type_hints: bool, + /// Whether parameter hints should be shown. + /// + /// Default: true + pub show_parameter_hints: bool, + /// Whether other hints should be shown. + /// + /// Default: true + pub show_other_hints: bool, + /// Whether to show a background for inlay hints. + /// + /// If set to `true`, the background will use the `hint.background` color + /// from the current theme. + /// + /// Default: false + pub show_background: bool, + /// Whether or not to debounce inlay hints updates after buffer edits. + /// + /// Set to 0 to disable debouncing. + /// + /// Default: 700 + pub edit_debounce_ms: u64, + /// Whether or not to debounce inlay hints updates after buffer scrolls. + /// + /// Set to 0 to disable debouncing. + /// + /// Default: 50 + pub scroll_debounce_ms: u64, + /// Toggles inlay hints (hides or shows) when the user presses the modifiers specified. + /// If only a subset of the modifiers specified is pressed, hints are not toggled. + /// If no modifiers are specified, this is equivalent to `None`. + /// + /// Default: None + pub toggle_on_modifiers_press: Option, } -impl EditPredictionProvider { - pub fn is_zed(&self) -> bool { - match self { - EditPredictionProvider::Zed => true, - EditPredictionProvider::None - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven => false, +impl InlayHintSettings { + /// Returns the kinds of inlay hints that are enabled based on the settings. + pub fn enabled_inlay_hint_kinds(&self) -> HashSet> { + let mut kinds = HashSet::default(); + if self.show_type_hints { + kinds.insert(Some(InlayHintKind::Type)); } + if self.show_parameter_hints { + kinds.insert(Some(InlayHintKind::Parameter)); + } + if self.show_other_hints { + kinds.insert(None); + } + kinds } } @@ -231,15 +368,17 @@ impl EditPredictionProvider { #[derive(Clone, Debug, Default)] pub struct EditPredictionSettings { /// The provider that supplies edit predictions. - pub provider: EditPredictionProvider, + pub provider: settings::EditPredictionProvider, /// A list of globs representing files that edit predictions should be disabled for. /// This list adds to a pre-existing, sensible default set of globs. /// Any additional ones you add are combined with them. pub disabled_globs: Vec, /// Configures how edit predictions are displayed in the buffer. - pub mode: EditPredictionsMode, + pub mode: settings::EditPredictionsMode, /// Settings specific to GitHub Copilot. pub copilot: CopilotSettings, + /// Settings specific to Codestral. + pub codestral: CodestralSettings, /// Whether edit predictions are enabled in the assistant panel. /// This setting has no effect if globally disabled. pub enabled_in_text_threads: bool, @@ -251,9 +390,9 @@ impl EditPredictionSettings { !self.disabled_globs.iter().any(|glob| { if glob.is_absolute { file.as_local() - .map_or(false, |local| glob.matcher.is_match(local.abs_path(cx))) + .is_some_and(|local| glob.matcher.is_match(local.abs_path(cx))) } else { - glob.matcher.is_match(file.path()) + glob.matcher.is_match(file.path().as_std_path()) } }) } @@ -265,20 +404,6 @@ pub struct DisabledGlob { is_absolute: bool, } -/// The mode in which edit predictions should be displayed. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum EditPredictionsMode { - /// If provider supports it, display inline when holding modifier key (e.g., alt). - /// Otherwise, eager preview is used. - #[serde(alias = "auto")] - Subtle, - /// Display inline when there are no language server completions available. - #[default] - #[serde(alias = "eager_preview")] - Eager, -} - #[derive(Clone, Debug, Default)] pub struct CopilotSettings { /// HTTP/HTTPS proxy to use for Copilot. @@ -289,1063 +414,255 @@ pub struct CopilotSettings { pub enterprise_uri: Option, } -/// The settings for all languages. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AllLanguageSettingsContent { - /// The settings for enabling/disabling features. - #[serde(default)] - pub features: Option, - /// The edit prediction settings. - #[serde(default)] - pub edit_predictions: Option, - /// The default language settings. - #[serde(flatten)] - pub defaults: LanguageSettingsContent, - /// The settings for individual languages. - #[serde(default)] - pub languages: LanguageToSettingsMap, - /// Settings for associating file extensions and filenames - /// with languages. - #[serde(default)] - pub file_types: HashMap, Vec>, +#[derive(Clone, Debug, Default)] +pub struct CodestralSettings { + /// Model to use for completions. + pub model: Option, + /// Maximum tokens to generate. + pub max_tokens: Option, + /// Custom API URL to use for Codestral. + pub api_url: Option, } -/// Map from language name to settings. Its `ParameterizedJsonSchema` allows only known language -/// names in the keys. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct LanguageToSettingsMap(pub HashMap); - -inventory::submit! { - ParameterizedJsonSchema { - add_and_get_ref: |generator, params, _cx| { - let language_settings_content_ref = generator - .subschema_for::() - .to_value(); - replace_subschema::(generator, || json_schema!({ - "type": "object", - "properties": params - .language_names - .iter() - .map(|name| { - ( - name.clone(), - language_settings_content_ref.clone(), - ) - }) - .collect::>() - })) +impl AllLanguageSettings { + /// Returns the [`LanguageSettings`] for the language with the specified name. + pub fn language<'a>( + &'a self, + location: Option>, + language_name: Option<&LanguageName>, + cx: &'a App, + ) -> Cow<'a, LanguageSettings> { + let settings = language_name + .and_then(|name| self.languages.get(name)) + .unwrap_or(&self.defaults); + + let editorconfig_properties = location.and_then(|location| { + cx.global::() + .editorconfig_properties(location.worktree_id, location.path) + }); + if let Some(editorconfig_properties) = editorconfig_properties { + let mut settings = settings.clone(); + merge_with_editorconfig(&mut settings, &editorconfig_properties); + Cow::Owned(settings) + } else { + Cow::Borrowed(settings) } } -} -/// Controls how completions are processed for this language. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct CompletionSettings { - /// Controls how words are completed. - /// For large documents, not all words may be fetched for completion. - /// - /// Default: `fallback` - #[serde(default = "default_words_completion_mode")] - pub words: WordsCompletionMode, - /// Whether to fetch LSP completions or not. - /// - /// Default: true - #[serde(default = "default_true")] - pub lsp: bool, - /// When fetching LSP completions, determines how long to wait for a response of a particular server. - /// When set to 0, waits indefinitely. - /// - /// Default: 0 - #[serde(default = "default_lsp_fetch_timeout_ms")] - pub lsp_fetch_timeout_ms: u64, - /// Controls how LSP completions are inserted. - /// - /// Default: "replace_suffix" - #[serde(default = "default_lsp_insert_mode")] - pub lsp_insert_mode: LspInsertMode, -} + /// Returns whether edit predictions are enabled for the given path. + pub fn edit_predictions_enabled_for_file(&self, file: &Arc, cx: &App) -> bool { + self.edit_predictions.enabled_for_file(file, cx) + } -/// Controls how document's words are completed. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum WordsCompletionMode { - /// Always fetch document's words for completions along with LSP completions. - Enabled, - /// Only if LSP response errors or times out, - /// use document's words to show completions. - Fallback, - /// Never fetch or complete document's words for completions. - /// (Word-based completions can still be queried via a separate action) - Disabled, -} + /// Returns whether edit predictions are enabled for the given language and path. + pub fn show_edit_predictions(&self, language: Option<&Arc>, cx: &App) -> bool { + self.language(None, language.map(|l| l.name()).as_ref(), cx) + .show_edit_predictions + } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum LspInsertMode { - /// Replaces text before the cursor, using the `insert` range described in the LSP specification. - Insert, - /// Replaces text before and after the cursor, using the `replace` range described in the LSP specification. - Replace, - /// Behaves like `"replace"` if the text that would be replaced is a subsequence of the completion text, - /// and like `"insert"` otherwise. - ReplaceSubsequence, - /// Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like - /// `"insert"` otherwise. - ReplaceSuffix, + /// Returns the edit predictions preview mode for the given language and path. + pub fn edit_predictions_mode(&self) -> EditPredictionsMode { + self.edit_predictions.mode + } } -fn default_words_completion_mode() -> WordsCompletionMode { - WordsCompletionMode::Fallback +fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { + let preferred_line_length = cfg.get::().ok().and_then(|v| match v { + MaxLineLen::Value(u) => Some(u as u32), + MaxLineLen::Off => None, + }); + let tab_size = cfg.get::().ok().and_then(|v| match v { + IndentSize::Value(u) => NonZeroU32::new(u as u32), + IndentSize::UseTabWidth => cfg.get::().ok().and_then(|w| match w { + TabWidth::Value(u) => NonZeroU32::new(u as u32), + }), + }); + let hard_tabs = cfg + .get::() + .map(|v| v.eq(&IndentStyle::Tabs)) + .ok(); + let ensure_final_newline_on_save = cfg + .get::() + .map(|v| match v { + FinalNewline::Value(b) => b, + }) + .ok(); + let remove_trailing_whitespace_on_save = cfg + .get::() + .map(|v| match v { + TrimTrailingWs::Value(b) => b, + }) + .ok(); + fn merge(target: &mut T, value: Option) { + if let Some(value) = value { + *target = value; + } + } + merge(&mut settings.preferred_line_length, preferred_line_length); + merge(&mut settings.tab_size, tab_size); + merge(&mut settings.hard_tabs, hard_tabs); + merge( + &mut settings.remove_trailing_whitespace_on_save, + remove_trailing_whitespace_on_save, + ); + merge( + &mut settings.ensure_final_newline_on_save, + ensure_final_newline_on_save, + ); } -fn default_lsp_insert_mode() -> LspInsertMode { - LspInsertMode::ReplaceSuffix -} +impl settings::Settings for AllLanguageSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let all_languages = &content.project.all_languages; + + fn load_from_content(settings: LanguageSettingsContent) -> LanguageSettings { + let inlay_hints = settings.inlay_hints.unwrap(); + let completions = settings.completions.unwrap(); + let prettier = settings.prettier.unwrap(); + let indent_guides = settings.indent_guides.unwrap(); + let tasks = settings.tasks.unwrap(); + let whitespace_map = settings.whitespace_map.unwrap(); + + LanguageSettings { + tab_size: settings.tab_size.unwrap(), + hard_tabs: settings.hard_tabs.unwrap(), + soft_wrap: settings.soft_wrap.unwrap(), + preferred_line_length: settings.preferred_line_length.unwrap(), + show_wrap_guides: settings.show_wrap_guides.unwrap(), + wrap_guides: settings.wrap_guides.unwrap(), + indent_guides: IndentGuideSettings { + enabled: indent_guides.enabled.unwrap(), + line_width: indent_guides.line_width.unwrap(), + active_line_width: indent_guides.active_line_width.unwrap(), + coloring: indent_guides.coloring.unwrap(), + background_coloring: indent_guides.background_coloring.unwrap(), + }, + format_on_save: settings.format_on_save.unwrap(), + remove_trailing_whitespace_on_save: settings + .remove_trailing_whitespace_on_save + .unwrap(), + ensure_final_newline_on_save: settings.ensure_final_newline_on_save.unwrap(), + formatter: settings.formatter.unwrap(), + prettier: PrettierSettings { + allowed: prettier.allowed.unwrap(), + parser: prettier.parser.filter(|parser| !parser.is_empty()), + plugins: prettier.plugins.unwrap_or_default(), + options: prettier.options.unwrap_or_default(), + }, + jsx_tag_auto_close: settings.jsx_tag_auto_close.unwrap().enabled.unwrap(), + enable_language_server: settings.enable_language_server.unwrap(), + language_servers: settings.language_servers.unwrap(), + allow_rewrap: settings.allow_rewrap.unwrap(), + show_edit_predictions: settings.show_edit_predictions.unwrap(), + edit_predictions_disabled_in: settings.edit_predictions_disabled_in.unwrap(), + show_whitespaces: settings.show_whitespaces.unwrap(), + whitespace_map: WhitespaceMap { + space: SharedString::new(whitespace_map.space.unwrap().to_string()), + tab: SharedString::new(whitespace_map.tab.unwrap().to_string()), + }, + extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(), + inlay_hints: InlayHintSettings { + enabled: inlay_hints.enabled.unwrap(), + show_value_hints: inlay_hints.show_value_hints.unwrap(), + show_type_hints: inlay_hints.show_type_hints.unwrap(), + show_parameter_hints: inlay_hints.show_parameter_hints.unwrap(), + show_other_hints: inlay_hints.show_other_hints.unwrap(), + show_background: inlay_hints.show_background.unwrap(), + edit_debounce_ms: inlay_hints.edit_debounce_ms.unwrap(), + scroll_debounce_ms: inlay_hints.scroll_debounce_ms.unwrap(), + toggle_on_modifiers_press: inlay_hints.toggle_on_modifiers_press, + }, + use_autoclose: settings.use_autoclose.unwrap(), + use_auto_surround: settings.use_auto_surround.unwrap(), + use_on_type_format: settings.use_on_type_format.unwrap(), + auto_indent: settings.auto_indent.unwrap(), + auto_indent_on_paste: settings.auto_indent_on_paste.unwrap(), + always_treat_brackets_as_autoclosed: settings + .always_treat_brackets_as_autoclosed + .unwrap(), + code_actions_on_format: settings.code_actions_on_format.unwrap(), + linked_edits: settings.linked_edits.unwrap(), + tasks: LanguageTaskSettings { + variables: tasks.variables.unwrap_or_default(), + enabled: tasks.enabled.unwrap(), + prefer_lsp: tasks.prefer_lsp.unwrap(), + }, + show_completions_on_input: settings.show_completions_on_input.unwrap(), + show_completion_documentation: settings.show_completion_documentation.unwrap(), + completions: CompletionSettings { + words: completions.words.unwrap(), + words_min_length: completions.words_min_length.unwrap() as usize, + lsp: completions.lsp.unwrap(), + lsp_fetch_timeout_ms: completions.lsp_fetch_timeout_ms.unwrap(), + lsp_insert_mode: completions.lsp_insert_mode.unwrap(), + }, + debuggers: settings.debuggers.unwrap(), + } + } -fn default_lsp_fetch_timeout_ms() -> u64 { - 0 -} - -/// The settings for a particular language. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct LanguageSettingsContent { - /// How many columns a tab should occupy. - /// - /// Default: 4 - #[serde(default)] - pub tab_size: Option, - /// Whether to indent lines using tab characters, as opposed to multiple - /// spaces. - /// - /// Default: false - #[serde(default)] - pub hard_tabs: Option, - /// How to soft-wrap long lines of text. - /// - /// Default: none - #[serde(default)] - pub soft_wrap: Option, - /// The column at which to soft-wrap lines, for buffers where soft-wrap - /// is enabled. - /// - /// Default: 80 - #[serde(default)] - pub preferred_line_length: Option, - /// Whether to show wrap guides in the editor. Setting this to true will - /// show a guide at the 'preferred_line_length' value if softwrap is set to - /// 'preferred_line_length', and will show any additional guides as specified - /// by the 'wrap_guides' setting. - /// - /// Default: true - #[serde(default)] - pub show_wrap_guides: Option, - /// Character counts at which to show wrap guides in the editor. - /// - /// Default: [] - #[serde(default)] - pub wrap_guides: Option>, - /// Indent guide related settings. - #[serde(default)] - pub indent_guides: Option, - /// Whether or not to perform a buffer format before saving. - /// - /// Default: on - #[serde(default)] - pub format_on_save: Option, - /// Whether or not to remove any trailing whitespace from lines of a buffer - /// before saving it. - /// - /// Default: true - #[serde(default)] - pub remove_trailing_whitespace_on_save: Option, - /// Whether or not to ensure there's a single newline at the end of a buffer - /// when saving it. - /// - /// Default: true - #[serde(default)] - pub ensure_final_newline_on_save: Option, - /// How to perform a buffer format. - /// - /// Default: auto - #[serde(default)] - pub formatter: Option, - /// Zed's Prettier integration settings. - /// Allows to enable/disable formatting with Prettier - /// and configure default Prettier, used when no project-level Prettier installation is found. - /// - /// Default: off - #[serde(default)] - pub prettier: Option, - /// Whether to automatically close JSX tags. - #[serde(default)] - pub jsx_tag_auto_close: Option, - /// Whether to use language servers to provide code intelligence. - /// - /// Default: true - #[serde(default)] - pub enable_language_server: Option, - /// The list of language servers to use (or disable) for this language. - /// - /// This array should consist of language server IDs, as well as the following - /// special tokens: - /// - `"!"` - A language server ID prefixed with a `!` will be disabled. - /// - `"..."` - A placeholder to refer to the **rest** of the registered language servers for this language. - /// - /// Default: ["..."] - #[serde(default)] - pub language_servers: Option>, - /// Controls where the `editor::Rewrap` action is allowed for this language. - /// - /// Note: This setting has no effect in Vim mode, as rewrap is already - /// allowed everywhere. - /// - /// Default: "in_comments" - #[serde(default)] - pub allow_rewrap: Option, - /// Controls whether edit predictions are shown immediately (true) - /// or manually by triggering `editor::ShowEditPrediction` (false). - /// - /// Default: true - #[serde(default)] - pub show_edit_predictions: Option, - /// Controls whether edit predictions are shown in the given language - /// scopes. - /// - /// Example: ["string", "comment"] - /// - /// Default: [] - #[serde(default)] - pub edit_predictions_disabled_in: Option>, - /// Whether to show tabs and spaces in the editor. - #[serde(default)] - pub show_whitespaces: Option, - /// Whether to start a new line with a comment when a previous line is a comment as well. - /// - /// Default: true - #[serde(default)] - pub extend_comment_on_newline: Option, - /// Inlay hint related settings. - #[serde(default)] - pub inlay_hints: Option, - /// Whether to automatically type closing characters for you. For example, - /// when you type (, Zed will automatically add a closing ) at the correct position. - /// - /// Default: true - pub use_autoclose: Option, - /// Whether to automatically surround text with characters for you. For example, - /// when you select text and type (, Zed will automatically surround text with (). - /// - /// Default: true - pub use_auto_surround: Option, - /// Controls how the editor handles the autoclosed characters. - /// When set to `false`(default), skipping over and auto-removing of the closing characters - /// happen only for auto-inserted characters. - /// Otherwise(when `true`), the closing characters are always skipped over and auto-removed - /// no matter how they were inserted. - /// - /// Default: false - pub always_treat_brackets_as_autoclosed: Option, - /// Whether to use additional LSP queries to format (and amend) the code after - /// every "trigger" symbol input, defined by LSP server capabilities. - /// - /// Default: true - pub use_on_type_format: Option, - /// Which code actions to run on save after the formatter. - /// These are not run if formatting is off. - /// - /// Default: {} (or {"source.organizeImports": true} for Go). - pub code_actions_on_format: Option>, - /// Whether to perform linked edits of associated ranges, if the language server supports it. - /// For example, when editing opening tag, the contents of the closing tag will be edited as well. - /// - /// Default: true - pub linked_edits: Option, - /// Whether indentation of pasted content should be adjusted based on the context. - /// - /// Default: true - pub auto_indent_on_paste: Option, - /// Task configuration for this language. - /// - /// Default: {} - pub tasks: Option, - /// Whether to pop the completions menu while typing in an editor without - /// explicitly requesting it. - /// - /// Default: true - pub show_completions_on_input: Option, - /// Whether to display inline and alongside documentation for items in the - /// completions menu. - /// - /// Default: true - pub show_completion_documentation: Option, - /// Controls how completions are processed for this language. - pub completions: Option, - /// Preferred debuggers for this language. - /// - /// Default: [] - pub debuggers: Option>, -} - -/// The behavior of `editor::Rewrap`. -#[derive(Debug, PartialEq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RewrapBehavior { - /// Only rewrap within comments. - #[default] - InComments, - /// Only rewrap within the current selection(s). - InSelections, - /// Allow rewrapping anywhere. - Anywhere, -} - -/// The contents of the edit prediction settings. -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] -pub struct EditPredictionSettingsContent { - /// A list of globs representing files that edit predictions should be disabled for. - /// This list adds to a pre-existing, sensible default set of globs. - /// Any additional ones you add are combined with them. - #[serde(default)] - pub disabled_globs: Option>, - /// The mode used to display edit predictions in the buffer. - /// Provider support required. - #[serde(default)] - pub mode: EditPredictionsMode, - /// Settings specific to GitHub Copilot. - #[serde(default)] - pub copilot: CopilotSettingsContent, - /// Whether edit predictions are enabled in the assistant prompt editor. - /// This has no effect if globally disabled. - #[serde(default = "default_true")] - pub enabled_in_text_threads: bool, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] -pub struct CopilotSettingsContent { - /// HTTP/HTTPS proxy to use for Copilot. - /// - /// Default: none - #[serde(default)] - pub proxy: Option, - /// Disable certificate verification for the proxy (not recommended). - /// - /// Default: false - #[serde(default)] - pub proxy_no_verify: Option, - /// Enterprise URI for Copilot. - /// - /// Default: none - #[serde(default)] - pub enterprise_uri: Option, -} - -/// The settings for enabling/disabling features. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct FeaturesContent { - /// Determines which edit prediction provider to use. - pub edit_prediction_provider: Option, -} - -/// Controls the soft-wrapping behavior in the editor. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SoftWrap { - /// Prefer a single line generally, unless an overly long line is encountered. - None, - /// Deprecated: use None instead. Left to avoid breaking existing users' configs. - /// Prefer a single line generally, unless an overly long line is encountered. - PreferLine, - /// Soft wrap lines that exceed the editor width. - EditorWidth, - /// Soft wrap lines at the preferred line length. - PreferredLineLength, - /// Soft wrap line at the preferred line length or the editor width (whichever is smaller). - Bounded, -} - -/// Controls the behavior of formatting files when they are saved. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FormatOnSave { - /// Files should be formatted on save. - On, - /// Files should not be formatted on save. - Off, - List(FormatterList), -} - -impl JsonSchema for FormatOnSave { - fn schema_name() -> Cow<'static, str> { - "OnSaveFormatter".into() - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let formatter_schema = Formatter::json_schema(generator); - - json_schema!({ - "oneOf": [ - { - "type": "array", - "items": formatter_schema - }, - { - "type": "string", - "enum": ["on", "off", "language_server"] - }, - formatter_schema - ] - }) - } -} - -impl Serialize for FormatOnSave { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - match self { - Self::On => serializer.serialize_str("on"), - Self::Off => serializer.serialize_str("off"), - Self::List(list) => list.serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for FormatOnSave { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - struct FormatDeserializer; - - impl<'d> Visitor<'d> for FormatDeserializer { - type Value = FormatOnSave; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a valid on-save formatter kind") - } - fn visit_str(self, v: &str) -> std::result::Result - where - E: serde::de::Error, - { - if v == "on" { - Ok(Self::Value::On) - } else if v == "off" { - Ok(Self::Value::Off) - } else if v == "language_server" { - Ok(Self::Value::List(FormatterList::Single( - Formatter::LanguageServer { name: None }, - ))) - } else { - let ret: Result = - Deserialize::deserialize(v.into_deserializer()); - ret.map(Self::Value::List) - } - } - fn visit_map(self, map: A) -> Result - where - A: MapAccess<'d>, - { - let ret: Result = - Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)); - ret.map(Self::Value::List) - } - fn visit_seq(self, map: A) -> Result - where - A: SeqAccess<'d>, - { - let ret: Result = - Deserialize::deserialize(de::value::SeqAccessDeserializer::new(map)); - ret.map(Self::Value::List) - } - } - deserializer.deserialize_any(FormatDeserializer) - } -} - -/// Controls how whitespace should be displayedin the editor. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ShowWhitespaceSetting { - /// Draw whitespace only for the selected text. - Selection, - /// Do not draw any tabs or spaces. - None, - /// Draw all invisible symbols. - All, - /// Draw whitespaces at boundaries only. - /// - /// For a whitespace to be on a boundary, any of the following conditions need to be met: - /// - It is a tab - /// - It is adjacent to an edge (start or end) - /// - It is adjacent to a whitespace (left or right) - Boundary, - /// Draw whitespaces only after non-whitespace characters. - Trailing, -} - -/// Controls which formatter should be used when formatting code. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub enum SelectedFormatter { - /// Format files using Zed's Prettier integration (if applicable), - /// or falling back to formatting via language server. - #[default] - Auto, - List(FormatterList), -} - -impl JsonSchema for SelectedFormatter { - fn schema_name() -> Cow<'static, str> { - "Formatter".into() - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - let formatter_schema = Formatter::json_schema(generator); - - json_schema!({ - "oneOf": [ - { - "type": "array", - "items": formatter_schema - }, - { - "type": "string", - "enum": ["auto", "language_server"] - }, - formatter_schema - ] - }) - } -} - -impl Serialize for SelectedFormatter { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - match self { - SelectedFormatter::Auto => serializer.serialize_str("auto"), - SelectedFormatter::List(list) => list.serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for SelectedFormatter { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - struct FormatDeserializer; - - impl<'d> Visitor<'d> for FormatDeserializer { - type Value = SelectedFormatter; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a valid formatter kind") - } - fn visit_str(self, v: &str) -> std::result::Result - where - E: serde::de::Error, - { - if v == "auto" { - Ok(Self::Value::Auto) - } else if v == "language_server" { - Ok(Self::Value::List(FormatterList::Single( - Formatter::LanguageServer { name: None }, - ))) - } else { - let ret: Result = - Deserialize::deserialize(v.into_deserializer()); - ret.map(SelectedFormatter::List) - } - } - fn visit_map(self, map: A) -> Result - where - A: MapAccess<'d>, - { - let ret: Result = - Deserialize::deserialize(de::value::MapAccessDeserializer::new(map)); - ret.map(SelectedFormatter::List) - } - fn visit_seq(self, map: A) -> Result - where - A: SeqAccess<'d>, - { - let ret: Result = - Deserialize::deserialize(de::value::SeqAccessDeserializer::new(map)); - ret.map(SelectedFormatter::List) - } - } - deserializer.deserialize_any(FormatDeserializer) - } -} - -/// Controls which formatters should be used when formatting code. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum FormatterList { - Single(Formatter), - Vec(Vec), -} - -impl AsRef<[Formatter]> for FormatterList { - fn as_ref(&self) -> &[Formatter] { - match &self { - Self::Single(single) => slice::from_ref(single), - Self::Vec(v) => v, - } - } -} - -/// Controls which formatter should be used when formatting code. If there are multiple formatters, they are executed in the order of declaration. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Formatter { - /// Format code using the current language server. - LanguageServer { name: Option }, - /// Format code using Zed's Prettier integration. - Prettier, - /// Format code using an external command. - External { - /// The external program to run. - command: Arc, - /// The arguments to pass to the program. - arguments: Option>, - }, - /// Files should be formatted using code actions executed by language servers. - CodeActions(HashMap), -} - -/// The settings for indent guides. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct IndentGuideSettings { - /// Whether to display indent guides in the editor. - /// - /// Default: true - #[serde(default = "default_true")] - pub enabled: bool, - /// The width of the indent guides in pixels, between 1 and 10. - /// - /// Default: 1 - #[serde(default = "line_width")] - pub line_width: u32, - /// The width of the active indent guide in pixels, between 1 and 10. - /// - /// Default: 1 - #[serde(default = "active_line_width")] - pub active_line_width: u32, - /// Determines how indent guides are colored. - /// - /// Default: Fixed - #[serde(default)] - pub coloring: IndentGuideColoring, - /// Determines how indent guide backgrounds are colored. - /// - /// Default: Disabled - #[serde(default)] - pub background_coloring: IndentGuideBackgroundColoring, -} - -fn line_width() -> u32 { - 1 -} - -fn active_line_width() -> u32 { - line_width() -} - -/// Determines how indent guides are colored. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum IndentGuideColoring { - /// Do not render any lines for indent guides. - Disabled, - /// Use the same color for all indentation levels. - #[default] - Fixed, - /// Use a different color for each indentation level. - IndentAware, -} - -/// Determines how indent guide backgrounds are colored. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum IndentGuideBackgroundColoring { - /// Do not render any background for indent guides. - #[default] - Disabled, - /// Use a different color for each indentation level. - IndentAware, -} - -/// The settings for inlay hints. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct InlayHintSettings { - /// Global switch to toggle hints on and off. - /// - /// Default: false - #[serde(default)] - pub enabled: bool, - /// Global switch to toggle inline values on and off when debugging. - /// - /// Default: true - #[serde(default = "default_true")] - pub show_value_hints: bool, - /// Whether type hints should be shown. - /// - /// Default: true - #[serde(default = "default_true")] - pub show_type_hints: bool, - /// Whether parameter hints should be shown. - /// - /// Default: true - #[serde(default = "default_true")] - pub show_parameter_hints: bool, - /// Whether other hints should be shown. - /// - /// Default: true - #[serde(default = "default_true")] - pub show_other_hints: bool, - /// Whether to show a background for inlay hints. - /// - /// If set to `true`, the background will use the `hint.background` color - /// from the current theme. - /// - /// Default: false - #[serde(default)] - pub show_background: bool, - /// Whether or not to debounce inlay hints updates after buffer edits. - /// - /// Set to 0 to disable debouncing. - /// - /// Default: 700 - #[serde(default = "edit_debounce_ms")] - pub edit_debounce_ms: u64, - /// Whether or not to debounce inlay hints updates after buffer scrolls. - /// - /// Set to 0 to disable debouncing. - /// - /// Default: 50 - #[serde(default = "scroll_debounce_ms")] - pub scroll_debounce_ms: u64, - /// Toggles inlay hints (hides or shows) when the user presses the modifiers specified. - /// If only a subset of the modifiers specified is pressed, hints are not toggled. - /// If no modifiers are specified, this is equivalent to `None`. - /// - /// Default: None - #[serde(default)] - pub toggle_on_modifiers_press: Option, -} - -fn edit_debounce_ms() -> u64 { - 700 -} - -fn scroll_debounce_ms() -> u64 { - 50 -} - -/// The task settings for a particular language. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema)] -pub struct LanguageTaskConfig { - /// Extra task variables to set for a particular language. - #[serde(default)] - pub variables: HashMap, - #[serde(default = "default_true")] - pub enabled: bool, - /// Use LSP tasks over Zed language extension ones. - /// If no LSP tasks are returned due to error/timeout or regular execution, - /// Zed language extension tasks will be used instead. - /// - /// Other Zed tasks will still be shown: - /// * Zed task from either of the task config file - /// * Zed task from history (e.g. one-off task was spawned before) - #[serde(default = "default_true")] - pub prefer_lsp: bool, -} - -impl InlayHintSettings { - /// Returns the kinds of inlay hints that are enabled based on the settings. - pub fn enabled_inlay_hint_kinds(&self) -> HashSet> { - let mut kinds = HashSet::default(); - if self.show_type_hints { - kinds.insert(Some(InlayHintKind::Type)); - } - if self.show_parameter_hints { - kinds.insert(Some(InlayHintKind::Parameter)); - } - if self.show_other_hints { - kinds.insert(None); - } - kinds - } -} - -impl AllLanguageSettings { - /// Returns the [`LanguageSettings`] for the language with the specified name. - pub fn language<'a>( - &'a self, - location: Option>, - language_name: Option<&LanguageName>, - cx: &'a App, - ) -> Cow<'a, LanguageSettings> { - let settings = language_name - .and_then(|name| self.languages.get(name)) - .unwrap_or(&self.defaults); - - let editorconfig_properties = location.and_then(|location| { - cx.global::() - .editorconfig_properties(location.worktree_id, location.path) - }); - if let Some(editorconfig_properties) = editorconfig_properties { - let mut settings = settings.clone(); - merge_with_editorconfig(&mut settings, &editorconfig_properties); - Cow::Owned(settings) - } else { - Cow::Borrowed(settings) - } - } - - /// Returns whether edit predictions are enabled for the given path. - pub fn edit_predictions_enabled_for_file(&self, file: &Arc, cx: &App) -> bool { - self.edit_predictions.enabled_for_file(file, cx) - } - - /// Returns whether edit predictions are enabled for the given language and path. - pub fn show_edit_predictions(&self, language: Option<&Arc>, cx: &App) -> bool { - self.language(None, language.map(|l| l.name()).as_ref(), cx) - .show_edit_predictions - } - - /// Returns the edit predictions preview mode for the given language and path. - pub fn edit_predictions_mode(&self) -> EditPredictionsMode { - self.edit_predictions.mode - } -} - -fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { - let tab_size = cfg.get::().ok().and_then(|v| match v { - IndentSize::Value(u) => NonZeroU32::new(u as u32), - IndentSize::UseTabWidth => cfg.get::().ok().and_then(|w| match w { - TabWidth::Value(u) => NonZeroU32::new(u as u32), - }), - }); - let hard_tabs = cfg - .get::() - .map(|v| v.eq(&IndentStyle::Tabs)) - .ok(); - let ensure_final_newline_on_save = cfg - .get::() - .map(|v| match v { - FinalNewline::Value(b) => b, - }) - .ok(); - let remove_trailing_whitespace_on_save = cfg - .get::() - .map(|v| match v { - TrimTrailingWs::Value(b) => b, - }) - .ok(); - fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; - } - } - merge(&mut settings.tab_size, tab_size); - merge(&mut settings.hard_tabs, hard_tabs); - merge( - &mut settings.remove_trailing_whitespace_on_save, - remove_trailing_whitespace_on_save, - ); - merge( - &mut settings.ensure_final_newline_on_save, - ensure_final_newline_on_save, - ); -} - -/// The kind of an inlay hint. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum InlayHintKind { - /// An inlay hint for a type. - Type, - /// An inlay hint for a parameter. - Parameter, -} - -impl InlayHintKind { - /// Returns the [`InlayHintKind`] from the given name. - /// - /// Returns `None` if `name` does not match any of the expected - /// string representations. - pub fn from_name(name: &str) -> Option { - match name { - "type" => Some(InlayHintKind::Type), - "parameter" => Some(InlayHintKind::Parameter), - _ => None, - } - } - - /// Returns the name of this [`InlayHintKind`]. - pub fn name(&self) -> &'static str { - match self { - InlayHintKind::Type => "type", - InlayHintKind::Parameter => "parameter", - } - } -} - -impl settings::Settings for AllLanguageSettings { - const KEY: Option<&'static str> = None; - - type FileContent = AllLanguageSettingsContent; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - let default_value = sources.default; - - // A default is provided for all settings. - let mut defaults: LanguageSettings = - serde_json::from_value(serde_json::to_value(&default_value.defaults)?)?; + let default_language_settings = load_from_content(all_languages.defaults.clone()); let mut languages = HashMap::default(); - for (language_name, settings) in &default_value.languages.0 { - let mut language_settings = defaults.clone(); - merge_settings(&mut language_settings, settings); - languages.insert(language_name.clone(), language_settings); + for (language_name, settings) in &all_languages.languages.0 { + let mut language_settings = all_languages.defaults.clone(); + settings::merge_from::MergeFrom::merge_from(&mut language_settings, settings); + languages.insert( + LanguageName(language_name.clone()), + load_from_content(language_settings), + ); } - let mut edit_prediction_provider = default_value + let edit_prediction_provider = all_languages .features .as_ref() .and_then(|f| f.edit_prediction_provider); - let mut edit_predictions_mode = default_value - .edit_predictions - .as_ref() - .map(|edit_predictions| edit_predictions.mode) - .ok_or_else(Self::missing_default)?; - - let mut completion_globs: HashSet<&String> = default_value - .edit_predictions - .as_ref() - .and_then(|c| c.disabled_globs.as_ref()) - .map(|globs| globs.iter().collect()) - .ok_or_else(Self::missing_default)?; - let mut copilot_settings = default_value - .edit_predictions - .as_ref() - .map(|settings| CopilotSettings { - proxy: settings.copilot.proxy.clone(), - proxy_no_verify: settings.copilot.proxy_no_verify, - enterprise_uri: settings.copilot.enterprise_uri.clone(), - }) - .unwrap_or_default(); + let edit_predictions = all_languages.edit_predictions.clone().unwrap(); + let edit_predictions_mode = edit_predictions.mode.unwrap(); - let mut enabled_in_text_threads = default_value - .edit_predictions + let disabled_globs: HashSet<&String> = edit_predictions + .disabled_globs .as_ref() - .map(|settings| settings.enabled_in_text_threads) - .unwrap_or(true); - - let mut file_types: FxHashMap, GlobSet> = FxHashMap::default(); - - for (language, patterns) in &default_value.file_types { - let mut builder = GlobSetBuilder::new(); - - for pattern in patterns { - builder.add(Glob::new(pattern)?); - } - - file_types.insert(language.clone(), builder.build()?); - } - - for user_settings in sources.customizations() { - if let Some(provider) = user_settings - .features - .as_ref() - .and_then(|f| f.edit_prediction_provider) - { - edit_prediction_provider = Some(provider); - } - - if let Some(edit_predictions) = user_settings.edit_predictions.as_ref() { - edit_predictions_mode = edit_predictions.mode; - enabled_in_text_threads = edit_predictions.enabled_in_text_threads; + .unwrap() + .iter() + .collect(); - if let Some(disabled_globs) = edit_predictions.disabled_globs.as_ref() { - completion_globs.extend(disabled_globs.iter()); - } - } + let copilot = edit_predictions.copilot.unwrap(); + let copilot_settings = CopilotSettings { + proxy: copilot.proxy, + proxy_no_verify: copilot.proxy_no_verify, + enterprise_uri: copilot.enterprise_uri, + }; - if let Some(proxy) = user_settings - .edit_predictions - .as_ref() - .and_then(|settings| settings.copilot.proxy.clone()) - { - copilot_settings.proxy = Some(proxy); - } + let codestral = edit_predictions.codestral.unwrap(); + let codestral_settings = CodestralSettings { + model: codestral.model, + max_tokens: codestral.max_tokens, + api_url: codestral.api_url, + }; - if let Some(proxy_no_verify) = user_settings - .edit_predictions - .as_ref() - .and_then(|settings| settings.copilot.proxy_no_verify) - { - copilot_settings.proxy_no_verify = Some(proxy_no_verify); - } + let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap(); - if let Some(enterprise_uri) = user_settings - .edit_predictions - .as_ref() - .and_then(|settings| settings.copilot.enterprise_uri.clone()) - { - copilot_settings.enterprise_uri = Some(enterprise_uri); - } + let mut file_types: FxHashMap, GlobSet> = FxHashMap::default(); - // A user's global settings override the default global settings and - // all default language-specific settings. - merge_settings(&mut defaults, &user_settings.defaults); - for language_settings in languages.values_mut() { - merge_settings(language_settings, &user_settings.defaults); - } + for (language, patterns) in all_languages.file_types.iter().flatten() { + let mut builder = GlobSetBuilder::new(); - // A user's language-specific settings override default language-specific settings. - for (language_name, user_language_settings) in &user_settings.languages.0 { - merge_settings( - languages - .entry(language_name.clone()) - .or_insert_with(|| defaults.clone()), - user_language_settings, - ); + for pattern in &patterns.0 { + builder.add(Glob::new(pattern).unwrap()); } - for (language, patterns) in &user_settings.file_types { - let mut builder = GlobSetBuilder::new(); - - let default_value = default_value.file_types.get(&language.clone()); - - // Merge the default value with the user's value. - if let Some(patterns) = default_value { - for pattern in patterns { - builder.add(Glob::new(pattern)?); - } - } - - for pattern in patterns { - builder.add(Glob::new(pattern)?); - } - - file_types.insert(language.clone(), builder.build()?); - } + file_types.insert(language.clone(), builder.build().unwrap()); } - Ok(Self { + Self { edit_predictions: EditPredictionSettings { provider: if let Some(provider) = edit_prediction_provider { provider } else { EditPredictionProvider::None }, - disabled_globs: completion_globs + disabled_globs: disabled_globs .iter() .filter_map(|g| { let expanded_g = shellexpand::tilde(g).into_owned(); @@ -1357,306 +674,27 @@ impl settings::Settings for AllLanguageSettings { .collect(), mode: edit_predictions_mode, copilot: copilot_settings, + codestral: codestral_settings, enabled_in_text_threads, }, - defaults, + defaults: default_language_settings, languages, file_types, - }) - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) { - let d = &mut current.defaults; - if let Some(size) = vscode - .read_value("editor.tabSize") - .and_then(|v| v.as_u64()) - .and_then(|n| NonZeroU32::new(n as u32)) - { - d.tab_size = Some(size); - } - if let Some(v) = vscode.read_bool("editor.insertSpaces") { - d.hard_tabs = Some(!v); - } - - vscode.enum_setting("editor.wordWrap", &mut d.soft_wrap, |s| match s { - "on" => Some(SoftWrap::EditorWidth), - "wordWrapColumn" => Some(SoftWrap::PreferLine), - "bounded" => Some(SoftWrap::Bounded), - "off" => Some(SoftWrap::None), - _ => None, - }); - vscode.u32_setting("editor.wordWrapColumn", &mut d.preferred_line_length); - - if let Some(arr) = vscode - .read_value("editor.rulers") - .and_then(|v| v.as_array()) - .map(|v| v.iter().map(|n| n.as_u64().map(|n| n as usize)).collect()) - { - d.wrap_guides = arr; - } - if let Some(b) = vscode.read_bool("editor.guides.indentation") { - if let Some(guide_settings) = d.indent_guides.as_mut() { - guide_settings.enabled = b; - } else { - d.indent_guides = Some(IndentGuideSettings { - enabled: b, - ..Default::default() - }); - } - } - - if let Some(b) = vscode.read_bool("editor.guides.formatOnSave") { - d.format_on_save = Some(if b { - FormatOnSave::On - } else { - FormatOnSave::Off - }); - } - vscode.bool_setting( - "editor.trimAutoWhitespace", - &mut d.remove_trailing_whitespace_on_save, - ); - vscode.bool_setting( - "files.insertFinalNewline", - &mut d.ensure_final_newline_on_save, - ); - vscode.bool_setting("editor.inlineSuggest.enabled", &mut d.show_edit_predictions); - vscode.enum_setting("editor.renderWhitespace", &mut d.show_whitespaces, |s| { - Some(match s { - "boundary" => ShowWhitespaceSetting::Boundary, - "trailing" => ShowWhitespaceSetting::Trailing, - "selection" => ShowWhitespaceSetting::Selection, - "all" => ShowWhitespaceSetting::All, - _ => ShowWhitespaceSetting::None, - }) - }); - vscode.enum_setting( - "editor.autoSurround", - &mut d.use_auto_surround, - |s| match s { - "languageDefined" | "quotes" | "brackets" => Some(true), - "never" => Some(false), - _ => None, - }, - ); - vscode.bool_setting("editor.formatOnType", &mut d.use_on_type_format); - vscode.bool_setting("editor.linkedEditing", &mut d.linked_edits); - vscode.bool_setting("editor.formatOnPaste", &mut d.auto_indent_on_paste); - vscode.bool_setting( - "editor.suggestOnTriggerCharacters", - &mut d.show_completions_on_input, - ); - if let Some(b) = vscode.read_bool("editor.suggest.showWords") { - let mode = if b { - WordsCompletionMode::Enabled - } else { - WordsCompletionMode::Disabled - }; - if let Some(completion_settings) = d.completions.as_mut() { - completion_settings.words = mode; - } else { - d.completions = Some(CompletionSettings { - words: mode, - lsp: true, - lsp_fetch_timeout_ms: 0, - lsp_insert_mode: LspInsertMode::ReplaceSuffix, - }); - } - } - // TODO: pull ^ out into helper and reuse for per-language settings - - // vscodes file association map is inverted from ours, so we flip the mapping before merging - let mut associations: HashMap, Vec> = HashMap::default(); - if let Some(map) = vscode - .read_value("files.associations") - .and_then(|v| v.as_object()) - { - for (k, v) in map { - let Some(v) = v.as_str() else { continue }; - associations.entry(v.into()).or_default().push(k.clone()); - } - } - - // TODO: do we want to merge imported globs per filetype? for now we'll just replace - current.file_types.extend(associations); - - // cursor global ignore list applies to cursor-tab, so transfer it to edit_predictions.disabled_globs - if let Some(disabled_globs) = vscode - .read_value("cursor.general.globalCursorIgnoreList") - .and_then(|v| v.as_array()) - { - current - .edit_predictions - .get_or_insert_default() - .disabled_globs - .get_or_insert_default() - .extend( - disabled_globs - .iter() - .filter_map(|glob| glob.as_str()) - .map(|s| s.to_string()), - ); - } - } -} - -fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent) { - fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; } } - - merge(&mut settings.tab_size, src.tab_size); - settings.tab_size = settings - .tab_size - .clamp(NonZeroU32::new(1).unwrap(), NonZeroU32::new(16).unwrap()); - - merge(&mut settings.hard_tabs, src.hard_tabs); - merge(&mut settings.soft_wrap, src.soft_wrap); - merge(&mut settings.use_autoclose, src.use_autoclose); - merge(&mut settings.use_auto_surround, src.use_auto_surround); - merge(&mut settings.use_on_type_format, src.use_on_type_format); - merge(&mut settings.auto_indent_on_paste, src.auto_indent_on_paste); - merge( - &mut settings.always_treat_brackets_as_autoclosed, - src.always_treat_brackets_as_autoclosed, - ); - merge(&mut settings.show_wrap_guides, src.show_wrap_guides); - merge(&mut settings.wrap_guides, src.wrap_guides.clone()); - merge(&mut settings.indent_guides, src.indent_guides); - merge( - &mut settings.code_actions_on_format, - src.code_actions_on_format.clone(), - ); - merge(&mut settings.linked_edits, src.linked_edits); - merge(&mut settings.tasks, src.tasks.clone()); - - merge( - &mut settings.preferred_line_length, - src.preferred_line_length, - ); - merge(&mut settings.formatter, src.formatter.clone()); - merge(&mut settings.prettier, src.prettier.clone()); - merge( - &mut settings.jsx_tag_auto_close, - src.jsx_tag_auto_close.clone(), - ); - merge(&mut settings.format_on_save, src.format_on_save.clone()); - merge( - &mut settings.remove_trailing_whitespace_on_save, - src.remove_trailing_whitespace_on_save, - ); - merge( - &mut settings.ensure_final_newline_on_save, - src.ensure_final_newline_on_save, - ); - merge( - &mut settings.enable_language_server, - src.enable_language_server, - ); - merge(&mut settings.language_servers, src.language_servers.clone()); - merge(&mut settings.allow_rewrap, src.allow_rewrap); - merge( - &mut settings.show_edit_predictions, - src.show_edit_predictions, - ); - merge( - &mut settings.edit_predictions_disabled_in, - src.edit_predictions_disabled_in.clone(), - ); - merge(&mut settings.show_whitespaces, src.show_whitespaces); - merge( - &mut settings.extend_comment_on_newline, - src.extend_comment_on_newline, - ); - merge(&mut settings.inlay_hints, src.inlay_hints); - merge( - &mut settings.show_completions_on_input, - src.show_completions_on_input, - ); - merge( - &mut settings.show_completion_documentation, - src.show_completion_documentation, - ); - merge(&mut settings.completions, src.completions); -} - -/// Allows to enable/disable formatting with Prettier -/// and configure default Prettier, used when no project-level Prettier installation is found. -/// Prettier formatting is disabled by default. -#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct PrettierSettings { - /// Enables or disables formatting with Prettier for a given language. - #[serde(default)] - pub allowed: bool, - - /// Forces Prettier integration to use a specific parser name when formatting files with the language. - #[serde(default)] - pub parser: Option, - - /// Forces Prettier integration to use specific plugins when formatting files with the language. - /// The default Prettier will be installed with these plugins. - #[serde(default)] - pub plugins: HashSet, - - /// Default Prettier options, in the format as in package.json section for Prettier. - /// If project installs Prettier via its package.json, these options will be ignored. - #[serde(flatten)] - pub options: HashMap, } -#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Default, Debug, Clone, PartialEq, Eq)] pub struct JsxTagAutoCloseSettings { /// Enables or disables auto-closing of JSX tags. - #[serde(default)] pub enabled: bool, } #[cfg(test)] mod tests { - use gpui::TestAppContext; - use super::*; - - #[test] - fn test_formatter_deserialization() { - let raw_auto = "{\"formatter\": \"auto\"}"; - let settings: LanguageSettingsContent = serde_json::from_str(raw_auto).unwrap(); - assert_eq!(settings.formatter, Some(SelectedFormatter::Auto)); - let raw = "{\"formatter\": \"language_server\"}"; - let settings: LanguageSettingsContent = serde_json::from_str(raw).unwrap(); - assert_eq!( - settings.formatter, - Some(SelectedFormatter::List(FormatterList::Single( - Formatter::LanguageServer { name: None } - ))) - ); - let raw = "{\"formatter\": [{\"language_server\": {\"name\": null}}]}"; - let settings: LanguageSettingsContent = serde_json::from_str(raw).unwrap(); - assert_eq!( - settings.formatter, - Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None } - ]))) - ); - let raw = "{\"formatter\": [{\"language_server\": {\"name\": null}}, \"prettier\"]}"; - let settings: LanguageSettingsContent = serde_json::from_str(raw).unwrap(); - assert_eq!( - settings.formatter, - Some(SelectedFormatter::List(FormatterList::Vec(vec![ - Formatter::LanguageServer { name: None }, - Formatter::Prettier - ]))) - ); - } - - #[test] - fn test_formatter_deserialization_invalid() { - let raw_auto = "{\"formatter\": {}}"; - let result: Result = serde_json::from_str(raw_auto); - assert!(result.is_err()); - } + use gpui::TestAppContext; + use util::rel_path::rel_path; #[gpui::test] fn test_edit_predictions_enabled_for_file(cx: &mut TestAppContext) { @@ -1698,11 +736,11 @@ mod tests { const WORKTREE_NAME: &str = "project"; let make_test_file = |segments: &[&str]| -> Arc { - let mut path_buf = PathBuf::new(); - path_buf.extend(segments); + let path = segments.join("/"); + let path = rel_path(&path); Arc::new(TestFile { - path: path_buf.as_path().into(), + path: path.into(), root_name: WORKTREE_NAME.to_string(), local_root: Some(PathBuf::from(if cfg!(windows) { "C:\\absolute\\" @@ -1755,7 +793,7 @@ mod tests { assert!(!settings.enabled_for_file(&test_file, &cx)); let test_file_root: Arc = Arc::new(TestFile { - path: PathBuf::from("file.rs").as_path().into(), + path: rel_path("file.rs").into(), root_name: WORKTREE_NAME.to_string(), local_root: Some(PathBuf::from("/absolute/")), }); @@ -1786,9 +824,13 @@ mod tests { assert!(!settings.enabled_for_file(&dot_env_file, &cx)); // Test tilde expansion - let home = shellexpand::tilde("~").into_owned().to_string(); - let home_file = make_test_file(&[&home, "test.rs"]); - let settings = build_settings(&["~/test.rs"]); + let home = shellexpand::tilde("~").into_owned(); + let home_file = Arc::new(TestFile { + path: rel_path("test.rs").into(), + root_name: "the-dir".to_string(), + local_root: Some(PathBuf::from(home)), + }) as Arc; + let settings = build_settings(&["~/the-dir/test.rs"]); assert!(!settings.enabled_for_file(&home_file, &cx)); } diff --git a/crates/language/src/manifest.rs b/crates/language/src/manifest.rs index 37505fec3b233c2ecd7e2ac7807a7ade6a9b3d4a..82ed164a032cb18d2d011f59938a0cd1410ba60f 100644 --- a/crates/language/src/manifest.rs +++ b/crates/language/src/manifest.rs @@ -1,7 +1,8 @@ -use std::{borrow::Borrow, path::Path, sync::Arc}; +use std::{borrow::Borrow, sync::Arc}; use gpui::SharedString; use settings::WorktreeId; +use util::rel_path::RelPath; #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ManifestName(SharedString); @@ -12,6 +13,12 @@ impl Borrow for ManifestName { } } +impl Borrow for ManifestName { + fn borrow(&self) -> &str { + &self.0 + } +} + impl From for ManifestName { fn from(value: SharedString) -> Self { Self(value) @@ -36,17 +43,17 @@ impl AsRef for ManifestName { /// For example, given a path like `foo/bar/baz`, a depth of 2 would explore `foo/bar/baz` and `foo/bar`, but not `foo`. pub struct ManifestQuery { /// Path to the file, relative to worktree root. - pub path: Arc, + pub path: Arc, pub depth: usize, pub delegate: Arc, } pub trait ManifestProvider { fn name(&self) -> ManifestName; - fn search(&self, query: ManifestQuery) -> Option>; + fn search(&self, query: ManifestQuery) -> Option>; } pub trait ManifestDelegate: Send + Sync { fn worktree_id(&self) -> WorktreeId; - fn exists(&self, path: &Path, is_dir: Option) -> bool; + fn exists(&self, path: &RelPath, is_dir: Option) -> bool; } diff --git a/crates/language/src/outline.rs b/crates/language/src/outline.rs index d96cd90e03142c6498ae17bc63e1787d99e8557a..2ce2b42734465a4710a7439f5e2225debc96b04a 100644 --- a/crates/language/src/outline.rs +++ b/crates/language/src/outline.rs @@ -16,6 +16,7 @@ pub struct Outline { pub struct OutlineItem { pub depth: usize, pub range: Range, + pub source_range_for_text: Range, pub text: String, pub highlight_ranges: Vec<(Range, HighlightStyle)>, pub name_ranges: Vec>, @@ -32,6 +33,8 @@ impl OutlineItem { OutlineItem { depth: self.depth, range: self.range.start.to_point(buffer)..self.range.end.to_point(buffer), + source_range_for_text: self.source_range_for_text.start.to_point(buffer) + ..self.source_range_for_text.end.to_point(buffer), text: self.text.clone(), highlight_ranges: self.highlight_ranges.clone(), name_ranges: self.name_ranges.clone(), @@ -205,6 +208,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), + source_range_for_text: Point::new(0, 0)..Point::new(0, 9), text: "class Foo".to_string(), highlight_ranges: vec![], name_ranges: vec![6..9], @@ -214,6 +218,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(2, 0)..Point::new(2, 7), + source_range_for_text: Point::new(0, 0)..Point::new(0, 7), text: "private".to_string(), highlight_ranges: vec![], name_ranges: vec![], @@ -238,6 +243,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), + source_range_for_text: Point::new(0, 0)..Point::new(0, 10), text: "fn process".to_string(), highlight_ranges: vec![], name_ranges: vec![3..10], @@ -247,6 +253,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(7, 0)..Point::new(12, 0), + source_range_for_text: Point::new(0, 0)..Point::new(0, 20), text: "struct DataProcessor".to_string(), highlight_ranges: vec![], name_ranges: vec![7..20], diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 18f6bb8709c707af9dd19223cac30d6728eda160..5c8200b84002c104ce1e2c3d1a42aff5876bd1ee 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -39,14 +39,14 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation { crate::Operation::Buffer(text::Operation::Undo(undo)) => { proto::operation::Variant::Undo(proto::operation::Undo { - replica_id: undo.timestamp.replica_id as u32, + replica_id: undo.timestamp.replica_id.as_u16() as u32, lamport_timestamp: undo.timestamp.value, version: serialize_version(&undo.version), counts: undo .counts .iter() .map(|(edit_id, count)| proto::UndoCount { - replica_id: edit_id.replica_id as u32, + replica_id: edit_id.replica_id.as_u16() as u32, lamport_timestamp: edit_id.value, count: *count, }) @@ -60,7 +60,7 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation { lamport_timestamp, cursor_shape, } => proto::operation::Variant::UpdateSelections(proto::operation::UpdateSelections { - replica_id: lamport_timestamp.replica_id as u32, + replica_id: lamport_timestamp.replica_id.as_u16() as u32, lamport_timestamp: lamport_timestamp.value, selections: serialize_selections(selections), line_mode: *line_mode, @@ -72,7 +72,7 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation { server_id, diagnostics, } => proto::operation::Variant::UpdateDiagnostics(proto::UpdateDiagnostics { - replica_id: lamport_timestamp.replica_id as u32, + replica_id: lamport_timestamp.replica_id.as_u16() as u32, lamport_timestamp: lamport_timestamp.value, server_id: server_id.0 as u64, diagnostics: serialize_diagnostics(diagnostics.iter()), @@ -84,12 +84,21 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation { server_id, } => proto::operation::Variant::UpdateCompletionTriggers( proto::operation::UpdateCompletionTriggers { - replica_id: lamport_timestamp.replica_id as u32, + replica_id: lamport_timestamp.replica_id.as_u16() as u32, lamport_timestamp: lamport_timestamp.value, - triggers: triggers.iter().cloned().collect(), + triggers: triggers.clone(), language_server_id: server_id.to_proto(), }, ), + + crate::Operation::UpdateLineEnding { + line_ending, + lamport_timestamp, + } => proto::operation::Variant::UpdateLineEnding(proto::operation::UpdateLineEnding { + replica_id: lamport_timestamp.replica_id.as_u16() as u32, + lamport_timestamp: lamport_timestamp.value, + line_ending: serialize_line_ending(*line_ending) as i32, + }), }), } } @@ -97,7 +106,7 @@ pub fn serialize_operation(operation: &crate::Operation) -> proto::Operation { /// Serializes an [`EditOperation`] to be sent over RPC. pub fn serialize_edit_operation(operation: &EditOperation) -> proto::operation::Edit { proto::operation::Edit { - replica_id: operation.timestamp.replica_id as u32, + replica_id: operation.timestamp.replica_id.as_u16() as u32, lamport_timestamp: operation.timestamp.value, version: serialize_version(&operation.version), ranges: operation.ranges.iter().map(serialize_range).collect(), @@ -114,12 +123,12 @@ pub fn serialize_undo_map_entry( (edit_id, counts): (&clock::Lamport, &[(clock::Lamport, u32)]), ) -> proto::UndoMapEntry { proto::UndoMapEntry { - replica_id: edit_id.replica_id as u32, + replica_id: edit_id.replica_id.as_u16() as u32, local_timestamp: edit_id.value, counts: counts .iter() .map(|(undo_id, count)| proto::UndoCount { - replica_id: undo_id.replica_id as u32, + replica_id: undo_id.replica_id.as_u16() as u32, lamport_timestamp: undo_id.value, count: *count, }) @@ -237,7 +246,7 @@ pub fn serialize_diagnostics<'a>( /// Serializes an [`Anchor`] to be sent over RPC. pub fn serialize_anchor(anchor: &Anchor) -> proto::Anchor { proto::Anchor { - replica_id: anchor.timestamp.replica_id as u32, + replica_id: anchor.timestamp.replica_id.as_u16() as u32, timestamp: anchor.timestamp.value, offset: anchor.offset as u64, bias: match anchor.bias { @@ -274,7 +283,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result { crate::Operation::Buffer(text::Operation::Undo(UndoOperation { timestamp: clock::Lamport { - replica_id: undo.replica_id as ReplicaId, + replica_id: ReplicaId::new(undo.replica_id as u16), value: undo.lamport_timestamp, }, version: deserialize_version(&undo.version), @@ -284,7 +293,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result Result Result { crate::Operation::UpdateDiagnostics { lamport_timestamp: clock::Lamport { - replica_id: message.replica_id as ReplicaId, + replica_id: ReplicaId::new(message.replica_id as u16), value: message.lamport_timestamp, }, server_id: LanguageServerId(message.server_id as usize), @@ -335,12 +344,24 @@ pub fn deserialize_operation(message: proto::Operation) -> Result { + crate::Operation::UpdateLineEnding { + lamport_timestamp: clock::Lamport { + replica_id: ReplicaId::new(message.replica_id as u16), + value: message.lamport_timestamp, + }, + line_ending: deserialize_line_ending( + proto::LineEnding::from_i32(message.line_ending) + .context("missing line_ending")?, + ), + } + } }, ) } @@ -349,7 +370,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result EditOperation { EditOperation { timestamp: clock::Lamport { - replica_id: edit.replica_id as ReplicaId, + replica_id: ReplicaId::new(edit.replica_id as u16), value: edit.lamport_timestamp, }, version: deserialize_version(&edit.version), @@ -364,7 +385,7 @@ pub fn deserialize_undo_map_entry( ) -> (clock::Lamport, Vec<(clock::Lamport, u32)>) { ( clock::Lamport { - replica_id: entry.replica_id as u16, + replica_id: ReplicaId::new(entry.replica_id as u16), value: entry.local_timestamp, }, entry @@ -373,7 +394,7 @@ pub fn deserialize_undo_map_entry( .map(|undo_count| { ( clock::Lamport { - replica_id: undo_count.replica_id as u16, + replica_id: ReplicaId::new(undo_count.replica_id as u16), value: undo_count.lamport_timestamp, }, undo_count.count, @@ -385,12 +406,10 @@ pub fn deserialize_undo_map_entry( /// Deserializes selections from the RPC representation. pub fn deserialize_selections(selections: Vec) -> Arc<[Selection]> { - Arc::from( - selections - .into_iter() - .filter_map(deserialize_selection) - .collect::>(), - ) + selections + .into_iter() + .filter_map(deserialize_selection) + .collect() } /// Deserializes a [`Selection`] from the RPC representation. @@ -433,7 +452,7 @@ pub fn deserialize_diagnostics( code: diagnostic.code.map(lsp::NumberOrString::from_string), code_description: diagnostic .code_description - .and_then(|s| lsp::Url::parse(&s).ok()), + .and_then(|s| lsp::Uri::from_str(&s).ok()), is_primary: diagnostic.is_primary, is_disk_based: diagnostic.is_disk_based, is_unnecessary: diagnostic.is_unnecessary, @@ -461,7 +480,7 @@ pub fn deserialize_anchor(anchor: proto::Anchor) -> Option { }; Some(Anchor { timestamp: clock::Lamport { - replica_id: anchor.replica_id as ReplicaId, + replica_id: ReplicaId::new(anchor.replica_id as u16), value: anchor.timestamp, }, offset: anchor.offset as usize, @@ -498,10 +517,14 @@ pub fn lamport_timestamp_for_operation(operation: &proto::Operation) -> Option { + replica_id = op.replica_id; + value = op.lamport_timestamp; + } } Some(clock::Lamport { - replica_id: replica_id as ReplicaId, + replica_id: ReplicaId::new(replica_id as u16), value, }) } @@ -536,7 +559,7 @@ pub fn deserialize_transaction(transaction: proto::Transaction) -> Result proto::LamportTimestamp { proto::LamportTimestamp { - replica_id: timestamp.replica_id as u32, + replica_id: timestamp.replica_id.as_u16() as u32, value: timestamp.value, } } @@ -544,7 +567,7 @@ pub fn serialize_timestamp(timestamp: clock::Lamport) -> proto::LamportTimestamp /// Deserializes a [`clock::Lamport`] timestamp from the RPC representation. pub fn deserialize_timestamp(timestamp: proto::LamportTimestamp) -> clock::Lamport { clock::Lamport { - replica_id: timestamp.replica_id as ReplicaId, + replica_id: ReplicaId::new(timestamp.replica_id as u16), value: timestamp.value, } } @@ -567,7 +590,7 @@ pub fn deserialize_version(message: &[proto::VectorClockEntry]) -> clock::Global let mut version = clock::Global::new(); for entry in message { version.observe(clock::Lamport { - replica_id: entry.replica_id as ReplicaId, + replica_id: ReplicaId::new(entry.replica_id as u16), value: entry.timestamp, }); } @@ -579,7 +602,7 @@ pub fn serialize_version(version: &clock::Global) -> Vec>(); self.reparse_with_ranges(text, root_language.clone(), edit_ranges, registry.as_ref()); - if let Some(registry) = registry { - if registry.version() != self.language_registry_version { - let mut resolved_injection_ranges = Vec::new(); - let mut cursor = self - .layers - .filter::<_, ()>(text, |summary| summary.contains_unknown_injections); - cursor.next(); - while let Some(layer) = cursor.item() { - let SyntaxLayerContent::Pending { language_name } = &layer.content else { - unreachable!() - }; - if registry - .language_for_name_or_extension(language_name) - .now_or_never() - .and_then(|language| language.ok()) - .is_some() - { - let range = layer.range.to_offset(text); - log::trace!("reparse range {range:?} for language {language_name:?}"); - resolved_injection_ranges.push(range); - } - - cursor.next(); - } - drop(cursor); - - if !resolved_injection_ranges.is_empty() { - self.reparse_with_ranges( - text, - root_language, - resolved_injection_ranges, - Some(®istry), - ); + if let Some(registry) = registry + && registry.version() != self.language_registry_version + { + let mut resolved_injection_ranges = Vec::new(); + let mut cursor = self + .layers + .filter::<_, ()>(text, |summary| summary.contains_unknown_injections); + cursor.next(); + while let Some(layer) = cursor.item() { + let SyntaxLayerContent::Pending { language_name } = &layer.content else { + unreachable!() + }; + if registry + .language_for_name_or_extension(language_name) + .now_or_never() + .and_then(|language| language.ok()) + .is_some() + { + let range = layer.range.to_offset(text); + log::trace!("reparse range {range:?} for language {language_name:?}"); + resolved_injection_ranges.push(range); } - self.language_registry_version = registry.version(); + + cursor.next(); } + drop(cursor); + + if !resolved_injection_ranges.is_empty() { + self.reparse_with_ranges( + text, + root_language, + resolved_injection_ranges, + Some(®istry), + ); + } + self.language_registry_version = registry.version(); } self.update_count += 1; @@ -832,7 +832,7 @@ impl SyntaxSnapshot { query: fn(&Grammar) -> Option<&Query>, ) -> SyntaxMapCaptures<'a> { SyntaxMapCaptures::new( - range.clone(), + range, text, [SyntaxLayer { language, @@ -1065,10 +1065,10 @@ impl<'a> SyntaxMapCaptures<'a> { pub fn set_byte_range(&mut self, range: Range) { for layer in &mut self.layers { layer.captures.set_byte_range(range.clone()); - if let Some(capture) = &layer.next_capture { - if capture.node.end_byte() > range.start { - continue; - } + if let Some(capture) = &layer.next_capture + && capture.node.end_byte() > range.start + { + continue; } layer.advance(); } @@ -1277,11 +1277,11 @@ fn join_ranges( (None, None) => break, }; - if let Some(last) = result.last_mut() { - if range.start <= last.end { - last.end = last.end.max(range.end); - continue; - } + if let Some(last) = result.last_mut() + && range.start <= last.end + { + last.end = last.end.max(range.end); + continue; } result.push(range); } @@ -1297,7 +1297,7 @@ fn parse_text( ) -> anyhow::Result { with_parser(|parser| { let mut chunks = text.chunks_in_range(start_byte..text.len()); - parser.set_included_ranges(&ranges)?; + parser.set_included_ranges(ranges)?; parser.set_language(&grammar.ts_language)?; parser .parse_with_options( @@ -1330,14 +1330,13 @@ fn get_injections( // if there currently no matches for that injection. combined_injection_ranges.clear(); for pattern in &config.patterns { - if let (Some(language_name), true) = (pattern.language.as_ref(), pattern.combined) { - if let Some(language) = language_registry + if let (Some(language_name), true) = (pattern.language.as_ref(), pattern.combined) + && let Some(language) = language_registry .language_for_name_or_extension(language_name) .now_or_never() .and_then(|language| language.ok()) - { - combined_injection_ranges.insert(language.id, (language, Vec::new())); - } + { + combined_injection_ranges.insert(language.id, (language, Vec::new())); } } @@ -1357,10 +1356,11 @@ fn get_injections( content_ranges.first().unwrap().start_byte..content_ranges.last().unwrap().end_byte; // Avoid duplicate matches if two changed ranges intersect the same injection. - if let Some((prev_pattern_ix, prev_range)) = &prev_match { - if mat.pattern_index == *prev_pattern_ix && content_range == *prev_range { - continue; - } + if let Some((prev_pattern_ix, prev_range)) = &prev_match + && mat.pattern_index == *prev_pattern_ix + && content_range == *prev_range + { + continue; } prev_match = Some((mat.pattern_index, content_range.clone())); @@ -1630,10 +1630,8 @@ impl<'a> SyntaxLayer<'a> { if offset < range.start || offset > range.end { continue; } - } else { - if offset <= range.start || offset >= range.end { - continue; - } + } else if offset <= range.start || offset >= range.end { + continue; } if let Some((_, smallest_range)) = &smallest_match { @@ -1777,13 +1775,13 @@ impl Default for SyntaxLayerSummary { } impl sum_tree::Summary for SyntaxLayerSummary { - type Context = BufferSnapshot; + type Context<'a> = &'a BufferSnapshot; fn zero(_cx: &BufferSnapshot) -> Self { Default::default() } - fn add_summary(&mut self, other: &Self, buffer: &Self::Context) { + fn add_summary(&mut self, other: &Self, buffer: Self::Context<'_>) { if other.max_depth > self.max_depth { self.max_depth = other.max_depth; self.range = other.range.clone(); diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index d576c95cd58eb823a7f8bdfdc42be9ba6a743410..9c4eecad363de386cddc6e943e20e5762634d713 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -6,7 +6,7 @@ use crate::{ use gpui::App; use rand::rngs::StdRng; use std::{env, ops::Range, sync::Arc}; -use text::{Buffer, BufferId}; +use text::{Buffer, BufferId, ReplicaId}; use tree_sitter::Node; use unindent::Unindent as _; use util::test::marked_text_ranges; @@ -58,8 +58,7 @@ fn test_splice_included_ranges() { assert_eq!(change, 0..1); // does not create overlapping ranges - let (new_ranges, change) = - splice_included_ranges(ranges.clone(), &[0..18], &[ts_range(20..32)]); + let (new_ranges, change) = splice_included_ranges(ranges, &[0..18], &[ts_range(20..32)]); assert_eq!( new_ranges, &[ts_range(20..32), ts_range(50..60), ts_range(80..90)] @@ -89,7 +88,7 @@ fn test_syntax_map_layers_for_range(cx: &mut App) { registry.add(language.clone()); let mut buffer = Buffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), r#" fn a() { @@ -104,7 +103,7 @@ fn test_syntax_map_layers_for_range(cx: &mut App) { ); let mut syntax_map = SyntaxMap::new(&buffer); - syntax_map.set_language_registry(registry.clone()); + syntax_map.set_language_registry(registry); syntax_map.reparse(language.clone(), &buffer); assert_layers_for_range( @@ -165,7 +164,7 @@ fn test_syntax_map_layers_for_range(cx: &mut App) { // Put the vec! macro back, adding back the syntactic layer. buffer.undo(); syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); + syntax_map.reparse(language, &buffer); assert_layers_for_range( &syntax_map, @@ -190,7 +189,7 @@ fn test_dynamic_language_injection(cx: &mut App) { registry.add(Arc::new(ruby_lang())); let mut buffer = Buffer::new( - 0, + ReplicaId::LOCAL, BufferId::new(1).unwrap(), r#" This is a code block: @@ -252,8 +251,8 @@ fn test_dynamic_language_injection(cx: &mut App) { assert!(syntax_map.contains_unknown_injections()); registry.add(Arc::new(html_lang())); - syntax_map.reparse(markdown.clone(), &buffer); - syntax_map.reparse(markdown_inline.clone(), &buffer); + syntax_map.reparse(markdown, &buffer); + syntax_map.reparse(markdown_inline, &buffer); assert_layers_for_range( &syntax_map, &buffer, @@ -812,7 +811,7 @@ fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { .unindent(); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), text); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text); let mut syntax_map = SyntaxMap::new(&buffer); syntax_map.set_language_registry(registry.clone()); @@ -862,7 +861,7 @@ fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { log::info!("editing"); buffer.edit_via_marked_text(&text); syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); + syntax_map.reparse(language, &buffer); assert_capture_ranges( &syntax_map, @@ -979,14 +978,14 @@ fn test_random_edits( .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), text); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), text); let mut syntax_map = SyntaxMap::new(&buffer); syntax_map.set_language_registry(registry.clone()); syntax_map.reparse(language.clone(), &buffer); let mut reference_syntax_map = SyntaxMap::new(&buffer); - reference_syntax_map.set_language_registry(registry.clone()); + reference_syntax_map.set_language_registry(registry); log::info!("initial text:\n{}", buffer.text()); @@ -1160,7 +1159,7 @@ fn test_edit_sequence(language_name: &str, steps: &[&str], cx: &mut App) -> (Buf .now_or_never() .unwrap() .unwrap(); - let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), ""); + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), ""); let mut mutated_syntax_map = SyntaxMap::new(&buffer); mutated_syntax_map.set_language_registry(registry.clone()); @@ -1410,12 +1409,15 @@ fn assert_capture_ranges( ) { let mut actual_ranges = Vec::>::new(); let captures = syntax_map.captures(0..buffer.len(), buffer, |grammar| { - grammar.highlights_query.as_ref() + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) }); let queries = captures .grammars() .iter() - .map(|grammar| grammar.highlights_query.as_ref().unwrap()) + .map(|grammar| &grammar.highlights_config.as_ref().unwrap().query) .collect::>(); for capture in captures { let name = &queries[capture.grammar_index].capture_names()[capture.index as usize]; diff --git a/crates/language/src/task_context.rs b/crates/language/src/task_context.rs index 5bede45cdb7aad30ebd4fb52f1b5e26779292180..b8cc6d13fff14576ca938e36d8982973f6307912 100644 --- a/crates/language/src/task_context.rs +++ b/crates/language/src/task_context.rs @@ -37,12 +37,7 @@ pub trait ContextProvider: Send + Sync { } /// Provides all tasks, associated with the current language. - fn associated_tasks( - &self, - _: Arc, - _: Option>, - _: &App, - ) -> Task> { + fn associated_tasks(&self, _: Option>, _: &App) -> Task> { Task::ready(None) } diff --git a/crates/language/src/text_diff.rs b/crates/language/src/text_diff.rs index f9221f571afb1baa0ba0b824922e799fcec01c88..5a74362d7d3cb2404cc67ed32595a06efd291ca4 100644 --- a/crates/language/src/text_diff.rs +++ b/crates/language/src/text_diff.rs @@ -1,4 +1,4 @@ -use crate::{CharClassifier, CharKind, LanguageScope}; +use crate::{CharClassifier, CharKind, CharScopeContext, LanguageScope}; use anyhow::{Context, anyhow}; use imara_diff::{ Algorithm, UnifiedDiffBuilder, diff, @@ -88,11 +88,11 @@ pub fn text_diff_with_options( let new_offset = new_byte_range.start; hunk_input.clear(); hunk_input.update_before(tokenize( - &old_text[old_byte_range.clone()], + &old_text[old_byte_range], options.language_scope.clone(), )); hunk_input.update_after(tokenize( - &new_text[new_byte_range.clone()], + &new_text[new_byte_range], options.language_scope.clone(), )); diff_internal(&hunk_input, |old_byte_range, new_byte_range, _, _| { @@ -103,7 +103,7 @@ pub fn text_diff_with_options( let replacement_text = if new_byte_range.is_empty() { empty.clone() } else { - new_text[new_byte_range.clone()].into() + new_text[new_byte_range].into() }; edits.push((old_byte_range, replacement_text)); }); @@ -111,9 +111,9 @@ pub fn text_diff_with_options( let replacement_text = if new_byte_range.is_empty() { empty.clone() } else { - new_text[new_byte_range.clone()].into() + new_text[new_byte_range].into() }; - edits.push((old_byte_range.clone(), replacement_text)); + edits.push((old_byte_range, replacement_text)); } }, ); @@ -154,19 +154,19 @@ fn diff_internal( input, |old_tokens: Range, new_tokens: Range| { old_offset += token_len( - &input, + input, &input.before[old_token_ix as usize..old_tokens.start as usize], ); new_offset += token_len( - &input, + input, &input.after[new_token_ix as usize..new_tokens.start as usize], ); let old_len = token_len( - &input, + input, &input.before[old_tokens.start as usize..old_tokens.end as usize], ); let new_len = token_len( - &input, + input, &input.after[new_tokens.start as usize..new_tokens.end as usize], ); let old_byte_range = old_offset..old_offset + old_len; @@ -181,19 +181,20 @@ fn diff_internal( } fn tokenize(text: &str, language_scope: Option) -> impl Iterator { - let classifier = CharClassifier::new(language_scope).for_completion(true); + let classifier = + CharClassifier::new(language_scope).scope_context(Some(CharScopeContext::Completion)); let mut chars = text.char_indices(); let mut prev = None; let mut start_ix = 0; iter::from_fn(move || { - while let Some((ix, c)) = chars.next() { + for (ix, c) in chars.by_ref() { let mut token = None; let kind = classifier.kind(c); - if let Some((prev_char, prev_kind)) = prev { - if kind != prev_kind || (kind == CharKind::Punctuation && c != prev_char) { - token = Some(&text[start_ix..ix]); - start_ix = ix; - } + if let Some((prev_char, prev_kind)) = prev + && (kind != prev_kind || (kind == CharKind::Punctuation && c != prev_char)) + { + token = Some(&text[start_ix..ix]); + start_ix = ix; } prev = Some((c, kind)); if token.is_some() { diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 1f4b038f68e5fcf1ed5c499d543fa92ba3c2de94..2896d4827c5e16047a471138122ef0256a24480e 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -4,53 +4,126 @@ //! which is a set of tools used to interact with the projects written in said language. //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{path::PathBuf, sync::Arc}; use async_trait::async_trait; use collections::HashMap; +use fs::Fs; use gpui::{AsyncApp, SharedString}; use settings::WorktreeId; +use task::ShellKind; +use util::rel_path::RelPath; use crate::{LanguageName, ManifestName}; /// Represents a single toolchain. -#[derive(Clone, Debug)] +#[derive(Clone, Eq, Debug)] pub struct Toolchain { /// User-facing label pub name: SharedString, + /// Absolute path pub path: SharedString, pub language_name: LanguageName, /// Full toolchain data (including language-specific details) pub as_json: serde_json::Value, } +/// Declares a scope of a toolchain added by user. +/// +/// When the user adds a toolchain, we give them an option to see that toolchain in: +/// - All of their projects +/// - A project they're currently in. +/// - Only in the subproject they're currently in. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ToolchainScope { + Subproject(WorktreeId, Arc), + Project, + /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. + Global, +} + +impl ToolchainScope { + pub fn label(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => "Subproject", + ToolchainScope::Project => "Project", + ToolchainScope::Global => "Global", + } + } + + pub fn description(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => { + "Available only in the subproject you're currently in." + } + ToolchainScope::Project => "Available in all locations in your current project.", + ToolchainScope::Global => "Available in all of your projects on this machine.", + } + } +} + +impl std::hash::Hash for Toolchain { + fn hash(&self, state: &mut H) { + let Self { + name, + path, + language_name, + as_json: _, + } = self; + name.hash(state); + path.hash(state); + language_name.hash(state); + } +} + impl PartialEq for Toolchain { fn eq(&self, other: &Self) -> bool { + let Self { + name, + path, + language_name, + as_json: _, + } = self; // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. // Thus, there could be multiple entries that look the same in the UI. - (&self.name, &self.path, &self.language_name).eq(&( - &other.name, - &other.path, - &other.language_name, - )) + (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) } } #[async_trait] -pub trait ToolchainLister: Send + Sync { +pub trait ToolchainLister: Send + Sync + 'static { + /// List all available toolchains for a given path. async fn list( &self, worktree_root: PathBuf, - subroot_relative_path: Option>, + subroot_relative_path: Arc, project_env: Option>, + fs: &dyn Fs, ) -> ToolchainList; - // Returns a term which we should use in UI to refer to a toolchain. - fn term(&self) -> SharedString; - /// Returns the name of the manifest file for this toolchain. - fn manifest_name(&self) -> ManifestName; + + /// Given a user-created toolchain, resolve lister-specific details. + /// Put another way: fill in the details of the toolchain so the user does not have to. + async fn resolve( + &self, + path: PathBuf, + project_env: Option>, + fs: &dyn Fs, + ) -> anyhow::Result; + + fn activation_script(&self, toolchain: &Toolchain, shell: ShellKind) -> Vec; + + /// Returns various "static" bits of information about this toolchain lister. This function should be pure. + fn meta(&self) -> ToolchainMetadata; +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ToolchainMetadata { + /// Returns a term which we should use in UI to refer to toolchains produced by a given `[ToolchainLister]`. + pub term: SharedString, + /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain. + pub new_toolchain_placeholder: SharedString, + /// The name of the manifest file for this toolchain. + pub manifest_name: ManifestName, } #[async_trait(?Send)] @@ -58,14 +131,37 @@ pub trait LanguageToolchainStore: Send + Sync + 'static { async fn active_toolchain( self: Arc, worktree_id: WorktreeId, - relative_path: Arc, + relative_path: Arc, language_name: LanguageName, cx: &mut AsyncApp, ) -> Option; } +pub trait LocalLanguageToolchainStore: Send + Sync + 'static { + fn active_toolchain( + self: Arc, + worktree_id: WorktreeId, + relative_path: &Arc, + language_name: LanguageName, + cx: &mut AsyncApp, + ) -> Option; +} + +#[async_trait(?Send)] +impl LanguageToolchainStore for T { + async fn active_toolchain( + self: Arc, + worktree_id: WorktreeId, + relative_path: Arc, + language_name: LanguageName, + cx: &mut AsyncApp, + ) -> Option { + self.active_toolchain(worktree_id, &relative_path, language_name, cx) + } +} + type DefaultIndex = usize; -#[derive(Default, Clone)] +#[derive(Default, Clone, Debug)] pub struct ToolchainList { pub toolchains: Vec, pub default: Option, diff --git a/crates/language_extension/Cargo.toml b/crates/language_extension/Cargo.toml index cc73b1f92396f7264bdde6650c257682f1adb6c9..de5af2246c9dfb2dd385875894a694da5e2a9c23 100644 --- a/crates/language_extension/Cargo.toml +++ b/crates/language_extension/Cargo.toml @@ -20,9 +20,9 @@ futures.workspace = true fs.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true lsp.workspace = true project.workspace = true serde.workspace = true serde_json.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 98b6fd4b5a2ef6e7f1b5adbc54dcecd0707b60ff..01b726748649e29b4fe69ce26df5564819894985 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -1,4 +1,3 @@ -use std::any::Any; use std::ops::Range; use std::path::PathBuf; use std::pin::Pin; @@ -8,12 +7,11 @@ use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::{HashMap, HashSet}; use extension::{Extension, ExtensionLanguageServerProxy, WorktreeDelegate}; -use fs::Fs; use futures::{Future, FutureExt, future::join_all}; use gpui::{App, AppContext, AsyncApp, Task}; use language::{ - BinaryStatus, CodeLabel, HighlightId, Language, LanguageName, LanguageToolchainStore, - LspAdapter, LspAdapterDelegate, + BinaryStatus, CodeLabel, DynLspInstaller, HighlightId, Language, LanguageName, LspAdapter, + LspAdapterDelegate, Toolchain, }; use lsp::{ CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName, @@ -21,7 +19,7 @@ use lsp::{ }; use serde::Serialize; use serde_json::Value; -use util::{ResultExt, fs::make_file_executable, maybe}; +use util::{ResultExt, fs::make_file_executable, maybe, rel_path::RelPath}; use crate::{LanguageServerRegistryProxy, LspAccess}; @@ -35,10 +33,10 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { } fn root_path(&self) -> String { - self.0.worktree_root_path().to_string_lossy().to_string() + self.0.worktree_root_path().to_string_lossy().into_owned() } - async fn read_text_file(&self, path: PathBuf) -> Result { + async fn read_text_file(&self, path: &RelPath) -> Result { self.0.read_text_file(path).await } @@ -46,7 +44,7 @@ impl WorktreeDelegate for WorktreeDelegateAdapter { self.0 .which(binary_name.as_ref()) .await - .map(|path| path.to_string_lossy().to_string()) + .map(|path| path.to_string_lossy().into_owned()) } async fn shell_env(&self) -> Vec<(String, String)> { @@ -125,6 +123,11 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { language_server_id: LanguageServerName, status: BinaryStatus, ) { + log::debug!( + "updating binary status for {} to {:?}", + language_server_id, + status + ); self.language_registry .update_lsp_binary_status(language_server_id, status); } @@ -151,17 +154,13 @@ impl ExtensionLspAdapter { } #[async_trait(?Send)] -impl LspAdapter for ExtensionLspAdapter { - fn name(&self) -> LanguageServerName { - self.language_server_id.clone() - } - +impl DynLspInstaller for ExtensionLspAdapter { fn get_language_server_command<'a>( self: Arc, delegate: Arc, - _: Arc, + _: Option, _: LanguageServerBinaryOptions, - _: futures::lock::MutexGuard<'a, Option>, + _: &'a mut Option<(bool, LanguageServerBinary)>, _: &'a mut AsyncApp, ) -> Pin>>> { async move { @@ -201,28 +200,21 @@ impl LspAdapter for ExtensionLspAdapter { .boxed_local() } - async fn fetch_latest_server_version( + async fn try_fetch_server_binary( &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - unreachable!("get_language_server_command is overridden") - } - - async fn fetch_server_binary( - &self, - _: Box, + _: &Arc, _: PathBuf, - _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, ) -> Result { unreachable!("get_language_server_command is overridden") } +} - async fn cached_server_binary( - &self, - _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - unreachable!("get_language_server_command is overridden") +#[async_trait(?Send)] +impl LspAdapter for ExtensionLspAdapter { + fn name(&self) -> LanguageServerName { + self.language_server_id.clone() } fn code_action_kinds(&self) -> Option> { @@ -263,7 +255,6 @@ impl LspAdapter for ExtensionLspAdapter { async fn initialization_options( self: Arc, - _: &dyn Fs, delegate: &Arc, ) -> Result> { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; @@ -286,9 +277,8 @@ impl LspAdapter for ExtensionLspAdapter { async fn workspace_configuration( self: Arc, - _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, _cx: &mut AsyncApp, ) -> Result { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; @@ -308,7 +298,6 @@ impl LspAdapter for ExtensionLspAdapter { async fn additional_initialization_options( self: Arc, target_language_server_id: LanguageServerName, - _: &dyn Fs, delegate: &Arc, ) -> Result> { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; @@ -334,9 +323,9 @@ impl LspAdapter for ExtensionLspAdapter { async fn additional_workspace_configuration( self: Arc, target_language_server_id: LanguageServerName, - _: &dyn Fs, + delegate: &Arc, - _: Arc, + _cx: &mut AsyncApp, ) -> Result> { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; @@ -397,6 +386,10 @@ impl LspAdapter for ExtensionLspAdapter { Ok(labels_from_extension(labels, language)) } + + fn is_extension(&self) -> bool { + true + } } fn labels_from_extension( @@ -470,11 +463,7 @@ fn build_code_label( let filter_range = label.filter_range.clone(); text.get(filter_range.clone())?; - Some(CodeLabel { - text, - runs, - filter_range, - }) + Some(CodeLabel::new(text, filter_range, runs)) } fn lsp_completion_to_extension(value: lsp::CompletionItem) -> extension::Completion { @@ -622,11 +611,7 @@ fn test_build_code_label() { assert_eq!( label, - CodeLabel { - text: label_text, - runs: label_runs, - filter_range: label.filter_range.clone() - } + CodeLabel::new(label_text, label.filter_range.clone(), label_runs) ) } diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index 1915eae2d18fe5fb96dbb0dcca614f8a4f41bb81..510f870ce8afbda090817e0ce515d4c5c2e3c63b 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -52,7 +52,7 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy { load: Arc Result + Send + Sync + 'static>, ) { self.language_registry - .register_language(language, grammar, matcher, hidden, load); + .register_language(language, grammar, matcher, hidden, None, load); } fn remove_languages( @@ -61,6 +61,6 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy { grammars_to_remove: &[Arc], ) { self.language_registry - .remove_languages(&languages_to_remove, &grammars_to_remove); + .remove_languages(languages_to_remove, grammars_to_remove); } } diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index f9920623b5ea3bff79535f92753fae0b723f850f..f572561f6a78b3cf2d9bfc2f7272895836f11614 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] [dependencies] anthropic = { workspace = true, features = ["schemars"] } +open_router.workspace = true anyhow.workspace = true base64.workspace = true client.workspace = true @@ -31,14 +32,13 @@ image.workspace = true log.workspace = true parking_lot.workspace = true proto.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true +settings.workspace = true smol.workspace = true telemetry_events.workspace = true thiserror.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index a9c7d5c0343295ff02d9d693f2cdbe3d92f1e07d..b06a475f9385012e5b88466c80fbb14e0ed744ac 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -1,14 +1,19 @@ use crate::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, + AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, }; -use futures::{FutureExt, StreamExt, channel::mpsc, future::BoxFuture, stream::BoxStream}; +use anyhow::anyhow; +use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; use http_client::Result; use parking_lot::Mutex; -use std::sync::Arc; +use smol::stream::StreamExt; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering::SeqCst}, +}; #[derive(Clone)] pub struct FakeLanguageModelProvider { @@ -62,7 +67,12 @@ impl LanguageModelProvider for FakeLanguageModelProvider { Task::ready(Ok(())) } - fn configuration_view(&self, _window: &mut Window, _: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: ConfigurationViewTargetAgent, + _window: &mut Window, + _: &mut App, + ) -> AnyView { unimplemented!() } @@ -95,9 +105,12 @@ pub struct FakeLanguageModel { current_completion_txs: Mutex< Vec<( LanguageModelRequest, - mpsc::UnboundedSender, + mpsc::UnboundedSender< + Result, + >, )>, >, + forbid_requests: AtomicBool, } impl Default for FakeLanguageModel { @@ -106,11 +119,20 @@ impl Default for FakeLanguageModel { provider_id: LanguageModelProviderId::from("fake".to_string()), provider_name: LanguageModelProviderName::from("Fake".to_string()), current_completion_txs: Mutex::new(Vec::new()), + forbid_requests: AtomicBool::new(false), } } } impl FakeLanguageModel { + pub fn allow_requests(&self) { + self.forbid_requests.store(false, SeqCst); + } + + pub fn forbid_requests(&self) { + self.forbid_requests.store(true, SeqCst); + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -145,7 +167,21 @@ impl FakeLanguageModel { .find(|(req, _)| req == request) .map(|(_, tx)| tx) .unwrap(); - tx.unbounded_send(event.into()).unwrap(); + tx.unbounded_send(Ok(event.into())).unwrap(); + } + + pub fn send_completion_stream_error( + &self, + request: &LanguageModelRequest, + error: impl Into, + ) { + let current_completion_txs = self.current_completion_txs.lock(); + let tx = current_completion_txs + .iter() + .find(|(req, _)| req == request) + .map(|(_, tx)| tx) + .unwrap(); + tx.unbounded_send(Err(error.into())).unwrap(); } pub fn end_completion_stream(&self, request: &LanguageModelRequest) { @@ -165,6 +201,13 @@ impl FakeLanguageModel { self.send_completion_stream_event(self.pending_completions().last().unwrap(), event); } + pub fn send_last_completion_stream_error( + &self, + error: impl Into, + ) { + self.send_completion_stream_error(self.pending_completions().last().unwrap(), error); + } + pub fn end_last_completion_stream(&self) { self.end_completion_stream(self.pending_completions().last().unwrap()); } @@ -222,9 +265,18 @@ impl LanguageModel for FakeLanguageModel { LanguageModelCompletionError, >, > { - let (tx, rx) = mpsc::unbounded(); - self.current_completion_txs.lock().push((request, tx)); - async move { Ok(rx.map(Ok).boxed()) }.boxed() + if self.forbid_requests.load(SeqCst) { + async move { + Err(LanguageModelCompletionError::Other(anyhow!( + "requests are forbidden" + ))) + } + .boxed() + } else { + let (tx, rx) = mpsc::unbounded(); + self.current_completion_txs.lock().push((request, tx)); + async move { Ok(rx.boxed()) }.boxed() + } } fn as_fake(&self) -> &Self { diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 1637d2de8a3c14b910ea345c03a4eb5db13df28d..24f9b84afcfa7b9a40b4a1b7684e9a9b036a5a85 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -14,12 +14,13 @@ use client::Client; use cloud_llm_client::{CompletionMode, CompletionRequestStatus}; use futures::FutureExt; use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyElement, AnyView, App, AsyncApp, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window}; use http_client::{StatusCode, http}; use icons::IconName; +use open_router::OpenRouterError; use parking_lot::Mutex; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize}; +pub use settings::LanguageModelCacheConfiguration; use std::ops::{Add, Sub}; use std::str::FromStr; use std::sync::Arc; @@ -48,27 +49,22 @@ pub const OPEN_AI_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId pub const OPEN_AI_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenAI"); +pub const X_AI_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); +pub const X_AI_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); + pub const ZED_CLOUD_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("zed.dev"); pub const ZED_CLOUD_PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Zed"); pub fn init(client: Arc, cx: &mut App) { init_settings(cx); - RefreshLlmTokenListener::register(client.clone(), cx); + RefreshLlmTokenListener::register(client, cx); } pub fn init_settings(cx: &mut App) { registry::init(cx); } -/// Configuration for caching language model messages. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct LanguageModelCacheConfiguration { - pub max_cache_anchors: usize, - pub should_speculate: bool, - pub min_total_token: u64, -} - /// A completion event from a language model. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub enum LanguageModelCompletionEvent { @@ -300,7 +296,7 @@ impl From for LanguageModelCompletionError { }, AnthropicError::ServerOverloaded { retry_after } => Self::ServerOverloaded { provider, - retry_after: retry_after, + retry_after, }, AnthropicError::ApiError(api_error) => api_error.into(), } @@ -347,6 +343,72 @@ impl From for LanguageModelCompletionError { } } +impl From for LanguageModelCompletionError { + fn from(error: OpenRouterError) -> Self { + let provider = LanguageModelProviderName::new("OpenRouter"); + match error { + OpenRouterError::SerializeRequest(error) => Self::SerializeRequest { provider, error }, + OpenRouterError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error }, + OpenRouterError::HttpSend(error) => Self::HttpSend { provider, error }, + OpenRouterError::DeserializeResponse(error) => { + Self::DeserializeResponse { provider, error } + } + OpenRouterError::ReadResponse(error) => Self::ApiReadResponseError { provider, error }, + OpenRouterError::RateLimit { retry_after } => Self::RateLimitExceeded { + provider, + retry_after: Some(retry_after), + }, + OpenRouterError::ServerOverloaded { retry_after } => Self::ServerOverloaded { + provider, + retry_after, + }, + OpenRouterError::ApiError(api_error) => api_error.into(), + } + } +} + +impl From for LanguageModelCompletionError { + fn from(error: open_router::ApiError) -> Self { + use open_router::ApiErrorCode::*; + let provider = LanguageModelProviderName::new("OpenRouter"); + match error.code { + InvalidRequestError => Self::BadRequestFormat { + provider, + message: error.message, + }, + AuthenticationError => Self::AuthenticationError { + provider, + message: error.message, + }, + PaymentRequiredError => Self::AuthenticationError { + provider, + message: format!("Payment required: {}", error.message), + }, + PermissionError => Self::PermissionError { + provider, + message: error.message, + }, + RequestTimedOut => Self::HttpResponseError { + provider, + status_code: StatusCode::REQUEST_TIMEOUT, + message: error.message, + }, + RateLimitError => Self::RateLimitExceeded { + provider, + retry_after: None, + }, + ApiError => Self::ApiInternalServerError { + provider, + message: error.message, + }, + OverloadedError => Self::ServerOverloaded { + provider, + retry_after: None, + }, + } + } +} + /// Indicates the format used to define the input schema for a language model tool. #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum LanguageModelToolSchemaFormat { @@ -538,7 +600,7 @@ pub trait LanguageModel: Send + Sync { if let Some(first_event) = events.next().await { match first_event { Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { - message_id = Some(id.clone()); + message_id = Some(id); } Ok(LanguageModelCompletionEvent::Text(text)) => { first_item_text = Some(text); @@ -606,14 +668,11 @@ pub trait LanguageModelExt: LanguageModel { } impl LanguageModelExt for dyn LanguageModel {} -pub trait LanguageModelTool: 'static + DeserializeOwned + JsonSchema { - fn name() -> String; - fn description() -> String; -} - /// An error that occurred when trying to authenticate the language model provider. #[derive(Debug, Error)] pub enum AuthenticateError { + #[error("connection refused")] + ConnectionRefused, #[error("credentials not found")] CredentialsNotFound, #[error(transparent)] @@ -634,20 +693,22 @@ pub trait LanguageModelProvider: 'static { } fn is_authenticated(&self, cx: &App) -> bool; fn authenticate(&self, cx: &mut App) -> Task>; - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView; - fn must_accept_terms(&self, _cx: &App) -> bool { - false - } - fn render_accept_terms( + fn configuration_view( &self, - _view: LanguageModelProviderTosView, - _cx: &mut App, - ) -> Option { - None - } + target_agent: ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView; fn reset_credentials(&self, cx: &mut App) -> Task>; } +#[derive(Default, Clone)] +pub enum ConfigurationViewTargetAgent { + #[default] + ZedAgent, + Other(SharedString), +} + #[derive(PartialEq, Eq)] pub enum LanguageModelProviderTosView { /// When there are some past interactions in the Agent Panel. diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index 0e10050dae92dcdbfcb3138e7cd3981d773c5aeb..e25ed0de50c4ddf03ff539dbce728dbc20def9b5 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Result; use client::Client; use cloud_api_types::websocket_protocol::MessageToClient; -use cloud_llm_client::Plan; +use cloud_llm_client::{Plan, PlanV1}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _}; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -29,13 +29,16 @@ pub struct ModelRequestLimitReachedError { impl fmt::Display for ModelRequestLimitReachedError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let message = match self.plan { - Plan::ZedFree => "Model request limit reached. Upgrade to Zed Pro for more requests.", - Plan::ZedPro => { + Plan::V1(PlanV1::ZedFree) => { + "Model request limit reached. Upgrade to Zed Pro for more requests." + } + Plan::V1(PlanV1::ZedPro) => { "Model request limit reached. Upgrade to usage-based billing for more requests." } - Plan::ZedProTrial => { + Plan::V1(PlanV1::ZedProTrial) => { "Model request limit reached. Upgrade to Zed Pro for more requests." } + Plan::V2(_) => "Model request limit reached.", }; write!(f, "{message}") @@ -82,7 +85,7 @@ impl LlmApiToken { let response = client.cloud_client().create_llm_token(system_id).await?; *lock = Some(response.token.0.clone()); - Ok(response.token.0.clone()) + Ok(response.token.0) } } diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 7cf071808a2c0d95bf9aa5a41eaa260cff533d57..6ed8bf07c4e976c88fecebd929843335333b1fa6 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -21,13 +21,10 @@ impl Global for GlobalLanguageModelRegistry {} pub enum ConfigurationError { #[error("Configure at least one LLM provider to start using the panel.")] NoProvider, - #[error("LLM Provider is not configured or does not support the configured model.")] + #[error("LLM provider is not configured or does not support the configured model.")] ModelNotFound, #[error("{} LLM provider is not configured.", .0.name().0)] ProviderNotAuthenticated(Arc), - #[error("Using the {} LLM provider requires accepting the Terms of Service.", - .0.name().0)] - ProviderPendingTermsAcceptance(Arc), } impl std::fmt::Debug for ConfigurationError { @@ -38,9 +35,6 @@ impl std::fmt::Debug for ConfigurationError { Self::ProviderNotAuthenticated(provider) => { write!(f, "ProviderNotAuthenticated({})", provider.id()) } - Self::ProviderPendingTermsAcceptance(provider) => { - write!(f, "ProviderPendingTermsAcceptance({})", provider.id()) - } } } } @@ -107,7 +101,7 @@ pub enum Event { InlineAssistantModelChanged, CommitMessageModelChanged, ThreadSummaryModelChanged, - ProviderStateChanged, + ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), } @@ -124,14 +118,14 @@ impl LanguageModelRegistry { } #[cfg(any(test, feature = "test-support"))] - pub fn test(cx: &mut App) -> crate::fake_provider::FakeLanguageModelProvider { - let fake_provider = crate::fake_provider::FakeLanguageModelProvider::default(); + pub fn test(cx: &mut App) -> Arc { + let fake_provider = Arc::new(crate::fake_provider::FakeLanguageModelProvider::default()); let registry = cx.new(|cx| { let mut registry = Self::default(); registry.register_provider(fake_provider.clone(), cx); let model = fake_provider.provided_models(cx)[0].clone(); let configured_model = ConfiguredModel { - provider: Arc::new(fake_provider.clone()), + provider: fake_provider.clone(), model, }; registry.set_default_model(Some(configured_model), cx); @@ -143,19 +137,22 @@ impl LanguageModelRegistry { pub fn register_provider( &mut self, - provider: T, + provider: Arc, cx: &mut Context, ) { let id = provider.id(); - let subscription = provider.subscribe(cx, |_, cx| { - cx.emit(Event::ProviderStateChanged); + let subscription = provider.subscribe(cx, { + let id = id.clone(); + move |_, cx| { + cx.emit(Event::ProviderStateChanged(id.clone())); + } }); if let Some(subscription) = subscription { subscription.detach(); } - self.providers.insert(id.clone(), Arc::new(provider)); + self.providers.insert(id.clone(), provider); cx.emit(Event::AddedProvider(id)); } @@ -197,12 +194,6 @@ impl LanguageModelRegistry { return Some(ConfigurationError::ProviderNotAuthenticated(model.provider)); } - if model.provider.must_accept_terms(cx) { - return Some(ConfigurationError::ProviderPendingTermsAcceptance( - model.provider, - )); - } - None } @@ -217,6 +208,7 @@ impl LanguageModelRegistry { ) -> impl Iterator> + 'a { self.providers .values() + .filter(|provider| provider.is_authenticated(cx)) .flat_map(|provider| provider.provided_models(cx)) } @@ -403,7 +395,7 @@ mod tests { fn test_register_providers(cx: &mut App) { let registry = cx.new(|_| LanguageModelRegistry::default()); - let provider = FakeLanguageModelProvider::default(); + let provider = Arc::new(FakeLanguageModelProvider::default()); registry.update(cx, |registry, cx| { registry.register_provider(provider.clone(), cx); }); diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index edce3d03b7063b383e51d88d4de7dc52ace0d04c..d0f7789e40dd71ada8dcae2712cefcef966ad52f 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -77,7 +77,7 @@ impl std::fmt::Debug for LanguageModelImage { } /// Anthropic wants uploaded images to be smaller than this in both dimensions. -const ANTHROPIC_SIZE_LIMT: f32 = 1568.; +const ANTHROPIC_SIZE_LIMIT: f32 = 1568.; impl LanguageModelImage { pub fn empty() -> Self { @@ -99,6 +99,10 @@ impl LanguageModelImage { .and_then(image::DynamicImage::from_decoder), ImageFormat::Gif => image::codecs::gif::GifDecoder::new(image_bytes) .and_then(image::DynamicImage::from_decoder), + ImageFormat::Bmp => image::codecs::bmp::BmpDecoder::new(image_bytes) + .and_then(image::DynamicImage::from_decoder), + ImageFormat::Tiff => image::codecs::tiff::TiffDecoder::new(image_bytes) + .and_then(image::DynamicImage::from_decoder), _ => return None, } .log_err()?; @@ -108,19 +112,19 @@ impl LanguageModelImage { let image_size = size(DevicePixels(width as i32), DevicePixels(height as i32)); let base64_image = { - if image_size.width.0 > ANTHROPIC_SIZE_LIMT as i32 - || image_size.height.0 > ANTHROPIC_SIZE_LIMT as i32 + if image_size.width.0 > ANTHROPIC_SIZE_LIMIT as i32 + || image_size.height.0 > ANTHROPIC_SIZE_LIMIT as i32 { let new_bounds = ObjectFit::ScaleDown.get_bounds( gpui::Bounds { origin: point(px(0.0), px(0.0)), - size: size(px(ANTHROPIC_SIZE_LIMT), px(ANTHROPIC_SIZE_LIMT)), + size: size(px(ANTHROPIC_SIZE_LIMIT), px(ANTHROPIC_SIZE_LIMIT)), }, image_size, ); let resized_image = dynamic_image.resize( - new_bounds.size.width.0 as u32, - new_bounds.size.height.0 as u32, + new_bounds.size.width.into(), + new_bounds.size.height.into(), image::imageops::FilterType::Triangle, ); @@ -220,42 +224,39 @@ impl<'de> Deserialize<'de> for LanguageModelToolResultContent { // Accept wrapped text format: { "type": "text", "text": "..." } if let (Some(type_value), Some(text_value)) = - (get_field(&obj, "type"), get_field(&obj, "text")) + (get_field(obj, "type"), get_field(obj, "text")) + && let Some(type_str) = type_value.as_str() + && type_str.to_lowercase() == "text" + && let Some(text) = text_value.as_str() { - if let Some(type_str) = type_value.as_str() { - if type_str.to_lowercase() == "text" { - if let Some(text) = text_value.as_str() { - return Ok(Self::Text(Arc::from(text))); - } - } - } + return Ok(Self::Text(Arc::from(text))); } // Check for wrapped Text variant: { "text": "..." } - if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "text") { - if obj.len() == 1 { - // Only one field, and it's "text" (case-insensitive) - if let Some(text) = value.as_str() { - return Ok(Self::Text(Arc::from(text))); - } + if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "text") + && obj.len() == 1 + { + // Only one field, and it's "text" (case-insensitive) + if let Some(text) = value.as_str() { + return Ok(Self::Text(Arc::from(text))); } } // Check for wrapped Image variant: { "image": { "source": "...", "size": ... } } - if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "image") { - if obj.len() == 1 { - // Only one field, and it's "image" (case-insensitive) - // Try to parse the nested image object - if let Some(image_obj) = value.as_object() { - if let Some(image) = LanguageModelImage::from_json(image_obj) { - return Ok(Self::Image(image)); - } - } + if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "image") + && obj.len() == 1 + { + // Only one field, and it's "image" (case-insensitive) + // Try to parse the nested image object + if let Some(image_obj) = value.as_object() + && let Some(image) = LanguageModelImage::from_json(image_obj) + { + return Ok(Self::Image(image)); } } // Try as direct Image (object with "source" and "size" fields) - if let Some(image) = LanguageModelImage::from_json(&obj) { + if let Some(image) = LanguageModelImage::from_json(obj) { return Ok(Self::Image(image)); } } @@ -272,7 +273,7 @@ impl<'de> Deserialize<'de> for LanguageModelToolResultContent { impl LanguageModelToolResultContent { pub fn to_str(&self) -> Option<&str> { match self { - Self::Text(text) => Some(&text), + Self::Text(text) => Some(text), Self::Image(_) => None, } } diff --git a/crates/language_model/src/role.rs b/crates/language_model/src/role.rs index 953dfa6fdff91c61a3a444076fd768f260b882c5..4b47ef36dd564e5950ce7d42a7e4f9263f3998b7 100644 --- a/crates/language_model/src/role.rs +++ b/crates/language_model/src/role.rs @@ -19,7 +19,7 @@ impl Role { } } - pub fn to_proto(&self) -> proto::LanguageModelRole { + pub fn to_proto(self) -> proto::LanguageModelRole { match self { Role::User => proto::LanguageModelRole::LanguageModelUser, Role::Assistant => proto::LanguageModelRole::LanguageModelAssistant, diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index b5bfb870f643452bd5be248c9910d99f16a8101e..7d4cd3a618d725429a3979951f04445b5a1fc8eb 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -28,7 +28,7 @@ convert_case.workspace = true copilot.workspace = true credentials_provider.workspace = true deepseek = { workspace = true, features = ["schemars"] } -editor.workspace = true +fs.workspace = true futures.workspace = true google_ai = { workspace = true, features = ["schemars"] } gpui.workspace = true @@ -51,7 +51,6 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true strum.workspace = true -theme.workspace = true thiserror.workspace = true tiktoken-rs.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } @@ -59,8 +58,8 @@ ui.workspace = true ui_input.workspace = true util.workspace = true vercel = { workspace = true, features = ["schemars"] } -workspace-hack.workspace = true x_ai = { workspace = true, features = ["schemars"] } +zed_env_vars.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/api_key.rs b/crates/language_models/src/api_key.rs new file mode 100644 index 0000000000000000000000000000000000000000..122234b6ced6d0bf1b7a0d684683c841824ccd2d --- /dev/null +++ b/crates/language_models/src/api_key.rs @@ -0,0 +1,295 @@ +use anyhow::{Result, anyhow}; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, future}; +use gpui::{AsyncApp, Context, SharedString, Task}; +use language_model::AuthenticateError; +use std::{ + fmt::{Display, Formatter}, + sync::Arc, +}; +use util::ResultExt as _; +use zed_env_vars::EnvVar; + +/// Manages a single API key for a language model provider. API keys either come from environment +/// variables or the system keychain. +/// +/// Keys from the system keychain are associated with a provider URL, and this ensures that they are +/// only used with that URL. +pub struct ApiKeyState { + url: SharedString, + load_status: LoadStatus, + load_task: Option>>, +} + +#[derive(Debug, Clone)] +pub enum LoadStatus { + NotPresent, + Error(String), + Loaded(ApiKey), +} + +#[derive(Debug, Clone)] +pub struct ApiKey { + source: ApiKeySource, + key: Arc, +} + +impl ApiKeyState { + pub fn new(url: SharedString) -> Self { + Self { + url, + load_status: LoadStatus::NotPresent, + load_task: None, + } + } + + pub fn has_key(&self) -> bool { + matches!(self.load_status, LoadStatus::Loaded { .. }) + } + + pub fn is_from_env_var(&self) -> bool { + match &self.load_status { + LoadStatus::Loaded(ApiKey { + source: ApiKeySource::EnvVar { .. }, + .. + }) => true, + _ => false, + } + } + + /// Get the stored API key, verifying that it is associated with the URL. Returns `None` if + /// there is no key or for URL mismatches, and the mismatch case is logged. + /// + /// To avoid URL mismatches, expects that `load_if_needed` or `handle_url_change` has been + /// called with this URL. + pub fn key(&self, url: &str) -> Option> { + let api_key = match &self.load_status { + LoadStatus::Loaded(api_key) => api_key, + _ => return None, + }; + if url == self.url.as_str() { + Some(api_key.key.clone()) + } else if let ApiKeySource::EnvVar(var_name) = &api_key.source { + log::warn!( + "{} is now being used with URL {}, when initially it was used with URL {}", + var_name, + url, + self.url + ); + Some(api_key.key.clone()) + } else { + // bug case because load_if_needed should be called whenever the url may have changed + log::error!( + "bug: Attempted to use API key associated with URL {} instead with URL {}", + self.url, + url + ); + None + } + } + + /// Set or delete the API key in the system keychain. + pub fn store( + &mut self, + url: SharedString, + key: Option, + get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + cx: &Context, + ) -> Task> { + if self.is_from_env_var() { + return Task::ready(Err(anyhow!( + "bug: attempted to store API key in system keychain when API key is from env var", + ))); + } + let credentials_provider = ::global(cx); + cx.spawn(async move |ent, cx| { + if let Some(key) = &key { + credentials_provider + .write_credentials(&url, "Bearer", key.as_bytes(), cx) + .await + .log_err(); + } else { + credentials_provider + .delete_credentials(&url, cx) + .await + .log_err(); + } + ent.update(cx, |ent, cx| { + let this = get_this(ent); + this.url = url; + this.load_status = match &key { + Some(key) => LoadStatus::Loaded(ApiKey { + source: ApiKeySource::SystemKeychain, + key: key.as_str().into(), + }), + None => LoadStatus::NotPresent, + }; + cx.notify(); + }) + }) + } + + /// Reloads the API key if the current API key is associated with a different URL. + /// + /// Note that it is not efficient to use this or `load_if_needed` with multiple URLs + /// interchangeably - URL change should correspond to some user initiated change. + pub fn handle_url_change( + &mut self, + url: SharedString, + env_var: &EnvVar, + get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + cx: &mut Context, + ) { + if url != self.url { + if !self.is_from_env_var() { + // loading will continue even though this result task is dropped + let _task = self.load_if_needed(url, env_var, get_this, cx); + } + } + } + + /// If needed, loads the API key associated with the given URL from the system keychain. When a + /// non-empty environment variable is provided, it will be used instead. If called when an API + /// key was already loaded for a different URL, that key will be cleared before loading. + /// + /// Dropping the returned Task does not cancel key loading. + pub fn load_if_needed( + &mut self, + url: SharedString, + env_var: &EnvVar, + get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, + cx: &mut Context, + ) -> Task> { + if let LoadStatus::Loaded { .. } = &self.load_status + && self.url == url + { + return Task::ready(Ok(())); + } + + if let Some(key) = &env_var.value + && !key.is_empty() + { + let api_key = ApiKey::from_env(env_var.name.clone(), key); + self.url = url; + self.load_status = LoadStatus::Loaded(api_key); + self.load_task = None; + cx.notify(); + return Task::ready(Ok(())); + } + + let task = if let Some(load_task) = &self.load_task { + load_task.clone() + } else { + let load_task = Self::load(url.clone(), get_this.clone(), cx).shared(); + self.url = url; + self.load_status = LoadStatus::NotPresent; + self.load_task = Some(load_task.clone()); + cx.notify(); + load_task + }; + + cx.spawn(async move |ent, cx| { + task.await; + ent.update(cx, |ent, _cx| { + get_this(ent).load_status.clone().into_authenticate_result() + }) + .ok(); + Ok(()) + }) + } + + fn load( + url: SharedString, + get_this: impl Fn(&mut Ent) -> &mut Self + 'static, + cx: &Context, + ) -> Task<()> { + let credentials_provider = ::global(cx); + cx.spawn({ + async move |ent, cx| { + let load_status = + ApiKey::load_from_system_keychain_impl(&url, credentials_provider.as_ref(), cx) + .await; + ent.update(cx, |ent, cx| { + let this = get_this(ent); + this.url = url; + this.load_status = load_status; + this.load_task = None; + cx.notify(); + }) + .ok(); + } + }) + } +} + +impl ApiKey { + pub fn key(&self) -> &str { + &self.key + } + + pub fn from_env(env_var_name: SharedString, key: &str) -> Self { + Self { + source: ApiKeySource::EnvVar(env_var_name), + key: key.into(), + } + } + + pub async fn load_from_system_keychain( + url: &str, + credentials_provider: &dyn CredentialsProvider, + cx: &AsyncApp, + ) -> Result { + Self::load_from_system_keychain_impl(url, credentials_provider, cx) + .await + .into_authenticate_result() + } + + async fn load_from_system_keychain_impl( + url: &str, + credentials_provider: &dyn CredentialsProvider, + cx: &AsyncApp, + ) -> LoadStatus { + if url.is_empty() { + return LoadStatus::NotPresent; + } + let read_result = credentials_provider.read_credentials(&url, cx).await; + let api_key = match read_result { + Ok(Some((_, api_key))) => api_key, + Ok(None) => return LoadStatus::NotPresent, + Err(err) => return LoadStatus::Error(err.to_string()), + }; + let key = match str::from_utf8(&api_key) { + Ok(key) => key, + Err(_) => return LoadStatus::Error(format!("API key for URL {url} is not utf8")), + }; + LoadStatus::Loaded(Self { + source: ApiKeySource::SystemKeychain, + key: key.into(), + }) + } +} + +impl LoadStatus { + fn into_authenticate_result(self) -> Result { + match self { + LoadStatus::Loaded(api_key) => Ok(api_key), + LoadStatus::NotPresent => Err(AuthenticateError::CredentialsNotFound), + LoadStatus::Error(err) => Err(AuthenticateError::Other(anyhow!(err))), + } + } +} + +#[derive(Debug, Clone)] +enum ApiKeySource { + EnvVar(SharedString), + SystemKeychain, +} + +impl Display for ApiKeySource { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ApiKeySource::EnvVar(var) => write!(f, "environment variable {}", var), + ApiKeySource::SystemKeychain => write!(f, "system keychain"), + } + } +} diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 18e6f47ed0591256591df578f98dcaf988ed6444..1b7243780ad30d737118046c8fc71fe9e4186fa6 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -7,6 +7,7 @@ use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; +mod api_key; pub mod provider; mod settings; pub mod ui; @@ -17,7 +18,7 @@ use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::lmstudio::LmStudioLanguageModelProvider; -use crate::provider::mistral::MistralLanguageModelProvider; +pub use crate::provider::mistral::MistralLanguageModelProvider; use crate::provider::ollama::OllamaLanguageModelProvider; use crate::provider::open_ai::OpenAiLanguageModelProvider; use crate::provider::open_ai_compatible::OpenAiCompatibleLanguageModelProvider; @@ -86,11 +87,11 @@ fn register_openai_compatible_providers( for provider_id in new { if !old.contains(provider_id) { registry.register_provider( - OpenAiCompatibleLanguageModelProvider::new( + Arc::new(OpenAiCompatibleLanguageModelProvider::new( provider_id.clone(), client.http_client(), cx, - ), + )), cx, ); } @@ -104,50 +105,62 @@ fn register_language_model_providers( cx: &mut Context, ) { registry.register_provider( - CloudLanguageModelProvider::new(user_store.clone(), client.clone(), cx), + Arc::new(CloudLanguageModelProvider::new( + user_store, + client.clone(), + cx, + )), + cx, + ); + registry.register_provider( + Arc::new(AnthropicLanguageModelProvider::new( + client.http_client(), + cx, + )), cx, ); - registry.register_provider( - AnthropicLanguageModelProvider::new(client.http_client(), cx), + Arc::new(OpenAiLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - OpenAiLanguageModelProvider::new(client.http_client(), cx), + Arc::new(OllamaLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - OllamaLanguageModelProvider::new(client.http_client(), cx), + Arc::new(LmStudioLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - LmStudioLanguageModelProvider::new(client.http_client(), cx), + Arc::new(DeepSeekLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - DeepSeekLanguageModelProvider::new(client.http_client(), cx), + Arc::new(GoogleLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - GoogleLanguageModelProvider::new(client.http_client(), cx), + MistralLanguageModelProvider::global(client.http_client(), cx), cx, ); registry.register_provider( - MistralLanguageModelProvider::new(client.http_client(), cx), + Arc::new(BedrockLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - BedrockLanguageModelProvider::new(client.http_client(), cx), + Arc::new(OpenRouterLanguageModelProvider::new( + client.http_client(), + cx, + )), cx, ); registry.register_provider( - OpenRouterLanguageModelProvider::new(client.http_client(), cx), + Arc::new(VercelLanguageModelProvider::new(client.http_client(), cx)), cx, ); registry.register_provider( - VercelLanguageModelProvider::new(client.http_client(), cx), + Arc::new(XAiLanguageModelProvider::new(client.http_client(), cx)), cx, ); - registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); - registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); + registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx); } diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index ef21e85f711e41722d4ac421ba1d0a89b422b6a6..9eb96cb79815bdbdc06c58ca4156e68e2962b0a4 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -1,37 +1,34 @@ -use crate::AllLanguageModelSettings; -use crate::ui::InstructionListItem; use anthropic::{ - AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent, ToolResultContent, - ToolResultPart, Usage, + ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent, + ToolResultContent, ToolResultPart, Usage, }; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; -use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; -use futures::Stream; -use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace, -}; +use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, - LanguageModelCompletionError, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, MessageContent, - RateLimiter, Role, + AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId, + LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, MessageContent, RateLimiter, Role, }; use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use theme::ThemeSettings; use ui::{Icon, IconName, List, Tooltip, prelude::*}; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::{EnvVar, env_var}; + +use crate::api_key::ApiKeyState; +use crate::ui::InstructionListItem; + +pub use settings::AnthropicAvailableModel as AvailableModel; const PROVIDER_ID: LanguageModelProviderId = language_model::ANTHROPIC_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::ANTHROPIC_PROVIDER_NAME; @@ -43,155 +40,57 @@ pub struct AnthropicSettings { pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - /// The model's name in the Anthropic API. e.g. claude-3-5-sonnet-latest, claude-3-opus-20240229, etc - pub name: String, - /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel. - pub display_name: Option, - /// The model's context window size. - pub max_tokens: u64, - /// A model `name` to substitute when calling tools, in case the primary model doesn't support tool calling. - pub tool_override: Option, - /// Configuration of Anthropic's caching API. - pub cache_configuration: Option, - pub max_output_tokens: Option, - pub default_temperature: Option, - #[serde(default)] - pub extra_beta_headers: Vec, - /// The model's mode (e.g. thinking) - pub mode: Option, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ModelMode { - #[default] - Default, - Thinking { - /// The maximum number of tokens to use for reasoning. Must be lower than the model's `max_output_tokens`. - budget_tokens: Option, - }, -} - -impl From for AnthropicModelMode { - fn from(value: ModelMode) -> Self { - match value { - ModelMode::Default => AnthropicModelMode::Default, - ModelMode::Thinking { budget_tokens } => AnthropicModelMode::Thinking { budget_tokens }, - } - } -} - -impl From for ModelMode { - fn from(value: AnthropicModelMode) -> Self { - match value { - AnthropicModelMode::Default => ModelMode::Default, - AnthropicModelMode::Thinking { budget_tokens } => ModelMode::Thinking { budget_tokens }, - } - } -} - pub struct AnthropicLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } -const ANTHROPIC_API_KEY_VAR: &str = "ANTHROPIC_API_KEY"; +const API_KEY_ENV_VAR_NAME: &str = "ANTHROPIC_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } impl State { - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .anthropic - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .ok(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) - } - - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .anthropic - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .ok(); - - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) - } - fn is_authenticated(&self) -> bool { - self.api_key.is_some() + self.api_key_state.has_key() } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .anthropic - .api_url - .clone(); - - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(ANTHROPIC_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) + } - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl AnthropicLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -206,12 +105,25 @@ impl AnthropicLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn settings(cx: &App) -> &AnthropicSettings { + &crate::AllLanguageModelSettings::get_global(cx).anthropic + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + ANTHROPIC_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for AnthropicLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -239,8 +151,8 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { fn recommended_models(&self, _cx: &App) -> Vec> { [ - anthropic::Model::ClaudeSonnet4, - anthropic::Model::ClaudeSonnet4Thinking, + anthropic::Model::ClaudeSonnet4_5, + anthropic::Model::ClaudeSonnet4_5Thinking, ] .into_iter() .map(|model| self.create_language_model(model)) @@ -258,11 +170,7 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { } // Override with available models from settings - for model in AllLanguageModelSettings::get_global(cx) - .anthropic - .available_models - .iter() - { + for model in &AnthropicLanguageModelProvider::settings(cx).available_models { models.insert( model.name.clone(), anthropic::Model::Custom { @@ -280,7 +188,7 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { max_output_tokens: model.max_output_tokens, default_temperature: model.default_temperature, extra_beta_headers: model.extra_beta_headers.clone(), - mode: model.mode.clone().unwrap_or_default().into(), + mode: model.mode.unwrap_or_default().into(), }, ); } @@ -299,20 +207,26 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) + fn configuration_view( + &self, + target_agent: ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { + cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct AnthropicModel { id: LanguageModelId, model: anthropic::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -395,21 +309,28 @@ impl AnthropicModel { > { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).anthropic; - (state.api_key.clone(), settings.api_url.clone()) + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped").into())).boxed(); + return future::ready(Err(anyhow!("App state dropped").into())).boxed(); }; + let beta_headers = self.model.beta_headers(); + async move { let Some(api_key) = api_key else { return Err(LanguageModelCompletionError::NoApiKey { provider: PROVIDER_NAME, }); }; - let request = - anthropic::stream_completion(http_client.as_ref(), &api_url, &api_key, request); + let request = anthropic::stream_completion( + http_client.as_ref(), + &api_url, + &api_key, + request, + beta_headers, + ); request.await.map_err(Into::into) } .boxed() @@ -454,7 +375,10 @@ impl LanguageModel for AnthropicModel { } fn api_key(&self, cx: &App) -> Option { - self.state.read(cx).api_key.clone() + self.state.read_with(cx, |state, cx| { + let api_url = AnthropicLanguageModelProvider::api_url(cx); + state.api_key_state.key(&api_url).map(|key| key.to_string()) + }) } fn max_token_count(&self) -> u64 { @@ -532,7 +456,7 @@ pub fn into_anthropic( .into_iter() .filter_map(|content| match content { MessageContent::Text(text) => { - let text = if text.chars().last().map_or(false, |c| c.is_whitespace()) { + let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) { text.trim_end().to_string() } else { text @@ -611,11 +535,11 @@ pub fn into_anthropic( Role::Assistant => anthropic::Role::Assistant, Role::System => unreachable!("System role should never occur here"), }; - if let Some(last_message) = new_messages.last_mut() { - if last_message.role == anthropic_role { - last_message.content.extend(anthropic_message_content); - continue; - } + if let Some(last_message) = new_messages.last_mut() + && last_message.role == anthropic_role + { + last_message.content.extend(anthropic_message_content); + continue; } // Mark the last segment of the message as cached @@ -791,7 +715,7 @@ impl AnthropicEventMapper { ))]; } } - return vec![]; + vec![] } }, Event::ContentBlockStop { index } => { @@ -899,15 +823,21 @@ fn convert_usage(usage: &Usage) -> language_model::TokenUsage { } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, + target_agent: ConfigurationViewTargetAgent, } impl ConfigurationView { const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new( + state: Entity, + target_agent: ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut Context, + ) -> Self { cx.observe(&state, |_, _, cx| { cx.notify(); }) @@ -932,13 +862,10 @@ impl ConfigurationView { })); Self { - api_key_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(Self::PLACEHOLDER_TEXT, cx); - editor - }), + api_key_editor: cx.new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_TEXT)), state, load_credentials_task, + target_agent, } } @@ -948,15 +875,17 @@ impl ConfigurationView { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { @@ -965,36 +894,11 @@ impl ConfigurationView { let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); - } - - fn render_api_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - white_space: WhiteSpace::Normal, - ..Default::default() - }; - EditorElement::new( - &self.api_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -1004,7 +908,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials...")).into_any() @@ -1012,7 +916,10 @@ impl Render for ConfigurationView { v_flex() .size_full() .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Anthropic, you need to add an API key. Follow these steps:")) + .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { + ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Anthropic".into(), + ConfigurationViewTargetAgent::Other(agent) => agent.clone(), + }))) .child( List::new() .child( @@ -1023,24 +930,13 @@ impl Render for ConfigurationView { ) ) .child( - InstructionListItem::text_only("Paste your API key below and hit enter to start using the assistant") + InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent") ) ) - .child( - h_flex() - .w_full() - .my_2() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .child(self.render_api_key_editor(cx)), - ) + .child(self.api_key_editor.clone()) .child( Label::new( - format!("You can also assign the {ANTHROPIC_API_KEY_VAR} environment variable and restart Zed."), + format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small) .color(Color::Muted), @@ -1060,9 +956,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {ANTHROPIC_API_KEY_VAR} environment variable.") + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + let api_url = AnthropicLanguageModelProvider::api_url(cx); + if api_url == ANTHROPIC_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -1073,7 +974,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .disabled(env_var_set) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {ANTHROPIC_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 6df96c5c566aac6f23af837491292cc89a56c74a..f3e265e925822b2de7950af9fbef5b121da3ed82 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -23,12 +23,8 @@ use bedrock::{ }; use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FontStyle, FontWeight, Subscription, Task, TextStyle, - WhiteSpace, -}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, FontWeight, Subscription, Task}; use gpui_tokio::Tokio; use http_client::HttpClient; use language_model::{ @@ -42,11 +38,11 @@ use language_model::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use settings::{Settings, SettingsStore}; +use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; use smol::lock::OnceCell; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; -use theme::ThemeSettings; use ui::{Icon, IconName, List, Tooltip, prelude::*}; +use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; @@ -83,15 +79,14 @@ pub enum BedrockAuthMethod { Automatic, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub cache_configuration: Option, - pub max_output_tokens: Option, - pub default_temperature: Option, - pub mode: Option, +impl From for BedrockAuthMethod { + fn from(value: settings::BedrockAuthMethodContent) -> Self { + match value { + settings::BedrockAuthMethodContent::SingleSignOn => BedrockAuthMethod::SingleSignOn, + settings::BedrockAuthMethodContent::Automatic => BedrockAuthMethod::Automatic, + settings::BedrockAuthMethodContent::NamedProfile => BedrockAuthMethod::NamedProfile, + } + } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -150,7 +145,7 @@ impl State { let credentials_provider = ::global(cx); cx.spawn(async move |this, cx| { credentials_provider - .delete_credentials(AMAZON_AWS_URL, &cx) + .delete_credentials(AMAZON_AWS_URL, cx) .await .log_err(); this.update(cx, |this, cx| { @@ -174,7 +169,7 @@ impl State { AMAZON_AWS_URL, "Bearer", &serde_json::to_vec(&credentials)?, - &cx, + cx, ) .await?; this.update(cx, |this, cx| { @@ -206,7 +201,7 @@ impl State { (credentials, true) } else { let (_, credentials) = credentials_provider - .read_credentials(AMAZON_AWS_URL, &cx) + .read_credentials(AMAZON_AWS_URL, cx) .await? .ok_or_else(|| AuthenticateError::CredentialsNotFound)?; ( @@ -244,7 +239,7 @@ impl State { pub struct BedrockLanguageModelProvider { http_client: AwsHttpClient, handle: tokio::runtime::Handle, - state: gpui::Entity, + state: Entity, } impl BedrockLanguageModelProvider { @@ -348,7 +343,12 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } @@ -362,7 +362,7 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { impl LanguageModelProviderState for BedrockLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -373,7 +373,7 @@ struct BedrockModel { http_client: AwsHttpClient, handle: tokio::runtime::Handle, client: OnceCell, - state: gpui::Entity, + state: Entity, request_limiter: RateLimiter, } @@ -407,10 +407,10 @@ impl BedrockModel { .region(Region::new(region)) .timeout_config(TimeoutConfig::disabled()); - if let Some(endpoint_url) = endpoint { - if !endpoint_url.is_empty() { - config_builder = config_builder.endpoint_url(endpoint_url); - } + if let Some(endpoint_url) = endpoint + && !endpoint_url.is_empty() + { + config_builder = config_builder.endpoint_url(endpoint_url); } match auth_method { @@ -460,7 +460,7 @@ impl BedrockModel { Result>>, > { let Ok(runtime_client) = self - .get_or_init_client(&cx) + .get_or_init_client(cx) .cloned() .context("Bedrock client not initialized") else { @@ -723,11 +723,11 @@ pub fn into_bedrock( Role::Assistant => bedrock::BedrockRole::Assistant, Role::System => unreachable!("System role should never occur here"), }; - if let Some(last_message) = new_messages.last_mut() { - if last_message.role == bedrock_role { - last_message.content.extend(bedrock_message_content); - continue; - } + if let Some(last_message) = new_messages.last_mut() + && last_message.role == bedrock_role + { + last_message.content.extend(bedrock_message_content); + continue; } new_messages.push( BedrockMessage::builder() @@ -912,7 +912,7 @@ pub fn map_to_language_model_completion_events( Some(ContentBlockDelta::ReasoningContent(thinking)) => match thinking { ReasoningContentBlockDelta::Text(thoughts) => { Some(Ok(LanguageModelCompletionEvent::Thinking { - text: thoughts.clone(), + text: thoughts, signature: None, })) } @@ -963,7 +963,7 @@ pub fn map_to_language_model_completion_events( id: tool_use.id.into(), name: tool_use.name.into(), is_input_complete: true, - raw_input: tool_use.input_json.clone(), + raw_input: tool_use.input_json, input, }, )) @@ -1006,11 +1006,11 @@ pub fn map_to_language_model_completion_events( } struct ConfigurationView { - access_key_id_editor: Entity, - secret_access_key_editor: Entity, - session_token_editor: Entity, - region_editor: Entity, - state: gpui::Entity, + access_key_id_editor: Entity, + secret_access_key_editor: Entity, + session_token_editor: Entity, + region_editor: Entity, + state: Entity, load_credentials_task: Option>, } @@ -1021,7 +1021,7 @@ impl ConfigurationView { const PLACEHOLDER_SESSION_TOKEN_TEXT: &'static str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; const PLACEHOLDER_REGION: &'static str = "us-east-1"; - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { cx.observe(&state, |_, _, cx| { cx.notify(); }) @@ -1047,25 +1047,19 @@ impl ConfigurationView { Self { access_key_id_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT, cx); - editor + InputField::new(window, cx, Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT) + .label("Access Key ID") }), secret_access_key_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT, cx); - editor + InputField::new(window, cx, Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT) + .label("Secret Access Key") }), session_token_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(Self::PLACEHOLDER_SESSION_TOKEN_TEXT, cx); - editor - }), - region_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(Self::PLACEHOLDER_REGION, cx); - editor + InputField::new(window, cx, Self::PLACEHOLDER_SESSION_TOKEN_TEXT) + .label("Session Token (Optional)") }), + region_editor: cx + .new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_REGION).label("Region")), state, load_credentials_task, } @@ -1081,21 +1075,18 @@ impl ConfigurationView { .access_key_id_editor .read(cx) .text(cx) - .to_string() .trim() .to_string(); let secret_access_key = self .secret_access_key_editor .read(cx) .text(cx) - .to_string() .trim() .to_string(); let session_token = self .session_token_editor .read(cx) .text(cx) - .to_string() .trim() .to_string(); let session_token = if session_token.is_empty() { @@ -1103,13 +1094,7 @@ impl ConfigurationView { } else { Some(session_token) }; - let region = self - .region_editor - .read(cx) - .text(cx) - .to_string() - .trim() - .to_string(); + let region = self.region_editor.read(cx).text(cx).trim().to_string(); let region = if region.is_empty() { "us-east-1".to_string() } else { @@ -1153,41 +1138,6 @@ impl ConfigurationView { .detach_and_log_err(cx); } - fn make_text_style(&self, cx: &Context) -> TextStyle { - let settings = ThemeSettings::get_global(cx); - TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - background_color: None, - underline: None, - strikethrough: None, - white_space: WhiteSpace::Normal, - text_overflow: None, - text_align: Default::default(), - line_clamp: None, - } - } - - fn make_input_styles(&self, cx: &Context) -> Div { - let bg_color = cx.theme().colors().editor_background; - let border_color = cx.theme().colors().border; - - h_flex() - .w_full() - .px_2() - .py_1() - .bg(bg_color) - .border_1() - .border_color(border_color) - .rounded_sm() - } - fn should_render_editor(&self, cx: &Context) -> bool { self.state.read(cx).is_authenticated() } @@ -1270,8 +1220,8 @@ impl Render for ConfigurationView { ) ) ) - .child(self.render_static_credentials_ui(cx)) - .child(self.render_common_fields(cx)) + .child(self.render_static_credentials_ui()) + .child(self.region_editor.clone()) .child( Label::new( format!("You can also assign the {ZED_BEDROCK_ACCESS_KEY_ID_VAR}, {ZED_BEDROCK_SECRET_ACCESS_KEY_VAR} AND {ZED_BEDROCK_REGION_VAR} environment variables and restart Zed."), @@ -1292,63 +1242,7 @@ impl Render for ConfigurationView { } impl ConfigurationView { - fn render_access_key_id_editor(&self, cx: &mut Context) -> impl IntoElement { - let text_style = self.make_text_style(cx); - - EditorElement::new( - &self.access_key_id_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } - - fn render_secret_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let text_style = self.make_text_style(cx); - - EditorElement::new( - &self.secret_access_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } - - fn render_session_token_editor(&self, cx: &mut Context) -> impl IntoElement { - let text_style = self.make_text_style(cx); - - EditorElement::new( - &self.session_token_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } - - fn render_region_editor(&self, cx: &mut Context) -> impl IntoElement { - let text_style = self.make_text_style(cx); - - EditorElement::new( - &self.region_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } - - fn render_static_credentials_ui(&self, cx: &mut Context) -> AnyElement { + fn render_static_credentials_ui(&self) -> AnyElement { v_flex() .my_2() .gap_1p5() @@ -1381,41 +1275,10 @@ impl ConfigurationView { "Enter these credentials below", )), ) - .child( - v_flex() - .gap_0p5() - .child(Label::new("Access Key ID").size(LabelSize::Small)) - .child( - self.make_input_styles(cx) - .child(self.render_access_key_id_editor(cx)), - ), - ) - .child( - v_flex() - .gap_0p5() - .child(Label::new("Secret Access Key").size(LabelSize::Small)) - .child(self.make_input_styles(cx).child(self.render_secret_key_editor(cx))), - ) - .child( - v_flex() - .gap_0p5() - .child(Label::new("Session Token (Optional)").size(LabelSize::Small)) - .child( - self.make_input_styles(cx) - .child(self.render_session_token_editor(cx)), - ), - ) - .into_any_element() - } - - fn render_common_fields(&self, cx: &mut Context) -> AnyElement { - v_flex() - .gap_0p5() - .child(Label::new("Region").size(LabelSize::Small)) - .child( - self.make_input_styles(cx) - .child(self.render_region_editor(cx)), - ) + .child(self.access_key_id_editor.clone()) + .child(self.secret_access_key_editor.clone()) + .child(self.session_token_editor.clone()) + .child(self.region_editor.clone()) .into_any_element() } } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index c1337399f993a5a9be247cec31c5b048cedbf731..d85533ecce63441fe5aaa7a382bf04af79992f63 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -4,9 +4,10 @@ use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use client::{Client, ModelRequestUsage, UserStore, zed_urls}; use cloud_llm_client::{ - CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody, - CompletionEvent, CompletionRequestStatus, CountTokensBody, CountTokensResponse, - EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE, Plan, + CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_X_AI_HEADER_NAME, + CURRENT_PLAN_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, + CountTokensBody, CountTokensResponse, EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, + MODEL_REQUESTS_RESOURCE_HEADER_VALUE, Plan, PlanV1, PlanV2, SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME, TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME, }; @@ -18,19 +19,21 @@ use gpui::{ AnyElement, AnyView, App, AsyncApp, Context, Entity, SemanticVersion, Subscription, Task, }; use http_client::http::{HeaderMap, HeaderValue}; -use http_client::{AsyncBody, HttpClient, Method, Response, StatusCode}; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Response, StatusCode}; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelProviderTosView, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, - ModelRequestLimitReachedError, PaymentRequiredError, RateLimiter, RefreshLlmTokenListener, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, LlmApiToken, ModelRequestLimitReachedError, + PaymentRequiredError, RateLimiter, RefreshLlmTokenListener, }; use release_channel::AppVersion; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use settings::SettingsStore; +pub use settings::ZedDotDevAvailableModel as AvailableModel; +pub use settings::ZedDotDevAvailableProvider as AvailableProvider; use smol::io::{AsyncReadExt, BufReader}; use std::pin::Pin; use std::str::FromStr as _; @@ -43,6 +46,7 @@ use util::{ResultExt as _, maybe}; use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic}; use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai}; +use crate::provider::x_ai::count_xai_tokens; const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; @@ -51,42 +55,6 @@ const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVI pub struct ZedDotDevSettings { pub available_models: Vec, } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum AvailableProvider { - Anthropic, - OpenAi, - Google, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - /// The provider of the language model. - pub provider: AvailableProvider, - /// The model's name in the provider's API. e.g. claude-3-5-sonnet-20240620 - pub name: String, - /// The name displayed in the UI, such as in the assistant panel model dropdown menu. - pub display_name: Option, - /// The size of the context window, indicating the maximum number of tokens the model can process. - pub max_tokens: usize, - /// The maximum number of output tokens allowed by the model. - pub max_output_tokens: Option, - /// The maximum number of completion tokens allowed by the model (o1-* only) - pub max_completion_tokens: Option, - /// Override this model with a different Anthropic model for tool calls. - pub tool_override: Option, - /// Indicates whether this custom model supports caching. - pub cache_configuration: Option, - /// The default temperature to use for this model. - pub default_temperature: Option, - /// Any extra beta headers to provide when using the model. - #[serde(default)] - pub extra_beta_headers: Vec, - /// The model's mode (e.g. thinking) - pub mode: Option, -} - #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ModelMode { @@ -109,7 +77,7 @@ impl From for AnthropicModelMode { pub struct CloudLanguageModelProvider { client: Arc, - state: gpui::Entity, + state: Entity, _maintain_client_status: Task<()>, } @@ -118,7 +86,6 @@ pub struct State { llm_api_token: LlmApiToken, user_store: Entity, status: client::Status, - accept_terms_of_service_task: Option>>, models: Vec>, default_model: Option>, default_fast_model: Option>, @@ -140,9 +107,8 @@ impl State { Self { client: client.clone(), llm_api_token: LlmApiToken::default(), - user_store: user_store.clone(), + user_store, status, - accept_terms_of_service_task: None, models: Vec::new(), default_model: None, default_fast_model: None, @@ -193,28 +159,10 @@ impl State { fn authenticate(&self, cx: &mut Context) -> Task> { let client = self.client.clone(); cx.spawn(async move |state, cx| { - client.sign_in_with_optional_connect(true, &cx).await?; + client.sign_in_with_optional_connect(true, cx).await?; state.update(cx, |_, cx| cx.notify()) }) } - - fn has_accepted_terms_of_service(&self, cx: &App) -> bool { - self.user_store.read(cx).has_accepted_terms_of_service() - } - - fn accept_terms_of_service(&mut self, cx: &mut Context) { - let user_store = self.user_store.clone(); - self.accept_terms_of_service_task = Some(cx.spawn(async move |this, cx| { - let _ = user_store - .update(cx, |store, cx| store.accept_terms_of_service(cx))? - .await; - this.update(cx, |this, cx| { - this.accept_terms_of_service_task = None; - cx.notify() - }) - })); - } - fn update_models(&mut self, response: ListModelsResponse, cx: &mut Context) { let mut models = Vec::new(); @@ -234,11 +182,21 @@ impl State { self.default_model = models .iter() - .find(|model| model.id == response.default_model) + .find(|model| { + response + .default_model + .as_ref() + .is_some_and(|default_model_id| &model.id == default_model_id) + }) .cloned(); self.default_fast_model = models .iter() - .find(|model| model.id == response.default_fast_model) + .find(|model| { + response + .default_fast_model + .as_ref() + .is_some_and(|default_fast_model_id| &model.id == default_fast_model_id) + }) .cloned(); self.recommended_models = response .recommended_models @@ -259,6 +217,7 @@ impl State { let request = http_client::Request::builder() .method(Method::GET) + .header(CLIENT_SUPPORTS_X_AI_HEADER_NAME, "true") .uri(http_client.build_zed_llm_url("/models", &[])?.as_ref()) .header("Authorization", format!("Bearer {token}")) .body(AsyncBody::empty())?; @@ -270,7 +229,7 @@ impl State { if response.status().is_success() { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - return Ok(serde_json::from_str(&body)?); + Ok(serde_json::from_str(&body)?) } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; @@ -307,7 +266,7 @@ impl CloudLanguageModelProvider { Self { client, - state: state.clone(), + state, _maintain_client_status: maintain_client_status, } } @@ -320,7 +279,7 @@ impl CloudLanguageModelProvider { Arc::new(CloudLanguageModel { id: LanguageModelId(SharedString::from(model.id.0.clone())), model, - llm_api_token: llm_api_token.clone(), + llm_api_token, client: self.client.clone(), request_limiter: RateLimiter::new(4), }) @@ -330,7 +289,7 @@ impl CloudLanguageModelProvider { impl LanguageModelProviderState for CloudLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -384,40 +343,21 @@ impl LanguageModelProvider for CloudLanguageModelProvider { fn is_authenticated(&self, cx: &App) -> bool { let state = self.state.read(cx); - !state.is_signed_out(cx) && state.has_accepted_terms_of_service(cx) + !state.is_signed_out(cx) } fn authenticate(&self, _cx: &mut App) -> Task> { Task::ready(Ok(())) } - fn configuration_view(&self, _: &mut Window, cx: &mut App) -> AnyView { - cx.new(|_| ConfigurationView::new(self.state.clone())) - .into() - } - - fn must_accept_terms(&self, cx: &App) -> bool { - !self.state.read(cx).has_accepted_terms_of_service(cx) - } - - fn render_accept_terms( + fn configuration_view( &self, - view: LanguageModelProviderTosView, + _target_agent: language_model::ConfigurationViewTargetAgent, + _: &mut Window, cx: &mut App, - ) -> Option { - let state = self.state.read(cx); - if state.has_accepted_terms_of_service(cx) { - return None; - } - Some( - render_accept_terms(view, state.accept_terms_of_service_task.is_some(), { - let state = self.state.clone(); - move |_window, cx| { - state.update(cx, |state, cx| state.accept_terms_of_service(cx)); - } - }) - .into_any_element(), - ) + ) -> AnyView { + cx.new(|_| ConfigurationView::new(self.state.clone())) + .into() } fn reset_credentials(&self, _cx: &mut App) -> Task> { @@ -425,83 +365,6 @@ impl LanguageModelProvider for CloudLanguageModelProvider { } } -fn render_accept_terms( - view_kind: LanguageModelProviderTosView, - accept_terms_of_service_in_progress: bool, - accept_terms_callback: impl Fn(&mut Window, &mut App) + 'static, -) -> impl IntoElement { - let thread_fresh_start = matches!(view_kind, LanguageModelProviderTosView::ThreadFreshStart); - let thread_empty_state = matches!(view_kind, LanguageModelProviderTosView::ThreadEmptyState); - - let terms_button = Button::new("terms_of_service", "Terms of Service") - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .when(thread_empty_state, |this| this.label_size(LabelSize::Small)) - .on_click(move |_, _window, cx| cx.open_url("https://zed.dev/terms-of-service")); - - let button_container = h_flex().child( - Button::new("accept_terms", "I accept the Terms of Service") - .when(!thread_empty_state, |this| { - this.full_width() - .style(ButtonStyle::Tinted(TintColor::Accent)) - .icon(IconName::Check) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - }) - .when(thread_empty_state, |this| { - this.style(ButtonStyle::Tinted(TintColor::Warning)) - .label_size(LabelSize::Small) - }) - .disabled(accept_terms_of_service_in_progress) - .on_click(move |_, window, cx| (accept_terms_callback)(window, cx)), - ); - - if thread_empty_state { - h_flex() - .w_full() - .flex_wrap() - .justify_between() - .child( - h_flex() - .child( - Label::new("To start using Zed AI, please read and accept the") - .size(LabelSize::Small), - ) - .child(terms_button), - ) - .child(button_container) - } else { - v_flex() - .w_full() - .gap_2() - .child( - h_flex() - .flex_wrap() - .when(thread_fresh_start, |this| this.justify_center()) - .child(Label::new( - "To start using Zed AI, please read and accept the", - )) - .child(terms_button), - ) - .child({ - match view_kind { - LanguageModelProviderTosView::TextThreadPopup => { - button_container.w_full().justify_end() - } - LanguageModelProviderTosView::Configuration => { - button_container.w_full().justify_start() - } - LanguageModelProviderTosView::ThreadFreshStart => { - button_container.w_full().justify_center() - } - LanguageModelProviderTosView::ThreadEmptyState => div().w_0(), - } - }) - } -} - pub struct CloudLanguageModel { id: LanguageModelId, model: Arc, @@ -530,20 +393,17 @@ impl CloudLanguageModel { let mut refreshed_token = false; loop { - let request_builder = http_client::Request::builder() + let request = http_client::Request::builder() .method(Method::POST) - .uri(http_client.build_zed_llm_url("/completions", &[])?.as_ref()); - let request_builder = if let Some(app_version) = app_version { - request_builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string()) - } else { - request_builder - }; - - let request = request_builder + .uri(http_client.build_zed_llm_url("/completions", &[])?.as_ref()) + .when_some(app_version, |builder, app_version| { + builder.header(ZED_VERSION_HEADER_NAME, app_version.to_string()) + }) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {token}")) .header(CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") .body(serde_json::to_string(&body)?.into())?; + let mut response = http_client.send(request).await?; let status = response.status(); if status.is_success() { @@ -592,15 +452,14 @@ impl CloudLanguageModel { .headers() .get(SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME) .and_then(|resource| resource.to_str().ok()) - { - if let Some(plan) = response + && let Some(plan) = response .headers() .get(CURRENT_PLAN_HEADER_NAME) .and_then(|plan| plan.to_str().ok()) - .and_then(|plan| cloud_llm_client::Plan::from_str(plan).ok()) - { - return Err(anyhow!(ModelRequestLimitReachedError { plan })); - } + .and_then(|plan| cloud_llm_client::PlanV1::from_str(plan).ok()) + .map(Plan::V1) + { + return Err(anyhow!(ModelRequestLimitReachedError { plan })); } } else if status == StatusCode::PAYMENT_REQUIRED { return Err(anyhow!(PaymentRequiredError)); @@ -680,6 +539,13 @@ impl From for LanguageModelCompletionError { retry_after: cloud_error.retry_after.map(Duration::from_secs_f64), }; } + + return LanguageModelCompletionError::from_http_status( + PROVIDER_NAME, + error.status, + cloud_error.message, + None, + ); } let retry_after = None; @@ -715,6 +581,7 @@ impl LanguageModel for CloudLanguageModel { Anthropic => language_model::ANTHROPIC_PROVIDER_ID, OpenAi => language_model::OPEN_AI_PROVIDER_ID, Google => language_model::GOOGLE_PROVIDER_ID, + XAi => language_model::X_AI_PROVIDER_ID, } } @@ -724,6 +591,7 @@ impl LanguageModel for CloudLanguageModel { Anthropic => language_model::ANTHROPIC_PROVIDER_NAME, OpenAi => language_model::OPEN_AI_PROVIDER_NAME, Google => language_model::GOOGLE_PROVIDER_NAME, + XAi => language_model::X_AI_PROVIDER_NAME, } } @@ -754,7 +622,8 @@ impl LanguageModel for CloudLanguageModel { fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { match self.model.provider { cloud_llm_client::LanguageModelProvider::Anthropic - | cloud_llm_client::LanguageModelProvider::OpenAi => { + | cloud_llm_client::LanguageModelProvider::OpenAi + | cloud_llm_client::LanguageModelProvider::XAi => { LanguageModelToolSchemaFormat::JsonSchema } cloud_llm_client::LanguageModelProvider::Google => { @@ -784,6 +653,7 @@ impl LanguageModel for CloudLanguageModel { }) } cloud_llm_client::LanguageModelProvider::OpenAi + | cloud_llm_client::LanguageModelProvider::XAi | cloud_llm_client::LanguageModelProvider::Google => None, } } @@ -804,6 +674,13 @@ impl LanguageModel for CloudLanguageModel { }; count_open_ai_tokens(request, model, cx) } + cloud_llm_client::LanguageModelProvider::XAi => { + let model = match x_ai::Model::from_id(&self.model.id.0) { + Ok(model) => model, + Err(err) => return async move { Err(anyhow!(err)) }.boxed(), + }; + count_xai_tokens(request, model, cx) + } cloud_llm_client::LanguageModelProvider::Google => { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); @@ -933,15 +810,11 @@ impl LanguageModel for CloudLanguageModel { } cloud_llm_client::LanguageModelProvider::OpenAi => { let client = self.client.clone(); - let model = match open_ai::Model::from_id(&self.model.id.0) { - Ok(model) => model, - Err(err) => return async move { Err(anyhow!(err).into()) }.boxed(), - }; let request = into_open_ai( request, - model.id(), - model.supports_parallel_tool_calls(), - model.supports_prompt_cache_key(), + &self.model.id.0, + self.model.supports_parallel_tool_calls, + true, None, None, ); @@ -981,6 +854,52 @@ impl LanguageModel for CloudLanguageModel { }); async move { Ok(future.await?.boxed()) }.boxed() } + cloud_llm_client::LanguageModelProvider::XAi => { + let client = self.client.clone(); + let request = into_open_ai( + request, + &self.model.id.0, + self.model.supports_parallel_tool_calls, + false, + None, + None, + ); + let llm_api_token = self.llm_api_token.clone(); + let future = self.request_limiter.stream(async move { + let PerformLlmCompletionResponse { + response, + usage, + includes_status_messages, + tool_use_limit_reached, + } = Self::perform_llm_completion( + client.clone(), + llm_api_token, + app_version, + CompletionBody { + thread_id, + prompt_id, + intent, + mode, + provider: cloud_llm_client::LanguageModelProvider::XAi, + model: request.model.clone(), + provider_request: serde_json::to_value(&request) + .map_err(|e| anyhow!(e))?, + }, + ) + .await?; + + let mut mapper = OpenAiEventMapper::new(); + Ok(map_cloud_completion_events( + Box::pin( + response_lines(response, includes_status_messages) + .chain(usage_updated_event(usage)) + .chain(tool_use_limit_reached_event(tool_use_limit_reached)), + ), + move |event| mapper.map_event(event), + )) + }); + async move { Ok(future.await?.boxed()) }.boxed() + } cloud_llm_client::LanguageModelProvider::Google => { let client = self.client.clone(); let request = @@ -1104,28 +1023,32 @@ struct ZedAiConfiguration { plan: Option, subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, - has_accepted_terms_of_service: bool, account_too_young: bool, - accept_terms_of_service_in_progress: bool, - accept_terms_of_service_callback: Arc, sign_in_callback: Arc, } impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let young_account_banner = YoungAccountBanner; - - let is_pro = self.plan == Some(Plan::ZedPro); + let is_pro = self.plan.is_some_and(|plan| { + matches!(plan, Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)) + }); let subscription_text = match (self.plan, self.subscription_period) { - (Some(Plan::ZedPro), Some(_)) => { + (Some(Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)), Some(_)) => { "You have access to Zed's hosted models through your Pro subscription." } - (Some(Plan::ZedProTrial), Some(_)) => { + (Some(Plan::V1(PlanV1::ZedProTrial) | Plan::V2(PlanV2::ZedProTrial)), Some(_)) => { "You have access to Zed's hosted models through your Pro trial." } - (Some(Plan::ZedFree), Some(_)) => { + (Some(Plan::V1(PlanV1::ZedFree)), Some(_)) => { "You have basic access to Zed's hosted models through the Free plan." } + (Some(Plan::V2(PlanV2::ZedFree)), Some(_)) => { + if self.eligible_for_trial { + "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." + } else { + "Subscribe for access to Zed's hosted models." + } + } _ => { if self.eligible_for_trial { "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." @@ -1173,58 +1096,30 @@ impl RenderOnce for ZedAiConfiguration { ); } - v_flex() - .gap_2() - .w_full() - .when(!self.has_accepted_terms_of_service, |this| { - this.child(render_accept_terms( - LanguageModelProviderTosView::Configuration, - self.accept_terms_of_service_in_progress, - { - let callback = self.accept_terms_of_service_callback.clone(); - move |window, cx| (callback)(window, cx) - }, - )) - }) - .map(|this| { - if self.has_accepted_terms_of_service && self.account_too_young { - this.child(young_account_banner).child( - Button::new("upgrade", "Upgrade to Pro") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) - .full_width() - .on_click(|_, _, cx| { - cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) - }), - ) - } else if self.has_accepted_terms_of_service { - this.text_sm() - .child(subscription_text) - .child(manage_subscription_buttons) - } else { - this - } - }) - .when(self.has_accepted_terms_of_service, |this| this) + v_flex().gap_2().w_full().map(|this| { + if self.account_too_young { + this.child(YoungAccountBanner).child( + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .full_width() + .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), + ) + } else { + this.text_sm() + .child(subscription_text) + .child(manage_subscription_buttons) + } + }) } } struct ConfigurationView { state: Entity, - accept_terms_of_service_callback: Arc, sign_in_callback: Arc, } impl ConfigurationView { fn new(state: Entity) -> Self { - let accept_terms_of_service_callback = Arc::new({ - let state = state.clone(); - move |_window: &mut Window, cx: &mut App| { - state.update(cx, |state, cx| { - state.accept_terms_of_service(cx); - }); - } - }); - let sign_in_callback = Arc::new({ let state = state.clone(); move |_window: &mut Window, cx: &mut App| { @@ -1236,7 +1131,6 @@ impl ConfigurationView { Self { state, - accept_terms_of_service_callback, sign_in_callback, } } @@ -1252,10 +1146,7 @@ impl Render for ConfigurationView { plan: user_store.plan(), subscription_period: user_store.subscription_period(), eligible_for_trial: user_store.trial_started_at().is_none(), - has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), account_too_young: user_store.account_too_young(), - accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(), - accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), sign_in_callback: self.sign_in_callback.clone(), } } @@ -1280,7 +1171,6 @@ impl Component for ZedAiConfiguration { plan: Option, eligible_for_trial: bool, account_too_young: bool, - has_accepted_terms_of_service: bool, ) -> AnyElement { ZedAiConfiguration { is_connected, @@ -1289,10 +1179,7 @@ impl Component for ZedAiConfiguration { .is_some() .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), eligible_for_trial, - has_accepted_terms_of_service, account_too_young, - accept_terms_of_service_in_progress: false, - accept_terms_of_service_callback: Arc::new(|_, _| {}), sign_in_callback: Arc::new(|_, _| {}), } .into_any_element() @@ -1303,33 +1190,30 @@ impl Component for ZedAiConfiguration { .p_4() .gap_4() .children(vec![ - single_example( - "Not connected", - configuration(false, None, false, false, true), - ), + single_example("Not connected", configuration(false, None, false, false)), single_example( "Accept Terms of Service", - configuration(true, None, true, false, false), + configuration(true, None, true, false), ), single_example( "No Plan - Not eligible for trial", - configuration(true, None, false, false, true), + configuration(true, None, false, false), ), single_example( "No Plan - Eligible for trial", - configuration(true, None, true, false, true), + configuration(true, None, true, false), ), single_example( "Free Plan", - configuration(true, Some(Plan::ZedFree), true, false, true), + configuration(true, Some(Plan::V1(PlanV1::ZedFree)), true, false), ), single_example( "Zed Pro Trial Plan", - configuration(true, Some(Plan::ZedProTrial), true, false, true), + configuration(true, Some(Plan::V1(PlanV1::ZedProTrial)), true, false), ), single_example( "Zed Pro Plan", - configuration(true, Some(Plan::ZedPro), true, false, true), + configuration(true, Some(Plan::V1(PlanV1::ZedPro)), true, false), ), ]) .into_any_element(), diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 73f73a9a313c764d45adfd14910efd801a472f1c..6c665a0c1f06aa44e2b86f96517f7998fc02f4d3 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -14,10 +14,8 @@ use copilot::{Copilot, Status}; use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; -use gpui::{ - Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, - Transformation, percentage, svg, -}; +use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg}; +use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -28,14 +26,9 @@ use language_model::{ StopReason, TokenUsage, }; use settings::SettingsStore; -use std::time::Duration; -use ui::prelude::*; +use ui::{CommonAnimationExt, prelude::*}; use util::debug_panic; -use super::anthropic::count_anthropic_tokens; -use super::google::count_google_tokens; -use super::open_ai::count_open_ai_tokens; - const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -97,7 +90,7 @@ impl CopilotChatLanguageModelProvider { impl LanguageModelProviderState for CopilotChatLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -176,7 +169,12 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { Task::ready(Err(err.into())) } - fn configuration_view(&self, _: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + _: &mut Window, + cx: &mut App, + ) -> AnyView { let state = self.state.clone(); cx.new(|cx| ConfigurationView::new(state, cx)).into() } @@ -188,6 +186,25 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { } } +fn collect_tiktoken_messages( + request: LanguageModelRequest, +) -> Vec { + request + .messages + .into_iter() + .map(|message| tiktoken_rs::ChatCompletionRequestMessage { + role: match message.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: Some(message.string_contents()), + name: None, + function_call: None, + }) + .collect::>() +} + pub struct CopilotChatLanguageModel { model: CopilotChatModel, request_limiter: RateLimiter, @@ -223,7 +240,9 @@ impl LanguageModel for CopilotChatLanguageModel { ModelVendor::OpenAI | ModelVendor::Anthropic => { LanguageModelToolSchemaFormat::JsonSchema } - ModelVendor::Google => LanguageModelToolSchemaFormat::JsonSchemaSubset, + ModelVendor::Google | ModelVendor::XAI | ModelVendor::Unknown => { + LanguageModelToolSchemaFormat::JsonSchemaSubset + } } } @@ -248,14 +267,20 @@ impl LanguageModel for CopilotChatLanguageModel { request: LanguageModelRequest, cx: &App, ) -> BoxFuture<'static, Result> { - match self.model.vendor() { - ModelVendor::Anthropic => count_anthropic_tokens(request, cx), - ModelVendor::Google => count_google_tokens(request, cx), - ModelVendor::OpenAI => { - let model = open_ai::Model::from_id(self.model.id()).unwrap_or_default(); - count_open_ai_tokens(request, model, cx) - } - } + let model = self.model.clone(); + cx.background_spawn(async move { + let messages = collect_tiktoken_messages(request); + // Copilot uses OpenAI tiktoken tokenizer for all it's model irrespective of the underlying provider(vendor). + let tokenizer_model = match model.tokenizer() { + Some("o200k_base") => "gpt-4o", + Some("cl100k_base") => "gpt-4", + _ => "gpt-4o", + }; + + tiktoken_rs::num_tokens_from_messages(tokenizer_model, &messages) + .map(|tokens| tokens as u64) + }) + .boxed() } fn stream_completion( @@ -282,6 +307,23 @@ impl LanguageModel for CopilotChatLanguageModel { | CompletionIntent::EditFile => false, }); + if self.model.supports_response() { + let responses_request = into_copilot_responses(&self.model, request); + let request_limiter = self.request_limiter.clone(); + let future = cx.spawn(async move |cx| { + let request = + CopilotChat::stream_response(responses_request, is_user_initiated, cx.clone()); + request_limiter + .stream(async move { + let stream = request.await?; + let mapper = CopilotResponsesEventMapper::new(); + Ok(mapper.map_stream(stream).boxed()) + }) + .await + }); + return async move { Ok(future.await?.boxed()) }.boxed(); + } + let copilot_request = match into_copilot_chat(&self.model, request) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -356,11 +398,9 @@ pub fn map_to_language_model_completion_events( events.push(Ok(LanguageModelCompletionEvent::Text(content))); } - for tool_call in &delta.tool_calls { - let entry = state - .tool_calls_by_index - .entry(tool_call.index) - .or_default(); + for (index, tool_call) in delta.tool_calls.iter().enumerate() { + let tool_index = tool_call.index.unwrap_or(index); + let entry = state.tool_calls_by_index.entry(tool_index).or_default(); if let Some(tool_id) = tool_call.id.clone() { entry.id = tool_id; @@ -409,11 +449,11 @@ pub fn map_to_language_model_completion_events( match arguments { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { - id: tool_call.id.clone().into(), + id: tool_call.id.into(), name: tool_call.name.as_str().into(), is_input_complete: true, input, - raw_input: tool_call.arguments.clone(), + raw_input: tool_call.arguments, }, )), Err(error) => Ok( @@ -453,6 +493,191 @@ pub fn map_to_language_model_completion_events( .flat_map(futures::stream::iter) } +pub struct CopilotResponsesEventMapper { + pending_stop_reason: Option, +} + +impl CopilotResponsesEventMapper { + pub fn new() -> Self { + Self { + pending_stop_reason: None, + } + } + + pub fn map_stream( + mut self, + events: Pin>>>, + ) -> impl Stream> + { + events.flat_map(move |event| { + futures::stream::iter(match event { + Ok(event) => self.map_event(event), + Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))], + }) + }) + } + + fn map_event( + &mut self, + event: copilot::copilot_responses::StreamEvent, + ) -> Vec> { + match event { + copilot::copilot_responses::StreamEvent::OutputItemAdded { item, .. } => match item { + copilot::copilot_responses::ResponseOutputItem::Message { id, .. } => { + vec![Ok(LanguageModelCompletionEvent::StartMessage { + message_id: id, + })] + } + _ => Vec::new(), + }, + + copilot::copilot_responses::StreamEvent::OutputTextDelta { delta, .. } => { + if delta.is_empty() { + Vec::new() + } else { + vec![Ok(LanguageModelCompletionEvent::Text(delta))] + } + } + + copilot::copilot_responses::StreamEvent::OutputItemDone { item, .. } => match item { + copilot::copilot_responses::ResponseOutputItem::Message { .. } => Vec::new(), + copilot::copilot_responses::ResponseOutputItem::FunctionCall { + call_id, + name, + arguments, + .. + } => { + let mut events = Vec::new(); + match serde_json::from_str::(&arguments) { + Ok(input) => events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: call_id.into(), + name: name.as_str().into(), + is_input_complete: true, + input, + raw_input: arguments.clone(), + }, + ))), + Err(error) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + id: call_id.into(), + tool_name: name.as_str().into(), + raw_input: arguments.clone().into(), + json_parse_error: error.to_string(), + })) + } + } + // Record that we already emitted a tool-use stop so we can avoid duplicating + // a Stop event on Completed. + self.pending_stop_reason = Some(StopReason::ToolUse); + events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); + events + } + copilot::copilot_responses::ResponseOutputItem::Reasoning { + summary, + encrypted_content, + .. + } => { + let mut events = Vec::new(); + + if let Some(blocks) = summary { + let mut text = String::new(); + for block in blocks { + text.push_str(&block.text); + } + if !text.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Thinking { + text, + signature: None, + })); + } + } + + if let Some(data) = encrypted_content { + events.push(Ok(LanguageModelCompletionEvent::RedactedThinking { data })); + } + + events + } + }, + + copilot::copilot_responses::StreamEvent::Completed { response } => { + let mut events = Vec::new(); + if let Some(usage) = response.usage { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: usage.input_tokens.unwrap_or(0), + output_tokens: usage.output_tokens.unwrap_or(0), + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }))); + } + if self.pending_stop_reason.take() != Some(StopReason::ToolUse) { + events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); + } + events + } + + copilot::copilot_responses::StreamEvent::Incomplete { response } => { + let reason = response + .incomplete_details + .as_ref() + .and_then(|details| details.reason.as_ref()); + let stop_reason = match reason { + Some(copilot::copilot_responses::IncompleteReason::MaxOutputTokens) => { + StopReason::MaxTokens + } + Some(copilot::copilot_responses::IncompleteReason::ContentFilter) => { + StopReason::Refusal + } + _ => self + .pending_stop_reason + .take() + .unwrap_or(StopReason::EndTurn), + }; + + let mut events = Vec::new(); + if let Some(usage) = response.usage { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: usage.input_tokens.unwrap_or(0), + output_tokens: usage.output_tokens.unwrap_or(0), + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }))); + } + events.push(Ok(LanguageModelCompletionEvent::Stop(stop_reason))); + events + } + + copilot::copilot_responses::StreamEvent::Failed { response } => { + let provider = PROVIDER_NAME; + let (status_code, message) = match response.error { + Some(error) => { + let status_code = StatusCode::from_str(&error.code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status_code, error.message) + } + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + "response.failed".to_string(), + ), + }; + vec![Err(LanguageModelCompletionError::HttpResponseError { + provider, + status_code, + message, + })] + } + + copilot::copilot_responses::StreamEvent::GenericError { error } => vec![Err( + LanguageModelCompletionError::Other(anyhow!(format!("{error:?}"))), + )], + + copilot::copilot_responses::StreamEvent::Created { .. } + | copilot::copilot_responses::StreamEvent::Unknown => Vec::new(), + } + } +} + fn into_copilot_chat( model: &copilot::copilot_chat::Model, request: LanguageModelRequest, @@ -470,7 +695,6 @@ fn into_copilot_chat( } } - let mut tool_called = false; let mut messages: Vec = Vec::new(); for message in request_messages { match message.role { @@ -540,7 +764,6 @@ fn into_copilot_chat( let mut tool_calls = Vec::new(); for content in &message.content { if let MessageContent::ToolUse(tool_use) = content { - tool_called = true; tool_calls.push(ToolCall { id: tool_use.id.to_string(), content: copilot::copilot_chat::ToolCallContent::Function { @@ -585,7 +808,7 @@ fn into_copilot_chat( } } - let mut tools = request + let tools = request .tools .iter() .map(|tool| Tool::Function { @@ -597,22 +820,6 @@ fn into_copilot_chat( }) .collect::>(); - // The API will return a Bad Request (with no error message) when tools - // were used previously in the conversation but no tools are provided as - // part of this request. Inserting a dummy tool seems to circumvent this - // error. - if tool_called && tools.is_empty() { - tools.push(Tool::Function { - function: copilot::copilot_chat::Function { - name: "noop".to_string(), - description: "No operation".to_string(), - parameters: serde_json::json!({ - "type": "object" - }), - }, - }); - } - Ok(CopilotChatRequest { intent: true, n: 1, @@ -629,6 +836,470 @@ fn into_copilot_chat( }) } +fn into_copilot_responses( + model: &copilot::copilot_chat::Model, + request: LanguageModelRequest, +) -> copilot::copilot_responses::Request { + use copilot::copilot_responses as responses; + + let LanguageModelRequest { + thread_id: _, + prompt_id: _, + intent: _, + mode: _, + messages, + tools, + tool_choice, + stop: _, + temperature, + thinking_allowed: _, + } = request; + + let mut input_items: Vec = Vec::new(); + + for message in messages { + match message.role { + Role::User => { + for content in &message.content { + if let MessageContent::ToolResult(tool_result) = content { + let output = if let Some(out) = &tool_result.output { + match out { + serde_json::Value::String(s) => { + responses::ResponseFunctionOutput::Text(s.clone()) + } + serde_json::Value::Null => { + responses::ResponseFunctionOutput::Text(String::new()) + } + other => responses::ResponseFunctionOutput::Text(other.to_string()), + } + } else { + match &tool_result.content { + LanguageModelToolResultContent::Text(text) => { + responses::ResponseFunctionOutput::Text(text.to_string()) + } + LanguageModelToolResultContent::Image(image) => { + if model.supports_vision() { + responses::ResponseFunctionOutput::Content(vec![ + responses::ResponseInputContent::InputImage { + image_url: Some(image.to_base64_url()), + detail: Default::default(), + }, + ]) + } else { + debug_panic!( + "This should be caught at {} level", + tool_result.tool_name + ); + responses::ResponseFunctionOutput::Text( + "[Tool responded with an image, but this model does not support vision]".into(), + ) + } + } + } + }; + + input_items.push(responses::ResponseInputItem::FunctionCallOutput { + call_id: tool_result.tool_use_id.to_string(), + output, + status: None, + }); + } + } + + let mut parts: Vec = Vec::new(); + for content in &message.content { + match content { + MessageContent::Text(text) => { + parts.push(responses::ResponseInputContent::InputText { + text: text.clone(), + }); + } + + MessageContent::Image(image) => { + if model.supports_vision() { + parts.push(responses::ResponseInputContent::InputImage { + image_url: Some(image.to_base64_url()), + detail: Default::default(), + }); + } + } + _ => {} + } + } + + if !parts.is_empty() { + input_items.push(responses::ResponseInputItem::Message { + role: "user".into(), + content: Some(parts), + status: None, + }); + } + } + + Role::Assistant => { + for content in &message.content { + if let MessageContent::ToolUse(tool_use) = content { + input_items.push(responses::ResponseInputItem::FunctionCall { + call_id: tool_use.id.to_string(), + name: tool_use.name.to_string(), + arguments: tool_use.raw_input.clone(), + status: None, + }); + } + } + + for content in &message.content { + if let MessageContent::RedactedThinking(data) = content { + input_items.push(responses::ResponseInputItem::Reasoning { + id: None, + summary: Vec::new(), + encrypted_content: data.clone(), + }); + } + } + + let mut parts: Vec = Vec::new(); + for content in &message.content { + match content { + MessageContent::Text(text) => { + parts.push(responses::ResponseInputContent::OutputText { + text: text.clone(), + }); + } + MessageContent::Image(_) => { + parts.push(responses::ResponseInputContent::OutputText { + text: "[image omitted]".to_string(), + }); + } + _ => {} + } + } + + if !parts.is_empty() { + input_items.push(responses::ResponseInputItem::Message { + role: "assistant".into(), + content: Some(parts), + status: Some("completed".into()), + }); + } + } + + Role::System => { + let mut parts: Vec = Vec::new(); + for content in &message.content { + if let MessageContent::Text(text) = content { + parts.push(responses::ResponseInputContent::InputText { + text: text.clone(), + }); + } + } + + if !parts.is_empty() { + input_items.push(responses::ResponseInputItem::Message { + role: "system".into(), + content: Some(parts), + status: None, + }); + } + } + } + } + + let converted_tools: Vec = tools + .into_iter() + .map(|tool| responses::ToolDefinition::Function { + name: tool.name, + description: Some(tool.description), + parameters: Some(tool.input_schema), + strict: None, + }) + .collect(); + + let mapped_tool_choice = tool_choice.map(|choice| match choice { + LanguageModelToolChoice::Auto => responses::ToolChoice::Auto, + LanguageModelToolChoice::Any => responses::ToolChoice::Any, + LanguageModelToolChoice::None => responses::ToolChoice::None, + }); + + responses::Request { + model: model.id().to_string(), + input: input_items, + stream: model.uses_streaming(), + temperature, + tools: converted_tools, + tool_choice: mapped_tool_choice, + reasoning: None, // We would need to add support for setting from user settings. + include: Some(vec![ + copilot::copilot_responses::ResponseIncludable::ReasoningEncryptedContent, + ]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use copilot::copilot_responses as responses; + use futures::StreamExt; + + fn map_events(events: Vec) -> Vec { + futures::executor::block_on(async { + CopilotResponsesEventMapper::new() + .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + .collect::>() + .await + .into_iter() + .map(Result::unwrap) + .collect() + }) + } + + #[test] + fn responses_stream_maps_text_and_usage() { + let events = vec![ + responses::StreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::Message { + id: "msg_1".into(), + role: "assistant".into(), + content: Some(Vec::new()), + }, + }, + responses::StreamEvent::OutputTextDelta { + item_id: "msg_1".into(), + output_index: 0, + delta: "Hello".into(), + }, + responses::StreamEvent::Completed { + response: responses::Response { + usage: Some(responses::ResponseUsage { + input_tokens: Some(5), + output_tokens: Some(3), + total_tokens: Some(8), + }), + ..Default::default() + }, + }, + ]; + + let mapped = map_events(events); + assert!(matches!( + mapped[0], + LanguageModelCompletionEvent::StartMessage { ref message_id } if message_id == "msg_1" + )); + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Text(ref text) if text == "Hello" + )); + assert!(matches!( + mapped[2], + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 5, + output_tokens: 3, + .. + }) + )); + assert!(matches!( + mapped[3], + LanguageModelCompletionEvent::Stop(StopReason::EndTurn) + )); + } + + #[test] + fn responses_stream_maps_tool_calls() { + let events = vec![responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::FunctionCall { + id: Some("fn_1".into()), + call_id: "call_1".into(), + name: "do_it".into(), + arguments: "{\"x\":1}".into(), + status: None, + }, + }]; + + let mapped = map_events(events); + assert!(matches!( + mapped[0], + LanguageModelCompletionEvent::ToolUse(ref use_) if use_.id.to_string() == "call_1" && use_.name.as_ref() == "do_it" + )); + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Stop(StopReason::ToolUse) + )); + } + + #[test] + fn responses_stream_handles_json_parse_error() { + let events = vec![responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::FunctionCall { + id: Some("fn_1".into()), + call_id: "call_1".into(), + name: "do_it".into(), + arguments: "{not json}".into(), + status: None, + }, + }]; + + let mapped = map_events(events); + assert!(matches!( + mapped[0], + LanguageModelCompletionEvent::ToolUseJsonParseError { ref id, ref tool_name, .. } + if id.to_string() == "call_1" && tool_name.as_ref() == "do_it" + )); + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Stop(StopReason::ToolUse) + )); + } + + #[test] + fn responses_stream_maps_reasoning_summary_and_encrypted_content() { + let events = vec![responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::Reasoning { + id: "r1".into(), + summary: Some(vec![responses::ResponseReasoningItem { + kind: "summary_text".into(), + text: "Chain".into(), + }]), + encrypted_content: Some("ENC".into()), + }, + }]; + + let mapped = map_events(events); + assert!(matches!( + mapped[0], + LanguageModelCompletionEvent::Thinking { ref text, signature: None } if text == "Chain" + )); + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::RedactedThinking { ref data } if data == "ENC" + )); + } + + #[test] + fn responses_stream_handles_incomplete_max_tokens() { + let events = vec![responses::StreamEvent::Incomplete { + response: responses::Response { + usage: Some(responses::ResponseUsage { + input_tokens: Some(10), + output_tokens: Some(0), + total_tokens: Some(10), + }), + incomplete_details: Some(responses::IncompleteDetails { + reason: Some(responses::IncompleteReason::MaxOutputTokens), + }), + ..Default::default() + }, + }]; + + let mapped = map_events(events); + assert!(matches!( + mapped[0], + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 10, + output_tokens: 0, + .. + }) + )); + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Stop(StopReason::MaxTokens) + )); + } + + #[test] + fn responses_stream_handles_incomplete_content_filter() { + let events = vec![responses::StreamEvent::Incomplete { + response: responses::Response { + usage: None, + incomplete_details: Some(responses::IncompleteDetails { + reason: Some(responses::IncompleteReason::ContentFilter), + }), + ..Default::default() + }, + }]; + + let mapped = map_events(events); + assert!(matches!( + mapped.last().unwrap(), + LanguageModelCompletionEvent::Stop(StopReason::Refusal) + )); + } + + #[test] + fn responses_stream_completed_no_duplicate_after_tool_use() { + let events = vec![ + responses::StreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: responses::ResponseOutputItem::FunctionCall { + id: Some("fn_1".into()), + call_id: "call_1".into(), + name: "do_it".into(), + arguments: "{}".into(), + status: None, + }, + }, + responses::StreamEvent::Completed { + response: responses::Response::default(), + }, + ]; + + let mapped = map_events(events); + + let mut stop_count = 0usize; + let mut saw_tool_use_stop = false; + for event in mapped { + if let LanguageModelCompletionEvent::Stop(reason) = event { + stop_count += 1; + if matches!(reason, StopReason::ToolUse) { + saw_tool_use_stop = true; + } + } + } + assert_eq!(stop_count, 1, "should emit exactly one Stop event"); + assert!(saw_tool_use_stop, "Stop reason should be ToolUse"); + } + + #[test] + fn responses_stream_failed_maps_http_response_error() { + let events = vec![responses::StreamEvent::Failed { + response: responses::Response { + error: Some(responses::ResponseError { + code: "429".into(), + message: "too many requests".into(), + }), + ..Default::default() + }, + }]; + + let mapped_results = futures::executor::block_on(async { + CopilotResponsesEventMapper::new() + .map_stream(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + .collect::>() + .await + }); + + assert_eq!(mapped_results.len(), 1); + match &mapped_results[0] { + Err(LanguageModelCompletionError::HttpResponseError { + status_code, + message, + .. + }) => { + assert_eq!(*status_code, http_client::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(message, "too many requests"); + } + other => panic!("expected HttpResponseError, got {:?}", other), + } + } +} struct ConfigurationView { copilot_status: Option, state: Entity, @@ -677,11 +1348,7 @@ impl Render for ConfigurationView { }), ) } else { - let loading_icon = Icon::new(IconName::ArrowCircle).with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(4)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ); + let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4); const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider."; @@ -710,11 +1377,12 @@ impl Render for ConfigurationView { v_flex().gap_2().child(Label::new(LABEL)).child( Button::new("sign_in", "Sign in to use GitHub Copilot") + .full_width() + .style(ButtonStyle::Outlined) .icon_color(Color::Muted) .icon(IconName::Github) .icon_position(IconPosition::Start) - .icon_size(IconSize::Medium) - .full_width() + .icon_size(IconSize::Small) .on_click(|_, window, cx| copilot::initiate_sign_in(window, cx)), ) } diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index a568ef4034193b5b1078d2ec4907d18fb0762efa..8784d3805f22974ffa441ecd04ddea4b56be911b 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -1,13 +1,10 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; -use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; +use deepseek::DEEPSEEK_API_URL; + use futures::Stream; -use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{ - AnyView, AppContext as _, AsyncApp, Entity, FontStyle, Subscription, Task, TextStyle, - WhiteSpace, -}; +use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -16,21 +13,24 @@ use language_model::{ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; -use std::sync::Arc; -use theme::ThemeSettings; +use std::sync::{Arc, LazyLock}; + use ui::{Icon, IconName, List, prelude::*}; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::{EnvVar, env_var}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek"); -const DEEPSEEK_API_KEY_VAR: &str = "DEEPSEEK_API_KEY"; + +const API_KEY_ENV_VAR_NAME: &str = "DEEPSEEK_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); #[derive(Default)] struct RawToolCall { @@ -44,110 +44,54 @@ pub struct DeepSeekSettings { pub api_url: String, pub available_models: Vec, } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, -} - pub struct DeepSeekLanguageModelProvider { http_client: Arc, state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() - } - - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .deepseek - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + self.api_key_state.has_key() } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .deepseek - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await?; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .deepseek - .api_url - .clone(); - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(DEEPSEEK_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl DeepSeekLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_this: &mut State, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -160,7 +104,20 @@ impl DeepSeekLanguageModelProvider { state: self.state.clone(), http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), - }) as Arc + }) + } + + fn settings(cx: &App) -> &DeepSeekSettings { + &crate::AllLanguageModelSettings::get_global(cx).deepseek + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + DEEPSEEK_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } } } @@ -199,11 +156,7 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { models.insert("deepseek-chat", deepseek::Model::Chat); models.insert("deepseek-reasoner", deepseek::Model::Reasoner); - for available_model in AllLanguageModelSettings::get_global(cx) - .deepseek - .available_models - .iter() - { + for available_model in &Self::settings(cx).available_models { models.insert( &available_model.name, deepseek::Model::Custom { @@ -229,13 +182,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } @@ -254,15 +213,20 @@ impl DeepSeekLanguageModel { cx: &AsyncApp, ) -> BoxFuture<'static, Result>>> { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).deepseek; - (state.api_key.clone(), settings.api_url.clone()) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let future = self.request_limiter.stream(async move { - let api_key = api_key.context("Missing DeepSeek API Key")?; + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + }); + }; let request = deepseek::stream_completion(http_client.as_ref(), &api_url, &api_key, request); let response = request.await?; @@ -561,18 +525,15 @@ impl DeepSeekEventMapper { } struct ConfigurationView { - api_key_editor: Entity, + api_key_editor: Entity, state: Entity, load_credentials_task: Option>, } impl ConfigurationView { fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("sk-00000000000000000000000000000000", cx); - editor - }); + let api_key_editor = + cx.new(|cx| InputField::new(window, cx, "sk-00000000000000000000000000000000")); cx.observe(&state, |_, _, cx| { cx.notify(); @@ -605,7 +566,7 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); if api_key.is_empty() { return; } @@ -613,12 +574,10 @@ impl ConfigurationView { let state = self.state.clone(); cx.spawn(async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { @@ -626,38 +585,12 @@ impl ConfigurationView { .update(cx, |editor, cx| editor.set_text("", window, cx)); let state = self.state.clone(); - cx.spawn(async move |_, cx| state.update(cx, |state, cx| state.reset_api_key(cx))?.await) - .detach_and_log_err(cx); - - cx.notify(); - } - - fn render_api_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - background_color: None, - underline: None, - strikethrough: None, - white_space: WhiteSpace::Normal, - ..Default::default() - }; - EditorElement::new( - &self.api_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) + cx.spawn(async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await + }) + .detach_and_log_err(cx); } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -667,7 +600,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials...")).into_any() @@ -687,22 +620,10 @@ impl Render for ConfigurationView { "Paste your API key below and hit enter to start using the assistant", )), ) - .child( - h_flex() - .w_full() - .my_2() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .child(self.render_api_key_editor(cx)), - ) + .child(self.api_key_editor.clone()) .child( Label::new(format!( - "Or set the {} environment variable.", - DEEPSEEK_API_KEY_VAR + "Or set the {API_KEY_ENV_VAR_NAME} environment variable." )) .size(LabelSize::Small) .color(Color::Muted), @@ -722,9 +643,17 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {}", DEEPSEEK_API_KEY_VAR) + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured".to_string() + let api_url = DeepSeekLanguageModelProvider::api_url(cx); + if api_url == DEEPSEEK_API_URL { + "API key configured".to_string() + } else { + format!( + "API key configured for {}", + truncate_and_trailoff(&api_url, 32) + ) + } })), ) .child( diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index b287e8181a2ac5d04650d799a0cd9b23d51749c2..a4d1202bee4fc4b2f1e071a815bc2f5887d2457d 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -1,20 +1,17 @@ use anyhow::{Context as _, Result, anyhow}; use collections::BTreeMap; use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; +use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture}; use google_ai::{ FunctionDeclaration, GenerateContentResponse, GoogleModelMode, Part, SystemInstruction, ThinkingConfig, UsageMetadata, }; -use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace, -}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, LanguageModelToolUse, - LanguageModelToolUseId, MessageContent, StopReason, + AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, + LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason, }; use language_model::{ LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, @@ -23,18 +20,21 @@ use language_model::{ }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +pub use settings::GoogleAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::sync::{ - Arc, + Arc, LazyLock, atomic::{self, AtomicU64}, }; use strum::IntoEnumIterator; -use theme::ThemeSettings; use ui::{Icon, IconName, List, Tooltip, prelude::*}; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::EnvVar; -use crate::AllLanguageModelSettings; +use crate::api_key::ApiKey; +use crate::api_key::ApiKeyState; use crate::ui::InstructionListItem; const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID; @@ -57,133 +57,62 @@ pub enum ModelMode { }, } -impl From for GoogleModelMode { - fn from(value: ModelMode) -> Self { - match value { - ModelMode::Default => GoogleModelMode::Default, - ModelMode::Thinking { budget_tokens } => GoogleModelMode::Thinking { budget_tokens }, - } - } -} - -impl From for ModelMode { - fn from(value: GoogleModelMode) -> Self { - match value { - GoogleModelMode::Default => ModelMode::Default, - GoogleModelMode::Thinking { budget_tokens } => ModelMode::Thinking { budget_tokens }, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - name: String, - display_name: Option, - max_tokens: u64, - mode: Option, -} - pub struct GoogleLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } -const GEMINI_API_KEY_VAR: &str = "GEMINI_API_KEY"; -const GOOGLE_AI_API_KEY_VAR: &str = "GOOGLE_AI_API_KEY"; +const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY"; +const GOOGLE_AI_API_KEY_VAR_NAME: &str = "GOOGLE_AI_API_KEY"; + +static API_KEY_ENV_VAR: LazyLock = LazyLock::new(|| { + // Try GEMINI_API_KEY first as primary, fallback to GOOGLE_AI_API_KEY + EnvVar::new(GEMINI_API_KEY_VAR_NAME.into()).or(EnvVar::new(GOOGLE_AI_API_KEY_VAR_NAME.into())) +}); impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() + self.api_key_state.has_key() } - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .google - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = GoogleLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .google - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await?; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) - } - - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .google - .api_url - .clone(); - - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(GOOGLE_AI_API_KEY_VAR) { - (api_key, true) - } else if let Ok(api_key) = std::env::var(GEMINI_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = GoogleLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl GoogleLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -198,12 +127,41 @@ impl GoogleLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + pub fn api_key_for_gemini_cli(cx: &mut App) -> Task> { + if let Some(key) = API_KEY_ENV_VAR.value.clone() { + return Task::ready(Ok(key)); + } + let credentials_provider = ::global(cx); + let api_url = Self::api_url(cx).to_string(); + cx.spawn(async move |cx| { + Ok( + ApiKey::load_from_system_keychain(&api_url, credentials_provider.as_ref(), cx) + .await? + .key() + .to_string(), + ) + }) + } + + fn settings(cx: &App) -> &GoogleSettings { + &crate::AllLanguageModelSettings::get_global(cx).google + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + google_ai::API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for GoogleLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -240,17 +198,14 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { } // Override with available models from settings - for model in &AllLanguageModelSettings::get_global(cx) - .google - .available_models - { + for model in &GoogleLanguageModelProvider::settings(cx).available_models { models.insert( model.name.clone(), google_ai::Model::Custom { name: model.name.clone(), display_name: model.display_name.clone(), max_tokens: model.max_tokens, - mode: model.mode.unwrap_or_default().into(), + mode: model.mode.unwrap_or_default(), }, ); } @@ -277,20 +232,26 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) + fn configuration_view( + &self, + target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { + cx.new(|cx| ConfigurationView::new(self.state.clone(), target_agent, window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct GoogleLanguageModel { id: LanguageModelId, model: google_ai::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -306,11 +267,11 @@ impl GoogleLanguageModel { > { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).google; - (state.api_key.clone(), settings.api_url.clone()) + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = GoogleLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; async move { @@ -382,15 +343,18 @@ impl LanguageModel for GoogleLanguageModel { cx: &App, ) -> BoxFuture<'static, Result> { let model_id = self.model.request_id().to_string(); - let request = into_google(request, model_id.clone(), self.model.mode()); + let request = into_google(request, model_id, self.model.mode()); let http_client = self.http_client.clone(); - let api_key = self.state.read(cx).api_key.clone(); - - let settings = &AllLanguageModelSettings::get_global(cx).google; - let api_url = settings.api_url.clone(); + let api_url = GoogleLanguageModelProvider::api_url(cx); + let api_key = self.state.read(cx).api_key_state.key(&api_url); async move { - let api_key = api_key.context("Missing Google API key")?; + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + } + .into()); + }; let response = google_ai::count_tokens( http_client.as_ref(), &api_url, @@ -525,7 +489,7 @@ pub fn into_google( let system_instructions = if request .messages .first() - .map_or(false, |msg| matches!(msg.role, Role::System)) + .is_some_and(|msg| matches!(msg.role, Role::System)) { let message = request.messages.remove(0); Some(SystemInstruction { @@ -572,7 +536,7 @@ pub fn into_google( top_k: None, }), safety_settings: None, - tools: (request.tools.len() > 0).then(|| { + tools: (!request.tools.is_empty()).then(|| { vec![google_ai::Tool { function_declarations: request .tools @@ -644,6 +608,24 @@ impl GoogleEventMapper { convert_usage(&self.usage), ))) } + + if let Some(prompt_feedback) = event.prompt_feedback + && let Some(block_reason) = prompt_feedback.block_reason.as_deref() + { + self.stop_reason = match block_reason { + "SAFETY" | "OTHER" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "IMAGE_SAFETY" => { + StopReason::Refusal + } + _ => { + log::error!("Unexpected Google block_reason: {block_reason}"); + StopReason::Refusal + } + }; + events.push(Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))); + + return events; + } + if let Some(candidates) = event.candidates { for candidate in candidates { if let Some(finish_reason) = candidate.finish_reason.as_deref() { @@ -769,13 +751,19 @@ fn convert_usage(usage: &UsageMetadata) -> language_model::TokenUsage { } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, + target_agent: language_model::ConfigurationViewTargetAgent, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new( + state: Entity, + target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut Context, + ) -> Self { cx.observe(&state, |_, _, cx| { cx.notify(); }) @@ -800,31 +788,30 @@ impl ConfigurationView { })); Self { - api_key_editor: cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("AIzaSy...", cx); - editor - }), + api_key_editor: cx.new(|cx| InputField::new(window, cx, "AIzaSy...")), + target_agent, state, load_credentials_task, } } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { @@ -833,36 +820,11 @@ impl ConfigurationView { let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); - } - - fn render_api_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - white_space: WhiteSpace::Normal, - ..Default::default() - }; - EditorElement::new( - &self.api_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -872,7 +834,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials...")).into_any() @@ -880,7 +842,10 @@ impl Render for ConfigurationView { v_flex() .size_full() .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Google AI, you need to add an API key. Follow these steps:")) + .child(Label::new(format!("To use {}, you need to add an API key. Follow these steps:", match &self.target_agent { + ConfigurationViewTargetAgent::ZedAgent => "Zed's agent with Google AI".into(), + ConfigurationViewTargetAgent::Other(agent) => agent.clone(), + }))) .child( List::new() .child(InstructionListItem::new( @@ -892,21 +857,10 @@ impl Render for ConfigurationView { "Paste your API key below and hit enter to start using the assistant", )), ) - .child( - h_flex() - .w_full() - .my_2() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .child(self.render_api_key_editor(cx)), - ) + .child(self.api_key_editor.clone()) .child( Label::new( - format!("You can also assign the {GEMINI_API_KEY_VAR} environment variable and restart Zed."), + format!("You can also assign the {GEMINI_API_KEY_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small).color(Color::Muted), ) @@ -925,9 +879,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {GEMINI_API_KEY_VAR} environment variable.") + format!("API key set in {} environment variable", API_KEY_ENV_VAR.name) } else { - "API key configured.".to_string() + let api_url = GoogleLanguageModelProvider::api_url(cx); + if api_url == google_ai::API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -938,7 +897,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .disabled(env_var_set) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR} and {GOOGLE_AI_API_KEY_VAR} environment variables are unset."))) + this.tooltip(Tooltip::text(format!("To reset your API key, make sure {GEMINI_API_KEY_VAR_NAME} and {GOOGLE_AI_API_KEY_VAR_NAME} environment variables are unset."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 36a32ab941ec65eb790a59ba3a7ed4fe3e6eb575..c0b3509c0e2c9636ca48cdb0de0cc6ed32a2b792 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -2,7 +2,7 @@ use anyhow::{Result, anyhow}; use collections::HashMap; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Subscription, Task}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -15,8 +15,7 @@ use language_model::{ LanguageModelRequest, RateLimiter, Role, }; use lmstudio::{ModelType, get_models}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +pub use settings::LmStudioAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; @@ -40,18 +39,9 @@ pub struct LmStudioSettings { pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub supports_tool_calls: bool, - pub supports_images: bool, -} - pub struct LmStudioLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { @@ -111,7 +101,30 @@ impl State { } let fetch_models_task = self.fetch_models(cx); - cx.spawn(async move |_this, _cx| Ok(fetch_models_task.await?)) + cx.spawn(async move |_this, _cx| { + match fetch_models_task.await { + Ok(()) => Ok(()), + Err(err) => { + // If any cause in the error chain is an std::io::Error with + // ErrorKind::ConnectionRefused, treat this as "credentials not found" + // (i.e. LM Studio not running). + let mut connection_refused = false; + for cause in err.chain() { + if let Some(io_err) = cause.downcast_ref::() { + if io_err.kind() == std::io::ErrorKind::ConnectionRefused { + connection_refused = true; + break; + } + } + } + if connection_refused { + Err(AuthenticateError::ConnectionRefused) + } else { + Err(AuthenticateError::Other(err)) + } + } + } + }) } } @@ -149,7 +162,7 @@ impl LmStudioLanguageModelProvider { impl LanguageModelProviderState for LmStudioLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -210,7 +223,7 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { .map(|model| { Arc::new(LmStudioLanguageModel { id: LanguageModelId::from(model.name.clone()), - model: model.clone(), + model, http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), }) as Arc @@ -226,7 +239,12 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, _window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + _window: &mut Window, + cx: &mut App, + ) -> AnyView { let state = self.state.clone(); cx.new(|cx| ConfigurationView::new(state, cx)).into() } @@ -617,12 +635,12 @@ fn add_message_content_part( } struct ConfigurationView { - state: gpui::Entity, + state: Entity, loading_models_task: Option>, } impl ConfigurationView { - pub fn new(state: gpui::Entity, cx: &mut Context) -> Self { + pub fn new(state: Entity, cx: &mut Context) -> Self { let loading_models_task = Some(cx.spawn({ let state = state.clone(); async move |this, cx| { diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 4a0d740334e38b9f5ea512344161fe5ca3f8db71..acd4a1c768e0d6ffdffbc3d69dcdc2bfd37fa928 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1,11 +1,8 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::BTreeMap; -use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace, -}; +use fs::Fs; +use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -14,141 +11,137 @@ use language_model::{ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; -use mistral::StreamResponse; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse}; +pub use settings::MistralAvailableModel as AvailableModel; +use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file}; use std::collections::HashMap; use std::pin::Pin; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use theme::ThemeSettings; use ui::{Icon, IconName, List, Tooltip, prelude::*}; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::{EnvVar, env_var}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral"); +const API_KEY_ENV_VAR_NAME: &str = "MISTRAL_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + +const CODESTRAL_API_KEY_ENV_VAR_NAME: &str = "CODESTRAL_API_KEY"; +static CODESTRAL_API_KEY_ENV_VAR: LazyLock = env_var!(CODESTRAL_API_KEY_ENV_VAR_NAME); + #[derive(Default, Clone, Debug, PartialEq)] pub struct MistralSettings { pub api_url: String, pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, - pub supports_tools: Option, - pub supports_images: Option, - pub supports_thinking: Option, -} - pub struct MistralLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, + codestral_api_key_state: ApiKeyState, } -const MISTRAL_API_KEY_VAR: &str = "MISTRAL_API_KEY"; - impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() - } - - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .mistral - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + self.api_key_state.has_key() } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .mistral - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await?; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = MistralLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } + fn set_codestral_api_key( + &mut self, + api_key: Option, + cx: &mut Context, + ) -> Task> { + self.codestral_api_key_state.store( + CODESTRAL_API_URL.into(), + api_key, + |this| &mut this.codestral_api_key_state, + cx, + ) + } - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .mistral - .api_url - .clone(); - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(MISTRAL_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = MistralLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) + } - Ok(()) - }) + fn authenticate_codestral( + &mut self, + cx: &mut Context, + ) -> Task> { + self.codestral_api_key_state.load_if_needed( + CODESTRAL_API_URL.into(), + &CODESTRAL_API_KEY_ENV_VAR, + |this| &mut this.codestral_api_key_state, + cx, + ) } } +struct GlobalMistralLanguageModelProvider(Arc); + +impl Global for GlobalMistralLanguageModelProvider {} + impl MistralLanguageModelProvider { - pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_this: &mut State, cx| { + pub fn try_global(cx: &App) -> Option<&Arc> { + cx.try_global::() + .map(|this| &this.0) + } + + pub fn global(http_client: Arc, cx: &mut App) -> Arc { + if let Some(this) = cx.try_global::() { + return this.0.clone(); + } + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()), + } }); - Self { http_client, state } + let this = Arc::new(Self { http_client, state }); + cx.set_global(GlobalMistralLanguageModelProvider(this)); + cx.global::().0.clone() + } + + pub fn load_codestral_api_key(&self, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.authenticate_codestral(cx)) + } + + pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option> { + self.state.read(cx).codestral_api_key_state.key(url) } fn create_language_model(&self, model: mistral::Model) -> Arc { @@ -160,12 +153,25 @@ impl MistralLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn settings(cx: &App) -> &MistralSettings { + &crate::AllLanguageModelSettings::get_global(cx).mistral + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + mistral::MISTRAL_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for MistralLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -202,10 +208,7 @@ impl LanguageModelProvider for MistralLanguageModelProvider { } // Override with available models from settings - for model in &AllLanguageModelSettings::get_global(cx) - .mistral - .available_models - { + for model in &Self::settings(cx).available_models { models.insert( model.name.clone(), mistral::Model::Custom { @@ -243,20 +246,26 @@ impl LanguageModelProvider for MistralLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct MistralLanguageModel { id: LanguageModelId, model: mistral::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -271,15 +280,20 @@ impl MistralLanguageModel { Result>>, > { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).mistral; - (state.api_key.clone(), settings.api_url.clone()) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = MistralLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let future = self.request_limiter.stream(async move { - let api_key = api_key.context("Missing Mistral API Key")?; + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + }); + }; let request = mistral::stream_completion(http_client.as_ref(), &api_url, &api_key, request); let response = request.await?; @@ -730,18 +744,18 @@ struct RawToolCall { } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + codestral_api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("0aBCDEFGhIjKLmNOpqrSTUVwxyzabCDE1f2", cx); - editor - }); + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { + let api_key_editor = + cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); + let codestral_api_key_editor = + cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); cx.observe(&state, |_, _, cx| { cx.notify(); @@ -758,6 +772,12 @@ impl ConfigurationView { // We don't log an error, because "not signed in" is also an error. let _ = task.await; } + if let Some(task) = state + .update(cx, |state, cx| state.authenticate_codestral(cx)) + .log_err() + { + let _ = task.await; + } this.update(cx, |this, cx| { this.load_credentials_task = None; @@ -769,26 +789,29 @@ impl ConfigurationView { Self { api_key_editor, + codestral_api_key_editor, state, load_credentials_task, } } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { @@ -797,90 +820,99 @@ impl ConfigurationView { let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); + } - cx.notify(); - } - - fn render_api_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - white_space: WhiteSpace::Normal, - ..Default::default() - }; - EditorElement::new( - &self.api_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) + fn save_codestral_api_key( + &mut self, + _: &menu::Confirm, + window: &mut Window, + cx: &mut Context, + ) { + let api_key = self + .codestral_api_key_editor + .read(cx) + .text(cx) + .trim() + .to_string(); + if api_key.is_empty() { + return; + } + + // url changes can cause the editor to be displayed again + self.codestral_api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| { + state.set_codestral_api_key(Some(api_key), cx) + })? + .await?; + cx.update(|_window, cx| { + set_edit_prediction_provider(EditPredictionProvider::Codestral, cx) + }) + }) + .detach_and_log_err(cx); } - fn should_render_editor(&self, cx: &mut Context) -> bool { - !self.state.read(cx).is_authenticated() + fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context) { + self.codestral_api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_codestral_api_key(None, cx))? + .await?; + cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx)) + }) + .detach_and_log_err(cx); } -} -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + fn should_render_api_key_editor(&self, cx: &mut Context) -> bool { + !self.state.read(cx).is_authenticated() + } - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() - } else if self.should_render_editor(cx) { + fn render_codestral_api_key_editor(&mut self, cx: &mut Context) -> AnyElement { + let key_state = &self.state.read(cx).codestral_api_key_state; + let should_show_editor = !key_state.has_key(); + let env_var_set = key_state.is_from_env_var(); + if should_show_editor { v_flex() + .id("codestral") .size_full() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) + .mt_2() + .on_action(cx.listener(Self::save_codestral_api_key)) + .child(Label::new( + "To use Codestral as an edit prediction provider, \ + you need to add a Codestral-specific API key. Follow these steps:", + )) .child( List::new() .child(InstructionListItem::new( "Create one by visiting", - Some("Mistral's console"), - Some("https://console.mistral.ai/api-keys"), + Some("the Codestral section of Mistral's console"), + Some("https://console.mistral.ai/codestral"), )) - .child(InstructionListItem::text_only( - "Ensure your Mistral account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), - ) - .child( - h_flex() - .w_full() - .my_2() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .child(self.render_api_key_editor(cx)), + .child(InstructionListItem::text_only("Paste your API key below and hit enter")), ) + .child(self.codestral_api_key_editor.clone()) .child( Label::new( - format!("You can also assign the {MISTRAL_API_KEY_VAR} environment variable and restart Zed."), + format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small).color(Color::Muted), - ) - .into_any() + ).into_any() } else { h_flex() - .mt_1() + .id("codestral") + .mt_2() .p_1() .justify_between() .rounded_md() @@ -892,9 +924,9 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {MISTRAL_API_KEY_VAR} environment variable.") + format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + "Codestral API key configured".to_string() })), ) .child( @@ -905,15 +937,121 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .disabled(env_var_set) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {MISTRAL_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!( + "To reset your API key, \ + unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable." + ))) }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)), + ), + ).into_any() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); + + if self.load_credentials_task.is_some() { + div().child(Label::new("Loading credentials...")).into_any() + } else if self.should_render_api_key_editor(cx) { + v_flex() + .size_full() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) + .child( + List::new() + .child(InstructionListItem::new( + "Create one by visiting", + Some("Mistral's console"), + Some("https://console.mistral.ai/api-keys"), + )) + .child(InstructionListItem::text_only( + "Ensure your Mistral account has credits", + )) + .child(InstructionListItem::text_only( + "Paste your API key below and hit enter to start using the assistant", + )), + ) + .child(self.api_key_editor.clone()) + .child( + Label::new( + format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), + ) + .size(LabelSize::Small).color(Color::Muted), + ) + .child(self.render_codestral_api_key_editor(cx)) + .into_any() + } else { + v_flex() + .size_full() + .child( + h_flex() + .mt_1() + .p_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new(if env_var_set { + format!( + "API key set in {API_KEY_ENV_VAR_NAME} environment variable" + ) + } else { + let api_url = MistralLanguageModelProvider::api_url(cx); + if api_url == MISTRAL_API_URL { + "API key configured".to_string() + } else { + format!( + "API key configured for {}", + truncate_and_trailoff(&api_url, 32) + ) + } + })), + ) + .child( + Button::new("reset-key", "Reset Key") + .label_size(LabelSize::Small) + .icon(Some(IconName::Trash)) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .disabled(env_var_set) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!( + "To reset your API key, \ + unset the {API_KEY_ENV_VAR_NAME} environment variable." + ))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_api_key(window, cx) + })), + ), ) + .child(self.render_codestral_api_key_editor(cx)) .into_any() } } } +fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) { + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .edit_prediction_provider = Some(provider); + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 0c2b1107b18cf72f70e46c195e7c61bfae607285..2150966c1af0fdb1bdcc028cba67bcb7b7cbf89f 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -1,7 +1,8 @@ use anyhow::{Result, anyhow}; +use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; -use gpui::{AnyView, App, AsyncApp, Context, Subscription, Task}; +use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -10,20 +11,23 @@ use language_model::{ LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; +use menu; use ollama::{ - ChatMessage, ChatOptions, ChatRequest, ChatResponseDelta, KeepAlive, OllamaFunctionTool, - OllamaToolCall, get_models, show_model, stream_chat_completion, + ChatMessage, ChatOptions, ChatRequest, ChatResponseDelta, OLLAMA_API_URL, OllamaFunctionCall, + OllamaFunctionTool, OllamaToolCall, get_models, show_model, stream_chat_completion, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +pub use settings::OllamaAvailableModel as AvailableModel; +use settings::{Settings, SettingsStore, update_settings_file}; use std::pin::Pin; +use std::sync::LazyLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::{collections::HashMap, sync::Arc}; -use ui::{ButtonLike, Indicator, List, prelude::*}; -use util::ResultExt; +use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*}; +use ui_input::InputField; +use zed_env_vars::{EnvVar, env_var}; use crate::AllLanguageModelSettings; +use crate::api_key::ApiKeyState; use crate::ui::InstructionListItem; const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download"; @@ -33,55 +37,75 @@ const OLLAMA_SITE: &str = "https://ollama.com/"; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("ollama"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Ollama"); +const API_KEY_ENV_VAR_NAME: &str = "OLLAMA_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + #[derive(Default, Debug, Clone, PartialEq)] pub struct OllamaSettings { pub api_url: String, pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - /// The model name in the Ollama API (e.g. "llama3.2:latest") - pub name: String, - /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel. - pub display_name: Option, - /// The Context Length parameter to the model (aka num_ctx or n_ctx) - pub max_tokens: u64, - /// The number of seconds to keep the connection open after the last request - pub keep_alive: Option, - /// Whether the model supports tools - pub supports_tools: Option, - /// Whether the model supports vision - pub supports_images: Option, - /// Whether to enable think mode - pub supports_thinking: Option, -} - pub struct OllamaLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { + api_key_state: ApiKeyState, http_client: Arc, - available_models: Vec, + fetched_models: Vec, fetch_model_task: Option>>, - _subscription: Subscription, } impl State { fn is_authenticated(&self) -> bool { - !self.available_models.is_empty() + !self.fetched_models.is_empty() + } + + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = OllamaLanguageModelProvider::api_url(cx); + let task = self + .api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx); + + self.fetched_models.clear(); + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + result + }) + } + + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = OllamaLanguageModelProvider::api_url(cx); + let task = self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); + + // Always try to fetch models - if no API key is needed (local Ollama), it will work + // If API key is needed and provided, it will work + // If API key is needed and not provided, it will fail gracefully + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + result + }) } fn fetch_models(&mut self, cx: &mut Context) -> Task> { - let settings = &AllLanguageModelSettings::get_global(cx).ollama; let http_client = Arc::clone(&self.http_client); - let api_url = settings.api_url.clone(); + let api_url = OllamaLanguageModelProvider::api_url(cx); + let api_key = self.api_key_state.key(&api_url); // As a proxy for the server being "authenticated", we'll check if its up by fetching the models cx.spawn(async move |this, cx| { - let models = get_models(http_client.as_ref(), &api_url, None).await?; + let models = get_models(http_client.as_ref(), &api_url, api_key.as_deref()).await?; let tasks = models .into_iter() @@ -92,16 +116,19 @@ impl State { .map(|model| { let http_client = Arc::clone(&http_client); let api_url = api_url.clone(); + let api_key = api_key.clone(); async move { let name = model.name.as_str(); - let capabilities = show_model(http_client.as_ref(), &api_url, name).await?; + let model = + show_model(http_client.as_ref(), &api_url, api_key.as_deref(), name) + .await?; let ollama_model = ollama::Model::new( name, None, - None, - Some(capabilities.supports_tools()), - Some(capabilities.supports_vision()), - Some(capabilities.supports_thinking()), + model.context_length, + Some(model.supports_tools()), + Some(model.supports_vision()), + Some(model.supports_thinking()), ); Ok(ollama_model) } @@ -119,7 +146,7 @@ impl State { ollama_models.sort_by(|a, b| a.name.cmp(&b.name)); this.update(cx, |this, cx| { - this.available_models = ollama_models; + this.fetched_models = ollama_models; cx.notify(); }) }) @@ -129,15 +156,6 @@ impl State { let task = self.fetch_models(cx); self.fetch_model_task.replace(task); } - - fn authenticate(&mut self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let fetch_models_task = self.fetch_models(cx); - cx.spawn(async move |_this, _cx| Ok(fetch_models_task.await?)) - } } impl OllamaLanguageModelProvider { @@ -145,36 +163,53 @@ impl OllamaLanguageModelProvider { let this = Self { http_client: http_client.clone(), state: cx.new(|cx| { - let subscription = cx.observe_global::({ - let mut settings = AllLanguageModelSettings::get_global(cx).ollama.clone(); + cx.observe_global::({ + let mut last_settings = OllamaLanguageModelProvider::settings(cx).clone(); move |this: &mut State, cx| { - let new_settings = &AllLanguageModelSettings::get_global(cx).ollama; - if &settings != new_settings { - settings = new_settings.clone(); - this.restart_fetch_models_task(cx); + let current_settings = OllamaLanguageModelProvider::settings(cx); + let settings_changed = current_settings != &last_settings; + if settings_changed { + let url_changed = last_settings.api_url != current_settings.api_url; + last_settings = current_settings.clone(); + if url_changed { + this.fetched_models.clear(); + this.authenticate(cx).detach(); + } cx.notify(); } } - }); + }) + .detach(); State { http_client, - available_models: Default::default(), + fetched_models: Default::default(), fetch_model_task: None, - _subscription: subscription, + api_key_state: ApiKeyState::new(Self::api_url(cx)), } }), }; - this.state - .update(cx, |state, cx| state.restart_fetch_models_task(cx)); this } + + fn settings(cx: &App) -> &OllamaSettings { + &AllLanguageModelSettings::get_global(cx).ollama + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + OLLAMA_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for OllamaLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -208,28 +243,37 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { let mut models: HashMap = HashMap::new(); // Add models from the Ollama API - for model in self.state.read(cx).available_models.iter() { + for model in self.state.read(cx).fetched_models.iter() { models.insert(model.name.clone(), model.clone()); } // Override with available models from settings - for model in AllLanguageModelSettings::get_global(cx) - .ollama - .available_models - .iter() - { - models.insert( - model.name.clone(), - ollama::Model { - name: model.name.clone(), - display_name: model.display_name.clone(), - max_tokens: model.max_tokens, - keep_alive: model.keep_alive.clone(), - supports_tools: model.supports_tools, - supports_vision: model.supports_images, - supports_thinking: model.supports_thinking, - }, - ); + for setting_model in &OllamaLanguageModelProvider::settings(cx).available_models { + let setting_base = setting_model.name.split(':').next().unwrap(); + if let Some(model) = models + .values_mut() + .find(|m| m.name.split(':').next().unwrap() == setting_base) + { + model.max_tokens = setting_model.max_tokens; + model.display_name = setting_model.display_name.clone(); + model.keep_alive = setting_model.keep_alive.clone(); + model.supports_tools = setting_model.supports_tools; + model.supports_vision = setting_model.supports_images; + model.supports_thinking = setting_model.supports_thinking; + } else { + models.insert( + setting_model.name.clone(), + ollama::Model { + name: setting_model.name.clone(), + display_name: setting_model.display_name.clone(), + max_tokens: setting_model.max_tokens, + keep_alive: setting_model.keep_alive.clone(), + supports_tools: setting_model.supports_tools, + supports_vision: setting_model.supports_images, + supports_thinking: setting_model.supports_thinking, + }, + ); + } } let mut models = models @@ -237,9 +281,10 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { .map(|model| { Arc::new(OllamaLanguageModel { id: LanguageModelId::from(model.name.clone()), - model: model.clone(), + model, http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), + state: self.state.clone(), }) as Arc }) .collect::>(); @@ -255,14 +300,20 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { let state = self.state.clone(); cx.new(|cx| ConfigurationView::new(state, window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.fetch_models(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } @@ -271,65 +322,92 @@ pub struct OllamaLanguageModel { model: ollama::Model, http_client: Arc, request_limiter: RateLimiter, + state: Entity, } impl OllamaLanguageModel { fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest { let supports_vision = self.model.supports_vision.unwrap_or(false); - ChatRequest { - model: self.model.name.clone(), - messages: request - .messages - .into_iter() - .map(|msg| { - let images = if supports_vision { - msg.content - .iter() - .filter_map(|content| match content { - MessageContent::Image(image) => Some(image.source.to_string()), - _ => None, - }) - .collect::>() - } else { - vec![] - }; + let mut messages = Vec::with_capacity(request.messages.len()); + + for mut msg in request.messages.into_iter() { + let images = if supports_vision { + msg.content + .iter() + .filter_map(|content| match content { + MessageContent::Image(image) => Some(image.source.to_string()), + _ => None, + }) + .collect::>() + } else { + vec![] + }; - match msg.role { - Role::User => ChatMessage::User { + match msg.role { + Role::User => { + for tool_result in msg + .content + .extract_if(.., |x| matches!(x, MessageContent::ToolResult(..))) + { + match tool_result { + MessageContent::ToolResult(tool_result) => { + messages.push(ChatMessage::Tool { + tool_name: tool_result.tool_name.to_string(), + content: tool_result.content.to_str().unwrap_or("").to_string(), + }) + } + _ => unreachable!("Only tool result should be extracted"), + } + } + if !msg.content.is_empty() { + messages.push(ChatMessage::User { content: msg.string_contents(), images: if images.is_empty() { None } else { Some(images) }, - }, - Role::Assistant => { - let content = msg.string_contents(); - let thinking = - msg.content.into_iter().find_map(|content| match content { - MessageContent::Thinking { text, .. } if !text.is_empty() => { - Some(text) - } - _ => None, - }); - ChatMessage::Assistant { - content, - tool_calls: None, - images: if images.is_empty() { - None - } else { - Some(images) - }, - thinking, + }) + } + } + Role::Assistant => { + let content = msg.string_contents(); + let mut thinking = None; + let mut tool_calls = Vec::new(); + for content in msg.content.into_iter() { + match content { + MessageContent::Thinking { text, .. } if !text.is_empty() => { + thinking = Some(text) } + MessageContent::ToolUse(tool_use) => { + tool_calls.push(OllamaToolCall::Function(OllamaFunctionCall { + name: tool_use.name.to_string(), + arguments: tool_use.input, + })); + } + _ => (), } - Role::System => ChatMessage::System { - content: msg.string_contents(), - }, } - }) - .collect(), + messages.push(ChatMessage::Assistant { + content, + tool_calls: Some(tool_calls), + images: if images.is_empty() { + None + } else { + Some(images) + }, + thinking, + }) + } + Role::System => messages.push(ChatMessage::System { + content: msg.string_contents(), + }), + } + } + ChatRequest { + model: self.model.name.clone(), + messages, keep_alive: self.model.keep_alive.clone().unwrap_or_default(), stream: true, options: Some(ChatOptions { @@ -342,7 +420,11 @@ impl OllamaLanguageModel { .model .supports_thinking .map(|supports_thinking| supports_thinking && request.thinking_allowed), - tools: request.tools.into_iter().map(tool_into_ollama).collect(), + tools: if self.model.supports_tools.unwrap_or(false) { + request.tools.into_iter().map(tool_into_ollama).collect() + } else { + vec![] + }, } } } @@ -419,15 +501,17 @@ impl LanguageModel for OllamaLanguageModel { let request = self.to_ollama_request(request); let http_client = self.http_client.clone(); - let Ok(api_url) = cx.update(|cx| { - let settings = &AllLanguageModelSettings::get_global(cx).ollama; - settings.api_url.clone() + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = OllamaLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { return futures::future::ready(Err(anyhow!("App state dropped").into())).boxed(); }; let future = self.request_limiter.stream(async move { - let stream = stream_chat_completion(http_client.as_ref(), &api_url, request).await?; + let stream = + stream_chat_completion(http_client.as_ref(), &api_url, api_key.as_deref(), request) + .await?; let stream = map_to_language_model_completion_events(stream); Ok(stream) }); @@ -474,6 +558,9 @@ fn map_to_language_model_completion_events( ChatMessage::System { content } => { events.push(Ok(LanguageModelCompletionEvent::Text(content))); } + ChatMessage::Tool { content, .. } => { + events.push(Ok(LanguageModelCompletionEvent::Text(content))); + } ChatMessage::Assistant { content, tool_calls, @@ -536,138 +623,306 @@ fn map_to_language_model_completion_events( } struct ConfigurationView { - state: gpui::Entity, - loading_models_task: Option>, + api_key_editor: Entity, + api_url_editor: Entity, + state: Entity, } impl ConfigurationView { - pub fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { - let loading_models_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = state - .update(cx, |state, cx| state.authenticate(cx)) - .log_err() - { - task.await.log_err(); - } - this.update(cx, |this, cx| { - this.loading_models_task = None; - cx.notify(); - }) - .log_err(); - } - })); + pub fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { + let api_key_editor = cx.new(|cx| InputField::new(window, cx, "63e02e...").label("API key")); + + let api_url_editor = cx.new(|cx| { + let input = InputField::new(window, cx, OLLAMA_API_URL).label("API URL"); + input.set_text(OllamaLanguageModelProvider::api_url(cx), window, cx); + input + }); + + cx.observe(&state, |_, _, cx| { + cx.notify(); + }) + .detach(); Self { + api_key_editor, + api_url_editor, state, - loading_models_task, } } fn retry_connection(&self, cx: &mut App) { self.state - .update(cx, |state, cx| state.fetch_models(cx)) - .detach_and_log_err(cx); + .update(cx, |state, cx| state.restart_fetch_models_task(cx)); } -} -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let is_authenticated = self.state.read(cx).is_authenticated(); + fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { + return; + } - let ollama_intro = - "Get up & running with Llama 3.3, Mistral, Gemma 2, and other LLMs with Ollama."; + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); - if self.loading_models_task.is_some() { - div().child(Label::new("Loading models...")).into_any() - } else { + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? + .await + }) + .detach_and_log_err(cx); + } + + fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await + }) + .detach_and_log_err(cx); + + cx.notify(); + } + + fn save_api_url(&mut self, cx: &mut Context) { + let api_url = self.api_url_editor.read(cx).text(cx).trim().to_string(); + let current_url = OllamaLanguageModelProvider::api_url(cx); + if !api_url.is_empty() && &api_url != ¤t_url { + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _| { + settings + .language_models + .get_or_insert_default() + .ollama + .get_or_insert_default() + .api_url = Some(api_url); + }); + } + } + + fn reset_api_url(&mut self, window: &mut Window, cx: &mut Context) { + self.api_url_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + let fs = ::global(cx); + update_settings_file(fs, cx, |settings, _cx| { + if let Some(settings) = settings + .language_models + .as_mut() + .and_then(|models| models.ollama.as_mut()) + { + settings.api_url = Some(OLLAMA_API_URL.into()); + } + }); + cx.notify(); + } + + fn render_instructions() -> Div { + v_flex() + .gap_2() + .child(Label::new( + "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \ + Can provide access to Llama, Mistral, Gemma, and hundreds of other models.", + )) + .child(Label::new("To use local Ollama:")) + .child( + List::new() + .child(InstructionListItem::new( + "Download and install Ollama from", + Some("ollama.com"), + Some("https://ollama.com/download"), + )) + .child(InstructionListItem::text_only( + "Start Ollama and download a model: `ollama run gpt-oss:20b`", + )) + .child(InstructionListItem::text_only( + "Click 'Connect' below to start using Ollama in Zed", + )), + ) + .child(Label::new( + "Alternatively, you can connect to an Ollama server by specifying its \ + URL and API key (may not be required):", + )) + } + + fn render_api_key_editor(&self, cx: &Context) -> Div { + let state = self.state.read(cx); + let env_var_set = state.api_key_state.is_from_env_var(); + + if !state.api_key_state.has_key() { v_flex() - .gap_2() + .on_action(cx.listener(Self::save_api_key)) + .child(self.api_key_editor.clone()) + .child( + Label::new( + format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.") + ) + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + h_flex() + .p_3() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child( + Label::new( + if env_var_set { + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable.") + } else { + "API key configured".to_string() + } + ) + ) + ) .child( - v_flex().gap_1().child(Label::new(ollama_intro)).child( - List::new() - .child(InstructionListItem::text_only("Ollama must be running with at least one model installed to use it in the assistant.")) - .child(InstructionListItem::text_only( - "Once installed, try `ollama run llama3.2`", - )), - ), + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) + } + } + + fn render_api_url_editor(&self, cx: &Context) -> Div { + let api_url = OllamaLanguageModelProvider::api_url(cx); + let custom_api_url_set = api_url != OLLAMA_API_URL; + + if custom_api_url_set { + h_flex() + .p_3() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) .child( h_flex() - .w_full() - .justify_between() .gap_2() - .child( - h_flex() - .w_full() - .gap_2() - .map(|this| { - if is_authenticated { - this.child( - Button::new("ollama-site", "Ollama") - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE)) - .into_any_element(), - ) - } else { - this.child( - Button::new( - "download_ollama_button", - "Download Ollama", - ) + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(v_flex().gap_1().child(Label::new(api_url))), + ) + .child( + Button::new("reset-api-url", "Reset API URL") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .on_click( + cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), + ), + ) + } else { + v_flex() + .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| { + this.save_api_url(cx); + cx.notify(); + })) + .gap_2() + .child(self.api_url_editor.clone()) + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.state.read(cx).is_authenticated(); + + v_flex() + .gap_2() + .child(Self::render_instructions()) + .child(self.render_api_url_editor(cx)) + .child(self.render_api_key_editor(cx)) + .child( + h_flex() + .w_full() + .justify_between() + .gap_2() + .child( + h_flex() + .w_full() + .gap_2() + .map(|this| { + if is_authenticated { + this.child( + Button::new("ollama-site", "Ollama") + .style(ButtonStyle::Subtle) + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE)) + .into_any_element(), + ) + } else { + this.child( + Button::new("download_ollama_button", "Download Ollama") .style(ButtonStyle::Subtle) .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .icon_color(Color::Muted) .on_click(move |_, _, cx| { cx.open_url(OLLAMA_DOWNLOAD_URL) }) .into_any_element(), - ) - } - }) - .child( - Button::new("view-models", "View All Models") - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)), - ), - ) - .map(|this| { - if is_authenticated { - this.child( - ButtonLike::new("connected") - .disabled(true) - .cursor_style(gpui::CursorStyle::Arrow) - .child( - h_flex() - .gap_2() - .child(Indicator::dot().color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), - ), - ) - } else { - this.child( - Button::new("retry_ollama_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayFilled) - .on_click(cx.listener(move |this, _, _, cx| { + ) + } + }) + .child( + Button::new("view-models", "View All Models") + .style(ButtonStyle::Subtle) + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)), + ), + ) + .map(|this| { + if is_authenticated { + this.child( + ButtonLike::new("connected") + .disabled(true) + .cursor_style(CursorStyle::Arrow) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new("Connected")) + .into_any_element(), + ), + ) + } else { + this.child( + Button::new("retry_ollama_models", "Connect") + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon(IconName::PlayOutlined) + .on_click( + cx.listener(move |this, _, _, cx| { this.retry_connection(cx) - })), - ) - } - }) - ) - .into_any() - } + }), + ), + ) + } + }), + ) } } diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index eaf8d885b304ea0d6526c8e61d3a71a467f41376..6c3f063c1111f31a37325f0767a14e8533c1b23f 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -1,10 +1,8 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::{BTreeMap, HashMap}; -use credentials_provider::CredentialsProvider; - use futures::Stream; -use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window}; +use futures::{FutureExt, StreamExt, future, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -14,138 +12,81 @@ use language_model::{ RateLimiter, Role, StopReason, TokenUsage, }; use menu; -use open_ai::{ImageUrl, Model, ReasoningEffort, ResponseStreamEvent, stream_completion}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use open_ai::{ + ImageUrl, Model, OPEN_AI_API_URL, ReasoningEffort, ResponseStreamEvent, stream_completion, +}; +use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr as _; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; - use ui::{ElevationIndex, List, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::{EnvVar, env_var}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME; +const API_KEY_ENV_VAR_NAME: &str = "OPENAI_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenAiSettings { pub api_url: String, pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, - pub reasoning_effort: Option, -} - pub struct OpenAiLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } -const OPENAI_API_KEY_VAR: &str = "OPENAI_API_KEY"; - impl State { - // fn is_authenticated(&self) -> bool { - self.api_key.is_some() - } - - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .openai - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + self.api_key_state.has_key() } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .openai - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .openai - .api_url - .clone(); - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(OPENAI_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl OpenAiLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_this: &mut State, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -160,12 +101,25 @@ impl OpenAiLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn settings(cx: &App) -> &OpenAiSettings { + &crate::AllLanguageModelSettings::get_global(cx).openai + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + open_ai::OPEN_AI_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for OpenAiLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -202,10 +156,7 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { } // Override with available models from settings - for model in &AllLanguageModelSettings::get_global(cx) - .openai - .available_models - { + for model in &OpenAiLanguageModelProvider::settings(cx).available_models { models.insert( model.name.clone(), open_ai::Model::Custom { @@ -233,20 +184,26 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct OpenAiLanguageModel { id: LanguageModelId, model: open_ai::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -259,11 +216,12 @@ impl OpenAiLanguageModel { ) -> BoxFuture<'static, Result>>> { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).openai; - (state.api_key.clone(), settings.api_url.clone()) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = OpenAiLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let future = self.request_limiter.stream(async move { @@ -399,7 +357,7 @@ pub fn into_open_ai( match content { MessageContent::Text(text) | MessageContent::Thinking { text, .. } => { add_message_content_part( - open_ai::MessagePart::Text { text: text }, + open_ai::MessagePart::Text { text }, message.role, &mut messages, ) @@ -581,7 +539,9 @@ impl OpenAiEventMapper { }; if let Some(content) = choice.delta.content.clone() { - events.push(Ok(LanguageModelCompletionEvent::Text(content))); + if !content.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Text(content))); + } } if let Some(tool_calls) = choice.delta.tool_calls.as_ref() { @@ -715,15 +675,15 @@ pub fn count_open_ai_tokens( } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| { - SingleLineInput::new( + InputField::new( window, cx, "sk-000000000000000000000000000000000000000000000000", @@ -761,45 +721,35 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self - .api_key_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - // Don't proceed if no API key is provided and we're not authenticated - if api_key.is_empty() && !self.state.read(cx).is_authenticated() { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor.update(cx, |input, cx| { - input.editor.update(cx, |editor, cx| { - editor.set_text("", window, cx); - }); - }); + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -809,7 +759,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -831,10 +781,11 @@ impl Render for ConfigurationView { ) .child(self.api_key_editor.clone()) .child( - Label::new( - format!("You can also assign the {OPENAI_API_KEY_VAR} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), + Label::new(format!( + "You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), ) .child( Label::new( @@ -857,9 +808,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {OPENAI_API_KEY_VAR} environment variable.") + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + let api_url = OpenAiLanguageModelProvider::api_url(cx); + if api_url == OPEN_AI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -870,7 +826,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .layer(ElevationIndex::ModalSurface) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENAI_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 5f546f52194d37a4ee97e59ed38e681e0ac26440..c8a1da5f5af9feb72ec514854403d15d6e73774b 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,29 +1,27 @@ -use anyhow::{Context as _, Result, anyhow}; -use credentials_provider::CredentialsProvider; - +use anyhow::{Result, anyhow}; use convert_case::{Case, Casing}; -use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window}; +use futures::{FutureExt, StreamExt, future, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, }; use menu; use open_ai::{ResponseStreamEvent, stream_completion}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::sync::Arc; - use ui::{ElevationIndex, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::EnvVar; -use crate::AllLanguageModelSettings; +use crate::api_key::ApiKeyState; use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai}; +pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; +pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenAiCompatibleSettings { @@ -31,122 +29,76 @@ pub struct OpenAiCompatibleSettings { pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, -} - pub struct OpenAiCompatibleLanguageModelProvider { id: LanguageModelProviderId, name: LanguageModelProviderName, http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { id: Arc, - env_var_name: Arc, - api_key: Option, - api_key_from_env: bool, + api_key_env_var: EnvVar, + api_key_state: ApiKeyState, settings: OpenAiCompatibleSettings, - _subscription: Subscription, } impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() - } - - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = self.settings.api_url.clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + self.api_key_state.has_key() } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = self.settings.api_url.clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = SharedString::new(self.settings.api_url.as_str()); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let env_var_name = self.env_var_name.clone(); - let api_url = self.settings.api_url.clone(); - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(env_var_name.as_ref()) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = SharedString::new(self.settings.api_url.clone()); + self.api_key_state.load_if_needed( + api_url, + &self.api_key_env_var, + |this| &mut this.api_key_state, + cx, + ) } } impl OpenAiCompatibleLanguageModelProvider { pub fn new(id: Arc, http_client: Arc, cx: &mut App) -> Self { fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { - AllLanguageModelSettings::get_global(cx) + crate::AllLanguageModelSettings::get_global(cx) .openai_compatible .get(id) } - let state = cx.new(|cx| State { - id: id.clone(), - env_var_name: format!("{}_API_KEY", id).to_case(Case::Constant).into(), - settings: resolve_settings(&id, cx).cloned().unwrap_or_default(), - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|this: &mut State, cx| { - let Some(settings) = resolve_settings(&this.id, cx) else { + let api_key_env_var_name = format!("{}_API_KEY", id).to_case(Case::UpperSnake).into(); + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let Some(settings) = resolve_settings(&this.id, cx).cloned() else { return; }; - if &this.settings != settings { - this.settings = settings.clone(); + if &this.settings != &settings { + let api_url = SharedString::new(settings.api_url.as_str()); + this.api_key_state.handle_url_change( + api_url, + &this.api_key_env_var, + |this| &mut this.api_key_state, + cx, + ); + this.settings = settings; cx.notify(); } - }), + }) + .detach(); + let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); + State { + id: id.clone(), + api_key_env_var: EnvVar::new(api_key_env_var_name), + api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())), + settings, + } }); Self { @@ -173,7 +125,7 @@ impl OpenAiCompatibleLanguageModelProvider { impl LanguageModelProviderState for OpenAiCompatibleLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -222,13 +174,19 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } @@ -237,7 +195,7 @@ pub struct OpenAiCompatibleLanguageModel { provider_id: LanguageModelProviderId, provider_name: LanguageModelProviderName, model: AvailableModel, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -250,10 +208,15 @@ impl OpenAiCompatibleLanguageModel { ) -> BoxFuture<'static, Result>>> { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, _| { - (state.api_key.clone(), state.settings.api_url.clone()) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, _cx| { + let api_url = &state.settings.api_url; + ( + state.api_key_state.key(api_url), + state.settings.api_url.clone(), + ) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let provider = self.provider_name.clone(); @@ -293,17 +256,21 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } fn supports_tools(&self) -> bool { - true + self.model.capabilities.tools + } + + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { + LanguageModelToolSchemaFormat::JsonSchemaSubset } fn supports_images(&self) -> bool { - false + self.model.capabilities.images } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { - LanguageModelToolChoice::Auto => true, - LanguageModelToolChoice::Any => true, + LanguageModelToolChoice::Auto => self.model.capabilities.tools, + LanguageModelToolChoice::Any => self.model.capabilities.tools, LanguageModelToolChoice::None => true, } } @@ -355,13 +322,11 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { LanguageModelCompletionError, >, > { - let supports_parallel_tool_call = true; - let supports_prompt_cache_key = false; let request = into_open_ai( request, &self.model.name, - supports_parallel_tool_call, - supports_prompt_cache_key, + self.model.capabilities.parallel_tool_calls, + self.model.capabilities.prompt_cache_key, self.max_output_tokens(), None, ); @@ -375,15 +340,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| { - SingleLineInput::new( + InputField::new( window, cx, "000000000000000000000000000000000000000000000000000", @@ -421,56 +386,47 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self - .api_key_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - // Don't proceed if no API key is provided and we're not authenticated - if api_key.is_empty() && !self.state.read(cx).is_authenticated() { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor.update(cx, |input, cx| { - input.editor.update(cx, |editor, cx| { - editor.set_text("", window, cx); - }); - }); + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); } - fn should_render_editor(&self, cx: &mut Context) -> bool { + fn should_render_editor(&self, cx: &Context) -> bool { !self.state.read(cx).is_authenticated() } } impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; - let env_var_name = self.state.read(cx).env_var_name.clone(); + let state = self.state.read(cx); + let env_var_set = state.api_key_state.is_from_env_var(); + let env_var_name = &state.api_key_env_var.name; let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -502,9 +458,9 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {env_var_name} environment variable.") + format!("API key set in {env_var_name} environment variable") } else { - "API key configured.".to_string() + format!("API key configured for {}", truncate_and_trailoff(&state.settings.api_url, 32)) })), ) .child( diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 3a492086f16e1f9b53a196b7bb2e9817a3cac0e7..50131f0a8ef7076420df9a9dc1dbdcd4c840a5c2 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -1,11 +1,7 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::HashMap; -use credentials_provider::CredentialsProvider; -use editor::{Editor, EditorElement, EditorStyle}; -use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; -use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace, -}; +use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -15,180 +11,99 @@ use language_model::{ LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; use open_router::{ - Model, ModelMode as OpenRouterModelMode, ResponseStreamEvent, list_models, stream_completion, + Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr as _; -use std::sync::Arc; -use theme::ThemeSettings; +use std::sync::{Arc, LazyLock}; use ui::{Icon, IconName, List, Tooltip, prelude::*}; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use zed_env_vars::{EnvVar, env_var}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); +const API_KEY_ENV_VAR_NAME: &str = "OPENROUTER_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenRouterSettings { pub api_url: String, pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, - pub supports_tools: Option, - pub supports_images: Option, - pub mode: Option, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ModelMode { - #[default] - Default, - Thinking { - budget_tokens: Option, - }, -} - -impl From for OpenRouterModelMode { - fn from(value: ModelMode) -> Self { - match value { - ModelMode::Default => OpenRouterModelMode::Default, - ModelMode::Thinking { budget_tokens } => { - OpenRouterModelMode::Thinking { budget_tokens } - } - } - } -} - -impl From for ModelMode { - fn from(value: OpenRouterModelMode) -> Self { - match value { - OpenRouterModelMode::Default => ModelMode::Default, - OpenRouterModelMode::Thinking { budget_tokens } => { - ModelMode::Thinking { budget_tokens } - } - } - } -} - pub struct OpenRouterLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, + api_key_state: ApiKeyState, http_client: Arc, available_models: Vec, - fetch_models_task: Option>>, - settings: OpenRouterSettings, - _subscription: Subscription, + fetch_models_task: Option>>, } -const OPENROUTER_API_KEY_VAR: &str = "OPENROUTER_API_KEY"; - impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() + self.api_key_state.has_key() } - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .open_router - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .open_router - .api_url - .clone(); - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.restart_fetch_models_task(cx); - cx.notify(); - }) - }) - } + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + let task = self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let api_url = AllLanguageModelSettings::get_global(cx) - .open_router - .api_url - .clone(); cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(OPENROUTER_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key) - .context(format!("invalid {} API key", PROVIDER_NAME))?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - this.restart_fetch_models_task(cx); - cx.notify(); - })?; - - Ok(()) + let result = task.await; + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + result }) } - fn fetch_models(&mut self, cx: &mut Context) -> Task> { - let settings = &AllLanguageModelSettings::get_global(cx).open_router; + fn fetch_models( + &mut self, + cx: &mut Context, + ) -> Task> { let http_client = self.http_client.clone(); - let api_url = settings.api_url.clone(); - + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + let Some(api_key) = self.api_key_state.key(&api_url) else { + return Task::ready(Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })); + }; cx.spawn(async move |this, cx| { - let models = list_models(http_client.as_ref(), &api_url).await?; + let models = list_models(http_client.as_ref(), &api_url, &api_key) + .await + .map_err(|e| { + LanguageModelCompletionError::Other(anyhow::anyhow!( + "OpenRouter error: {:?}", + e + )) + })?; this.update(cx, |this, cx| { this.available_models = models; cx.notify(); }) + .map_err(|e| LanguageModelCompletionError::Other(e))?; + + Ok(()) }) } @@ -196,33 +111,52 @@ impl State { if self.is_authenticated() { let task = self.fetch_models(cx); self.fetch_models_task.replace(task); + } else { + self.available_models = Vec::new(); } } } impl OpenRouterLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - http_client: http_client.clone(), - available_models: Vec::new(), - fetch_models_task: None, - settings: OpenRouterSettings::default(), - _subscription: cx.observe_global::(|this: &mut State, cx| { - let current_settings = &AllLanguageModelSettings::get_global(cx).open_router; - let settings_changed = current_settings != &this.settings; - if settings_changed { - this.settings = current_settings.clone(); - this.restart_fetch_models_task(cx); + let state = cx.new(|cx| { + cx.observe_global::({ + let mut last_settings = OpenRouterLanguageModelProvider::settings(cx).clone(); + move |this: &mut State, cx| { + let current_settings = OpenRouterLanguageModelProvider::settings(cx); + let settings_changed = current_settings != &last_settings; + if settings_changed { + last_settings = current_settings.clone(); + this.authenticate(cx).detach(); + cx.notify(); + } } - cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + http_client: http_client.clone(), + available_models: Vec::new(), + fetch_models_task: None, + } }); Self { http_client, state } } + fn settings(cx: &App) -> &OpenRouterSettings { + &crate::AllLanguageModelSettings::get_global(cx).open_router + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + OPEN_ROUTER_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } + fn create_language_model(&self, model: open_router::Model) -> Arc { Arc::new(OpenRouterLanguageModel { id: LanguageModelId::from(model.id().to_string()), @@ -237,7 +171,7 @@ impl OpenRouterLanguageModelProvider { impl LanguageModelProviderState for OpenRouterLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -267,17 +201,15 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { let mut models_from_api = self.state.read(cx).available_models.clone(); let mut settings_models = Vec::new(); - for model in &AllLanguageModelSettings::get_global(cx) - .open_router - .available_models - { + for model in &Self::settings(cx).available_models { settings_models.push(open_router::Model { name: model.name.clone(), display_name: model.display_name.clone(), max_tokens: model.max_tokens, supports_tools: model.supports_tools, supports_images: model.supports_images, - mode: model.mode.clone().unwrap_or_default().into(), + mode: model.mode.unwrap_or_default(), + provider: model.provider.clone(), }); } @@ -306,20 +238,26 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct OpenRouterLanguageModel { id: LanguageModelId, model: open_router::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -329,27 +267,35 @@ impl OpenRouterLanguageModel { &self, request: open_router::Request, cx: &AsyncApp, - ) -> BoxFuture<'static, Result>>> - { + ) -> BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + > { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).open_router; - (state.api_key.clone(), settings.api_url.clone()) + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!( - "App state dropped: Unable to read API key or API URL from the application state" - ))) - .boxed(); + return future::ready(Err(anyhow!("App state dropped").into())).boxed(); }; - let future = self.request_limiter.stream(async move { - let api_key = api_key.ok_or_else(|| anyhow!("Missing OpenRouter API Key"))?; - let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request); - let response = request.await?; - Ok(response) - }); - - async move { Ok(future.await?.boxed()) }.boxed() + async move { + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + }); + }; + let request = + open_router::stream_completion(http_client.as_ref(), &api_url, &api_key, request); + request.await.map_err(Into::into) + } + .boxed() } } @@ -376,7 +322,7 @@ impl LanguageModel for OpenRouterLanguageModel { fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { let model_id = self.model.id().trim().to_lowercase(); - if model_id.contains("gemini") || model_id.contains("grok-4") { + if model_id.contains("gemini") || model_id.contains("grok") { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema @@ -430,12 +376,12 @@ impl LanguageModel for OpenRouterLanguageModel { >, > { let request = into_open_router(request, &self.model, self.max_output_tokens()); - let completions = self.stream_completion(request, cx); - async move { - let mapper = OpenRouterEventMapper::new(); - Ok(mapper.map_stream(completions.await?).boxed()) - } - .boxed() + let request = self.stream_completion(request, cx); + let future = self.request_limiter.stream(async move { + let response = request.await?; + Ok(OpenRouterEventMapper::new().map_stream(response)) + }); + async move { Ok(future.await?.boxed()) }.boxed() } } @@ -551,6 +497,7 @@ pub fn into_open_router( LanguageModelToolChoice::Any => open_router::ToolChoice::Required, LanguageModelToolChoice::None => open_router::ToolChoice::None, }), + provider: model.provider.clone(), } } @@ -603,13 +550,17 @@ impl OpenRouterEventMapper { pub fn map_stream( mut self, - events: Pin>>>, + events: Pin< + Box< + dyn Send + Stream>, + >, + >, ) -> impl Stream> { events.flat_map(move |event| { futures::stream::iter(match event { Ok(event) => self.map_event(event), - Err(error) => vec![Err(LanguageModelCompletionError::from(anyhow!(error)))], + Err(error) => vec![Err(error.into())], }) }) } @@ -741,18 +692,19 @@ pub fn count_open_router_tokens( } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor - .set_placeholder_text("sk_or_000000000000000000000000000000000000000000000000", cx); - editor + InputField::new( + window, + cx, + "sk_or_000000000000000000000000000000000000000000000000", + ) }); cx.observe(&state, |_, _, cx| { @@ -786,20 +738,22 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx); + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { @@ -808,36 +762,11 @@ impl ConfigurationView { let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); - } - - fn render_api_key_editor(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - white_space: WhiteSpace::Normal, - ..Default::default() - }; - EditorElement::new( - &self.api_key_editor, - EditorStyle { - background: cx.theme().colors().editor_background, - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -847,7 +776,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); if self.load_credentials_task.is_some() { div().child(Label::new("Loading credentials...")).into_any() @@ -870,21 +799,10 @@ impl Render for ConfigurationView { "Paste your API key below and hit enter to start using the assistant", )), ) - .child( - h_flex() - .w_full() - .my_2() - .px_2() - .py_1() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .child(self.render_api_key_editor(cx)), - ) + .child(self.api_key_editor.clone()) .child( Label::new( - format!("You can also assign the {OPENROUTER_API_KEY_VAR} environment variable and restart Zed."), + format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small).color(Color::Muted), ) @@ -903,9 +821,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {OPENROUTER_API_KEY_VAR} environment variable.") + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + let api_url = OpenRouterLanguageModelProvider::api_url(cx); + if api_url == OPEN_ROUTER_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -916,7 +839,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .disabled(env_var_set) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENROUTER_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 9f447cb68b96739b0ab2997ed25e6307c6db5ba0..ff5d4567c60423939c38d00a1203f613df353ccf 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -1,8 +1,7 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::BTreeMap; -use credentials_provider::CredentialsProvider; -use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window}; +use futures::{FutureExt, StreamExt, future, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -10,142 +9,79 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, }; -use menu; use open_ai::ResponseStreamEvent; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +pub use settings::VercelAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use vercel::Model; - use ui::{ElevationIndex, List, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use vercel::{Model, VERCEL_API_URL}; +use zed_env_vars::{EnvVar, env_var}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel"); -#[derive(Default, Clone, Debug, PartialEq)] +const API_KEY_ENV_VAR_NAME: &str = "VERCEL_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); + +#[derive(Clone, Debug, PartialEq)] pub struct VercelSettings { pub api_url: String, pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, -} - pub struct VercelLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } -const VERCEL_API_KEY_VAR: &str = "VERCEL_API_KEY"; - impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() + self.api_key_state.has_key() } - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).vercel; - let api_url = if settings.api_url.is_empty() { - vercel::VERCEL_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = VercelLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).vercel; - let api_url = if settings.api_url.is_empty() { - vercel::VERCEL_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) - } - - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).vercel; - let api_url = if settings.api_url.is_empty() { - vercel::VERCEL_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(VERCEL_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = VercelLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl VercelLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_this: &mut State, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -160,12 +96,25 @@ impl VercelLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn settings(cx: &App) -> &VercelSettings { + &crate::AllLanguageModelSettings::get_global(cx).vercel + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + VERCEL_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for VercelLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } @@ -200,10 +149,7 @@ impl LanguageModelProvider for VercelLanguageModelProvider { } } - for model in &AllLanguageModelSettings::get_global(cx) - .vercel - .available_models - { + for model in &Self::settings(cx).available_models { models.insert( model.name.clone(), vercel::Model::Custom { @@ -230,20 +176,26 @@ impl LanguageModelProvider for VercelLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct VercelLanguageModel { id: LanguageModelId, model: vercel::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -256,16 +208,12 @@ impl VercelLanguageModel { ) -> BoxFuture<'static, Result>>> { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).vercel; - let api_url = if settings.api_url.is_empty() { - vercel::VERCEL_API_URL.to_string() - } else { - settings.api_url.clone() - }; - (state.api_key.clone(), api_url) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = VercelLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let future = self.request_limiter.stream(async move { @@ -414,15 +362,15 @@ pub fn count_vercel_tokens( } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| { - SingleLineInput::new( + InputField::new( window, cx, "v1:0000000000000000000000000000000000000000000000000", @@ -461,45 +409,35 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self - .api_key_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - // Don't proceed if no API key is provided and we're not authenticated - if api_key.is_empty() && !self.state.read(cx).is_authenticated() { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor.update(cx, |input, cx| { - input.editor.update(cx, |editor, cx| { - editor.set_text("", window, cx); - }); - }); + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -509,7 +447,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -529,7 +467,7 @@ impl Render for ConfigurationView { .child(self.api_key_editor.clone()) .child( Label::new(format!( - "You can also assign the {VERCEL_API_KEY_VAR} environment variable and restart Zed." + "You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." )) .size(LabelSize::Small) .color(Color::Muted), @@ -554,9 +492,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {VERCEL_API_KEY_VAR} environment variable.") + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + let api_url = VercelLanguageModelProvider::api_url(cx); + if api_url == VERCEL_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -567,7 +510,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .layer(ElevationIndex::ModalSurface) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {VERCEL_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index fed6fe92bfaac45a810f2531e934310f248f17b6..8b51ca12099691e4ae70084b509c6c40547bd432 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -1,8 +1,7 @@ -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Result, anyhow}; use collections::BTreeMap; -use credentials_provider::CredentialsProvider; -use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window}; +use futures::{FutureExt, StreamExt, future, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, @@ -10,23 +9,24 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role, }; -use menu; use open_ai::ResponseStreamEvent; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use x_ai::Model; - use ui::{ElevationIndex, List, Tooltip, prelude::*}; -use ui_input::SingleLineInput; -use util::ResultExt; +use ui_input::InputField; +use util::{ResultExt, truncate_and_trailoff}; +use x_ai::{Model, XAI_API_URL}; +use zed_env_vars::{EnvVar, env_var}; + +use crate::{api_key::ApiKeyState, ui::InstructionListItem}; -use crate::{AllLanguageModelSettings, ui::InstructionListItem}; +const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); +const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); -const PROVIDER_ID: &str = "x_ai"; -const PROVIDER_NAME: &str = "xAI"; +const API_KEY_ENV_VAR_NAME: &str = "XAI_API_KEY"; +static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); #[derive(Default, Clone, Debug, PartialEq)] pub struct XAiSettings { @@ -34,118 +34,54 @@ pub struct XAiSettings { pub available_models: Vec, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct AvailableModel { - pub name: String, - pub display_name: Option, - pub max_tokens: u64, - pub max_output_tokens: Option, - pub max_completion_tokens: Option, -} - pub struct XAiLanguageModelProvider { http_client: Arc, - state: gpui::Entity, + state: Entity, } pub struct State { - api_key: Option, - api_key_from_env: bool, - _subscription: Subscription, + api_key_state: ApiKeyState, } -const XAI_API_KEY_VAR: &str = "XAI_API_KEY"; - impl State { fn is_authenticated(&self) -> bool { - self.api_key.is_some() - } - - fn reset_api_key(&self, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).x_ai; - let api_url = if settings.api_url.is_empty() { - x_ai::XAI_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - credentials_provider - .delete_credentials(&api_url, &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = None; - this.api_key_from_env = false; - cx.notify(); - }) - }) + self.api_key_state.has_key() } - fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).x_ai; - let api_url = if settings.api_url.is_empty() { - x_ai::XAI_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - credentials_provider - .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) - .await - .log_err(); - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - cx.notify(); - }) - }) + fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { + let api_url = XAiLanguageModelProvider::api_url(cx); + self.api_key_state + .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn authenticate(&self, cx: &mut Context) -> Task> { - if self.is_authenticated() { - return Task::ready(Ok(())); - } - - let credentials_provider = ::global(cx); - let settings = &AllLanguageModelSettings::get_global(cx).x_ai; - let api_url = if settings.api_url.is_empty() { - x_ai::XAI_API_URL.to_string() - } else { - settings.api_url.clone() - }; - cx.spawn(async move |this, cx| { - let (api_key, from_env) = if let Ok(api_key) = std::env::var(XAI_API_KEY_VAR) { - (api_key, true) - } else { - let (_, api_key) = credentials_provider - .read_credentials(&api_url, &cx) - .await? - .ok_or(AuthenticateError::CredentialsNotFound)?; - ( - String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, - false, - ) - }; - this.update(cx, |this, cx| { - this.api_key = Some(api_key); - this.api_key_from_env = from_env; - cx.notify(); - })?; - - Ok(()) - }) + fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = XAiLanguageModelProvider::api_url(cx); + self.api_key_state.load_if_needed( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ) } } impl XAiLanguageModelProvider { pub fn new(http_client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State { - api_key: None, - api_key_from_env: false, - _subscription: cx.observe_global::(|_this: &mut State, cx| { + let state = cx.new(|cx| { + cx.observe_global::(|this: &mut State, cx| { + let api_url = Self::api_url(cx); + this.api_key_state.handle_url_change( + api_url, + &API_KEY_ENV_VAR, + |this| &mut this.api_key_state, + cx, + ); cx.notify(); - }), + }) + .detach(); + State { + api_key_state: ApiKeyState::new(Self::api_url(cx)), + } }); Self { http_client, state } @@ -160,23 +96,36 @@ impl XAiLanguageModelProvider { request_limiter: RateLimiter::new(4), }) } + + fn settings(cx: &App) -> &XAiSettings { + &crate::AllLanguageModelSettings::get_global(cx).x_ai + } + + fn api_url(cx: &App) -> SharedString { + let api_url = &Self::settings(cx).api_url; + if api_url.is_empty() { + XAI_API_URL.into() + } else { + SharedString::new(api_url.as_str()) + } + } } impl LanguageModelProviderState for XAiLanguageModelProvider { type ObservableEntity = State; - fn observable_entity(&self) -> Option> { + fn observable_entity(&self) -> Option> { Some(self.state.clone()) } } impl LanguageModelProvider for XAiLanguageModelProvider { fn id(&self) -> LanguageModelProviderId { - LanguageModelProviderId(PROVIDER_ID.into()) + PROVIDER_ID } fn name(&self) -> LanguageModelProviderName { - LanguageModelProviderName(PROVIDER_NAME.into()) + PROVIDER_NAME } fn icon(&self) -> IconName { @@ -200,10 +149,7 @@ impl LanguageModelProvider for XAiLanguageModelProvider { } } - for model in &AllLanguageModelSettings::get_global(cx) - .x_ai - .available_models - { + for model in &Self::settings(cx).available_models { models.insert( model.name.clone(), x_ai::Model::Custom { @@ -212,6 +158,9 @@ impl LanguageModelProvider for XAiLanguageModelProvider { max_tokens: model.max_tokens, max_output_tokens: model.max_output_tokens, max_completion_tokens: model.max_completion_tokens, + supports_images: model.supports_images, + supports_tools: model.supports_tools, + parallel_tool_calls: model.parallel_tool_calls, }, ); } @@ -230,20 +179,26 @@ impl LanguageModelProvider for XAiLanguageModelProvider { self.state.update(cx, |state, cx| state.authenticate(cx)) } - fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.state.update(cx, |state, cx| state.reset_api_key(cx)) + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) } } pub struct XAiLanguageModel { id: LanguageModelId, model: x_ai::Model, - state: gpui::Entity, + state: Entity, http_client: Arc, request_limiter: RateLimiter, } @@ -256,20 +211,20 @@ impl XAiLanguageModel { ) -> BoxFuture<'static, Result>>> { let http_client = self.http_client.clone(); - let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { - let settings = &AllLanguageModelSettings::get_global(cx).x_ai; - let api_url = if settings.api_url.is_empty() { - x_ai::XAI_API_URL.to_string() - } else { - settings.api_url.clone() - }; - (state.api_key.clone(), api_url) + + let Ok((api_key, api_url)) = self.state.read_with(cx, |state, cx| { + let api_url = XAiLanguageModelProvider::api_url(cx); + (state.api_key_state.key(&api_url), api_url) }) else { - return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + return future::ready(Err(anyhow!("App state dropped"))).boxed(); }; let future = self.request_limiter.stream(async move { - let api_key = api_key.context("Missing xAI API Key")?; + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + }); + }; let request = open_ai::stream_completion(http_client.as_ref(), &api_url, &api_key, request); let response = request.await?; @@ -290,11 +245,11 @@ impl LanguageModel for XAiLanguageModel { } fn provider_id(&self) -> LanguageModelProviderId { - LanguageModelProviderId(PROVIDER_ID.into()) + PROVIDER_ID } fn provider_name(&self) -> LanguageModelProviderName { - LanguageModelProviderName(PROVIDER_NAME.into()) + PROVIDER_NAME } fn supports_tools(&self) -> bool { @@ -314,7 +269,7 @@ impl LanguageModel for XAiLanguageModel { } fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { let model_id = self.model.id().trim().to_lowercase(); - if model_id.eq(x_ai::Model::Grok4.id()) { + if model_id.eq(x_ai::Model::Grok4.id()) || model_id.eq(x_ai::Model::GrokCodeFast1.id()) { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema @@ -404,15 +359,15 @@ pub fn count_xai_tokens( } struct ConfigurationView { - api_key_editor: Entity, - state: gpui::Entity, + api_key_editor: Entity, + state: Entity, load_credentials_task: Option>, } impl ConfigurationView { - fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| { - SingleLineInput::new( + InputField::new( window, cx, "xai-0000000000000000000000000000000000000000000000000", @@ -451,45 +406,35 @@ impl ConfigurationView { } fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self - .api_key_editor - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string(); - - // Don't proceed if no API key is provided and we're not authenticated - if api_key.is_empty() && !self.state.read(cx).is_authenticated() { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { return; } + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |editor, cx| editor.set_text("", window, cx)); + let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { state - .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx))? .await }) .detach_and_log_err(cx); - - cx.notify(); } fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor.update(cx, |input, cx| { - input.editor.update(cx, |editor, cx| { - editor.set_text("", window, cx); - }); - }); + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); let state = self.state.clone(); cx.spawn_in(window, async move |_, cx| { - state.update(cx, |state, cx| state.reset_api_key(cx))?.await + state + .update(cx, |state, cx| state.set_api_key(None, cx))? + .await }) .detach_and_log_err(cx); - - cx.notify(); } fn should_render_editor(&self, cx: &mut Context) -> bool { @@ -499,7 +444,7 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let env_var_set = self.state.read(cx).api_key_from_env; + let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); let api_key_section = if self.should_render_editor(cx) { v_flex() @@ -519,7 +464,7 @@ impl Render for ConfigurationView { .child(self.api_key_editor.clone()) .child( Label::new(format!( - "You can also assign the {XAI_API_KEY_VAR} environment variable and restart Zed." + "You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." )) .size(LabelSize::Small) .color(Color::Muted), @@ -544,9 +489,14 @@ impl Render for ConfigurationView { .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new(if env_var_set { - format!("API key set in {XAI_API_KEY_VAR} environment variable.") + format!("API key set in {API_KEY_ENV_VAR_NAME} environment variable") } else { - "API key configured.".to_string() + let api_url = XAiLanguageModelProvider::api_url(cx); + if api_url == XAI_API_URL { + "API key configured".to_string() + } else { + format!("API key configured for {}", truncate_and_trailoff(&api_url, 32)) + } })), ) .child( @@ -557,7 +507,7 @@ impl Render for ConfigurationView { .icon_position(IconPosition::Start) .layer(ElevationIndex::ModalSurface) .when(env_var_set, |this| { - this.tooltip(Tooltip::text(format!("To reset your API key, unset the {XAI_API_KEY_VAR} environment variable."))) + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable."))) }) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), ) diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index b163585aa7b745447381aa62f710e8c5dbdf469c..ce29be38431055ddce992552607259066ab9f3cb 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -1,27 +1,15 @@ use std::sync::Arc; -use anyhow::Result; use collections::HashMap; use gpui::App; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use settings::Settings; use crate::provider::{ - self, - anthropic::AnthropicSettings, - bedrock::AmazonBedrockSettings, - cloud::{self, ZedDotDevSettings}, - deepseek::DeepSeekSettings, - google::GoogleSettings, - lmstudio::LmStudioSettings, - mistral::MistralSettings, - ollama::OllamaSettings, - open_ai::OpenAiSettings, - open_ai_compatible::OpenAiCompatibleSettings, - open_router::OpenRouterSettings, - vercel::VercelSettings, - x_ai::XAiSettings, + anthropic::AnthropicSettings, bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings, + deepseek::DeepSeekSettings, google::GoogleSettings, lmstudio::LmStudioSettings, + mistral::MistralSettings, ollama::OllamaSettings, open_ai::OpenAiSettings, + open_ai_compatible::OpenAiCompatibleSettings, open_router::OpenRouterSettings, + vercel::VercelSettings, x_ai::XAiSettings, }; /// Initializes the language model settings. @@ -29,7 +17,6 @@ pub fn init_settings(cx: &mut App) { AllLanguageModelSettings::register(cx); } -#[derive(Default)] pub struct AllLanguageModelSettings { pub anthropic: AnthropicSettings, pub bedrock: AmazonBedrockSettings, @@ -46,280 +33,88 @@ pub struct AllLanguageModelSettings { pub zed_dot_dev: ZedDotDevSettings, } -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct AllLanguageModelSettingsContent { - pub anthropic: Option, - pub bedrock: Option, - pub deepseek: Option, - pub google: Option, - pub lmstudio: Option, - pub mistral: Option, - pub ollama: Option, - pub open_router: Option, - pub openai: Option, - pub openai_compatible: Option, OpenAiCompatibleSettingsContent>>, - pub vercel: Option, - pub x_ai: Option, - #[serde(rename = "zed.dev")] - pub zed_dot_dev: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct AnthropicSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct AmazonBedrockSettingsContent { - available_models: Option>, - endpoint_url: Option, - region: Option, - profile: Option, - authentication_method: Option, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct OllamaSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct LmStudioSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct DeepseekSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct MistralSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct OpenAiSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct OpenAiCompatibleSettingsContent { - pub api_url: String, - pub available_models: Vec, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct VercelSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct GoogleSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct XAiSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct ZedDotDevSettingsContent { - available_models: Option>, -} - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] -pub struct OpenRouterSettingsContent { - pub api_url: Option, - pub available_models: Option>, -} - impl settings::Settings for AllLanguageModelSettings { - const KEY: Option<&'static str> = Some("language_models"); - const PRESERVED_KEYS: Option<&'static [&'static str]> = Some(&["version"]); - type FileContent = AllLanguageModelSettingsContent; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; - } - } - - let mut settings = AllLanguageModelSettings::default(); - - for value in sources.defaults_and_customizations() { - // Anthropic - let anthropic = value.anthropic.clone(); - merge( - &mut settings.anthropic.api_url, - anthropic.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.anthropic.available_models, - anthropic.as_ref().and_then(|s| s.available_models.clone()), - ); - - // Bedrock - let bedrock = value.bedrock.clone(); - merge( - &mut settings.bedrock.profile_name, - bedrock.as_ref().map(|s| s.profile.clone()), - ); - merge( - &mut settings.bedrock.authentication_method, - bedrock.as_ref().map(|s| s.authentication_method.clone()), - ); - merge( - &mut settings.bedrock.region, - bedrock.as_ref().map(|s| s.region.clone()), - ); - merge( - &mut settings.bedrock.endpoint, - bedrock.as_ref().map(|s| s.endpoint_url.clone()), - ); - - // Ollama - let ollama = value.ollama.clone(); - - merge( - &mut settings.ollama.api_url, - value.ollama.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.ollama.available_models, - ollama.as_ref().and_then(|s| s.available_models.clone()), - ); - - // LM Studio - let lmstudio = value.lmstudio.clone(); - - merge( - &mut settings.lmstudio.api_url, - value.lmstudio.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.lmstudio.available_models, - lmstudio.as_ref().and_then(|s| s.available_models.clone()), - ); - - // DeepSeek - let deepseek = value.deepseek.clone(); - - merge( - &mut settings.deepseek.api_url, - value.deepseek.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.deepseek.available_models, - deepseek.as_ref().and_then(|s| s.available_models.clone()), - ); - - // OpenAI - let openai = value.openai.clone(); - merge( - &mut settings.openai.api_url, - openai.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.openai.available_models, - openai.as_ref().and_then(|s| s.available_models.clone()), - ); - - // OpenAI Compatible - if let Some(openai_compatible) = value.openai_compatible.clone() { - for (id, openai_compatible_settings) in openai_compatible { - settings.openai_compatible.insert( - id, + fn from_settings(content: &settings::SettingsContent) -> Self { + let language_models = content.language_models.clone().unwrap(); + let anthropic = language_models.anthropic.unwrap(); + let bedrock = language_models.bedrock.unwrap(); + let deepseek = language_models.deepseek.unwrap(); + let google = language_models.google.unwrap(); + let lmstudio = language_models.lmstudio.unwrap(); + let mistral = language_models.mistral.unwrap(); + let ollama = language_models.ollama.unwrap(); + let open_router = language_models.open_router.unwrap(); + let openai = language_models.openai.unwrap(); + let openai_compatible = language_models.openai_compatible.unwrap(); + let vercel = language_models.vercel.unwrap(); + let x_ai = language_models.x_ai.unwrap(); + let zed_dot_dev = language_models.zed_dot_dev.unwrap(); + Self { + anthropic: AnthropicSettings { + api_url: anthropic.api_url.unwrap(), + available_models: anthropic.available_models.unwrap_or_default(), + }, + bedrock: AmazonBedrockSettings { + available_models: bedrock.available_models.unwrap_or_default(), + region: bedrock.region, + endpoint: bedrock.endpoint_url, // todo(should be api_url) + profile_name: bedrock.profile, + role_arn: None, // todo(was never a setting for this...) + authentication_method: bedrock.authentication_method.map(Into::into), + }, + deepseek: DeepSeekSettings { + api_url: deepseek.api_url.unwrap(), + available_models: deepseek.available_models.unwrap_or_default(), + }, + google: GoogleSettings { + api_url: google.api_url.unwrap(), + available_models: google.available_models.unwrap_or_default(), + }, + lmstudio: LmStudioSettings { + api_url: lmstudio.api_url.unwrap(), + available_models: lmstudio.available_models.unwrap_or_default(), + }, + mistral: MistralSettings { + api_url: mistral.api_url.unwrap(), + available_models: mistral.available_models.unwrap_or_default(), + }, + ollama: OllamaSettings { + api_url: ollama.api_url.unwrap(), + available_models: ollama.available_models.unwrap_or_default(), + }, + open_router: OpenRouterSettings { + api_url: open_router.api_url.unwrap(), + available_models: open_router.available_models.unwrap_or_default(), + }, + openai: OpenAiSettings { + api_url: openai.api_url.unwrap(), + available_models: openai.available_models.unwrap_or_default(), + }, + openai_compatible: openai_compatible + .into_iter() + .map(|(key, value)| { + ( + key, OpenAiCompatibleSettings { - api_url: openai_compatible_settings.api_url, - available_models: openai_compatible_settings.available_models, + api_url: value.api_url, + available_models: value.available_models, }, - ); - } - } - - // Vercel - let vercel = value.vercel.clone(); - merge( - &mut settings.vercel.api_url, - vercel.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.vercel.available_models, - vercel.as_ref().and_then(|s| s.available_models.clone()), - ); - - // XAI - let x_ai = value.x_ai.clone(); - merge( - &mut settings.x_ai.api_url, - x_ai.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.x_ai.available_models, - x_ai.as_ref().and_then(|s| s.available_models.clone()), - ); - - // ZedDotDev - merge( - &mut settings.zed_dot_dev.available_models, - value - .zed_dot_dev - .as_ref() - .and_then(|s| s.available_models.clone()), - ); - merge( - &mut settings.google.api_url, - value.google.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.google.available_models, - value - .google - .as_ref() - .and_then(|s| s.available_models.clone()), - ); - - // Mistral - let mistral = value.mistral.clone(); - merge( - &mut settings.mistral.api_url, - mistral.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.mistral.available_models, - mistral.as_ref().and_then(|s| s.available_models.clone()), - ); - - // OpenRouter - let open_router = value.open_router.clone(); - merge( - &mut settings.open_router.api_url, - open_router.as_ref().and_then(|s| s.api_url.clone()), - ); - merge( - &mut settings.open_router.available_models, - open_router - .as_ref() - .and_then(|s| s.available_models.clone()), - ); + ) + }) + .collect(), + vercel: VercelSettings { + api_url: vercel.api_url.unwrap(), + available_models: vercel.available_models.unwrap_or_default(), + }, + x_ai: XAiSettings { + api_url: x_ai.api_url.unwrap(), + available_models: x_ai.available_models.unwrap_or_default(), + }, + zed_dot_dev: ZedDotDevSettings { + available_models: zed_dot_dev.available_models.unwrap_or_default(), + }, } - - Ok(settings) } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} } diff --git a/crates/language_models/src/ui/instruction_list_item.rs b/crates/language_models/src/ui/instruction_list_item.rs index 3dee97aff6ca78f97f0e4386e9518f5a5d1f29e0..bdb5fbe242ee902dc98a37addfaa0f103ef9ad20 100644 --- a/crates/language_models/src/ui/instruction_list_item.rs +++ b/crates/language_models/src/ui/instruction_list_item.rs @@ -37,7 +37,7 @@ impl IntoElement for InstructionListItem { let item_content = if let (Some(button_label), Some(button_link)) = (self.button_label, self.button_link) { - let link = button_link.clone(); + let link = button_link; let unique_id = SharedString::from(format!("{}-button", self.label)); h_flex() diff --git a/crates/language_onboarding/Cargo.toml b/crates/language_onboarding/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..38cf8a604a87f403e2d2720be6a2ba69a61e7484 --- /dev/null +++ b/crates/language_onboarding/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "language_onboarding" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/python.rs" + +[features] +default = [] + +[dependencies] +db.workspace = true +editor.workspace = true +gpui.workspace = true +project.workspace = true +ui.workspace = true +workspace.workspace = true + +# Uncomment other workspace dependencies as needed +# assistant.workspace = true +# client.workspace = true +# project.workspace = true +# settings.workspace = true diff --git a/crates/language_onboarding/LICENSE-GPL b/crates/language_onboarding/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/language_onboarding/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/language_onboarding/src/python.rs b/crates/language_onboarding/src/python.rs new file mode 100644 index 0000000000000000000000000000000000000000..e715cb7c806f417980a93a62210c72ca8529fcb5 --- /dev/null +++ b/crates/language_onboarding/src/python.rs @@ -0,0 +1,102 @@ +use db::kvp::Dismissable; +use editor::Editor; +use gpui::{Context, EventEmitter, Subscription}; +use ui::{Banner, FluentBuilder as _, prelude::*}; +use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace}; + +pub struct BasedPyrightBanner { + dismissed: bool, + have_basedpyright: bool, + _subscriptions: [Subscription; 1], +} + +impl Dismissable for BasedPyrightBanner { + const KEY: &str = "basedpyright-banner"; +} + +impl BasedPyrightBanner { + pub fn new(workspace: &Workspace, cx: &mut Context) -> Self { + let subscription = cx.subscribe(workspace.project(), |this, _, event, _| { + if let project::Event::LanguageServerAdded(_, name, _) = event + && name == "basedpyright" + { + this.have_basedpyright = true; + } + }); + let dismissed = Self::dismissed(); + Self { + dismissed, + have_basedpyright: false, + _subscriptions: [subscription], + } + } + + fn onboarding_banner_enabled(&self) -> bool { + !self.dismissed && self.have_basedpyright + } +} + +impl EventEmitter for BasedPyrightBanner {} + +impl Render for BasedPyrightBanner { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("basedpyright-banner") + .when(self.onboarding_banner_enabled(), |el| { + el.child( + Banner::new() + .child( + v_flex() + .gap_0p5() + .child(Label::new("Basedpyright is now the only default language server for Python").mt_0p5()) + .child(Label::new("We have disabled PyRight and pylsp by default. They can be re-enabled in your settings.").size(LabelSize::Small).color(Color::Muted)) + ) + .action_slot( + h_flex() + .gap_0p5() + .child( + Button::new("learn-more", "Learn More") + .icon(IconName::ArrowUpRight) + .label_size(LabelSize::Small) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(|_, _, cx| { + cx.open_url("https://zed.dev/docs/languages/python") + }), + ) + .child(IconButton::new("dismiss", IconName::Close).icon_size(IconSize::Small).on_click( + cx.listener(|this, _, _, cx| { + this.dismissed = true; + Self::set_dismissed(true, cx); + cx.notify(); + }), + )) + ) + .into_any_element(), + ) + }) + } +} + +impl ToolbarItemView for BasedPyrightBanner { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn workspace::ItemHandle>, + _window: &mut ui::Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if !self.onboarding_banner_enabled() { + return ToolbarItemLocation::Hidden; + } + if let Some(item) = active_pane_item + && let Some(editor) = item.act_as::(cx) + && let Some(path) = editor.update(cx, |editor, cx| editor.target_file_abs_path(cx)) + && let Some(file_name) = path.file_name() + && file_name.as_encoded_bytes().ends_with(".py".as_bytes()) + { + return ToolbarItemLocation::Secondary; + } + + ToolbarItemLocation::Hidden + } +} diff --git a/crates/language_selector/Cargo.toml b/crates/language_selector/Cargo.toml index a7312c71198e46fcef33d9c272eabb86cc544220..47ad9b9f8802a3964ababad593b7c6a604f1c98f 100644 --- a/crates/language_selector/Cargo.toml +++ b/crates/language_selector/Cargo.toml @@ -26,7 +26,6 @@ settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index c5c5eceab54f2c34f4b1e2aae1b04f85fc5d9ab6..c75c3954cc6590c2e0cb4326c073ed004eaac280 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -1,11 +1,12 @@ -use editor::{Editor, EditorSettings}; +use editor::Editor; use gpui::{ - Context, Entity, IntoElement, ParentElement, Render, Subscription, WeakEntity, Window, div, + Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, + div, }; use language::LanguageName; use settings::Settings as _; use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, Tooltip}; -use workspace::{StatusItemView, Workspace, item::ItemHandle}; +use workspace::{StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; use crate::{LanguageSelector, Toggle}; @@ -28,10 +29,10 @@ impl ActiveBufferLanguage { self.active_language = Some(None); let editor = editor.read(cx); - if let Some((_, buffer, _)) = editor.active_excerpt(cx) { - if let Some(language) = buffer.read(cx).language() { - self.active_language = Some(Some(language.name())); - } + if let Some((_, buffer, _)) = editor.active_excerpt(cx) + && let Some(language) = buffer.read(cx).language() + { + self.active_language = Some(Some(language.name())); } cx.notify(); @@ -40,11 +41,8 @@ impl ActiveBufferLanguage { impl Render for ActiveBufferLanguage { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - if !EditorSettings::get_global(cx) - .status_bar - .active_language_button - { - return div(); + if !StatusBarSettings::get_global(cx).active_language_button { + return div().hidden(); } div().when_some(self.active_language.as_ref(), |el, active_language| { @@ -64,9 +62,7 @@ impl Render for ActiveBufferLanguage { }); } })) - .tooltip(|window, cx| { - Tooltip::for_action("Select Language", &Toggle, window, cx) - }), + .tooltip(|_window, cx| Tooltip::for_action("Select Language", &Toggle, cx)), ) }) } diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index f6e2d75015560582b30453767b1a3b30f7cce82e..991cce50baf82b2604e510a0eeb2eac4af1578dd 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -283,7 +283,7 @@ impl PickerDelegate for LanguageSelectorDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { - let mat = &self.matches[ix]; + let mat = &self.matches.get(ix)?; let (label, language_icon) = self.language_data_for_match(mat, cx); Some( ListItem::new(ix) diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 5aa914311a6eccc1cb68efa37e878ad12249d6fd..d251a297d4d0fd71b9c464230e2180c0e34fdfa4 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true client.workspace = true collections.workspace = true +command_palette_hooks.workspace = true copilot.workspace = true editor.workspace = true futures.workspace = true @@ -24,6 +25,7 @@ itertools.workspace = true language.workspace = true lsp.workspace = true project.workspace = true +proto.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true @@ -32,7 +34,6 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index 88131781ec3af336d3ae793cf1820e5bcf731605..cc34838010bfaf8cacd3773a18fde90fbefc105b 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -1,10 +1,10 @@ use gpui::{ Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, - KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription, actions, + KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription, Task, + actions, }; use itertools::Itertools; use serde_json::json; -use settings::get_key_equivalents; use ui::{Button, ButtonStyle}; use ui::{ ButtonCommon, Clickable, Context, FluentBuilder, InteractiveElement, Label, LabelCommon, @@ -71,12 +71,10 @@ impl KeyContextView { } else { None } + } else if this.action_matches(&e.action, binding.action()) { + Some(true) } else { - if this.action_matches(&e.action, binding.action()) { - Some(true) - } else { - Some(false) - } + Some(false) }; let predicate = if let Some(predicate) = binding.predicate() { format!("{}", predicate) @@ -98,9 +96,7 @@ impl KeyContextView { cx.notify(); }); let sub2 = cx.observe_pending_input(window, |this, window, cx| { - this.pending_keystrokes = window - .pending_input_keystrokes() - .map(|k| k.iter().cloned().collect()); + this.pending_keystrokes = window.pending_input_keystrokes().map(|k| k.to_vec()); if this.pending_keystrokes.is_some() { this.last_keystrokes.take(); } @@ -157,23 +153,28 @@ impl Item for KeyContextView { None } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| KeyContextView::new(window, cx))) + Task::ready(Some(cx.new(|cx| KeyContextView::new(window, cx)))) } } impl Render for KeyContextView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { use itertools::Itertools; - let key_equivalents = get_key_equivalents(cx.keyboard_layout().id()); + + let key_equivalents = cx.keyboard_mapper().get_key_equivalents(); v_flex() .id("key-context-view") .overflow_scroll() @@ -211,11 +212,10 @@ impl Render for KeyContextView { .on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/key-bindings")), ) .child( - Button::new("view_default_keymap", "View default keymap") + Button::new("view_default_keymap", "View Default Keymap") .style(ButtonStyle::Filled) .key_binding(ui::KeyBinding::for_action( &zed_actions::OpenDefaultKeymap, - window, cx )) .on_click(|_, window, cx| { @@ -223,11 +223,11 @@ impl Render for KeyContextView { }), ) .child( - Button::new("edit_your_keymap", "Edit your keymap") + Button::new("edit_your_keymap", "Edit Keymap File") .style(ButtonStyle::Filled) - .key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymap, window, cx)) + .key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymapFile, cx)) .on_click(|_, window, cx| { - window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx); + window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); }), ), ) diff --git a/crates/language_tools/src/language_tools.rs b/crates/language_tools/src/language_tools.rs index cbf5756875f723b52fabbfe877c32265dd6f0aef..aa1672806417493c0c5a877a28fc7906f3da6ff8 100644 --- a/crates/language_tools/src/language_tools.rs +++ b/crates/language_tools/src/language_tools.rs @@ -1,20 +1,20 @@ mod key_context_view; -mod lsp_log; -pub mod lsp_tool; +pub mod lsp_button; +pub mod lsp_log_view; mod syntax_tree_view; #[cfg(test)] -mod lsp_log_tests; +mod lsp_log_view_tests; use gpui::{App, AppContext, Entity}; -pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView}; +pub use lsp_log_view::LspLogView; pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView}; use ui::{Context, Window}; use workspace::{Item, ItemHandle, SplitDirection, Workspace}; pub fn init(cx: &mut App) { - lsp_log::init(cx); + lsp_log_view::init(false, cx); syntax_tree_view::init(cx); key_context_view::init(cx); } diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_button.rs similarity index 88% rename from crates/language_tools/src/lsp_tool.rs rename to crates/language_tools/src/lsp_button.rs index 3244350a34e275a33f9b9a5c2d3841c34884d1df..7dc2e93a5c707eaa3829caba6d6d2a04773883b1 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -11,16 +11,20 @@ use editor::{Editor, EditorEvent}; use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings}; +use project::{ + LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore, + project_settings::ProjectSettings, +}; use settings::{Settings as _, SettingsStore}; use ui::{ - Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, - Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*, + Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationEdge, + DocumentationSide, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*, }; +use util::{ResultExt, rel_path::RelPath}; use workspace::{StatusItemView, Workspace}; -use crate::lsp_log::GlobalLogStore; +use crate::lsp_log_view; actions!( lsp_tool, @@ -30,7 +34,7 @@ actions!( ] ); -pub struct LspTool { +pub struct LspButton { server_state: Entity, popover_menu_handle: PopoverMenuHandle, lsp_menu: Option>, @@ -118,12 +122,10 @@ impl LanguageServerHealthStatus { impl LanguageServerState { fn fill_menu(&self, mut menu: ContextMenu, cx: &mut Context) -> ContextMenu { - menu = menu.align_popover_bottom(); let lsp_logs = cx .try_global::() - .and_then(|lsp_logs| lsp_logs.0.upgrade()); - let lsp_store = self.lsp_store.upgrade(); - let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else { + .map(|lsp_logs| lsp_logs.0.clone()); + let Some(lsp_logs) = lsp_logs else { return menu; }; @@ -147,6 +149,7 @@ impl LanguageServerState { return; }; let project = workspace.read(cx).project().clone(); + let path_style = project.read(cx).path_style(cx); let buffer_store = project.read(cx).buffer_store().clone(); let buffers = state .read(cx) @@ -158,6 +161,9 @@ impl LanguageServerState { servers.worktree.as_ref()?.upgrade()?.read(cx); let relative_path = abs_path.strip_prefix(&worktree.abs_path()).ok()?; + let relative_path = + RelPath::new(relative_path, path_style) + .log_err()?; let entry = worktree.entry_for_path(&relative_path)?; let project_path = project.read(cx).path_for_entry(entry.id, cx)?; @@ -210,10 +216,11 @@ impl LanguageServerState { }; let server_selector = server_info.server_selector(); - // TODO currently, Zed remote does not work well with the LSP logs - // https://github.com/zed-industries/zed/issues/28557 - let has_logs = lsp_store.read(cx).as_local().is_some() - && lsp_logs.read(cx).has_server_logs(&server_selector); + let is_remote = self + .lsp_store + .update(cx, |lsp_store, _| lsp_store.as_remote().is_some()) + .unwrap_or(false); + let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector); let status_color = server_info .binary_status @@ -241,10 +248,10 @@ impl LanguageServerState { .as_ref() .or_else(|| server_info.binary_status.as_ref()?.message.as_ref()) .cloned(); - let hover_label = if has_logs { - Some("View Logs") - } else if message.is_some() { + let hover_label = if message.is_some() { Some("View Message") + } else if has_logs { + Some("View Logs") } else { None }; @@ -288,21 +295,12 @@ impl LanguageServerState { let server_name = server_info.name.clone(); let workspace = self.workspace.clone(); move |window, cx| { - if has_logs { - lsp_logs.update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }); - } else if let Some(message) = &message { + if let Some(message) = &message { let Some(create_buffer) = workspace .update(cx, |workspace, cx| { workspace .project() - .update(cx, |project, cx| project.create_buffer(cx)) + .update(cx, |project, cx| project.create_buffer(false, cx)) }) .ok() else { @@ -347,15 +345,23 @@ impl LanguageServerState { anyhow::Ok(()) }) .detach(); + } else if has_logs { + lsp_log_view::open_server_trace( + &lsp_logs, + workspace.clone(), + server_selector.clone(), + window, + cx, + ); } else { cx.propagate(); - return; } } }, message.map(|server_message| { DocumentationAside::new( DocumentationSide::Right, + DocumentationEdge::Bottom, Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), ) }), @@ -511,7 +517,7 @@ impl ServerData<'_> { } } -impl LspTool { +impl LspButton { pub fn new( workspace: &Workspace, popover_menu_handle: PopoverMenuHandle, @@ -519,38 +525,59 @@ impl LspTool { cx: &mut Context, ) -> Self { let settings_subscription = - cx.observe_global_in::(window, move |lsp_tool, window, cx| { + cx.observe_global_in::(window, move |lsp_button, window, cx| { if ProjectSettings::get_global(cx).global_lsp_settings.button { - if lsp_tool.lsp_menu.is_none() { - lsp_tool.refresh_lsp_menu(true, window, cx); - return; + if lsp_button.lsp_menu.is_none() { + lsp_button.refresh_lsp_menu(true, window, cx); } - } else if lsp_tool.lsp_menu.take().is_some() { + } else if lsp_button.lsp_menu.take().is_some() { cx.notify(); } }); let lsp_store = workspace.project().read(cx).lsp_store(); + let mut language_servers = LanguageServers::default(); + for (_, status) in lsp_store.read(cx).language_server_statuses() { + language_servers.binary_statuses.insert( + status.name.clone(), + LanguageServerBinaryStatus { + status: BinaryStatus::None, + message: None, + }, + ); + } + let lsp_store_subscription = - cx.subscribe_in(&lsp_store, window, |lsp_tool, _, e, window, cx| { - lsp_tool.on_lsp_store_event(e, window, cx) + cx.subscribe_in(&lsp_store, window, |lsp_button, _, e, window, cx| { + lsp_button.on_lsp_store_event(e, window, cx) }); - let state = cx.new(|_| LanguageServerState { + let server_state = cx.new(|_| LanguageServerState { workspace: workspace.weak_handle(), items: Vec::new(), lsp_store: lsp_store.downgrade(), active_editor: None, - language_servers: LanguageServers::default(), + language_servers, }); - Self { - server_state: state, + let mut lsp_button = Self { + server_state, popover_menu_handle, lsp_menu: None, lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], + }; + if !lsp_button + .server_state + .read(cx) + .language_servers + .binary_statuses + .is_empty() + { + lsp_button.refresh_lsp_menu(true, window, cx); } + + lsp_button } fn on_lsp_store_event( @@ -710,6 +737,25 @@ impl LspTool { } } } + state + .lsp_store + .update(cx, |lsp_store, cx| { + for (server_id, status) in lsp_store.language_server_statuses() { + if let Some(worktree) = status.worktree.and_then(|worktree_id| { + lsp_store + .worktree_store() + .read(cx) + .worktree_for_id(worktree_id, cx) + }) { + server_ids_to_worktrees.insert(server_id, worktree.clone()); + server_names_to_worktrees + .entry(status.name.clone()) + .or_default() + .insert((worktree, server_id)); + } + } + }) + .ok(); let mut servers_per_worktree = BTreeMap::>::new(); let mut servers_without_worktree = Vec::::new(); @@ -726,7 +772,7 @@ impl LspTool { }); servers_with_health_checks.insert(&health.name); let worktree_name = - worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name())); + worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name_str())); let binary_status = state.language_servers.binary_statuses.get(&health.name); let server_data = ServerData::WithHealthCheck { @@ -785,7 +831,7 @@ impl LspTool { { Some((worktree, server_id)) => { let worktree_name = - SharedString::new(worktree.read(cx).root_name()); + SharedString::new(worktree.read(cx).root_name_str()); servers_per_worktree .entry(worktree_name.clone()) .or_default() @@ -854,18 +900,18 @@ impl LspTool { ) { if create_if_empty || self.lsp_menu.is_some() { let state = self.server_state.clone(); - self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| { + self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_button, cx| { cx.background_executor() .timer(Duration::from_millis(30)) .await; - lsp_tool - .update_in(cx, |lsp_tool, window, cx| { - lsp_tool.regenerate_items(cx); + lsp_button + .update_in(cx, |lsp_button, window, cx| { + lsp_button.regenerate_items(cx); let menu = ContextMenu::build(window, cx, |menu, _, cx| { state.update(cx, |state, cx| state.fill_menu(menu, cx)) }); - lsp_tool.lsp_menu = Some(menu.clone()); - lsp_tool.popover_menu_handle.refresh_menu( + lsp_button.lsp_menu = Some(menu.clone()); + lsp_button.popover_menu_handle.refresh_menu( window, cx, Rc::new(move |_, _| Some(menu.clone())), @@ -878,7 +924,7 @@ impl LspTool { } } -impl StatusItemView for LspTool { +impl StatusItemView for LspButton { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn workspace::ItemHandle>, @@ -901,9 +947,9 @@ impl StatusItemView for LspTool { let _editor_subscription = cx.subscribe_in( &editor, window, - |lsp_tool, _, e: &EditorEvent, window, cx| match e { + |lsp_button, _, e: &EditorEvent, window, cx| match e { EditorEvent::ExcerptsAdded { buffer, .. } => { - let updated = lsp_tool.server_state.update(cx, |state, cx| { + let updated = lsp_button.server_state.update(cx, |state, cx| { if let Some(active_editor) = state.active_editor.as_mut() { let buffer_id = buffer.read(cx).remote_id(); active_editor.editor_buffers.insert(buffer_id) @@ -912,13 +958,13 @@ impl StatusItemView for LspTool { } }); if updated { - lsp_tool.refresh_lsp_menu(false, window, cx); + lsp_button.refresh_lsp_menu(false, window, cx); } } EditorEvent::ExcerptsRemoved { removed_buffer_ids, .. } => { - let removed = lsp_tool.server_state.update(cx, |state, _| { + let removed = lsp_button.server_state.update(cx, |state, _| { let mut removed = false; if let Some(active_editor) = state.active_editor.as_mut() { for id in removed_buffer_ids { @@ -932,7 +978,7 @@ impl StatusItemView for LspTool { removed }); if removed { - lsp_tool.refresh_lsp_menu(false, window, cx); + lsp_button.refresh_lsp_menu(false, window, cx); } } _ => {} @@ -962,10 +1008,10 @@ impl StatusItemView for LspTool { } } -impl Render for LspTool { +impl Render for LspButton { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { - return div(); + return div().hidden(); } let mut has_errors = false; @@ -1007,11 +1053,11 @@ impl Render for LspTool { (None, "All Servers Operational") }; - let lsp_tool = cx.entity(); + let lsp_button = cx.entity(); div().child( PopoverMenu::new("lsp-tool") - .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone()) + .menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone()) .anchor(Corner::BottomLeft) .with_handle(self.popover_menu_handle.clone()) .trigger_with_tooltip( @@ -1019,14 +1065,8 @@ impl Render for LspTool { .when_some(indicator, IconButton::indicator) .icon_size(IconSize::Small) .indicator_border_color(Some(cx.theme().colors().status_bar_background)), - move |window, cx| { - Tooltip::with_meta( - "Language Servers", - Some(&ToggleMenu), - description, - window, - cx, - ) + move |_window, cx| { + Tooltip::with_meta("Language Servers", Some(&ToggleMenu), description, cx) }, ), ) diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log_view.rs similarity index 60% rename from crates/language_tools/src/lsp_log.rs rename to crates/language_tools/src/lsp_log_view.rs index 823d59ce12ea45bfd5bc45d5889bff5ee7800d2a..d480eadc73b9546e5a59b204b036a3ff88a018c7 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -1,20 +1,25 @@ -use collections::{HashMap, VecDeque}; +use collections::VecDeque; use copilot::Copilot; use editor::{Editor, EditorEvent, actions::MoveToEnd, scroll::Autoscroll}; -use futures::{StreamExt, channel::mpsc}; use gpui::{ - AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, Global, - IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div, + AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, Styled, Subscription, Task, WeakEntity, Window, actions, div, }; use itertools::Itertools; use language::{LanguageServerId, language_settings::SoftWrap}; use lsp::{ - IoKind, LanguageServer, LanguageServerName, LanguageServerSelector, MessageType, + LanguageServer, LanguageServerBinary, LanguageServerName, LanguageServerSelector, MessageType, SetTraceParams, TraceValue, notification::SetTrace, }; -use project::{Project, WorktreeId, search::SearchQuery}; +use project::{ + Project, + lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message}, + search::SearchQuery, +}; +use proto::toggle_lsp_logs::LogType; use std::{any::TypeId, borrow::Cow, sync::Arc}; use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*}; +use util::ResultExt as _; use workspace::{ SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, item::{Item, ItemHandle}, @@ -23,132 +28,53 @@ use workspace::{ use crate::get_or_create_tool; -const SEND_LINE: &str = "\n// Send:"; -const RECEIVE_LINE: &str = "\n// Receive:"; -const MAX_STORED_LOG_ENTRIES: usize = 2000; - -pub struct LogStore { - projects: HashMap, ProjectState>, - language_servers: HashMap, - copilot_log_subscription: Option, - _copilot_subscription: Option, - io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>, -} - -struct ProjectState { - _subscriptions: [gpui::Subscription; 2], -} - -trait Message: AsRef { - type Level: Copy + std::fmt::Debug; - fn should_include(&self, _: Self::Level) -> bool { - true - } -} - -pub(super) struct LogMessage { - message: String, - typ: MessageType, -} - -impl AsRef for LogMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for LogMessage { - type Level = MessageType; - - fn should_include(&self, level: Self::Level) -> bool { - match (self.typ, level) { - (MessageType::ERROR, _) => true, - (_, MessageType::ERROR) => false, - (MessageType::WARNING, _) => true, - (_, MessageType::WARNING) => false, - (MessageType::INFO, _) => true, - (_, MessageType::INFO) => false, - _ => true, - } - } -} - -pub(super) struct TraceMessage { - message: String, -} - -impl AsRef for TraceMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for TraceMessage { - type Level = (); -} - -struct RpcMessage { - message: String, -} - -impl AsRef for RpcMessage { - fn as_ref(&self) -> &str { - &self.message - } -} - -impl Message for RpcMessage { - type Level = (); -} - -pub(super) struct LanguageServerState { - name: Option, - worktree_id: Option, - kind: LanguageServerKind, - log_messages: VecDeque, - trace_messages: VecDeque, - rpc_state: Option, - trace_level: TraceValue, - log_level: MessageType, - io_logs_subscription: Option, -} - -#[derive(PartialEq, Clone)] -pub enum LanguageServerKind { - Local { project: WeakEntity }, - Remote { project: WeakEntity }, - Global, -} - -impl LanguageServerKind { - fn is_remote(&self) -> bool { - matches!(self, LanguageServerKind::Remote { .. }) - } -} - -impl std::fmt::Debug for LanguageServerKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), - LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), - LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), - } - } -} - -impl LanguageServerKind { - fn project(&self) -> Option<&WeakEntity> { - match self { - Self::Local { project } => Some(project), - Self::Remote { project } => Some(project), - Self::Global { .. } => None, - } - } -} - -struct LanguageServerRpcState { - rpc_messages: VecDeque, - last_message_kind: Option, +pub fn open_server_trace( + log_store: &Entity, + workspace: WeakEntity, + server: LanguageServerSelector, + window: &mut Window, + cx: &mut App, +) { + log_store.update(cx, |_, cx| { + cx.spawn_in(window, async move |log_store, cx| { + let Some(log_store) = log_store.upgrade() else { + return; + }; + workspace + .update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let tool_log_store = log_store.clone(); + let log_view = get_or_create_tool( + workspace, + SplitDirection::Right, + window, + cx, + move |window, cx| LspLogView::new(project, tool_log_store, window, cx), + ); + log_view.update(cx, |log_view, cx| { + let server_id = match server { + LanguageServerSelector::Id(id) => Some(id), + LanguageServerSelector::Name(name) => { + log_store.read(cx).language_servers.iter().find_map( + |(id, state)| { + if state.name.as_ref() == Some(&name) { + Some(*id) + } else { + None + } + }, + ) + } + }; + if let Some(server_id) = server_id { + log_view.show_rpc_trace_for_server(server_id, window, cx); + } + }); + }) + .ok(); + }) + .detach(); + }) } pub struct LspLogView { @@ -167,32 +93,6 @@ pub struct LspLogToolbarItemView { _log_view_subscription: Option, } -#[derive(Copy, Clone, PartialEq, Eq)] -enum MessageKind { - Send, - Receive, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum LogKind { - Rpc, - Trace, - #[default] - Logs, - ServerInfo, -} - -impl LogKind { - fn label(&self) -> &'static str { - match self { - LogKind::Rpc => RPC_MESSAGES, - LogKind::Trace => SERVER_TRACE, - LogKind::Logs => SERVER_LOGS, - LogKind::ServerInfo => SERVER_INFO, - } - } -} - #[derive(Clone, Debug, PartialEq)] pub(crate) struct LogMenuItem { pub server_id: LanguageServerId, @@ -212,505 +112,68 @@ actions!( ] ); -pub(super) struct GlobalLogStore(pub WeakEntity); - -impl Global for GlobalLogStore {} +pub fn init(on_headless_host: bool, cx: &mut App) { + let log_store = log_store::init(on_headless_host, cx); -pub fn init(cx: &mut App) { - let log_store = cx.new(LogStore::new); - cx.set_global(GlobalLogStore(log_store.downgrade())); - - cx.observe_new(move |workspace: &mut Workspace, _, cx| { - let project = workspace.project(); - if project.read(cx).is_local() || project.read(cx).is_via_ssh() { - log_store.update(cx, |store, cx| { - store.add_project(project, cx); - }); - } - - let log_store = log_store.clone(); - workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| { - let project = workspace.project().read(cx); - if project.is_local() || project.is_via_ssh() { - let project = workspace.project().clone(); - let log_store = log_store.clone(); - get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, log_store, window, cx), - ); - } - }); - }) - .detach(); -} - -impl LogStore { - pub fn new(cx: &mut Context) -> Self { - let (io_tx, mut io_rx) = mpsc::unbounded(); - - let copilot_subscription = Copilot::global(cx).map(|copilot| { + log_store.update(cx, |_, cx| { + Copilot::global(cx).map(|copilot| { let copilot = &copilot; - cx.subscribe(copilot, |this, copilot, edit_prediction_event, cx| { - if let copilot::Event::CopilotLanguageServerStarted = edit_prediction_event { - if let Some(server) = copilot.read(cx).language_server() { - let server_id = server.server_id(); - let weak_this = cx.weak_entity(); - this.copilot_log_subscription = - Some(server.on_notification::( - move |params, cx| { - weak_this - .update(cx, |this, cx| { - this.add_language_server_log( - server_id, - MessageType::LOG, - ¶ms.message, - cx, - ); - }) - .ok(); - }, - )); - let name = LanguageServerName::new_static("copilot"); - this.add_language_server( - LanguageServerKind::Global, - server.server_id(), - Some(name), - None, - Some(server.clone()), - cx, - ); - } + cx.subscribe(copilot, |log_store, copilot, edit_prediction_event, cx| { + if let copilot::Event::CopilotLanguageServerStarted = edit_prediction_event + && let Some(server) = copilot.read(cx).language_server() + { + let server_id = server.server_id(); + let weak_lsp_store = cx.weak_entity(); + log_store.copilot_log_subscription = + Some(server.on_notification::( + move |params, cx| { + weak_lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.add_language_server_log( + server_id, + MessageType::LOG, + ¶ms.message, + cx, + ); + }) + .ok(); + }, + )); + + let name = LanguageServerName::new_static("copilot"); + log_store.add_language_server( + LanguageServerKind::Global, + server.server_id(), + Some(name), + None, + Some(server.clone()), + cx, + ); } }) - }); - - let this = Self { - copilot_log_subscription: None, - _copilot_subscription: copilot_subscription, - projects: HashMap::default(), - language_servers: HashMap::default(), - io_tx, - }; - - cx.spawn(async move |this, cx| { - while let Some((server_id, io_kind, message)) = io_rx.next().await { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.on_io(server_id, io_kind, &message, cx); - })?; - } - } - anyhow::Ok(()) + .detach(); }) - .detach_and_log_err(cx); - this - } - - pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { - let weak_project = project.downgrade(); - self.projects.insert( - project.downgrade(), - ProjectState { - _subscriptions: [ - cx.observe_release(project, move |this, _, _| { - this.projects.remove(&weak_project); - this.language_servers - .retain(|_, state| state.kind.project() != Some(&weak_project)); - }), - cx.subscribe(project, |this, project, event, cx| { - let server_kind = if project.read(cx).is_via_ssh() { - LanguageServerKind::Remote { - project: project.downgrade(), - } - } else { - LanguageServerKind::Local { - project: project.downgrade(), - } - }; - - match event { - project::Event::LanguageServerAdded(id, name, worktree_id) => { - this.add_language_server( - server_kind, - *id, - Some(name.clone()), - *worktree_id, - project - .read(cx) - .lsp_store() - .read(cx) - .language_server_for_id(*id), - cx, - ); - } - project::Event::LanguageServerRemoved(id) => { - this.remove_language_server(*id, cx); - } - project::Event::LanguageServerLog(id, typ, message) => { - this.add_language_server(server_kind, *id, None, None, None, cx); - match typ { - project::LanguageServerLogType::Log(typ) => { - this.add_language_server_log(*id, *typ, message, cx); - } - project::LanguageServerLogType::Trace(_) => { - this.add_language_server_trace(*id, message, cx); - } - } - } - _ => {} - } - }), - ], - }, - ); - } - - pub(super) fn get_language_server_state( - &mut self, - id: LanguageServerId, - ) -> Option<&mut LanguageServerState> { - self.language_servers.get_mut(&id) - } + }); - fn add_language_server( - &mut self, - kind: LanguageServerKind, - server_id: LanguageServerId, - name: Option, - worktree_id: Option, - server: Option>, - cx: &mut Context, - ) -> Option<&mut LanguageServerState> { - let server_state = self.language_servers.entry(server_id).or_insert_with(|| { - cx.notify(); - LanguageServerState { - name: None, - worktree_id: None, - kind, - rpc_state: None, - log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_level: TraceValue::Off, - log_level: MessageType::LOG, - io_logs_subscription: None, - } + cx.observe_new(move |workspace: &mut Workspace, _, cx| { + log_store.update(cx, |store, cx| { + store.add_project(workspace.project(), cx); }); - if let Some(name) = name { - server_state.name = Some(name); - } - if let Some(worktree_id) = worktree_id { - server_state.worktree_id = Some(worktree_id); - } - - if let Some(server) = server - .clone() - .filter(|_| server_state.io_logs_subscription.is_none()) - { - let io_tx = self.io_tx.clone(); - let server_id = server.server_id(); - server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { - io_tx - .unbounded_send((server_id, io_kind, message.to_string())) - .ok(); - })); - } - - Some(server_state) - } - - fn add_language_server_log( - &mut self, - id: LanguageServerId, - typ: MessageType, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let language_server_state = self.get_language_server_state(id)?; - - let log_lines = &mut language_server_state.log_messages; - Self::add_language_server_message( - log_lines, - id, - LogMessage { - message: message.trim_end().to_string(), - typ, - }, - language_server_state.log_level, - LogKind::Logs, - cx, - ); - Some(()) - } - - fn add_language_server_trace( - &mut self, - id: LanguageServerId, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let language_server_state = self.get_language_server_state(id)?; - - let log_lines = &mut language_server_state.trace_messages; - Self::add_language_server_message( - log_lines, - id, - TraceMessage { - message: message.trim().to_string(), - }, - (), - LogKind::Trace, - cx, - ); - Some(()) - } - - fn add_language_server_message( - log_lines: &mut VecDeque, - id: LanguageServerId, - message: T, - current_severity: ::Level, - kind: LogKind, - cx: &mut Context, - ) { - while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - log_lines.pop_front(); - } - let text = message.as_ref().to_string(); - let visible = message.should_include(current_severity); - log_lines.push_back(message); - - if visible { - cx.emit(Event::NewServerLogEntry { id, kind, text }); - cx.notify(); - } - } - - fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { - self.language_servers.remove(&id); - cx.notify(); - } - - pub(super) fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.log_messages) - } - - pub(super) fn server_trace( - &self, - server_id: LanguageServerId, - ) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.trace_messages) - } - - fn server_ids_for_project<'a>( - &'a self, - lookup_project: &'a WeakEntity, - ) -> impl Iterator + 'a { - self.language_servers - .iter() - .filter_map(move |(id, state)| match &state.kind { - LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { - if project == lookup_project { - Some(*id) - } else { - None - } - } - LanguageServerKind::Global => Some(*id), - }) - } - - fn enable_rpc_trace_for_language_server( - &mut self, - server_id: LanguageServerId, - ) -> Option<&mut LanguageServerRpcState> { - let rpc_state = self - .language_servers - .get_mut(&server_id)? - .rpc_state - .get_or_insert_with(|| LanguageServerRpcState { - rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - last_message_kind: None, - }); - Some(rpc_state) - } - - pub fn disable_rpc_trace_for_language_server( - &mut self, - server_id: LanguageServerId, - ) -> Option<()> { - self.language_servers.get_mut(&server_id)?.rpc_state.take(); - Some(()) - } - - pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { - match server { - LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), - LanguageServerSelector::Name(name) => self - .language_servers - .iter() - .any(|(_, state)| state.name.as_ref() == Some(name)), - } - } - - pub fn open_server_log( - &mut self, - workspace: WeakEntity, - server: LanguageServerSelector, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |log_store, cx| { - let Some(log_store) = log_store.upgrade() else { - return; - }; - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let tool_log_store = log_store.clone(); - let log_view = get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, tool_log_store, window, cx), - ); - log_view.update(cx, |log_view, cx| { - let server_id = match server { - LanguageServerSelector::Id(id) => Some(id), - LanguageServerSelector::Name(name) => { - log_store.read(cx).language_servers.iter().find_map( - |(id, state)| { - if state.name.as_ref() == Some(&name) { - Some(*id) - } else { - None - } - }, - ) - } - }; - if let Some(server_id) = server_id { - log_view.show_logs_for_server(server_id, window, cx); - } - }); - }) - .ok(); - }) - .detach(); - } - - pub fn open_server_trace( - &mut self, - workspace: WeakEntity, - server: LanguageServerSelector, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |log_store, cx| { - let Some(log_store) = log_store.upgrade() else { - return; - }; - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let tool_log_store = log_store.clone(); - let log_view = get_or_create_tool( - workspace, - SplitDirection::Right, - window, - cx, - move |window, cx| LspLogView::new(project, tool_log_store, window, cx), - ); - log_view.update(cx, |log_view, cx| { - let server_id = match server { - LanguageServerSelector::Id(id) => Some(id), - LanguageServerSelector::Name(name) => { - log_store.read(cx).language_servers.iter().find_map( - |(id, state)| { - if state.name.as_ref() == Some(&name) { - Some(*id) - } else { - None - } - }, - ) - } - }; - if let Some(server_id) = server_id { - log_view.show_rpc_trace_for_server(server_id, window, cx); - } - }); - }) - .ok(); - }) - .detach(); - } - - fn on_io( - &mut self, - language_server_id: LanguageServerId, - io_kind: IoKind, - message: &str, - cx: &mut Context, - ) -> Option<()> { - let is_received = match io_kind { - IoKind::StdOut => true, - IoKind::StdIn => false, - IoKind::StdErr => { - self.add_language_server_log(language_server_id, MessageType::LOG, &message, cx); - return Some(()); - } - }; - - let state = self - .get_language_server_state(language_server_id)? - .rpc_state - .as_mut()?; - let kind = if is_received { - MessageKind::Receive - } else { - MessageKind::Send - }; - - let rpc_log_lines = &mut state.rpc_messages; - if state.last_message_kind != Some(kind) { - while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - rpc_log_lines.pop_front(); - } - let line_before_message = match kind { - MessageKind::Send => SEND_LINE, - MessageKind::Receive => RECEIVE_LINE, - }; - rpc_log_lines.push_back(RpcMessage { - message: line_before_message.to_string(), - }); - cx.emit(Event::NewServerLogEntry { - id: language_server_id, - kind: LogKind::Rpc, - text: line_before_message.to_string(), - }); - } - - while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { - rpc_log_lines.pop_front(); - } - - let message = message.trim(); - rpc_log_lines.push_back(RpcMessage { - message: message.to_string(), - }); - cx.emit(Event::NewServerLogEntry { - id: language_server_id, - kind: LogKind::Rpc, - text: message.to_string(), + let log_store = log_store.clone(); + workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| { + let log_store = log_store.clone(); + let project = workspace.project().clone(); + get_or_create_tool( + workspace, + SplitDirection::Right, + window, + cx, + move |window, cx| LspLogView::new(project, log_store, window, cx), + ); }); - cx.notify(); - Some(()) - } + }) + .detach(); } impl LspLogView { @@ -733,16 +196,14 @@ impl LspLogView { let first_server_id_for_project = store.read(cx).server_ids_for_project(&weak_project).next(); if let Some(current_lsp) = this.current_server_id { - if !store.read(cx).language_servers.contains_key(¤t_lsp) { - if let Some(server_id) = first_server_id_for_project { - match this.active_entry_kind { - LogKind::Rpc => { - this.show_rpc_trace_for_server(server_id, window, cx) - } - LogKind::Trace => this.show_trace_for_server(server_id, window, cx), - LogKind::Logs => this.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => this.show_server_info(server_id, window, cx), - } + if !store.read(cx).language_servers.contains_key(¤t_lsp) + && let Some(server_id) = first_server_id_for_project + { + match this.active_entry_kind { + LogKind::Rpc => this.show_rpc_trace_for_server(server_id, window, cx), + LogKind::Trace => this.show_trace_for_server(server_id, window, cx), + LogKind::Logs => this.show_logs_for_server(server_id, window, cx), + LogKind::ServerInfo => this.show_server_info(server_id, window, cx), } } } else if let Some(server_id) = first_server_id_for_project { @@ -756,19 +217,23 @@ impl LspLogView { cx.notify(); }); + let events_subscriptions = cx.subscribe_in( &log_store, window, move |log_view, _, e, window, cx| match e { Event::NewServerLogEntry { id, kind, text } => { if log_view.current_server_id == Some(*id) - && *kind == log_view.active_entry_kind + && LogKind::from_server_log_type(kind) == log_view.active_entry_kind { log_view.editor.update(cx, |editor, cx| { editor.set_read_only(false); let last_offset = editor.buffer().read(cx).len(cx); - let newest_cursor_is_at_end = - editor.selections.newest::(cx).start >= last_offset; + let newest_cursor_is_at_end = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .start + >= last_offset; editor.edit( vec![ (last_offset..last_offset, text.as_str()), @@ -776,21 +241,17 @@ impl LspLogView { ], cx, ); - if text.len() > 1024 { - if let Some((fold_offset, _)) = + if text.len() > 1024 + && let Some((fold_offset, _)) = text.char_indices().dropping(1024).next() - { - if fold_offset < text.len() { - editor.fold_ranges( - vec![ - last_offset + fold_offset..last_offset + text.len(), - ], - false, - window, - cx, - ); - } - } + && fold_offset < text.len() + { + editor.fold_ranges( + vec![last_offset + fold_offset..last_offset + text.len()], + false, + window, + cx, + ); } if newest_cursor_is_at_end { @@ -809,7 +270,20 @@ impl LspLogView { window.focus(&log_view.editor.focus_handle(cx)); }); - let mut this = Self { + cx.on_release(|log_view, cx| { + log_view.log_store.update(cx, |log_store, cx| { + for (server_id, state) in &log_store.language_servers { + if let Some(log_kind) = state.toggled_log_kind { + if let Some(log_type) = log_type(log_kind) { + send_toggle_log_message(state, *server_id, false, log_type, cx); + } + } + } + }); + }) + .detach(); + + let mut lsp_log_view = Self { focus_handle, editor, editor_subscriptions, @@ -824,9 +298,9 @@ impl LspLogView { ], }; if let Some(server_id) = server_id { - this.show_logs_for_server(server_id, window, cx); + lsp_log_view.show_logs_for_server(server_id, window, cx); } - this + lsp_log_view } fn editor_for_logs( @@ -847,14 +321,14 @@ impl LspLogView { } fn editor_for_server_info( - server: &LanguageServer, + info: ServerInfo, window: &mut Window, cx: &mut Context, ) -> (Entity, Vec) { let server_info = format!( "* Server: {NAME} (id {ID}) -* Binary: {BINARY:#?} +* Binary: {BINARY} * Registered workspace folders: {WORKSPACE_FOLDERS} @@ -862,22 +336,21 @@ impl LspLogView { * Capabilities: {CAPABILITIES} * Configuration: {CONFIGURATION}", - NAME = server.name(), - ID = server.server_id(), - BINARY = server.binary(), - WORKSPACE_FOLDERS = server - .workspace_folders() - .into_iter() - .filter_map(|path| path - .to_file_path() - .ok() - .map(|path| path.to_string_lossy().into_owned())) - .collect::>() - .join(", "), - CAPABILITIES = serde_json::to_string_pretty(&server.capabilities()) + NAME = info.name, + ID = info.id, + BINARY = info + .binary + .as_ref() + .map_or_else(|| "Unknown".to_string(), |binary| format!("{binary:#?}")), + WORKSPACE_FOLDERS = info.workspace_folders.join(", "), + CAPABILITIES = serde_json::to_string_pretty(&info.capabilities) .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")), - CONFIGURATION = serde_json::to_string_pretty(server.configuration()) - .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")), + CONFIGURATION = info + .configuration + .map(|configuration| serde_json::to_string_pretty(&configuration)) + .transpose() + .unwrap_or_else(|e| Some(format!("Failed to serialize configuration: {e}"))) + .unwrap_or_else(|| "Unknown".to_string()), ); let editor = initialize_new_editor(server_info, false, window, cx); let editor_subscription = cx.subscribe( @@ -900,11 +373,13 @@ impl LspLogView { .language_servers .iter() .map(|(server_id, state)| match &state.kind { - LanguageServerKind::Local { .. } | LanguageServerKind::Remote { .. } => { + LanguageServerKind::Local { .. } + | LanguageServerKind::Remote { .. } + | LanguageServerKind::LocalSsh { .. } => { let worktree_root_name = state .worktree_id .and_then(|id| self.project.read(cx).worktree_for_id(id, cx)) - .map(|worktree| worktree.read(cx).root_name().to_string()) + .map(|worktree| worktree.read(cx).root_name_str().to_string()) .unwrap_or_else(|| "Unknown worktree".to_string()); LogMenuItem { @@ -936,7 +411,7 @@ impl LspLogView { let state = log_store.language_servers.get(&server_id)?; Some(LogMenuItem { server_id, - server_name: name.clone(), + server_name: name, server_kind: state.kind.clone(), worktree_root_name: "supplementary".to_string(), rpc_trace_enabled: state.rpc_state.is_some(), @@ -978,6 +453,12 @@ impl LspLogView { cx.notify(); } self.editor.read(cx).focus_handle(cx).focus(window); + self.log_store.update(cx, |log_store, cx| { + let state = log_store.get_language_server_state(server_id)?; + state.toggled_log_kind = Some(LogKind::Logs); + send_toggle_log_message(state, server_id, true, LogType::Log, cx); + Some(()) + }); } fn update_log_level( @@ -1012,17 +493,29 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { + let trace_level = self + .log_store + .update(cx, |log_store, _| { + Some(log_store.get_language_server_state(server_id)?.trace_level) + }) + .unwrap_or(TraceValue::Messages); let log_contents = self .log_store .read(cx) .server_trace(server_id) - .map(|v| log_contents(v, ())); + .map(|v| log_contents(v, trace_level)); if let Some(log_contents) = log_contents { self.current_server_id = Some(server_id); self.active_entry_kind = LogKind::Trace; let (editor, editor_subscriptions) = Self::editor_for_logs(log_contents, window, cx); self.editor = editor; self.editor_subscriptions = editor_subscriptions; + self.log_store.update(cx, |log_store, cx| { + let state = log_store.get_language_server_state(server_id)?; + state.toggled_log_kind = Some(LogKind::Trace); + send_toggle_log_message(state, server_id, true, LogType::Trace, cx); + Some(()) + }); cx.notify(); } self.editor.read(cx).focus_handle(cx).focus(window); @@ -1034,6 +527,7 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { + self.toggle_rpc_trace_for_server(server_id, true, window, cx); let rpc_log = self.log_store.update(cx, |log_store, _| { log_store .enable_rpc_trace_for_language_server(server_id) @@ -1078,12 +572,16 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { - self.log_store.update(cx, |log_store, _| { + self.log_store.update(cx, |log_store, cx| { if enabled { log_store.enable_rpc_trace_for_language_server(server_id); } else { log_store.disable_rpc_trace_for_language_server(server_id); } + + if let Some(server_state) = log_store.language_servers.get(&server_id) { + send_toggle_log_message(server_state, server_id, enabled, LogType::Rpc, cx); + }; }); if !enabled && Some(server_id) == self.current_server_id { self.show_logs_for_server(server_id, window, cx); @@ -1111,7 +609,7 @@ impl LspLogView { }); server - .notify::(&SetTraceParams { value: level }) + .notify::(SetTraceParams { value: level }) .ok(); } } @@ -1122,17 +620,85 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) { - let lsp_store = self.project.read(cx).lsp_store(); - let Some(server) = lsp_store.read(cx).language_server_for_id(server_id) else { + let Some(server_info) = self + .project + .read(cx) + .lsp_store() + .update(cx, |lsp_store, _| { + lsp_store + .language_server_for_id(server_id) + .as_ref() + .map(|language_server| ServerInfo::new(language_server)) + .or_else(move || { + let capabilities = + lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); + let name = lsp_store + .language_server_statuses + .get(&server_id) + .map(|status| status.name.clone())?; + Some(ServerInfo { + id: server_id, + capabilities, + binary: None, + name, + workspace_folders: Vec::new(), + configuration: None, + }) + }) + }) + else { return; }; self.current_server_id = Some(server_id); self.active_entry_kind = LogKind::ServerInfo; - let (editor, editor_subscriptions) = Self::editor_for_server_info(&server, window, cx); + let (editor, editor_subscriptions) = Self::editor_for_server_info(server_info, window, cx); self.editor = editor; self.editor_subscriptions = editor_subscriptions; cx.notify(); self.editor.read(cx).focus_handle(cx).focus(window); + self.log_store.update(cx, |log_store, cx| { + let state = log_store.get_language_server_state(server_id)?; + if let Some(log_kind) = state.toggled_log_kind.take() { + if let Some(log_type) = log_type(log_kind) { + send_toggle_log_message(state, server_id, false, log_type, cx); + } + }; + Some(()) + }); + } +} + +fn log_type(log_kind: LogKind) -> Option { + match log_kind { + LogKind::Rpc => Some(LogType::Rpc), + LogKind::Trace => Some(LogType::Trace), + LogKind::Logs => Some(LogType::Log), + LogKind::ServerInfo => None, + } +} + +fn send_toggle_log_message( + server_state: &log_store::LanguageServerState, + server_id: LanguageServerId, + enabled: bool, + log_type: LogType, + cx: &mut App, +) { + if let LanguageServerKind::Remote { project } = &server_state.kind { + project + .update(cx, |project, cx| { + if let Some((client, project_id)) = project.lsp_store().read(cx).upstream_client() { + client + .send(proto::ToggleLspLogs { + project_id, + log_type: log_type as i32, + server_id: server_id.to_proto(), + enabled, + }) + .log_err(); + } + }) + .ok(); } } @@ -1192,16 +758,20 @@ impl Item for LspLogView { } } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { let mut new_view = Self::new(self.project.clone(), self.log_store.clone(), window, cx); if let Some(server_id) = self.current_server_id { match self.active_entry_kind { @@ -1212,7 +782,7 @@ impl Item for LspLogView { } } new_view - })) + }))) } } @@ -1311,14 +881,14 @@ impl ToolbarItemView for LspLogToolbarItemView { _: &mut Window, cx: &mut Context, ) -> workspace::ToolbarItemLocation { - if let Some(item) = active_pane_item { - if let Some(log_view) = item.downcast::() { - self.log_view = Some(log_view.clone()); - self._log_view_subscription = Some(cx.observe(&log_view, |_, _, cx| { - cx.notify(); - })); - return ToolbarItemLocation::PrimaryLeft; - } + if let Some(item) = active_pane_item + && let Some(log_view) = item.downcast::() + { + self.log_view = Some(log_view.clone()); + self._log_view_subscription = Some(cx.observe(&log_view, |_, _, cx| { + cx.notify(); + })); + return ToolbarItemLocation::PrimaryLeft; } self.log_view = None; self._log_view_subscription = None; @@ -1425,13 +995,18 @@ impl Render for LspLogToolbarItemView { let view_selector = current_server.map(|server| { let server_id = server.server_id; - let is_remote = server.server_kind.is_remote(); let rpc_trace_enabled = server.rpc_trace_enabled; let log_view = log_view.clone(); + let label = match server.selected_entry { + LogKind::Rpc => RPC_MESSAGES, + LogKind::Trace => SERVER_TRACE, + LogKind::Logs => SERVER_LOGS, + LogKind::ServerInfo => SERVER_INFO, + }; PopoverMenu::new("LspViewSelector") .anchor(Corner::TopLeft) .trigger( - Button::new("language_server_menu_header", server.selected_entry.label()) + Button::new("language_server_menu_header", label) .icon(IconName::ChevronDown) .icon_size(IconSize::Small) .icon_color(Color::Muted), @@ -1447,55 +1022,53 @@ impl Render for LspLogToolbarItemView { view.show_logs_for_server(server_id, window, cx); }), ) - .when(!is_remote, |this| { - this.entry( - SERVER_TRACE, - None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_trace_for_server(server_id, window, cx); - }), - ) - .custom_entry( - { - let log_toolbar_view = log_toolbar_view.clone(); - move |window, _| { - h_flex() - .w_full() - .justify_between() - .child(Label::new(RPC_MESSAGES)) - .child( - div().child( - Checkbox::new( - "LspLogEnableRpcTrace", - if rpc_trace_enabled { + .entry( + SERVER_TRACE, + None, + window.handler_for(&log_view, move |view, window, cx| { + view.show_trace_for_server(server_id, window, cx); + }), + ) + .custom_entry( + { + let log_toolbar_view = log_toolbar_view.clone(); + move |window, _| { + h_flex() + .w_full() + .justify_between() + .child(Label::new(RPC_MESSAGES)) + .child( + div().child( + Checkbox::new( + "LspLogEnableRpcTrace", + if rpc_trace_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .on_click(window.listener_for( + &log_toolbar_view, + move |view, selection, window, cx| { + let enabled = matches!( + selection, ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .on_click(window.listener_for( - &log_toolbar_view, - move |view, selection, window, cx| { - let enabled = matches!( - selection, - ToggleState::Selected - ); - view.toggle_rpc_logging_for_server( - server_id, enabled, window, cx, - ); - cx.stop_propagation(); - }, - )), - ), - ) - .into_any_element() - } - }, - window.handler_for(&log_view, move |view, window, cx| { - view.show_rpc_trace_for_server(server_id, window, cx); - }), - ) - }) + ); + view.toggle_rpc_logging_for_server( + server_id, enabled, window, cx, + ); + cx.stop_propagation(); + }, + )), + ), + ) + .into_any_element() + } + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_rpc_trace_for_server(server_id, window, cx); + }), + ) .entry( SERVER_INFO, None, @@ -1533,7 +1106,7 @@ impl Render for LspLogToolbarItemView { .icon_color(Color::Muted), ) .menu({ - let log_view = log_view.clone(); + let log_view = log_view; move |window, cx| { let id = log_view.read(cx).current_server_id?; @@ -1601,7 +1174,7 @@ impl Render for LspLogToolbarItemView { .icon_color(Color::Muted), ) .menu({ - let log_view = log_view.clone(); + let log_view = log_view; move |window, cx| { let id = log_view.read(cx).current_server_id?; @@ -1705,12 +1278,6 @@ const SERVER_LOGS: &str = "Server Logs"; const SERVER_TRACE: &str = "Server Trace"; const SERVER_INFO: &str = "Server Info"; -impl Default for LspLogToolbarItemView { - fn default() -> Self { - Self::new() - } -} - impl LspLogToolbarItemView { pub fn new() -> Self { Self { @@ -1743,15 +1310,35 @@ impl LspLogToolbarItemView { } } -pub enum Event { - NewServerLogEntry { - id: LanguageServerId, - kind: LogKind, - text: String, - }, +struct ServerInfo { + id: LanguageServerId, + capabilities: lsp::ServerCapabilities, + binary: Option, + name: LanguageServerName, + workspace_folders: Vec, + configuration: Option, +} + +impl ServerInfo { + fn new(server: &LanguageServer) -> Self { + Self { + id: server.server_id(), + capabilities: server.capabilities(), + binary: Some(server.binary().clone()), + name: server.name(), + workspace_folders: server + .workspace_folders() + .into_iter() + .filter_map(|path| { + path.to_file_path() + .ok() + .map(|path| path.to_string_lossy().into_owned()) + }) + .collect::>(), + configuration: Some(server.configuration().clone()), + } + } } -impl EventEmitter for LogStore {} -impl EventEmitter for LspLogView {} impl EventEmitter for LspLogView {} impl EventEmitter for LspLogView {} diff --git a/crates/language_tools/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs similarity index 88% rename from crates/language_tools/src/lsp_log_tests.rs rename to crates/language_tools/src/lsp_log_view_tests.rs index ad2b653fdcfd4dc228cac58da7ed15f844b4bb26..c521c03a2fe5fd457445ec0a42cebfd3db0010ba 100644 --- a/crates/language_tools/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -1,20 +1,22 @@ use std::sync::Arc; -use crate::lsp_log::LogMenuItem; +use crate::lsp_log_view::LogMenuItem; use super::*; use futures::StreamExt; use gpui::{AppContext as _, SemanticVersion, TestAppContext, VisualTestContext}; use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use lsp::LanguageServerName; -use lsp_log::LogKind; -use project::{FakeFs, Project}; +use project::{ + FakeFs, Project, + lsp_store::log_store::{LanguageServerKind, LogKind, LogStore}, +}; use serde_json::json; use settings::SettingsStore; use util::path; #[gpui::test] -async fn test_lsp_logs(cx: &mut TestAppContext) { +async fn test_lsp_log_view(cx: &mut TestAppContext) { zlog::init_test(); init_test(cx); @@ -51,7 +53,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { }, ); - let log_store = cx.new(LogStore::new); + let log_store = cx.new(|cx| LogStore::new(false, cx)); log_store.update(cx, |store, cx| store.add_project(&project, cx)); let _rust_buffer = project @@ -71,7 +73,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { let log_view = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window, cx); - language_server.notify::(&lsp::LogMessageParams { + language_server.notify::(lsp::LogMessageParams { message: "hello from the server".into(), typ: lsp::MessageType::INFO, }); @@ -89,12 +91,12 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { .next() .unwrap() .read(cx) - .root_name() + .root_name_str() .to_string(), rpc_trace_enabled: false, selected_entry: LogKind::Logs, trace_level: lsp::TraceValue::Off, - server_kind: lsp_log::LanguageServerKind::Local { + server_kind: LanguageServerKind::Local { project: project.downgrade() } }] diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 9946442ec88bf5aa2d1c1d5678ab39c08144591f..e2a0cd4c33a93b7806710e68abca6404290808ce 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -1,17 +1,23 @@ +use command_palette_hooks::CommandPaletteFilter; use editor::{Anchor, Editor, ExcerptId, SelectionEffects, scroll::Autoscroll}; use gpui::{ - App, AppContext as _, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, - InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, - Render, ScrollStrategy, SharedString, Styled, UniformListScrollHandle, WeakEntity, Window, - actions, div, rems, uniform_list, + App, AppContext as _, Context, Div, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, + ParentElement, Render, ScrollStrategy, SharedString, Styled, Task, UniformListScrollHandle, + WeakEntity, Window, actions, div, rems, uniform_list, }; use language::{Buffer, OwnedSyntaxLayer}; -use std::{mem, ops::Range}; +use std::{any::TypeId, mem, ops::Range}; use theme::ActiveTheme; use tree_sitter::{Node, TreeCursor}; -use ui::{ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu, h_flex}; +use ui::{ + ButtonCommon, ButtonLike, Clickable, Color, ContextMenu, FluentBuilder as _, IconButton, + IconName, Label, LabelCommon, LabelSize, PopoverMenu, StyledExt, Tooltip, WithScrollbar, + h_flex, v_flex, +}; use workspace::{ - SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + Event as WorkspaceEvent, SplitDirection, ToolbarItemEvent, ToolbarItemLocation, + ToolbarItemView, Workspace, item::{Item, ItemHandle}, }; @@ -19,17 +25,51 @@ actions!( dev, [ /// Opens the syntax tree view for the current file. - OpenSyntaxTreeView + OpenSyntaxTreeView, + ] +); + +actions!( + syntax_tree_view, + [ + /// Update the syntax tree view to show the last focused file. + UseActiveEditor ] ); pub fn init(cx: &mut App) { - cx.observe_new(|workspace: &mut Workspace, _, _| { - workspace.register_action(|workspace, _: &OpenSyntaxTreeView, window, cx| { + let syntax_tree_actions = [TypeId::of::()]; + + CommandPaletteFilter::update_global(cx, |this, _| { + this.hide_action_types(&syntax_tree_actions); + }); + + cx.observe_new(move |workspace: &mut Workspace, _, _| { + workspace.register_action(move |workspace, _: &OpenSyntaxTreeView, window, cx| { + CommandPaletteFilter::update_global(cx, |this, _| { + this.show_action_types(&syntax_tree_actions); + }); + let active_item = workspace.active_item(cx); let workspace_handle = workspace.weak_handle(); - let syntax_tree_view = - cx.new(|cx| SyntaxTreeView::new(workspace_handle, active_item, window, cx)); + let syntax_tree_view = cx.new(|cx| { + cx.on_release(move |view: &mut SyntaxTreeView, cx| { + if view + .workspace_handle + .read_with(cx, |workspace, cx| { + workspace.item_of_type::(cx).is_none() + }) + .unwrap_or_default() + { + CommandPaletteFilter::update_global(cx, |this, _| { + this.hide_action_types(&syntax_tree_actions); + }); + } + }) + .detach(); + + SyntaxTreeView::new(workspace_handle, active_item, window, cx) + }); workspace.split_item( SplitDirection::Right, Box::new(syntax_tree_view), @@ -37,6 +77,13 @@ pub fn init(cx: &mut App) { cx, ) }); + workspace.register_action(|workspace, _: &UseActiveEditor, window, cx| { + if let Some(tree_view) = workspace.item_of_type::(cx) { + tree_view.update(cx, |view, cx| { + view.update_active_editor(&Default::default(), window, cx) + }) + } + }); }) .detach(); } @@ -45,6 +92,9 @@ pub struct SyntaxTreeView { workspace_handle: WeakEntity, editor: Option, list_scroll_handle: UniformListScrollHandle, + /// The last active editor in the workspace. Note that this is specifically not the + /// currently shown editor. + last_active_editor: Option>, selected_descendant_ix: Option, hovered_descendant_ix: Option, focus_handle: FocusHandle, @@ -61,6 +111,14 @@ struct EditorState { _subscription: gpui::Subscription, } +impl EditorState { + fn has_language(&self) -> bool { + self.active_buffer + .as_ref() + .is_some_and(|buffer| buffer.active_layer.is_some()) + } +} + #[derive(Clone)] struct BufferState { buffer: Entity, @@ -79,17 +137,25 @@ impl SyntaxTreeView { workspace_handle: workspace_handle.clone(), list_scroll_handle: UniformListScrollHandle::new(), editor: None, + last_active_editor: None, hovered_descendant_ix: None, selected_descendant_ix: None, focus_handle: cx.focus_handle(), }; - this.workspace_updated(active_item, window, cx); - cx.observe_in( + this.handle_item_updated(active_item, window, cx); + + cx.subscribe_in( &workspace_handle.upgrade().unwrap(), window, - |this, workspace, window, cx| { - this.workspace_updated(workspace.read(cx).active_item(cx), window, cx); + move |this, workspace, event, window, cx| match event { + WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::ActiveItemChanged => { + this.handle_item_updated(workspace.read(cx).active_item(cx), window, cx) + } + WorkspaceEvent::ItemRemoved { item_id } => { + this.handle_item_removed(item_id, window, cx); + } + _ => {} }, ) .detach(); @@ -97,21 +163,56 @@ impl SyntaxTreeView { this } - fn workspace_updated( + fn handle_item_updated( &mut self, active_item: Option>, window: &mut Window, cx: &mut Context, ) { - if let Some(item) = active_item { - if item.item_id() != cx.entity_id() { - if let Some(editor) = item.act_as::(cx) { - self.set_editor(editor, window, cx); - } - } + let Some(editor) = active_item + .filter(|item| item.item_id() != cx.entity_id()) + .and_then(|item| item.act_as::(cx)) + else { + return; + }; + + if let Some(editor_state) = self.editor.as_ref().filter(|state| state.has_language()) { + self.last_active_editor = (editor_state.editor != editor).then_some(editor); + } else { + self.set_editor(editor, window, cx); + } + } + + fn handle_item_removed( + &mut self, + item_id: &EntityId, + window: &mut Window, + cx: &mut Context, + ) { + if self + .editor + .as_ref() + .is_some_and(|state| state.editor.entity_id() == *item_id) + { + self.editor = None; + // Try activating the last active editor if there is one + self.update_active_editor(&Default::default(), window, cx); + cx.notify(); } } + fn update_active_editor( + &mut self, + _: &UseActiveEditor, + window: &mut Window, + cx: &mut Context, + ) { + let Some(editor) = self.last_active_editor.take() else { + return; + }; + self.set_editor(editor, window, cx); + } + fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(state) = &self.editor { if state.editor == editor { @@ -151,13 +252,16 @@ impl SyntaxTreeView { .editor .update(cx, |editor, cx| editor.snapshot(window, cx)); let (buffer, range, excerpt_id) = editor_state.editor.update(cx, |editor, cx| { - let selection_range = editor.selections.last::(cx).range(); + let selection_range = editor + .selections + .last::(&editor.display_snapshot(cx)) + .range(); let multi_buffer = editor.buffer().read(cx); let (buffer, range, excerpt_id) = snapshot - .buffer_snapshot + .buffer_snapshot() .range_to_buffer_ranges(selection_range) .pop()?; - let buffer = multi_buffer.buffer(buffer.remote_id()).unwrap().clone(); + let buffer = multi_buffer.buffer(buffer.remote_id()).unwrap(); Some((buffer, range, excerpt_id)) })?; @@ -255,12 +359,7 @@ impl SyntaxTreeView { let multibuffer = editor_state.editor.read(cx).buffer(); let multibuffer = multibuffer.read(cx).snapshot(cx); let excerpt_id = buffer_state.excerpt_id; - let range = multibuffer - .anchor_in_excerpt(excerpt_id, range.start) - .unwrap() - ..multibuffer - .anchor_in_excerpt(excerpt_id, range.end) - .unwrap(); + let range = multibuffer.anchor_range_in_excerpt(excerpt_id, range)?; // Update the editor with the anchor range. editor_state.editor.update(cx, |editor, cx| { @@ -295,101 +394,156 @@ impl SyntaxTreeView { .pl(rems(depth as f32)) .hover(|style| style.bg(colors.element_hover)) } -} - -impl Render for SyntaxTreeView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let mut rendered = div().flex_1().bg(cx.theme().colors().editor_background); - if let Some(layer) = self - .editor - .as_ref() - .and_then(|editor| editor.active_buffer.as_ref()) - .and_then(|buffer| buffer.active_layer.as_ref()) - { - let layer = layer.clone(); - rendered = rendered.child(uniform_list( - "SyntaxTreeView", - layer.node().descendant_count(), - cx.processor(move |this, range: Range, _, cx| { - let mut items = Vec::new(); - let mut cursor = layer.node().walk(); - let mut descendant_ix = range.start; - cursor.goto_descendant(descendant_ix); - let mut depth = cursor.depth(); - let mut visited_children = false; - while descendant_ix < range.end { - if visited_children { - if cursor.goto_next_sibling() { - visited_children = false; - } else if cursor.goto_parent() { - depth -= 1; - } else { - break; - } - } else { - items.push( - Self::render_node( - &cursor, - depth, - Some(descendant_ix) == this.selected_descendant_ix, + fn compute_items( + &mut self, + layer: &OwnedSyntaxLayer, + range: Range, + cx: &Context, + ) -> Vec
{ + let mut items = Vec::new(); + let mut cursor = layer.node().walk(); + let mut descendant_ix = range.start; + cursor.goto_descendant(descendant_ix); + let mut depth = cursor.depth(); + let mut visited_children = false; + while descendant_ix < range.end { + if visited_children { + if cursor.goto_next_sibling() { + visited_children = false; + } else if cursor.goto_parent() { + depth -= 1; + } else { + break; + } + } else { + items.push( + Self::render_node( + &cursor, + depth, + Some(descendant_ix) == self.selected_descendant_ix, + cx, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| { + tree_view.update_editor_with_range_for_descendant_ix( + descendant_ix, + window, + cx, + |editor, mut range, window, cx| { + // Put the cursor at the beginning of the node. + mem::swap(&mut range.start, &mut range.end); + + editor.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + }, + ); + }), + ) + .on_mouse_move(cx.listener( + move |tree_view, _: &MouseMoveEvent, window, cx| { + if tree_view.hovered_descendant_ix != Some(descendant_ix) { + tree_view.hovered_descendant_ix = Some(descendant_ix); + tree_view.update_editor_with_range_for_descendant_ix( + descendant_ix, + window, cx, - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| { - tree_view.update_editor_with_range_for_descendant_ix( - descendant_ix, - window, cx, - |editor, mut range, window, cx| { - // Put the cursor at the beginning of the node. - mem::swap(&mut range.start, &mut range.end); - - editor.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, cx, - |selections| { - selections.select_ranges(vec![range]); - }, - ); + |editor, range, _, cx| { + editor.clear_background_highlights::(cx); + editor.highlight_background::( + &[range], + |theme| { + theme + .colors() + .editor_document_highlight_write_background }, + cx, ); - }), - ) - .on_mouse_move(cx.listener( - move |tree_view, _: &MouseMoveEvent, window, cx| { - if tree_view.hovered_descendant_ix != Some(descendant_ix) { - tree_view.hovered_descendant_ix = Some(descendant_ix); - tree_view.update_editor_with_range_for_descendant_ix(descendant_ix, window, cx, |editor, range, _, cx| { - editor.clear_background_highlights::( cx); - editor.highlight_background::( - &[range], - |theme| theme.colors().editor_document_highlight_write_background, - cx, - ); - }); - cx.notify(); - } }, - )), - ); - descendant_ix += 1; - if cursor.goto_first_child() { - depth += 1; - } else { - visited_children = true; + ); + cx.notify(); } - } - } - items - }), - ) - .size_full() - .track_scroll(self.list_scroll_handle.clone()) - .text_bg(cx.theme().colors().background).into_any_element()); + }, + )), + ); + descendant_ix += 1; + if cursor.goto_first_child() { + depth += 1; + } else { + visited_children = true; + } + } } + items + } +} - rendered +impl Render for SyntaxTreeView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex_1() + .bg(cx.theme().colors().editor_background) + .map(|this| { + let editor_state = self.editor.as_ref(); + + if let Some(layer) = editor_state + .and_then(|editor| editor.active_buffer.as_ref()) + .and_then(|buffer| buffer.active_layer.as_ref()) + { + let layer = layer.clone(); + this.child( + uniform_list( + "SyntaxTreeView", + layer.node().descendant_count(), + cx.processor(move |this, range: Range, _, cx| { + this.compute_items(&layer, range, cx) + }), + ) + .size_full() + .track_scroll(self.list_scroll_handle.clone()) + .text_bg(cx.theme().colors().background) + .into_any_element(), + ) + .vertical_scrollbar_for(self.list_scroll_handle.clone(), window, cx) + .into_any_element() + } else { + let inner_content = v_flex() + .items_center() + .text_center() + .gap_2() + .max_w_3_5() + .map(|this| { + if editor_state.is_some_and(|state| !state.has_language()) { + this.child(Label::new("Current editor has no associated language")) + .child( + Label::new(concat!( + "Try assigning a language or", + "switching to a different buffer" + )) + .size(LabelSize::Small), + ) + } else { + this.child(Label::new("Not attached to an editor")).child( + Label::new("Focus an editor to show a new tree view") + .size(LabelSize::Small), + ) + } + }); + + this.h_flex() + .size_full() + .justify_center() + .child(inner_content) + .into_any_element() + } + }) } } @@ -414,22 +568,26 @@ impl Item for SyntaxTreeView { None } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _: Option, window: &mut Window, cx: &mut Context, - ) -> Option> + ) -> Task>> where Self: Sized, { - Some(cx.new(|cx| { + Task::ready(Some(cx.new(|cx| { let mut clone = Self::new(self.workspace_handle.clone(), None, window, cx); if let Some(editor) = &self.editor { clone.set_editor(editor.editor.clone(), window, cx) } clone - })) + }))) } } @@ -507,6 +665,26 @@ impl SyntaxTreeToolbarItemView { .child(Label::new(active_layer.language.name())) .child(Label::new(format_node_range(active_layer.node()))) } + + fn render_update_button(&mut self, cx: &mut Context) -> Option { + self.tree_view.as_ref().and_then(|view| { + view.update(cx, |view, cx| { + view.last_active_editor.as_ref().map(|editor| { + IconButton::new("syntax-view-update", IconName::RotateCw) + .tooltip({ + let active_tab_name = editor.read_with(cx, |editor, cx| { + editor.tab_content_text(Default::default(), cx) + }); + + Tooltip::text(format!("Update view to '{active_tab_name}'")) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.update_active_editor(&Default::default(), window, cx); + })) + }) + }) + }) + } } fn format_node_range(node: Node) -> String { @@ -523,8 +701,10 @@ fn format_node_range(node: Node) -> String { impl Render for SyntaxTreeToolbarItemView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - self.render_menu(cx) - .unwrap_or_else(|| PopoverMenu::new("Empty Syntax Tree")) + h_flex() + .gap_1() + .children(self.render_menu(cx)) + .children(self.render_update_button(cx)) } } @@ -537,12 +717,12 @@ impl ToolbarItemView for SyntaxTreeToolbarItemView { window: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { - if let Some(item) = active_pane_item { - if let Some(view) = item.downcast::() { - self.tree_view = Some(view.clone()); - self.subscription = Some(cx.observe_in(&view, window, |_, _, _, cx| cx.notify())); - return ToolbarItemLocation::PrimaryLeft; - } + if let Some(item) = active_pane_item + && let Some(view) = item.downcast::() + { + self.tree_view = Some(view.clone()); + self.subscription = Some(cx.observe_in(&view, window, |_, _, _, cx| cx.notify())); + return ToolbarItemLocation::PrimaryLeft; } self.tree_view = None; self.subscription = None; diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 8e258180702626bb3dd32b28bfb0e82722a1f12f..2f123bb70fc3977784f5137a68fb63c09fb302c7 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -41,37 +41,35 @@ async-tar.workspace = true async-trait.workspace = true chrono.workspace = true collections.workspace = true -dap.workspace = true -feature_flags.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true +json_schema_store.workspace = true +itertools.workspace = true language.workspace = true log.workspace = true lsp.workspace = true node_runtime.workspace = true parking_lot.workspace = true -paths.workspace = true pet-conda.workspace = true pet-core.workspace = true pet-fs.workspace = true pet-poetry.workspace = true pet-reporter.workspace = true +pet-virtualenv.workspace = true pet.workspace = true project.workspace = true regex.workspace = true rope.workspace = true rust-embed.workspace = true -schemars.workspace = true -sha2.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true settings.workspace = true smol.workspace = true -snippet_provider.workspace = true +url.workspace = true task.workspace = true -tempfile.workspace = true +theme.workspace = true toml.workspace = true tree-sitter = { workspace = true, optional = true } tree-sitter-bash = { workspace = true, optional = true } @@ -92,7 +90,6 @@ tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } tree-sitter-yaml = { workspace = true, optional = true } util.workspace = true -workspace-hack.workspace = true [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index 0c6ab8cc14eca2ba87fa0b876946bee0b21d479b..8fabd4cf43aa4a79fa868854064942252deb4117 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -19,7 +19,7 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks { #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext}; - use language::{AutoindentMode, Buffer, language_settings::AllLanguageSettings}; + use language::{AutoindentMode, Buffer}; use settings::SettingsStore; use std::num::NonZeroU32; use unindent::Unindent; @@ -34,8 +34,8 @@ mod tests { cx.set_global(test_settings); language::init(cx); cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |s| { - s.defaults.tab_size = NonZeroU32::new(2) + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2) }); }); }); diff --git a/crates/languages/src/bash/injections.scm b/crates/languages/src/bash/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..9117c713b98fdd2896b13e4949a77c6489b9ee36 --- /dev/null +++ b/crates/languages/src/bash/injections.scm @@ -0,0 +1,3 @@ +((comment) @injection.content + (#set! injection.language "comment") +) diff --git a/extensions/toml/languages/toml/overrides.scm b/crates/languages/src/bash/overrides.scm similarity index 100% rename from extensions/toml/languages/toml/overrides.scm rename to crates/languages/src/bash/overrides.scm diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index aee1abee95fa2ea21931084ebe442c2ecd41da3c..8e90cf821368c0c88781b2d10e82ad9eaa05989c 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -3,48 +3,33 @@ use async_trait::async_trait; use futures::StreamExt; use gpui::{App, AsyncApp}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release}; +use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; use project::lsp_store::clangd_ext; use serde_json::json; use smol::fs; -use std::{any::Any, env::consts, path::PathBuf, sync::Arc}; +use std::{env::consts, path::PathBuf, sync::Arc}; use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into}; -use crate::github_download::{GithubBinaryMetadata, download_server_binary}; - pub struct CLspAdapter; impl CLspAdapter { const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("clangd"); } -#[async_trait(?Send)] -impl super::LspAdapter for CLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } - - async fn check_if_user_installed( - &self, - delegate: &dyn LspAdapterDelegate, - _: Arc, - _: &AsyncApp, - ) -> Option { - let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; - Some(LanguageServerBinary { - path, - arguments: Vec::new(), - env: None, - }) - } +impl LspInstaller for CLspAdapter { + type BinaryVersion = GitHubLspBinaryVersion; async fn fetch_latest_server_version( &self, delegate: &dyn LspAdapterDelegate, - ) -> Result> { + pre_release: bool, + _: &mut AsyncApp, + ) -> Result { let release = - latest_github_release("clangd/clangd", true, false, delegate.http_client()).await?; + latest_github_release("clangd/clangd", true, pre_release, delegate.http_client()) + .await?; let os_suffix = match consts::OS { "macos" => "mac", "linux" => "linux", @@ -62,12 +47,26 @@ impl super::LspAdapter for CLspAdapter { url: asset.browser_download_url.clone(), digest: asset.digest.clone(), }; - Ok(Box::new(version) as Box<_>) + Ok(version) + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; + Some(LanguageServerBinary { + path, + arguments: Vec::new(), + env: None, + }) } async fn fetch_server_binary( &self, - version: Box, + version: GitHubLspBinaryVersion, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { @@ -75,7 +74,7 @@ impl super::LspAdapter for CLspAdapter { name, url, digest: expected_digest, - } = *version.downcast::().unwrap(); + } = version; let version_dir = container_dir.join(format!("clangd_{name}")); let binary_path = version_dir.join("bin/clangd"); @@ -119,7 +118,7 @@ impl super::LspAdapter for CLspAdapter { } } download_server_binary( - delegate, + &*delegate.http_client(), &url, expected_digest.as_deref(), &container_dir, @@ -146,6 +145,13 @@ impl super::LspAdapter for CLspAdapter { ) -> Option { get_cached_server_binary(container_dir).await } +} + +#[async_trait(?Send)] +impl super::LspAdapter for CLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } async fn label_for_completion( &self, @@ -160,13 +166,24 @@ impl super::LspAdapter for CLspAdapter { None => "", }; - let label = completion + let mut label = completion .label .strip_prefix('•') .unwrap_or(&completion.label) .trim() - .to_owned() - + label_detail; + .to_owned(); + + if !label_detail.is_empty() { + let should_add_space = match completion.kind { + Some(lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD) => false, + _ => true, + }; + + if should_add_space && !label.ends_with(' ') && !label_detail.starts_with(' ') { + label.push(' '); + } + label.push_str(label_detail); + } match completion.kind { Some(lsp::CompletionItemKind::FIELD) if completion.detail.is_some() => { @@ -182,11 +199,7 @@ impl super::LspAdapter for CLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(detail.len() + 1..text.len()); - return Some(CodeLabel { - filter_range, - text, - runs, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some(lsp::CompletionItemKind::CONSTANT | lsp::CompletionItemKind::VARIABLE) if completion.detail.is_some() => @@ -202,11 +215,7 @@ impl super::LspAdapter for CLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(detail.len() + 1..text.len()); - return Some(CodeLabel { - filter_range, - text, - runs, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some(lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD) if completion.detail.is_some() => @@ -230,11 +239,7 @@ impl super::LspAdapter for CLspAdapter { filter_start..filter_end }); - return Some(CodeLabel { - filter_range, - text, - runs, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some(kind) => { let highlight_name = match kind { @@ -253,8 +258,7 @@ impl super::LspAdapter for CLspAdapter { .grammar() .and_then(|g| g.highlight_id_for_name(highlight_name?)) { - let mut label = - CodeLabel::plain(label.to_string(), completion.filter_text.as_deref()); + let mut label = CodeLabel::plain(label, completion.filter_text.as_deref()); label.runs.push(( 0..label.text.rfind('(').unwrap_or(label.text.len()), highlight_id, @@ -264,10 +268,7 @@ impl super::LspAdapter for CLspAdapter { } _ => {} } - Some(CodeLabel::plain( - label.to_string(), - completion.filter_text.as_deref(), - )) + Some(CodeLabel::plain(label, completion.filter_text.as_deref())) } async fn label_for_symbol( @@ -322,11 +323,11 @@ impl super::LspAdapter for CLspAdapter { _ => return None, }; - Some(CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), + Some(CodeLabel::new( + text[display_range.clone()].to_string(), filter_range, - }) + language.highlight_text(&text.as_str().into(), display_range), + )) } fn prepare_initialize_params( @@ -391,7 +392,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option(|store, cx| { - store.update_user_settings::(cx, |s| { - s.defaults.tab_size = NonZeroU32::new(2); + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2); }); }); }); diff --git a/crates/languages/src/c/config.toml b/crates/languages/src/c/config.toml index 74290fd9e2b31db93bb62187ab707110c818fc44..76a27ccc81911bcf25c7da3efef191214eab7b00 100644 --- a/crates/languages/src/c/config.toml +++ b/crates/languages/src/c/config.toml @@ -17,3 +17,4 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +import_path_strip_regex = "^<|>$" diff --git a/crates/languages/src/c/highlights.scm b/crates/languages/src/c/highlights.scm index b80c462ae6d32974d27d8a532bf6edd15ba86a82..40e0d7147e98287f5ed7587d690e25bc8bacaa0b 100644 --- a/crates/languages/src/c/highlights.scm +++ b/crates/languages/src/c/highlights.scm @@ -1,27 +1,30 @@ +[ + "const" + "enum" + "extern" + "inline" + "sizeof" + "static" + "struct" + "typedef" + "union" + "volatile" +] @keyword + [ "break" "case" - "const" "continue" "default" "do" "else" - "enum" - "extern" "for" "goto" "if" - "inline" "return" - "sizeof" - "static" - "struct" "switch" - "typedef" - "union" - "volatile" "while" -] @keyword +] @keyword.control [ "#define" diff --git a/crates/languages/src/c/imports.scm b/crates/languages/src/c/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..c3c2c9e68c4503d323d039f9c042d9501b5e4126 --- /dev/null +++ b/crates/languages/src/c/imports.scm @@ -0,0 +1,7 @@ +(preproc_include + path: [ + ( + (system_lib_string) @source @wildcard + (#strip! @source "[<>]")) + (string_literal (string_content) @source @wildcard) + ]) @import diff --git a/crates/languages/src/c/injections.scm b/crates/languages/src/c/injections.scm index 73d2628225f05db94d53381fc9a9e10c29b6189d..447897340cc735ed77099b20fd6fc8c52ac19ec8 100644 --- a/crates/languages/src/c/injections.scm +++ b/crates/languages/src/c/injections.scm @@ -1,3 +1,7 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (preproc_def value: (preproc_arg) @injection.content (#set! injection.language "c")) diff --git a/crates/languages/src/cpp/config.toml b/crates/languages/src/cpp/config.toml index 7e24415f9d44c75cfe18065bbe264f0da0f561de..4d3c0a0a38664f4dd584a0ce3f3544662b19bbae 100644 --- a/crates/languages/src/cpp/config.toml +++ b/crates/languages/src/cpp/config.toml @@ -17,3 +17,4 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +import_path_strip_regex = "^<|>$" diff --git a/crates/languages/src/cpp/highlights.scm b/crates/languages/src/cpp/highlights.scm index 6fa8bd7b0858d3a1844ce2d322564ce9c39babea..af906e67122333b6e1834f1280d4458189daf105 100644 --- a/crates/languages/src/cpp/highlights.scm +++ b/crates/languages/src/cpp/highlights.scm @@ -3,8 +3,27 @@ (namespace_identifier) @namespace (concept_definition - (identifier) @concept) + name: (identifier) @concept) +(requires_clause + constraint: (template_type + name: (type_identifier) @concept)) + +(module_name + (identifier) @module) + +(module_declaration + name: (module_name + (identifier) @module)) + +(import_declaration + name: (module_name + (identifier) @module)) + +(import_declaration + partition: (module_partition + (module_name + (identifier) @module))) (call_expression function: (qualified_identifier @@ -61,6 +80,9 @@ (operator_name (identifier)? @operator) @function +(operator_name + "<=>" @operator.spaceship) + (destructor_name (identifier) @function) ((namespace_identifier) @type @@ -68,74 +90,81 @@ (auto) @type (type_identifier) @type -type :(primitive_type) @type.primitive -(sized_type_specifier) @type.primitive - -(requires_clause - constraint: (template_type - name: (type_identifier) @concept)) +type: (primitive_type) @type.builtin +(sized_type_specifier) @type.builtin (attribute - name: (identifier) @keyword) + name: (identifier) @attribute) -((identifier) @constant - (#match? @constant "^_*[A-Z][A-Z\\d_]*$")) +((identifier) @constant.builtin + (#match? @constant.builtin "^_*[A-Z][A-Z\\d_]*$")) (statement_identifier) @label -(this) @variable.special +(this) @variable.builtin ("static_assert") @function.builtin [ "alignas" "alignof" - "break" - "case" - "catch" "class" - "co_await" - "co_return" - "co_yield" "concept" + "consteval" "constexpr" - "continue" + "constinit" "decltype" - "default" "delete" - "do" - "else" "enum" "explicit" + "export" "extern" "final" - "for" "friend" - "if" + "import" "inline" + "module" "namespace" "new" "noexcept" + "operator" "override" "private" "protected" "public" "requires" - "return" "sizeof" "struct" - "switch" "template" - "throw" - "try" + "thread_local" "typedef" "typename" "union" "using" "virtual" - "while" (storage_class_specifier) (type_qualifier) ] @keyword +[ + "break" + "case" + "catch" + "co_await" + "co_return" + "co_yield" + "continue" + "default" + "do" + "else" + "for" + "goto" + "if" + "return" + "switch" + "throw" + "try" + "while" +] @keyword.control + [ "#define" "#elif" @@ -146,7 +175,7 @@ type :(primitive_type) @type.primitive "#ifndef" "#include" (preproc_directive) -] @keyword +] @keyword.directive (comment) @comment @@ -224,10 +253,24 @@ type :(primitive_type) @type.primitive ">" "<=" ">=" - "<=>" - "||" "?" + "and" + "and_eq" + "bitand" + "bitor" + "compl" + "not" + "not_eq" + "or" + "or_eq" + "xor" + "xor_eq" ] @operator +"<=>" @operator.spaceship + +(binary_expression + operator: "<=>" @operator.spaceship) + (conditional_expression ":" @operator) (user_defined_literal (literal_suffix) @operator) diff --git a/crates/languages/src/cpp/imports.scm b/crates/languages/src/cpp/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..a4ef817a80dbcd44336bdd8cd681587662aad435 --- /dev/null +++ b/crates/languages/src/cpp/imports.scm @@ -0,0 +1,5 @@ +(preproc_include + path: [ + ((system_lib_string) @source @wildcard) + (string_literal (string_content) @source @wildcard) + ]) @import diff --git a/crates/languages/src/cpp/injections.scm b/crates/languages/src/cpp/injections.scm index e903e1affd53a5c641a30736599ebedb2f53169f..160770f3cc1d69f5cb3d1679c8a48726d8d437ed 100644 --- a/crates/languages/src/cpp/injections.scm +++ b/crates/languages/src/cpp/injections.scm @@ -1,3 +1,7 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (preproc_def value: (preproc_arg) @injection.content (#set! injection.language "c++")) diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index ffd9006c769a4ad14cc70beb988c7ea96a578872..035a2c693dbbdceed38adc8ccc0510274205670f 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -2,14 +2,13 @@ use anyhow::{Context as _, Result}; use async_trait::async_trait; use futures::StreamExt; use gpui::AsyncApp; -use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; -use project::{Fs, lsp_store::language_server_settings}; +use project::lsp_store::language_server_settings; use serde_json::json; use smol::fs; use std::{ - any::Any, ffi::OsString, path::{Path, PathBuf}, sync::Arc, @@ -34,16 +33,24 @@ impl CssLspAdapter { } } -#[async_trait(?Send)] -impl LspAdapter for CssLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("vscode-css-language-server".into()) +impl LspInstaller for CssLspAdapter { + type BinaryVersion = String; + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version("vscode-langservers-extracted") + .await } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate @@ -58,24 +65,12 @@ impl LspAdapter for CssLspAdapter { }) } - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new( - self.node - .npm_package_latest_version("vscode-langservers-extracted") - .await?, - ) as Box<_>) - } - async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: String, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(SERVER_PATH); self.node @@ -94,11 +89,10 @@ impl LspAdapter for CssLspAdapter { async fn check_if_version_installed( &self, - version: &(dyn 'static + Send + Any), + version: &String, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); let server_path = container_dir.join(SERVER_PATH); let should_install_language_server = self @@ -106,7 +100,7 @@ impl LspAdapter for CssLspAdapter { .should_install_npm_package( Self::PACKAGE_NAME, &server_path, - &container_dir, + container_dir, VersionStrategy::Latest(version), ) .await; @@ -129,10 +123,16 @@ impl LspAdapter for CssLspAdapter { ) -> Option { get_cached_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for CssLspAdapter { + fn name(&self) -> LanguageServerName { + LanguageServerName("vscode-css-language-server".into()) + } async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(Some(json!({ @@ -142,9 +142,8 @@ impl LspAdapter for CssLspAdapter { async fn workspace_configuration( self: Arc, - _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let mut default_config = json!({ @@ -236,7 +235,7 @@ mod tests { .unindent(); let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); - let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap()); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline .items diff --git a/crates/languages/src/gitcommit/injections.scm b/crates/languages/src/gitcommit/injections.scm index db0af176578cfe1ba50db0cc7543d9b805ed8163..8fb9b459679489be7588d1ab9b6d53e40ea10c60 100644 --- a/crates/languages/src/gitcommit/injections.scm +++ b/crates/languages/src/gitcommit/injections.scm @@ -1,3 +1,7 @@ +((comment) @content + (#set! injection.language "comment") +) + ((scissors) @content (#set! "language" "diff")) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 14f646133bf22ba7977cb23dca38a4700b527e1b..6c75abf123af62b3f4ab43a6e94d3b040e2f010a 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -5,17 +5,17 @@ use futures::StreamExt; use gpui::{App, AsyncApp, Task}; use http_client::github::latest_github_release; pub use language::*; +use language::{LanguageToolchainStore, LspAdapterDelegate, LspInstaller}; use lsp::{LanguageServerBinary, LanguageServerName}; -use project::Fs; + use regex::Regex; use serde_json::json; use smol::fs; use std::{ - any::Any, borrow::Cow, ffi::{OsStr, OsString}, ops::Range, - path::PathBuf, + path::{Path, PathBuf}, process::Output, str, sync::{ @@ -50,16 +50,32 @@ const BINARY: &str = if cfg!(target_os = "windows") { "gopls" }; -#[async_trait(?Send)] -impl super::LspAdapter for GoLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } +impl LspInstaller for GoLspAdapter { + type BinaryVersion = Option; async fn fetch_latest_server_version( &self, delegate: &dyn LspAdapterDelegate, - ) -> Result> { + _: bool, + cx: &mut AsyncApp, + ) -> Result> { + static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false); + + const NOTIFICATION_MESSAGE: &str = + "Could not install the Go language server `gopls`, because `go` was not found."; + + if delegate.which("go".as_ref()).await.is_none() { + if DID_SHOW_NOTIFICATION + .compare_exchange(false, true, SeqCst, SeqCst) + .is_ok() + { + cx.update(|cx| { + delegate.show_notification(NOTIFICATION_MESSAGE, cx); + })? + } + anyhow::bail!("cannot install gopls"); + } + let release = latest_github_release("golang/tools", false, false, delegate.http_client()).await?; let version: Option = release.tag_name.strip_prefix("gopls/v").map(str::to_string); @@ -69,13 +85,13 @@ impl super::LspAdapter for GoLspAdapter { release.tag_name ); } - Ok(Box::new(version) as Box<_>) + Ok(version) } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -86,36 +102,9 @@ impl super::LspAdapter for GoLspAdapter { }) } - fn will_fetch_server( - &self, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Option>> { - static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false); - - const NOTIFICATION_MESSAGE: &str = - "Could not install the Go language server `gopls`, because `go` was not found."; - - let delegate = delegate.clone(); - Some(cx.spawn(async move |cx| { - if delegate.which("go".as_ref()).await.is_none() { - if DID_SHOW_NOTIFICATION - .compare_exchange(false, true, SeqCst, SeqCst) - .is_ok() - { - cx.update(|cx| { - delegate.show_notification(NOTIFICATION_MESSAGE, cx); - })? - } - anyhow::bail!("cannot install gopls"); - } - Ok(()) - })) - } - async fn fetch_server_binary( &self, - version: Box, + version: Option, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { @@ -126,29 +115,24 @@ impl super::LspAdapter for GoLspAdapter { .await .context("failed to get go version via `go version` command`")?; let go_version = parse_version_output(&go_version_output)?; - let version = version.downcast::>().unwrap(); - let this = *self; - if let Some(version) = *version { + if let Some(version) = version { let binary_path = container_dir.join(format!("gopls_{version}_go_{go_version}")); - if let Ok(metadata) = fs::metadata(&binary_path).await { - if metadata.is_file() { - remove_matching(&container_dir, |entry| { - entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) - }) - .await; + if let Ok(metadata) = fs::metadata(&binary_path).await + && metadata.is_file() + { + remove_matching(&container_dir, |entry| { + entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) + }) + .await; - return Ok(LanguageServerBinary { - path: binary_path.to_path_buf(), - arguments: server_binary_arguments(), - env: None, - }); - } + return Ok(LanguageServerBinary { + path: binary_path.to_path_buf(), + arguments: server_binary_arguments(), + env: None, + }); } - } else if let Some(path) = this - .cached_server_binary(container_dir.clone(), delegate) - .await - { + } else if let Some(path) = get_cached_server_binary(&container_dir).await { return Ok(path); } @@ -194,16 +178,22 @@ impl super::LspAdapter for GoLspAdapter { container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - get_cached_server_binary(container_dir).await + get_cached_server_binary(&container_dir).await + } +} + +#[async_trait(?Send)] +impl LspAdapter for GoLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME } async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(Some(json!({ - "usePlaceholders": true, + "usePlaceholders": false, "hints": { "assignVariableTypes": true, "compositeLiteralFields": true, @@ -232,7 +222,7 @@ impl super::LspAdapter for GoLspAdapter { Some((lsp::CompletionItemKind::MODULE, detail)) => { let text = format!("{label} {detail}"); let source = Rope::from(format!("import {text}").as_str()); - let runs = language.highlight_text(&source, 7..7 + text.len()); + let runs = language.highlight_text(&source, 7..7 + text[name_offset..].len()); let filter_range = completion .filter_text .as_deref() @@ -241,11 +231,7 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - text, - runs, - filter_range, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some(( lsp::CompletionItemKind::CONSTANT | lsp::CompletionItemKind::VARIABLE, @@ -256,7 +242,7 @@ impl super::LspAdapter for GoLspAdapter { Rope::from(format!("var {} {}", &text[name_offset..], detail).as_str()); let runs = adjust_runs( name_offset, - language.highlight_text(&source, 4..4 + text.len()), + language.highlight_text(&source, 4..4 + text[name_offset..].len()), ); let filter_range = completion .filter_text @@ -266,18 +252,14 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - text, - runs, - filter_range, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some((lsp::CompletionItemKind::STRUCT, _)) => { let text = format!("{label} struct {{}}"); let source = Rope::from(format!("type {}", &text[name_offset..]).as_str()); let runs = adjust_runs( name_offset, - language.highlight_text(&source, 5..5 + text.len()), + language.highlight_text(&source, 5..5 + text[name_offset..].len()), ); let filter_range = completion .filter_text @@ -287,18 +269,14 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - text, - runs, - filter_range, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some((lsp::CompletionItemKind::INTERFACE, _)) => { let text = format!("{label} interface {{}}"); let source = Rope::from(format!("type {}", &text[name_offset..]).as_str()); let runs = adjust_runs( name_offset, - language.highlight_text(&source, 5..5 + text.len()), + language.highlight_text(&source, 5..5 + text[name_offset..].len()), ); let filter_range = completion .filter_text @@ -308,11 +286,7 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - text, - runs, - filter_range, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some((lsp::CompletionItemKind::FIELD, detail)) => { let text = format!("{label} {detail}"); @@ -320,7 +294,7 @@ impl super::LspAdapter for GoLspAdapter { Rope::from(format!("type T struct {{ {} }}", &text[name_offset..]).as_str()); let runs = adjust_runs( name_offset, - language.highlight_text(&source, 16..16 + text.len()), + language.highlight_text(&source, 16..16 + text[name_offset..].len()), ); let filter_range = completion .filter_text @@ -330,11 +304,7 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - text, - runs, - filter_range, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } Some((lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD, detail)) => { if let Some(signature) = detail.strip_prefix("func") { @@ -342,7 +312,7 @@ impl super::LspAdapter for GoLspAdapter { let source = Rope::from(format!("func {} {{}}", &text[name_offset..]).as_str()); let runs = adjust_runs( name_offset, - language.highlight_text(&source, 5..5 + text.len()), + language.highlight_text(&source, 5..5 + text[name_offset..].len()), ); let filter_range = completion .filter_text @@ -352,11 +322,7 @@ impl super::LspAdapter for GoLspAdapter { .map(|start| start..start + filter_text.len()) }) .unwrap_or(0..label.len()); - return Some(CodeLabel { - filter_range, - text, - runs, - }); + return Some(CodeLabel::new(text, filter_range, runs)); } } _ => {} @@ -416,11 +382,11 @@ impl super::LspAdapter for GoLspAdapter { _ => return None, }; - Some(CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), + Some(CodeLabel::new( + text[display_range.clone()].to_string(), filter_range, - }) + language.highlight_text(&text.as_str().into(), display_range), + )) } fn diagnostic_message_to_markdown(&self, message: &str) -> Option { @@ -442,17 +408,17 @@ fn parse_version_output(output: &Output) -> Result<&str> { Ok(version) } -async fn get_cached_server_binary(container_dir: PathBuf) -> Option { +async fn get_cached_server_binary(container_dir: &Path) -> Option { maybe!(async { let mut last_binary_path = None; - let mut entries = fs::read_dir(&container_dir).await?; + let mut entries = fs::read_dir(container_dir).await?; while let Some(entry) = entries.next().await { let entry = entry?; if entry.file_type().await?.is_file() && entry .file_name() .to_str() - .map_or(false, |name| name.starts_with("gopls_")) + .is_some_and(|name| name.starts_with("gopls_")) { last_binary_path = Some(entry.path()); } @@ -489,6 +455,8 @@ const GO_SUBTEST_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_SUBTEST_NAME")); const GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_TABLE_TEST_CASE_NAME")); +const GO_SUITE_NAME_TASK_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("GO_SUITE_NAME")); impl ContextProvider for GoContextProvider { fn build_context( @@ -525,7 +493,7 @@ impl ContextProvider for GoContextProvider { }) .unwrap_or_else(|| format!("{}", buffer_dir.to_string_lossy())); - (GO_PACKAGE_TASK_VARIABLE.clone(), package_name.to_string()) + (GO_PACKAGE_TASK_VARIABLE.clone(), package_name) }); let go_module_root_variable = local_abs_path @@ -536,7 +504,7 @@ impl ContextProvider for GoContextProvider { let module_dir = buffer_dir .ancestors() .find(|dir| dir.join("go.mod").is_file()) - .map(|dir| dir.to_string_lossy().to_string()) + .map(|dir| dir.to_string_lossy().into_owned()) .unwrap_or_else(|| ".".to_string()); (GO_MODULE_ROOT_TASK_VARIABLE.clone(), module_dir) @@ -547,19 +515,26 @@ impl ContextProvider for GoContextProvider { let go_subtest_variable = extract_subtest_name(_subtest_name.unwrap_or("")) .map(|subtest_name| (GO_SUBTEST_NAME_TASK_VARIABLE.clone(), subtest_name)); - let table_test_case_name = variables.get(&VariableName::Custom(Cow::Borrowed( + let _table_test_case_name = variables.get(&VariableName::Custom(Cow::Borrowed( "_table_test_case_name", ))); - let go_table_test_case_variable = table_test_case_name + let go_table_test_case_variable = _table_test_case_name .and_then(extract_subtest_name) .map(|case_name| (GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE.clone(), case_name)); + let _suite_name = variables.get(&VariableName::Custom(Cow::Borrowed("_suite_name"))); + + let go_suite_variable = _suite_name + .and_then(extract_subtest_name) + .map(|suite_name| (GO_SUITE_NAME_TASK_VARIABLE.clone(), suite_name)); + Task::ready(Ok(TaskVariables::from_iter( [ go_package_variable, go_subtest_variable, go_table_test_case_variable, + go_suite_variable, go_module_root_variable, ] .into_iter() @@ -567,12 +542,7 @@ impl ContextProvider for GoContextProvider { ))) } - fn associated_tasks( - &self, - _: Arc, - _: Option>, - _: &App, - ) -> Task> { + fn associated_tasks(&self, _: Option>, _: &App) -> Task> { let package_cwd = if GO_PACKAGE_TASK_VARIABLE.template_value() == "." { None } else { @@ -581,6 +551,28 @@ impl ContextProvider for GoContextProvider { let module_cwd = Some(GO_MODULE_ROOT_TASK_VARIABLE.template_value()); Task::ready(Some(TaskTemplates(vec![ + TaskTemplate { + label: format!( + "go test {} -v -run Test{}/{}", + GO_PACKAGE_TASK_VARIABLE.template_value(), + GO_SUITE_NAME_TASK_VARIABLE.template_value(), + VariableName::Symbol.template_value(), + ), + command: "go".into(), + args: vec![ + "test".into(), + "-v".into(), + "-run".into(), + format!( + "\\^Test{}\\$/\\^{}\\$", + GO_SUITE_NAME_TASK_VARIABLE.template_value(), + VariableName::Symbol.template_value(), + ), + ], + cwd: package_cwd.clone(), + tags: vec!["go-testify-suite".to_owned()], + ..TaskTemplate::default() + }, TaskTemplate { label: format!( "go test {} -v -run {}/{}", @@ -619,6 +611,22 @@ impl ContextProvider for GoContextProvider { cwd: package_cwd.clone(), ..TaskTemplate::default() }, + TaskTemplate { + label: format!( + "go test {} -run {}", + GO_PACKAGE_TASK_VARIABLE.template_value(), + VariableName::Symbol.template_value(), + ), + command: "go".into(), + args: vec![ + "test".into(), + "-run".into(), + format!("\\^{}\\$", VariableName::Symbol.template_value(),), + ], + tags: vec!["go-example".to_owned()], + cwd: package_cwd.clone(), + ..TaskTemplate::default() + }, TaskTemplate { label: format!("go test {}", GO_PACKAGE_TASK_VARIABLE.template_value()), command: "go".into(), @@ -702,7 +710,7 @@ impl ContextProvider for GoContextProvider { label: format!("go generate {}", GO_PACKAGE_TASK_VARIABLE.template_value()), command: "go".into(), args: vec!["generate".into()], - cwd: package_cwd.clone(), + cwd: package_cwd, tags: vec!["go-generate".to_owned()], ..TaskTemplate::default() }, @@ -710,7 +718,7 @@ impl ContextProvider for GoContextProvider { label: "go generate ./...".into(), command: "go".into(), args: vec!["generate".into(), "./...".into()], - cwd: module_cwd.clone(), + cwd: module_cwd, ..TaskTemplate::default() }, ]))) @@ -764,6 +772,7 @@ mod tests { let highlight_type = grammar.highlight_id_for_name("type").unwrap(); let highlight_keyword = grammar.highlight_id_for_name("keyword").unwrap(); let highlight_number = grammar.highlight_id_for_name("number").unwrap(); + let highlight_field = grammar.highlight_id_for_name("property").unwrap(); assert_eq!( adapter @@ -777,15 +786,15 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "Hello(a B) c.D".to_string(), - filter_range: 0..5, - runs: vec![ + Some(CodeLabel::new( + "Hello(a B) c.D".to_string(), + 0..5, + vec![ (0..5, highlight_function), (8..9, highlight_type), (13..14, highlight_type), - ], - }) + ] + )) ); // Nested methods @@ -801,15 +810,15 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "one.two.Three() [3]interface{}".to_string(), - filter_range: 0..13, - runs: vec![ + Some(CodeLabel::new( + "one.two.Three() [3]interface{}".to_string(), + 0..13, + vec![ (8..13, highlight_function), (17..18, highlight_number), (19..28, highlight_keyword), ], - }) + )) ); // Nested fields @@ -825,11 +834,64 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "two.Three a.Bcd".to_string(), - filter_range: 0..9, - runs: vec![(12..15, highlight_type)], - }) + Some(CodeLabel::new( + "two.Three a.Bcd".to_string(), + 0..9, + vec![(4..9, highlight_field), (12..15, highlight_type)], + )) + ); + } + + #[gpui::test] + fn test_testify_suite_detection(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let testify_suite = r#" + package main + + import ( + "testing" + + "github.com/stretchr/testify/suite" + ) + + type ExampleSuite struct { + suite.Suite + } + + func TestExampleSuite(t *testing.T) { + suite.Run(t, new(ExampleSuite)) + } + + func (s *ExampleSuite) TestSomething_Success() { + // test code + } + "#; + + let buffer = cx + .new(|cx| crate::Buffer::local(testify_suite, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..testify_suite.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + tag_strings.contains(&"go-test".to_string()), + "Should find go-test tag, found: {:?}", + tag_strings + ); + assert!( + tag_strings.contains(&"go-testify-suite".to_string()), + "Should find go-testify-suite tag, found: {:?}", + tag_strings ); } @@ -922,6 +984,43 @@ mod tests { ); } + #[gpui::test] + fn test_go_example_test_detection(cx: &mut TestAppContext) { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + + let example_test = r#" + package main + + import "fmt" + + func Example() { + fmt.Println("Hello, world!") + // Output: Hello, world! + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(example_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..example_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + assert!( + tag_strings.contains(&"go-example".to_string()), + "Should find go-example tag, found: {:?}", + tag_strings + ); + } + #[gpui::test] fn test_go_table_test_slice_detection(cx: &mut TestAppContext) { let language = language("go", tree_sitter_go::LANGUAGE.into()); @@ -946,6 +1045,10 @@ mod tests { name: "test case 2", anotherStr: "bar", }, + { + name: "test case 3", + anotherStr: "baz", + }, } notATableTest := []struct{ @@ -994,21 +1097,22 @@ mod tests { ); let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count(); - let go_table_test_count = tag_strings - .iter() - .filter(|&tag| tag == "go-table-test-case") - .count(); + // This is currently broken; see #39148 + // let go_table_test_count = tag_strings + // .iter() + // .filter(|&tag| tag == "go-table-test-case") + // .count(); assert!( go_test_count == 1, "Should find exactly 1 go-test, found: {}", go_test_count ); - assert!( - go_table_test_count == 2, - "Should find exactly 2 go-table-test-case, found: {}", - go_table_test_count - ); + // assert!( + // go_table_test_count == 3, + // "Should find exactly 3 go-table-test-case, found: {}", + // go_table_test_count + // ); } #[gpui::test] diff --git a/crates/languages/src/go/highlights.scm b/crates/languages/src/go/highlights.scm index 5aa23fca90b7e0295fc08af6a75a038e3abb0e3a..5d630cbdfc746b56320cd5083222897d84dbf528 100644 --- a/crates/languages/src/go/highlights.scm +++ b/crates/languages/src/go/highlights.scm @@ -1,13 +1,15 @@ (identifier) @variable (type_identifier) @type -(field_identifier) @variable.member +(field_identifier) @property (package_identifier) @namespace +(label_name) @label + (keyed_element . (literal_element - (identifier) @variable.member)) + (identifier) @property)) (call_expression function: (identifier) @function) diff --git a/crates/languages/src/go/imports.scm b/crates/languages/src/go/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..7f0ff2d46e6a271d4258d23f46cc942830e2c6f9 --- /dev/null +++ b/crates/languages/src/go/imports.scm @@ -0,0 +1,14 @@ +(import_spec + name: [ + (dot) + (package_identifier) + ] + path: (interpreted_string_literal + (interpreted_string_literal_content) @namespace) +) @wildcard @import + +(import_spec + !name + path: (interpreted_string_literal + (interpreted_string_literal_content) @namespace) +) @wildcard @import diff --git a/crates/languages/src/go/injections.scm b/crates/languages/src/go/injections.scm index 2be0844d97b7f9f16e8832b5b8aebbb0d0043e5d..52edce417798bcc8cd9cbc38ba3443ff3fc561c6 100644 --- a/crates/languages/src/go/injections.scm +++ b/crates/languages/src/go/injections.scm @@ -1,4 +1,8 @@ ; Refer to https://github.com/nvim-treesitter/nvim-treesitter/blob/master/queries/go/injections.scm#L4C1-L16C41 +((comment) @injection.content + (#set! injection.language "comment") +) + (call_expression (selector_expression) @_function (#any-of? @_function @@ -10,4 +14,365 @@ (raw_string_literal) (interpreted_string_literal) ] @injection.content - (#set! injection.language "regex"))) + (#set! injection.language "regex") + )) + +; INJECT SQL +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*sql\\s*\\*\\/") ; /* sql */ or /*sql*/ + (#set! injection.language "sql") +) + +; INJECT JSON +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*json\\s*\\*\\/") ; /* json */ or /*json*/ + (#set! injection.language "json") +) + +; INJECT YAML +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*yaml\\s*\\*\\/") ; /* yaml */ or /*yaml*/ + (#set! injection.language "yaml") +) + +; INJECT XML +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*xml\\s*\\*\\/") ; /* xml */ or /*xml*/ + (#set! injection.language "xml") +) + +; INJECT HTML +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*html\\s*\\*\\/") ; /* html */ or /*html*/ + (#set! injection.language "html") +) + +; INJECT JS +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*js\\s*\\*\\/") ; /* js */ or /*js*/ + (#set! injection.language "javascript") +) + +; INJECT CSS +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*css\\s*\\*\\/") ; /* css */ or /*css*/ + (#set! injection.language "css") +) + +; INJECT LUA +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*lua\\s*\\*\\/") ; /* lua */ or /*lua*/ + (#set! injection.language "lua") +) + +; INJECT BASH +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*bash\\s*\\*\\/") ; /* bash */ or /*bash*/ + (#set! injection.language "bash") +) + +; INJECT CSV +( + [ + ; var, const or short declaration of raw or interpreted string literal + ((comment) @comment + . + (expression_list + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a literal element (to struct field eg.) + ((comment) @comment + . + (literal_element + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content + )) + + ; when passing as a function parameter + ((comment) @comment + . + [ + (interpreted_string_literal) + (raw_string_literal) + ] @injection.content) + ] + + (#match? @comment "^\\/\\*\\s*csv\\s*\\*\\/") ; /* csv */ or /*csv*/ + (#set! injection.language "csv") +) diff --git a/crates/languages/src/go/runnables.scm b/crates/languages/src/go/runnables.scm index f56262f799c3d73c8eaadd03665565f75add27ba..d3002a06cce9f3a12456eca438ddc6cdbb0233a5 100644 --- a/crates/languages/src/go/runnables.scm +++ b/crates/languages/src/go/runnables.scm @@ -1,22 +1,28 @@ ; Functions names start with `Test` ( - [ + ( (function_declaration name: (_) @run (#match? @run "^Test.*")) + ) @_ + (#set! tag go-test) +) + +; Suite test methods (testify/suite) +( (method_declaration receiver: (parameter_list (parameter_declaration - name: (identifier) @_receiver_name - type: [ - (pointer_type (type_identifier) @_receiver_type) - (type_identifier) @_receiver_type - ] + type: [ + (pointer_type (type_identifier) @_suite_name) + (type_identifier) @_suite_name + ] ) ) - name: (field_identifier) @run @_method_name - (#match? @_method_name "^Test.*")) - ] @_ - (#set! tag go-test) + name: (field_identifier) @run @_subtest_name + (#match? @_subtest_name "^Test.*") + (#match? @_suite_name ".*Suite") + ) @_ + (#set! tag go-testify-suite) ) ; `go:generate` comments @@ -65,6 +71,15 @@ (#set! tag go-subtest) ) +; Functions names start with `Example` +( + ( + (function_declaration name: (_) @run @_name + (#match? @_name "^Example.*")) + ) @_ + (#set! tag go-example) +) + ; Functions names start with `Benchmark` ( ( @@ -141,9 +156,9 @@ [ ( (identifier) - (identifier) @_loop_var + (identifier) @_loop_var_inner ) - (identifier) @_loop_var + (identifier) @_loop_var_outer ] ) right: (identifier) @_range_var @@ -153,7 +168,7 @@ (expression_statement (call_expression function: (selector_expression - operand: (identifier) @_t_var + operand: (identifier) field: (field_identifier) @_run_method (#eq? @_run_method "Run") ) @@ -162,12 +177,12 @@ [ (selector_expression operand: (identifier) @_tc_var - (#eq? @_tc_var @_loop_var) + (#eq? @_tc_var @_loop_var_inner) field: (field_identifier) @_field_check (#eq? @_field_check @_field_name) ) (identifier) @_arg_var - (#eq? @_arg_var @_loop_var) + (#eq? @_arg_var @_loop_var_outer) ] . (func_literal diff --git a/crates/languages/src/gomod/injections.scm b/crates/languages/src/gomod/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..321c90add3710f35721daeb6b42abe38af094953 --- /dev/null +++ b/crates/languages/src/gomod/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/crates/languages/src/gowork/injections.scm b/crates/languages/src/gowork/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..321c90add3710f35721daeb6b42abe38af094953 --- /dev/null +++ b/crates/languages/src/gowork/injections.scm @@ -0,0 +1,2 @@ +((comment) @injection.content + (#set! injection.language "comment")) diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index 0df57d985e82595bdabb97517f56e79591343e7b..265f362ce4b655371471649c03c5a4a201da320c 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -6,6 +6,7 @@ first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -22,6 +23,7 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" debuggers = ["JavaScript"] +import_path_strip_regex = "(?:/index)?\\.[jt]s$" [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" @@ -29,6 +31,9 @@ close_tag_node_name = "jsx_closing_element" jsx_element_node_name = "jsx_element" tag_name_node_name = "identifier" +[overrides.default] +linked_edit_characters = ["."] + [overrides.element] line_comments = { remove = true } block_comment = { start = "{/* ", prefix = "", end = "*/}", tab_size = 0 } diff --git a/crates/languages/src/javascript/debugger.scm b/crates/languages/src/javascript/debugger.scm new file mode 100644 index 0000000000000000000000000000000000000000..a99f194a4a4130210b47f8170fca039acc163411 --- /dev/null +++ b/crates/languages/src/javascript/debugger.scm @@ -0,0 +1,23 @@ +(lexical_declaration (variable_declarator name: (identifier) @debug-variable)) + +(for_in_statement left: (identifier) @debug-variable) +(for_statement initializer: (lexical_declaration (variable_declarator name: (identifier) @debug-variable))) + +(binary_expression left: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(binary_expression right: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(unary_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(update_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(return_statement (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(parenthesized_expression (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(array (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(pair value: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(member_expression object: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(statement_block) @debug-scope +(program) @debug-scope diff --git a/crates/languages/src/javascript/highlights.scm b/crates/languages/src/javascript/highlights.scm index 9d5ebbaf711f931491ddb57f8a78e5fc38d36896..e5b84ab68df2b32061691f469046569a6597750e 100644 --- a/crates/languages/src/javascript/highlights.scm +++ b/crates/languages/src/javascript/highlights.scm @@ -171,47 +171,52 @@ "as" "async" "await" - "break" - "case" - "catch" "class" "const" - "continue" "debugger" "default" "delete" - "do" - "else" "export" "extends" - "finally" - "for" "from" "function" "get" - "if" "import" "in" "instanceof" "let" "new" "of" - "return" "set" "static" - "switch" "target" - "throw" - "try" "typeof" "using" "var" "void" - "while" "with" - "yield" ] @keyword +[ + "break" + "case" + "catch" + "continue" + "do" + "else" + "finally" + "for" + "if" + "return" + "switch" + "throw" + "try" + "while" + "yield" +] @keyword.control + +(switch_default "default" @keyword.control) + (template_substitution "${" @punctuation.special "}" @punctuation.special) @embedded @@ -231,6 +236,7 @@ "implements" "interface" "keyof" + "module" "namespace" "private" "protected" @@ -250,4 +256,4 @@ (jsx_closing_element ([""]) @punctuation.bracket.jsx) (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx) (jsx_attribute "=" @punctuation.delimiter.jsx) -(jsx_text) @text.jsx \ No newline at end of file +(jsx_text) @text.jsx diff --git a/crates/languages/src/javascript/imports.scm b/crates/languages/src/javascript/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..e26b97aeef9cb62395e7030f3173208d79187bd6 --- /dev/null +++ b/crates/languages/src/javascript/imports.scm @@ -0,0 +1,14 @@ +(import_statement + import_clause: (import_clause + [ + (identifier) @name + (named_imports + (import_specifier + name: (_) @name + alias: (_)? @alias)) + ]) + source: (string (string_fragment) @source)) @import + +(import_statement + !import_clause + source: (string (string_fragment) @source @wildcard)) @import diff --git a/crates/languages/src/javascript/injections.scm b/crates/languages/src/javascript/injections.scm index 7baba5f227eb0df31cd753029296e165dfff0180..f79cd788d78964f61f611023d0645c95c88aaf17 100644 --- a/crates/languages/src/javascript/injections.scm +++ b/crates/languages/src/javascript/injections.scm @@ -1,3 +1,7 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (((comment) @_jsdoc_comment (#match? @_jsdoc_comment "(?s)^/[*][*][^*].*[*]/$")) @injection.content (#set! injection.language "jsdoc")) @@ -11,6 +15,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string) @injection.content @@ -58,3 +77,9 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "graphql"))) ) + +(call_expression + function: (identifier) @_name(#match? @_name "^iso$") + arguments: (arguments (template_string (string_fragment) @injection.content + (#set! injection.language "isograph"))) +) diff --git a/crates/languages/src/javascript/outline.scm b/crates/languages/src/javascript/outline.scm index ca16c27a27be3e1e09ced16cd2eef7aa28345f9e..5f72103bc63bdfab73f7b858c01abe8d34317b22 100644 --- a/crates/languages/src/javascript/outline.scm +++ b/crates/languages/src/javascript/outline.scm @@ -31,38 +31,103 @@ (export_statement (lexical_declaration ["let" "const"] @context - ; Multiple names may be exported - @item is on the declarator to keep - ; ranges distinct. (variable_declarator - name: (_) @name) @item))) + name: (identifier) @name) @item))) + +; Exported array destructuring +(program + (export_statement + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ]))))) + +; Exported object destructuring +(program + (export_statement + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern + value: (identifier) @name @item) + (pair_pattern + value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)]))))) (program (lexical_declaration ["let" "const"] @context - ; Multiple names may be defined - @item is on the declarator to keep - ; ranges distinct. (variable_declarator - name: (_) @name) @item)) + name: (identifier) @name) @item)) + +; Top-level array destructuring +(program + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ])))) + +; Top-level object destructuring +(program + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern + value: (identifier) @name @item) + (pair_pattern + value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)])))) (class_declaration "class" @context name: (_) @name) @item -(method_definition - [ - "get" - "set" - "async" - "*" - "readonly" - "static" - (override_modifier) - (accessibility_modifier) - ]* @context - name: (_) @name - parameters: (formal_parameters - "(" @context - ")" @context)) @item +; Method definitions in classes (not in object literals) +(class_body + (method_definition + [ + "get" + "set" + "async" + "*" + "readonly" + "static" + (override_modifier) + (accessibility_modifier) + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item) + +; Object literal methods +(variable_declarator + value: (object + (method_definition + [ + "get" + "set" + "async" + "*" + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item)) (public_field_definition [ @@ -116,4 +181,43 @@ ) ) @item +; Object properties +(pair + key: [ + (property_identifier) @name + (string (string_fragment) @name) + (number) @name + (computed_property_name) @name + ]) @item + +; Nested variables in function bodies +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (identifier) @name) @item)) + +; Nested array destructuring in functions +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ])))) + +; Nested object destructuring in functions +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern value: (identifier) @name @item) + (pair_pattern value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)])))) + (comment) @annotation diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 484631d01f0809334eecaacd2851be540771fe36..45fa2dd75cce051439980b40996dda9865246a99 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -3,58 +3,52 @@ use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use async_trait::async_trait; use collections::HashMap; -use dap::DapRegistry; use futures::StreamExt; use gpui::{App, AsyncApp, Task}; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use language::{ - ContextProvider, LanguageName, LanguageRegistry, LanguageToolchainStore, LocalFile as _, - LspAdapter, LspAdapterDelegate, + ContextProvider, LanguageName, LocalFile as _, LspAdapter, LspAdapterDelegate, LspInstaller, + Toolchain, }; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; -use project::{Fs, lsp_store::language_server_settings}; +use project::lsp_store::language_server_settings; use serde_json::{Value, json}; -use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; use smol::{ fs::{self}, io::BufReader, - lock::RwLock, }; use std::{ - any::Any, env::consts, ffi::OsString, path::{Path, PathBuf}, str::FromStr, sync::Arc, }; -use task::{AdapterSchemas, TaskTemplate, TaskTemplates, VariableName}; -use util::{ResultExt, archive::extract_zip, fs::remove_matching, maybe, merge_json_value_into}; +use task::{TaskTemplate, TaskTemplates, VariableName}; +use util::{ + ResultExt, archive::extract_zip, fs::remove_matching, maybe, merge_json_value_into, + rel_path::RelPath, +}; use crate::PackageJsonData; const SERVER_PATH: &str = "node_modules/vscode-langservers-extracted/bin/vscode-json-language-server"; -// Origin: https://github.com/SchemaStore/schemastore -const TSCONFIG_SCHEMA: &str = include_str!("json/schemas/tsconfig.json"); -const PACKAGE_JSON_SCHEMA: &str = include_str!("json/schemas/package.json"); - pub(crate) struct JsonTaskProvider; impl ContextProvider for JsonTaskProvider { fn associated_tasks( &self, - _: Arc, file: Option>, cx: &App, ) -> gpui::Task> { let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else { return Task::ready(None); }; - let is_package_json = file.path.ends_with("package.json"); - let is_composer_json = file.path.ends_with("composer.json"); + let is_package_json = file.path.ends_with(RelPath::unix("package.json").unwrap()); + let is_composer_json = file.path.ends_with(RelPath::unix("composer.json").unwrap()); if !is_package_json && !is_composer_json { return Task::ready(None); } @@ -136,174 +130,34 @@ fn server_binary_arguments(server_path: &Path) -> Vec { pub struct JsonLspAdapter { node: NodeRuntime, - languages: Arc, - workspace_config: RwLock>, } impl JsonLspAdapter { const PACKAGE_NAME: &str = "vscode-langservers-extracted"; - pub fn new(node: NodeRuntime, languages: Arc) -> Self { - Self { - node, - languages, - workspace_config: Default::default(), - } - } - - fn get_workspace_config( - language_names: Vec, - adapter_schemas: AdapterSchemas, - cx: &mut App, - ) -> Value { - let keymap_schema = KeymapFile::generate_json_schema_for_registered_actions(cx); - let font_names = &cx.text_system().all_font_names(); - let settings_schema = cx.global::().json_schema( - &SettingsJsonSchemaParams { - language_names: &language_names, - font_names, - }, - cx, - ); - - let tasks_schema = task::TaskTemplates::generate_json_schema(); - let debug_schema = task::DebugTaskFile::generate_json_schema(&adapter_schemas); - let snippets_schema = snippet_provider::format::VsSnippetsFile::generate_json_schema(); - let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap(); - let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap(); - - #[allow(unused_mut)] - let mut schemas = serde_json::json!([ - { - "fileMatch": ["tsconfig.json"], - "schema":tsconfig_schema - }, - { - "fileMatch": ["package.json"], - "schema":package_json_schema - }, - { - "fileMatch": [ - schema_file_match(paths::settings_file()), - paths::local_settings_file_relative_path() - ], - "schema": settings_schema, - }, - { - "fileMatch": [schema_file_match(paths::keymap_file())], - "schema": keymap_schema, - }, - { - "fileMatch": [ - schema_file_match(paths::tasks_file()), - paths::local_tasks_file_relative_path() - ], - "schema": tasks_schema, - }, - { - "fileMatch": [ - schema_file_match( - paths::snippets_dir() - .join("*.json") - .as_path() - ) - ], - "schema": snippets_schema, - }, - { - "fileMatch": [ - schema_file_match(paths::debug_scenarios_file()), - paths::local_debug_file_relative_path() - ], - "schema": debug_schema, - }, - ]); - - #[cfg(debug_assertions)] - { - schemas.as_array_mut().unwrap().push(serde_json::json!( - { - "fileMatch": [ - "zed-inspector-style.json" - ], - "schema": generate_inspector_style_schema(), - } - )) - } - - schemas - .as_array_mut() - .unwrap() - .extend(cx.all_action_names().into_iter().map(|&name| { - project::lsp_store::json_language_server_ext::url_schema_for_action(name) - })); - - // This can be viewed via `dev: open language server logs` -> `json-language-server` -> - // `Server Info` - serde_json::json!({ - "json": { - "format": { - "enable": true, - }, - "validate": - { - "enable": true, - }, - "schemas": schemas - } - }) - } - - async fn get_or_init_workspace_config(&self, cx: &mut AsyncApp) -> Result { - { - let reader = self.workspace_config.read().await; - if let Some(config) = reader.as_ref() { - return Ok(config.clone()); - } - } - let mut writer = self.workspace_config.write().await; - - let adapter_schemas = cx - .read_global::(|dap_registry, _| dap_registry.to_owned())? - .adapters_schema() - .await; - - let config = cx.update(|cx| { - Self::get_workspace_config( - self.languages - .language_names() - .into_iter() - .map(|name| name.to_string()) - .collect(), - adapter_schemas, - cx, - ) - })?; - writer.replace(config.clone()); - return Ok(config); + pub fn new(node: NodeRuntime) -> Self { + Self { node } } } -#[cfg(debug_assertions)] -fn generate_inspector_style_schema() -> serde_json_lenient::Value { - let schema = schemars::generate::SchemaSettings::draft2019_09() - .with_transform(util::schemars::DefaultDenyUnknownFields) - .into_generator() - .root_schema_for::(); - - serde_json_lenient::to_value(schema).unwrap() -} +impl LspInstaller for JsonLspAdapter { + type BinaryVersion = String; -#[async_trait(?Send)] -impl LspAdapter for JsonLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("json-language-server".into()) + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version(Self::PACKAGE_NAME) + .await } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate @@ -318,24 +172,12 @@ impl LspAdapter for JsonLspAdapter { }) } - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new( - self.node - .npm_package_latest_version(Self::PACKAGE_NAME) - .await?, - ) as Box<_>) - } - async fn check_if_version_installed( &self, - version: &(dyn 'static + Send + Any), + version: &String, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); let server_path = container_dir.join(SERVER_PATH); let should_install_language_server = self @@ -343,7 +185,7 @@ impl LspAdapter for JsonLspAdapter { .should_install_npm_package( Self::PACKAGE_NAME, &server_path, - &container_dir, + container_dir, VersionStrategy::Latest(version), ) .await; @@ -361,11 +203,10 @@ impl LspAdapter for JsonLspAdapter { async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: String, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(SERVER_PATH); self.node @@ -389,10 +230,16 @@ impl LspAdapter for JsonLspAdapter { ) -> Option { get_cached_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for JsonLspAdapter { + fn name(&self) -> LanguageServerName { + LanguageServerName("json-language-server".into()) + } async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(Some(json!({ @@ -402,13 +249,27 @@ impl LspAdapter for JsonLspAdapter { async fn workspace_configuration( self: Arc, - _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { - let mut config = self.get_or_init_workspace_config(cx).await?; - + let mut config = cx.update(|cx| { + let schemas = json_schema_store::all_schema_file_associations(cx); + + // This can be viewed via `dev: open language server logs` -> `json-language-server` -> + // `Server Info` + serde_json::json!({ + "json": { + "format": { + "enable": true, + }, + "validate": { + "enable": true, + }, + "schemas": schemas + } + }) + })?; let project_options = cx.update(|cx| { language_server_settings(delegate.as_ref(), &self.name(), cx) .and_then(|s| s.settings.clone()) @@ -433,10 +294,6 @@ impl LspAdapter for JsonLspAdapter { fn is_primary_zed_json_schema_adapter(&self) -> bool { true } - - async fn clear_zed_json_schema_cache(&self) { - self.workspace_config.write().await.take(); - } } async fn get_cached_server_binary( @@ -469,15 +326,6 @@ async fn get_cached_server_binary( .log_err() } -#[inline] -fn schema_file_match(path: &Path) -> String { - path.strip_prefix(path.parent().unwrap().parent().unwrap()) - .unwrap() - .display() - .to_string() - .replace('\\', "/") -} - pub struct NodeVersionAdapter; impl NodeVersionAdapter { @@ -485,16 +333,15 @@ impl NodeVersionAdapter { LanguageServerName::new_static("package-version-server"); } -#[async_trait(?Send)] -impl LspAdapter for NodeVersionAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } +impl LspInstaller for NodeVersionAdapter { + type BinaryVersion = GitHubLspBinaryVersion; async fn fetch_latest_server_version( &self, delegate: &dyn LspAdapterDelegate, - ) -> Result> { + _: bool, + _: &mut AsyncApp, + ) -> Result { let release = latest_github_release( "zed-industries/package-version-server", true, @@ -519,17 +366,17 @@ impl LspAdapter for NodeVersionAdapter { .iter() .find(|asset| asset.name == asset_name) .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; - Ok(Box::new(GitHubLspBinaryVersion { + Ok(GitHubLspBinaryVersion { name: release.tag_name, url: asset.browser_download_url.clone(), digest: asset.digest.clone(), - })) + }) } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -542,11 +389,11 @@ impl LspAdapter for NodeVersionAdapter { async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: GitHubLspBinaryVersion, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { - let version = latest_version.downcast::().unwrap(); + let version = &latest_version; let destination_path = container_dir.join(format!( "{}-{}{}", Self::SERVER_NAME, @@ -596,6 +443,13 @@ impl LspAdapter for NodeVersionAdapter { } } +#[async_trait(?Send)] +impl LspAdapter for NodeVersionAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } +} + async fn get_cached_version_server_binary(container_dir: PathBuf) -> Option { maybe!(async { let mut last = None; diff --git a/crates/languages/src/jsonc/overrides.scm b/crates/languages/src/jsonc/overrides.scm index cc966ad4c13e0cc7f7fc27a1152b461f24e3c6b0..81fec9a5f57b28fc67b4781ec37df43559e21dc9 100644 --- a/crates/languages/src/jsonc/overrides.scm +++ b/crates/languages/src/jsonc/overrides.scm @@ -1 +1,2 @@ +(comment) @comment.inclusive (string) @string diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 195ba79e1d0e96acea7ac1a53590c1a947334069..76e1ae5edd2593907bd374d398946a1f6083a82e 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::Context as _; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; -use gpui::{App, UpdateGlobal}; +use gpui::{App, SharedString, UpdateGlobal}; use node_runtime::NodeRuntime; +use project::Fs; use python::PyprojectTomlManifestProvider; use rust::CargoManifestProvider; use rust_embed::RustEmbed; @@ -12,12 +12,14 @@ use util::{ResultExt, asset_str}; pub use language::*; -use crate::{json::JsonTaskProvider, python::BasedPyrightLspAdapter}; +use crate::{ + json::JsonTaskProvider, + python::{BasedPyrightLspAdapter, RuffLspAdapter}, +}; mod bash; mod c; mod css; -mod github_download; mod go; mod json; mod package_json; @@ -54,13 +56,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = )) }); -struct BasedPyrightFeatureFlag; - -impl FeatureFlag for BasedPyrightFeatureFlag { - const NAME: &'static str = "basedpyright"; -} - -pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { +pub fn init(languages: Arc, fs: Arc, node: NodeRuntime, cx: &mut App) { #[cfg(feature = "load-grammars")] languages.register_native_grammars([ ("bash", tree_sitter_bash::LANGUAGE), @@ -91,20 +87,25 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { let go_context_provider = Arc::new(go::GoContextProvider); let go_lsp_adapter = Arc::new(go::GoLspAdapter); let json_context_provider = Arc::new(JsonTaskProvider); - let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(node.clone(), languages.clone())); + let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(node.clone())); let node_version_lsp_adapter = Arc::new(json::NodeVersionAdapter); let py_lsp_adapter = Arc::new(python::PyLspAdapter::new()); + let ty_lsp_adapter = Arc::new(python::TyLspAdapter::new(fs.clone())); let python_context_provider = Arc::new(python::PythonContextProvider); - let python_lsp_adapter = Arc::new(python::PythonLspAdapter::new(node.clone())); - let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new()); - let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::default()); + let python_lsp_adapter = Arc::new(python::PyrightLspAdapter::new(node.clone())); + let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new(node.clone())); + let ruff_lsp_adapter = Arc::new(RuffLspAdapter::new(fs.clone())); + let python_toolchain_provider = Arc::new(python::PythonToolchainProvider); let rust_context_provider = Arc::new(rust::RustContextProvider); let rust_lsp_adapter = Arc::new(rust::RustLspAdapter); let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone())); - let typescript_context = Arc::new(typescript::TypeScriptContextProvider::new()); - let typescript_lsp_adapter = Arc::new(typescript::TypeScriptLspAdapter::new(node.clone())); - let vtsls_adapter = Arc::new(vtsls::VtslsLspAdapter::new(node.clone())); - let yaml_lsp_adapter = Arc::new(yaml::YamlLspAdapter::new(node.clone())); + let typescript_context = Arc::new(typescript::TypeScriptContextProvider::new(fs.clone())); + let typescript_lsp_adapter = Arc::new(typescript::TypeScriptLspAdapter::new( + node.clone(), + fs.clone(), + )); + let vtsls_adapter = Arc::new(vtsls::VtslsLspAdapter::new(node.clone(), fs.clone())); + let yaml_lsp_adapter = Arc::new(yaml::YamlLspAdapter::new(node)); let built_in_languages = [ LanguageInfo { @@ -119,12 +120,12 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { }, LanguageInfo { name: "cpp", - adapters: vec![c_lsp_adapter.clone()], + adapters: vec![c_lsp_adapter], ..Default::default() }, LanguageInfo { name: "css", - adapters: vec![css_lsp_adapter.clone()], + adapters: vec![css_lsp_adapter], ..Default::default() }, LanguageInfo { @@ -146,20 +147,20 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { }, LanguageInfo { name: "gowork", - adapters: vec![go_lsp_adapter.clone()], - context: Some(go_context_provider.clone()), + adapters: vec![go_lsp_adapter], + context: Some(go_context_provider), ..Default::default() }, LanguageInfo { name: "json", - adapters: vec![json_lsp_adapter.clone(), node_version_lsp_adapter.clone()], + adapters: vec![json_lsp_adapter.clone(), node_version_lsp_adapter], context: Some(json_context_provider.clone()), ..Default::default() }, LanguageInfo { name: "jsonc", - adapters: vec![json_lsp_adapter.clone()], - context: Some(json_context_provider.clone()), + adapters: vec![json_lsp_adapter], + context: Some(json_context_provider), ..Default::default() }, LanguageInfo { @@ -174,14 +175,16 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { }, LanguageInfo { name: "python", - adapters: vec![python_lsp_adapter.clone(), py_lsp_adapter.clone()], + adapters: vec![basedpyright_lsp_adapter, ruff_lsp_adapter], context: Some(python_context_provider), toolchain: Some(python_toolchain_provider), + manifest_name: Some(SharedString::new_static("pyproject.toml").into()), }, LanguageInfo { name: "rust", adapters: vec![rust_lsp_adapter], context: Some(rust_context_provider), + manifest_name: Some(SharedString::new_static("Cargo.toml").into()), ..Default::default() }, LanguageInfo { @@ -199,7 +202,7 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { LanguageInfo { name: "javascript", adapters: vec![typescript_lsp_adapter.clone(), vtsls_adapter.clone()], - context: Some(typescript_context.clone()), + context: Some(typescript_context), ..Default::default() }, LanguageInfo { @@ -234,23 +237,10 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { registration.adapters, registration.context, registration.toolchain, + registration.manifest_name, ); } - let mut basedpyright_lsp_adapter = Some(basedpyright_lsp_adapter); - cx.observe_flag::({ - let languages = languages.clone(); - move |enabled, _| { - if enabled { - if let Some(adapter) = basedpyright_lsp_adapter.take() { - languages - .register_available_lsp_adapter(adapter.name(), move || adapter.clone()); - } - } - } - }) - .detach(); - // Register globally available language servers. // // This will allow users to add support for a built-in language server (e.g., Tailwind) @@ -267,27 +257,21 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { // ``` languages.register_available_lsp_adapter( LanguageServerName("tailwindcss-language-server".into()), - { - let adapter = tailwind_adapter.clone(); - move || adapter.clone() - }, + tailwind_adapter.clone(), ); - languages.register_available_lsp_adapter(LanguageServerName("eslint".into()), { - let adapter = eslint_adapter.clone(); - move || adapter.clone() - }); - languages.register_available_lsp_adapter(LanguageServerName("vtsls".into()), { - let adapter = vtsls_adapter.clone(); - move || adapter.clone() - }); + languages.register_available_lsp_adapter( + LanguageServerName("eslint".into()), + eslint_adapter.clone(), + ); + languages.register_available_lsp_adapter(LanguageServerName("vtsls".into()), vtsls_adapter); languages.register_available_lsp_adapter( LanguageServerName("typescript-language-server".into()), - { - let adapter = typescript_lsp_adapter.clone(); - move || adapter.clone() - }, + typescript_lsp_adapter, ); + languages.register_available_lsp_adapter(python_lsp_adapter.name(), python_lsp_adapter); + languages.register_available_lsp_adapter(py_lsp_adapter.name(), py_lsp_adapter); + languages.register_available_lsp_adapter(ty_lsp_adapter.name(), ty_lsp_adapter); // Register Tailwind for the existing languages that should have it by default. // // This can be driven by the `language_servers` setting once we have a way for @@ -296,10 +280,12 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { "Astro", "CSS", "ERB", + "HTML+ERB", "HTML/ERB", "HEEX", "HTML", "JavaScript", + "TypeScript", "PHP", "Svelte", "TSX", @@ -325,7 +311,12 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { cx.update(|cx| { SettingsStore::update_global(cx, |settings, cx| { settings - .set_extension_settings(language_settings.clone(), cx) + .set_extension_settings( + settings::ExtensionsSettingsContent { + all_languages: language_settings.clone(), + }, + cx, + ) .log_err(); }); })?; @@ -340,7 +331,7 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { Arc::from(PyprojectTomlManifestProvider), ]; for provider in manifest_providers { - project::ManifestProviders::global(cx).register(provider); + project::ManifestProvidersStore::global(cx).register(provider); } } @@ -350,6 +341,7 @@ struct LanguageInfo { adapters: Vec>, context: Option>, toolchain: Option>, + manifest_name: Option, } fn register_language( @@ -358,6 +350,7 @@ fn register_language( adapters: Vec>, context: Option>, toolchain: Option>, + manifest_name: Option, ) { let config = load_config(name); for adapter in adapters { @@ -368,12 +361,14 @@ fn register_language( config.grammar.clone(), config.matcher.clone(), config.hidden, + manifest_name.clone(), Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), queries: load_queries(name), context_provider: context.clone(), toolchain_provider: toolchain.clone(), + manifest_name: manifest_name.clone(), }) }), ); diff --git a/crates/languages/src/markdown-inline/highlights.scm b/crates/languages/src/markdown-inline/highlights.scm index 61c3e34c62973c822a07415aaf56fadffbabc2e2..3c9f6fbcc340bd085466055c7b35551dd71b8c53 100644 --- a/crates/languages/src/markdown-inline/highlights.scm +++ b/crates/languages/src/markdown-inline/highlights.scm @@ -1,6 +1,22 @@ -(emphasis) @emphasis -(strong_emphasis) @emphasis.strong -(code_span) @text.literal -(link_text) @link_text -(link_label) @link_text -(link_destination) @link_uri +(emphasis) @emphasis.markup +(strong_emphasis) @emphasis.strong.markup +(code_span) @text.literal.markup +(strikethrough) @strikethrough.markup + +[ + (inline_link) + (shortcut_link) + (collapsed_reference_link) + (full_reference_link) + (image) + (link_text) + (link_label) +] @link_text.markup + +(inline_link ["(" ")"] @link_uri.markup) +(image ["(" ")"] @link_uri.markup) +[ + (link_destination) + (uri_autolink) + (email_autolink) +] @link_uri.markup diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 926dcd70d9f9207c03154690e7d4e9866f9aacea..36071cb5392462a51c10e0513b39979580ec67f5 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -12,6 +12,7 @@ brackets = [ { start = "\"", end = "\"", close = false, newline = false }, { start = "'", end = "'", close = false, newline = false }, { start = "`", end = "`", close = false, newline = false }, + { start = "*", end = "*", close = false, newline = false, surround = true }, ] rewrap_prefixes = [ "[-*+]\\s+", diff --git a/crates/languages/src/markdown/highlights.scm b/crates/languages/src/markdown/highlights.scm index 6b9fa3482298c93207b4ab480116751259911d85..707bcc0816366f5cc875c9f1197b42a2363cab99 100644 --- a/crates/languages/src/markdown/highlights.scm +++ b/crates/languages/src/markdown/highlights.scm @@ -1,7 +1,15 @@ +[ + (paragraph) + (indented_code_block) + (pipe_table) +] @text + [ (atx_heading) (setext_heading) -] @title + (thematic_break) +] @title.markup +(setext_heading (paragraph) @title.markup) [ (list_marker_plus) @@ -9,8 +17,18 @@ (list_marker_star) (list_marker_dot) (list_marker_parenthesis) -] @punctuation.list_marker +] @punctuation.list_marker.markup + +(block_quote_marker) @punctuation.markup +(pipe_table_header "|" @punctuation.markup) +(pipe_table_row "|" @punctuation.markup) +(pipe_table_delimiter_row "|" @punctuation.markup) +(pipe_table_delimiter_cell "-" @punctuation.markup) + +[ + (fenced_code_block_delimiter) + (info_string) +] @punctuation.embedded.markup -(fenced_code_block - (info_string - (language) @text.literal)) +(link_reference_definition) @link_text.markup +(link_destination) @link_uri.markup diff --git a/crates/languages/src/package_json.rs b/crates/languages/src/package_json.rs index 8c1cb9f068d34873a4cd27c1b2f21deb236c789d..80e9e3cc0d5789b79592fdb490089b8d2f7879eb 100644 --- a/crates/languages/src/package_json.rs +++ b/crates/languages/src/package_json.rs @@ -15,6 +15,8 @@ pub struct PackageJsonData { pub mocha_package_path: Option>, pub vitest_package_path: Option>, pub jasmine_package_path: Option>, + pub bun_package_path: Option>, + pub node_package_path: Option>, pub scripts: BTreeSet<(Arc, String)>, pub package_manager: Option<&'static str>, } @@ -35,6 +37,8 @@ impl PackageJsonData { let mut mocha_package_path = None; let mut vitest_package_path = None; let mut jasmine_package_path = None; + let mut bun_package_path = None; + let mut node_package_path = None; if let Some(Value::Object(dependencies)) = package_json.get("devDependencies") { if dependencies.contains_key("jest") { jest_package_path.get_or_insert_with(|| path.clone()); @@ -48,6 +52,12 @@ impl PackageJsonData { if dependencies.contains_key("jasmine") { jasmine_package_path.get_or_insert_with(|| path.clone()); } + if dependencies.contains_key("@types/bun") { + bun_package_path.get_or_insert_with(|| path.clone()); + } + if dependencies.contains_key("@types/node") { + node_package_path.get_or_insert_with(|| path.clone()); + } } if let Some(Value::Object(dev_dependencies)) = package_json.get("dependencies") { if dev_dependencies.contains_key("jest") { @@ -62,6 +72,12 @@ impl PackageJsonData { if dev_dependencies.contains_key("jasmine") { jasmine_package_path.get_or_insert_with(|| path.clone()); } + if dev_dependencies.contains_key("@types/bun") { + bun_package_path.get_or_insert_with(|| path.clone()); + } + if dev_dependencies.contains_key("@types/node") { + node_package_path.get_or_insert_with(|| path.clone()); + } } let package_manager = package_json @@ -74,6 +90,8 @@ impl PackageJsonData { Some("yarn") } else if value.starts_with("npm") { Some("npm") + } else if value.starts_with("bun") { + Some("bun") } else { None } @@ -84,6 +102,8 @@ impl PackageJsonData { mocha_package_path, vitest_package_path, jasmine_package_path, + bun_package_path, + node_package_path, scripts, package_manager, } @@ -100,6 +120,8 @@ impl PackageJsonData { .jasmine_package_path .take() .or(other.jasmine_package_path); + self.bun_package_path = self.bun_package_path.take().or(other.bun_package_path); + self.node_package_path = self.node_package_path.take().or(other.node_package_path); self.scripts.extend(other.scripts); self.package_manager = self.package_manager.or(other.package_manager); } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 40131089d1ccb8bc211df23f2c7def3810006181..f676f5a7a6f028c095d52273fb8c616472a35ee5 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -2,41 +2,51 @@ use anyhow::{Context as _, ensure}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use collections::HashMap; -use gpui::{App, Task}; -use gpui::{AsyncApp, SharedString}; -use language::ToolchainList; -use language::ToolchainLister; +use futures::{AsyncBufReadExt, StreamExt as _}; +use gpui::{App, AsyncApp, SharedString, Task}; +use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release}; use language::language_settings::language_settings; -use language::{ContextLocation, LanguageToolchainStore}; +use language::{ContextLocation, LanguageToolchainStore, LspInstaller}; use language::{ContextProvider, LspAdapter, LspAdapterDelegate}; use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery}; -use language::{Toolchain, WorkspaceFoldersContent}; +use language::{Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata}; use lsp::LanguageServerBinary; use lsp::LanguageServerName; use node_runtime::{NodeRuntime, VersionStrategy}; use pet_core::Configuration; use pet_core::os_environment::Environment; -use pet_core::python_environment::PythonEnvironmentKind; +use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind}; +use pet_virtualenv::is_virtualenv_dir; use project::Fs; use project::lsp_store::language_server_settings; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use smol::lock::OnceCell; use std::cmp::Ordering; +use std::env::consts; +use util::command::new_smol_command; +use util::fs::{make_file_executable, remove_matching}; +use util::rel_path::RelPath; +use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; use parking_lot::Mutex; use std::str::FromStr; use std::{ - any::Any, borrow::Cow, - ffi::OsString, fmt::Write, - fs, - io::{self, BufRead}, path::{Path, PathBuf}, sync::Arc, }; -use task::{TaskTemplate, TaskTemplates, VariableName}; -use util::ResultExt; +use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName}; +use util::{ResultExt, maybe}; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct PythonToolchainData { + #[serde(flatten)] + environment: PythonEnvironment, + #[serde(skip_serializing_if = "Option::is_none")] + activation_scripts: Option>, +} pub(crate) struct PyprojectTomlManifestProvider; @@ -52,9 +62,9 @@ impl ManifestProvider for PyprojectTomlManifestProvider { depth, delegate, }: ManifestQuery, - ) -> Option> { + ) -> Option> { for path in path.ancestors().take(depth) { - let p = path.join("pyproject.toml"); + let p = path.join(RelPath::unix("pyproject.toml").unwrap()); if delegate.exists(&p, Some(false)) { return Some(path.into()); } @@ -64,9 +74,6 @@ impl ManifestProvider for PyprojectTomlManifestProvider { } } -const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js"; -const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js"; - enum TestRunner { UNITTEST, PYTEST, @@ -84,174 +91,313 @@ impl FromStr for TestRunner { } } -fn server_binary_arguments(server_path: &Path) -> Vec { - vec![server_path.into(), "--stdio".into()] +/// Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`. +/// Where `XX` is the sorting category, `YYYY` is based on most recent usage, +/// and `name` is the symbol name itself. +/// +/// The problem with it is that Pyright adjusts the sort text based on previous resolutions (items for which we've issued `completion/resolve` call have their sortText adjusted), +/// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail. +/// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873 +fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { + for item in items { + item.sort_text.take(); + } } -pub struct PythonLspAdapter { - node: NodeRuntime, +pub struct TyLspAdapter { + fs: Arc, } -impl PythonLspAdapter { - const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pyright"); +#[cfg(target_os = "macos")] +impl TyLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "apple-darwin"; +} - pub fn new(node: NodeRuntime) -> Self { - PythonLspAdapter { node } +#[cfg(target_os = "linux")] +impl TyLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "unknown-linux-gnu"; +} + +#[cfg(target_os = "freebsd")] +impl TyLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "unknown-freebsd"; +} + +#[cfg(target_os = "windows")] +impl TyLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip; + const ARCH_SERVER_NAME: &str = "pc-windows-msvc"; +} + +impl TyLspAdapter { + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ty"); + + pub fn new(fs: Arc) -> TyLspAdapter { + TyLspAdapter { fs } + } + + fn build_asset_name() -> Result<(String, String)> { + let arch = match consts::ARCH { + "x86" => "i686", + _ => consts::ARCH, + }; + let os = Self::ARCH_SERVER_NAME; + let suffix = match consts::OS { + "windows" => "zip", + _ => "tar.gz", + }; + let asset_name = format!("ty-{arch}-{os}.{suffix}"); + let asset_stem = format!("ty-{arch}-{os}"); + Ok((asset_stem, asset_name)) } } #[async_trait(?Send)] -impl LspAdapter for PythonLspAdapter { +impl LspAdapter for TyLspAdapter { fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() + Self::SERVER_NAME } - async fn initialization_options( + async fn workspace_configuration( self: Arc, - _: &dyn Fs, - _: &Arc, - ) -> Result> { - // Provide minimal initialization options - // Virtual environment configuration will be handled through workspace configuration - Ok(Some(json!({ - "python": { - "analysis": { - "autoSearchPaths": true, - "useLibraryCodeForTypes": true, - "autoImportCompletions": true - } - } - }))) - } - - async fn check_if_user_installed( - &self, - delegate: &dyn LspAdapterDelegate, - _: Arc, - _: &AsyncApp, - ) -> Option { - if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: pyright_bin, - env: Some(env), - arguments: vec!["--stdio".into()], - }) - } else { - let node = delegate.which("node".as_ref()).await?; - let (node_modules_path, _) = delegate - .npm_package_installed_version(Self::SERVER_NAME.as_ref()) - .await - .log_err()??; - - let path = node_modules_path.join(NODE_MODULE_RELATIVE_SERVER_PATH); - - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: node, - env: Some(env), - arguments: server_binary_arguments(&path), - }) + delegate: &Arc, + toolchain: Option, + cx: &mut AsyncApp, + ) -> Result { + let mut ret = cx + .update(|cx| { + language_server_settings(delegate.as_ref(), &self.name(), cx) + .and_then(|s| s.settings.clone()) + })? + .unwrap_or_else(|| json!({})); + if let Some(toolchain) = toolchain.and_then(|toolchain| { + serde_json::from_value::(toolchain.as_json).ok() + }) { + _ = maybe!({ + let uri = + url::Url::from_file_path(toolchain.environment.executable.as_ref()?).ok()?; + let sys_prefix = toolchain.environment.prefix.clone()?; + let environment = json!({ + "executable": { + "uri": uri, + "sysPrefix": sys_prefix + } + }); + ret.as_object_mut()? + .entry("pythonExtension") + .or_insert_with(|| json!({ "activeEnvironment": environment })); + Some(()) + }); } + Ok(json!({"ty": ret})) } +} +impl LspInstaller for TyLspAdapter { + type BinaryVersion = GitHubLspBinaryVersion; async fn fetch_latest_server_version( &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new( - self.node - .npm_package_latest_version(Self::SERVER_NAME.as_ref()) - .await?, - ) as Box<_>) + delegate: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + let release = + latest_github_release("astral-sh/ty", true, true, delegate.http_client()).await?; + let (_, asset_name) = Self::build_asset_name()?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == asset_name) + .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; + Ok(GitHubLspBinaryVersion { + name: release.tag_name, + url: asset.browser_download_url, + digest: asset.digest, + }) } async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: Self::BinaryVersion, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); - let server_path = container_dir.join(SERVER_PATH); + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = latest_version; + let destination_path = container_dir.join(format!("ty-{name}")); + + async_fs::create_dir_all(&destination_path).await?; + + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ty"), + AssetKind::Zip => destination_path.clone().join("ty.exe"), + }; - self.node - .npm_install_packages( - &container_dir, - &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], - ) - .await?; + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; - let env = delegate.shell_env().await; Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: server_binary_arguments(&server_path), + path: server_path, + env: None, + arguments: vec!["server".into()], }) } - async fn check_if_version_installed( + async fn cached_server_binary( &self, - version: &(dyn 'static + Send + Any), - container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); - let server_path = container_dir.join(SERVER_PATH); + maybe!(async { + let mut last = None; + let mut entries = self.fs.read_dir(&container_dir).await?; + while let Some(entry) = entries.next().await { + let path = entry?; + if path.extension().is_some_and(|ext| ext == "metadata") { + continue; + } + last = Some(path); + } - let should_install_language_server = self - .node - .should_install_npm_package( - Self::SERVER_NAME.as_ref(), - &server_path, - &container_dir, - VersionStrategy::Latest(version), - ) - .await; + let path = last.context("no cached binary")?; + let path = match TyLspAdapter::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::Gz => { + path.join(Self::build_asset_name()?.0).join("ty") + } + AssetKind::Zip => path.join("ty.exe"), + }; - if should_install_language_server { - None - } else { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: Some(env), - arguments: server_binary_arguments(&server_path), + anyhow::Ok(LanguageServerBinary { + path, + env: None, + arguments: vec!["server".into()], }) - } + }) + .await + .log_err() } +} - async fn cached_server_binary( - &self, +pub struct PyrightLspAdapter { + node: NodeRuntime, +} + +impl PyrightLspAdapter { + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("pyright"); + const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js"; + const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js"; + + pub fn new(node: NodeRuntime) -> Self { + PyrightLspAdapter { node } + } + + async fn get_cached_server_binary( container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, + node: &NodeRuntime, ) -> Option { - let mut binary = get_cached_server_binary(container_dir, &self.node).await?; - binary.env = Some(delegate.shell_env().await); - Some(binary) + let server_path = container_dir.join(Self::SERVER_PATH); + if server_path.exists() { + Some(LanguageServerBinary { + path: node.binary_path().await.log_err()?, + env: None, + arguments: vec![server_path.into(), "--stdio".into()], + }) + } else { + log::error!("missing executable in directory {:?}", server_path); + None + } + } +} + +#[async_trait(?Send)] +impl LspAdapter for PyrightLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } + + async fn initialization_options( + self: Arc, + _: &Arc, + ) -> Result> { + // Provide minimal initialization options + // Virtual environment configuration will be handled through workspace configuration + Ok(Some(json!({ + "python": { + "analysis": { + "autoSearchPaths": true, + "useLibraryCodeForTypes": true, + "autoImportCompletions": true + } + } + }))) } async fn process_completions(&self, items: &mut [lsp::CompletionItem]) { - // Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`. - // Where `XX` is the sorting category, `YYYY` is based on most recent usage, - // and `name` is the symbol name itself. - // - // Because the symbol name is included, there generally are not ties when - // sorting by the `sortText`, so the symbol's fuzzy match score is not taken - // into account. Here, we remove the symbol name from the sortText in order - // to allow our own fuzzy score to be used to break ties. - // - // see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873 - for item in items { - let Some(sort_text) = &mut item.sort_text else { - continue; - }; - let mut parts = sort_text.split('.'); - let Some(first) = parts.next() else { continue }; - let Some(second) = parts.next() else { continue }; - let Some(_) = parts.next() else { continue }; - sort_text.replace_range(first.len() + second.len() + 1.., ""); - } + process_pyright_completions(items); } async fn label_for_completion( @@ -262,22 +408,31 @@ impl LspAdapter for PythonLspAdapter { let label = &item.label; let grammar = language.grammar()?; let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?, - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function")?, - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type")?, - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?, - _ => return None, + lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), + lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), + lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), + lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), + lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), + _ => { + return None; + } }; - let filter_range = item - .filter_text - .as_deref() - .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..label.len()); - Some(language::CodeLabel { - text: label.clone(), - runs: vec![(0..label.len(), highlight_id)], - filter_range, - }) + let mut text = label.clone(); + if let Some(completion_details) = item + .label_details + .as_ref() + .and_then(|details| details.description.as_ref()) + { + write!(&mut text, " {}", completion_details).ok(); + } + Some(language::CodeLabel::filtered( + text, + item.filter_text.as_deref(), + highlight_id + .map(|id| (0..label.len(), id)) + .into_iter() + .collect(), + )) } async fn label_for_symbol( @@ -308,28 +463,19 @@ impl LspAdapter for PythonLspAdapter { _ => return None, }; - Some(language::CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), + Some(language::CodeLabel::new( + text[display_range.clone()].to_string(), filter_range, - }) + language.highlight_text(&text.as_str().into(), display_range), + )) } async fn workspace_configuration( self: Arc, - _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -337,41 +483,34 @@ impl LspAdapter for PythonLspAdapter { .unwrap_or_default(); // If we have a detected toolchain, configure Pyright to use it - if let Some(toolchain) = toolchain { - if user_settings.is_null() { + if let Some(toolchain) = toolchain + && let Ok(env) = + serde_json::from_value::(toolchain.as_json.clone()) + { + if !user_settings.is_object() { user_settings = Value::Object(serde_json::Map::default()); } let object = user_settings.as_object_mut().unwrap(); let interpreter_path = toolchain.path.to_string(); + if let Some(venv_dir) = &env.environment.prefix { + // Set venvPath and venv at the root level + // This matches the format of a pyrightconfig.json file + if let Some(parent) = venv_dir.parent() { + // Use relative path if the venv is inside the workspace + let venv_path = if parent == adapter.worktree_root_path() { + ".".to_string() + } else { + parent.to_string_lossy().into_owned() + }; + object.insert("venvPath".to_string(), Value::String(venv_path)); + } - // Detect if this is a virtual environment - if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() { - if let Some(venv_dir) = interpreter_dir.parent() { - // Check if this looks like a virtual environment - if venv_dir.join("pyvenv.cfg").exists() - || venv_dir.join("bin/activate").exists() - || venv_dir.join("Scripts/activate.bat").exists() - { - // Set venvPath and venv at the root level - // This matches the format of a pyrightconfig.json file - if let Some(parent) = venv_dir.parent() { - // Use relative path if the venv is inside the workspace - let venv_path = if parent == adapter.worktree_root_path() { - ".".to_string() - } else { - parent.to_string_lossy().into_owned() - }; - object.insert("venvPath".to_string(), Value::String(venv_path)); - } - - if let Some(venv_name) = venv_dir.file_name() { - object.insert( - "venv".to_owned(), - Value::String(venv_name.to_string_lossy().into_owned()), - ); - } - } + if let Some(venv_name) = venv_dir.file_name() { + object.insert( + "venv".to_owned(), + Value::String(venv_name.to_string_lossy().into_owned()), + ); } } @@ -379,9 +518,13 @@ impl LspAdapter for PythonLspAdapter { // Get or create the python section let python = object .entry("python") - .or_insert(Value::Object(serde_json::Map::default())) - .as_object_mut() - .unwrap(); + .and_modify(|v| { + if !v.is_object() { + *v = Value::Object(serde_json::Map::default()); + } + }) + .or_insert(Value::Object(serde_json::Map::default())); + let python = python.as_object_mut().unwrap(); // Set both pythonPath and defaultInterpreterPath for compatibility python.insert( @@ -397,28 +540,114 @@ impl LspAdapter for PythonLspAdapter { user_settings }) } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) +} + +impl LspInstaller for PyrightLspAdapter { + type BinaryVersion = String; + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version(Self::SERVER_NAME.as_ref()) + .await } - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: pyright_bin, + env: Some(env), + arguments: vec!["--stdio".into()], + }) + } else { + let node = delegate.which("node".as_ref()).await?; + let (node_modules_path, _) = delegate + .npm_package_installed_version(Self::SERVER_NAME.as_ref()) + .await + .log_err()??; + + let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node, + env: Some(env), + arguments: vec![path.into(), "--stdio".into()], + }) + } } -} -async fn get_cached_server_binary( - container_dir: PathBuf, - node: &NodeRuntime, -) -> Option { - let server_path = container_dir.join(SERVER_PATH); - if server_path.exists() { - Some(LanguageServerBinary { - path: node.binary_path().await.log_err()?, - env: None, - arguments: server_binary_arguments(&server_path), + async fn fetch_server_binary( + &self, + latest_version: Self::BinaryVersion, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let server_path = container_dir.join(Self::SERVER_PATH); + + self.node + .npm_install_packages( + &container_dir, + &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], + ) + .await?; + + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: self.node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], }) - } else { - log::error!("missing executable in directory {:?}", server_path); - None + } + + async fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = self + .node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + container_dir, + VersionStrategy::Latest(version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: self.node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } + } + + async fn cached_server_binary( + &self, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?; + binary.env = Some(delegate.shell_env().await); + Some(binary) } } @@ -430,9 +659,6 @@ const PYTHON_TEST_TARGET_TASK_VARIABLE: VariableName = const PYTHON_ACTIVE_TOOLCHAIN_PATH: VariableName = VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN")); -const PYTHON_ACTIVE_TOOLCHAIN_PATH_RAW: VariableName = - VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN_RAW")); - const PYTHON_MODULE_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("PYTHON_MODULE_NAME")); @@ -456,12 +682,12 @@ impl ContextProvider for PythonContextProvider { let worktree_id = location_file.as_ref().map(|f| f.worktree_id(cx)); cx.spawn(async move |cx| { - let raw_toolchain = if let Some(worktree_id) = worktree_id { + let active_toolchain = if let Some(worktree_id) = worktree_id { let file_path = location_file .as_ref() .and_then(|f| f.path().parent()) .map(Arc::from) - .unwrap_or_else(|| Arc::from("".as_ref())); + .unwrap_or_else(|| RelPath::empty().into()); toolchains .active_toolchain(worktree_id, file_path, "Python".into(), cx) @@ -474,22 +700,19 @@ impl ContextProvider for PythonContextProvider { String::from("python3") }; - let active_toolchain = format!("\"{raw_toolchain}\""); let toolchain = (PYTHON_ACTIVE_TOOLCHAIN_PATH, active_toolchain); - let raw_toolchain_var = (PYTHON_ACTIVE_TOOLCHAIN_PATH_RAW, raw_toolchain); Ok(task::TaskVariables::from_iter( test_target .into_iter() .chain(module_target.into_iter()) - .chain([toolchain, raw_toolchain_var]), + .chain([toolchain]), )) }) } fn associated_tasks( &self, - _: Arc, file: Option>, cx: &App, ) -> Task> { @@ -504,7 +727,7 @@ impl ContextProvider for PythonContextProvider { "-c".to_owned(), VariableName::SelectedText.template_value_with_whitespace(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), ..TaskTemplate::default() }, // Execute an entire file @@ -512,7 +735,7 @@ impl ContextProvider for PythonContextProvider { label: format!("run '{}'", VariableName::File.template_value()), command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(), args: vec![VariableName::File.template_value_with_whitespace()], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), ..TaskTemplate::default() }, // Execute a file as module @@ -523,7 +746,7 @@ impl ContextProvider for PythonContextProvider { "-m".to_owned(), PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), tags: vec!["python-module-main-method".to_owned()], ..TaskTemplate::default() }, @@ -541,7 +764,7 @@ impl ContextProvider for PythonContextProvider { "unittest".to_owned(), VariableName::File.template_value_with_whitespace(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), ..TaskTemplate::default() }, // Run test(s) for a specific target within a file @@ -557,7 +780,7 @@ impl ContextProvider for PythonContextProvider { "python-unittest-class".to_owned(), "python-unittest-method".to_owned(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), ..TaskTemplate::default() }, ] @@ -573,7 +796,7 @@ impl ContextProvider for PythonContextProvider { "pytest".to_owned(), VariableName::File.template_value_with_whitespace(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), ..TaskTemplate::default() }, // Run test(s) for a specific target within a file @@ -585,7 +808,7 @@ impl ContextProvider for PythonContextProvider { "pytest".to_owned(), PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(), ], - cwd: Some("$ZED_WORKTREE_ROOT".into()), + cwd: Some(VariableName::WorktreeRoot.template_value()), tags: vec![ "python-pytest-class".to_owned(), "python-pytest-method".to_owned(), @@ -691,6 +914,21 @@ fn python_module_name_from_relative_path(relative_path: &str) -> String { .to_string() } +fn is_python_env_global(k: &PythonEnvironmentKind) -> bool { + matches!( + k, + PythonEnvironmentKind::Homebrew + | PythonEnvironmentKind::Pyenv + | PythonEnvironmentKind::GlobalPaths + | PythonEnvironmentKind::MacPythonOrg + | PythonEnvironmentKind::MacCommandLineTools + | PythonEnvironmentKind::LinuxGlobal + | PythonEnvironmentKind::MacXCode + | PythonEnvironmentKind::WindowsStore + | PythonEnvironmentKind::WindowsRegistry + ) +} + fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str { match k { PythonEnvironmentKind::Conda => "Conda", @@ -713,19 +951,9 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str { } } -pub(crate) struct PythonToolchainProvider { - term: SharedString, -} +pub(crate) struct PythonToolchainProvider; -impl Default for PythonToolchainProvider { - fn default() -> Self { - Self { - term: SharedString::new_static("Virtual Environment"), - } - } -} - -static ENV_PRIORITY_LIST: &'static [PythonEnvironmentKind] = &[ +static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[ // Prioritize non-Conda environments. PythonEnvironmentKind::Poetry, PythonEnvironmentKind::Pipenv, @@ -755,26 +983,56 @@ fn env_priority(kind: Option) -> usize { /// Return the name of environment declared in Option { - fs::File::open(worktree_root.join(".venv")) - .and_then(|file| { - let mut venv_name = String::new(); - io::BufReader::new(file).read_line(&mut venv_name)?; - Ok(venv_name.trim().to_string()) - }) - .ok() +async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option { + let file = async_fs::File::open(worktree_root.join(".venv")) + .await + .ok()?; + let mut venv_name = String::new(); + smol::io::BufReader::new(file) + .read_line(&mut venv_name) + .await + .ok()?; + Some(venv_name.trim().to_string()) +} + +fn get_venv_parent_dir(env: &PythonEnvironment) -> Option { + // If global, we aren't a virtual environment + if let Some(kind) = env.kind + && is_python_env_global(&kind) + { + return None; + } + + // Check to be sure we are a virtual environment using pet's most generic + // virtual environment type, VirtualEnv + let venv = env + .executable + .as_ref() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .filter(|p| is_virtualenv_dir(p))?; + + venv.parent().map(|parent| parent.to_path_buf()) +} + +fn wr_distance(wr: &PathBuf, venv: Option<&PathBuf>) -> usize { + if let Some(venv) = venv + && let Ok(p) = venv.strip_prefix(wr) + { + p.components().count() + } else { + usize::MAX + } } #[async_trait] impl ToolchainLister for PythonToolchainProvider { - fn manifest_name(&self) -> language::ManifestName { - ManifestName::from(SharedString::new_static("pyproject.toml")) - } async fn list( &self, worktree_root: PathBuf, - subroot_relative_path: Option>, + subroot_relative_path: Arc, project_env: Option>, + fs: &dyn Fs, ) -> ToolchainList { let env = project_env.unwrap_or_default(); let environment = EnvironmentApi::from_env(&env); @@ -785,13 +1043,14 @@ impl ToolchainLister for PythonToolchainProvider { ); let mut config = Configuration::default(); - let mut directories = vec![worktree_root.clone()]; - if let Some(subroot_relative_path) = subroot_relative_path { - debug_assert!(subroot_relative_path.is_relative()); - directories.push(worktree_root.join(subroot_relative_path)); - } - - config.workspace_directories = Some(directories); + // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use + // worktree root as the workspace directory. + config.workspace_directories = Some( + subroot_relative_path + .ancestors() + .map(|ancestor| worktree_root.join(ancestor.as_std_path())) + .collect(), + ); for locator in locators.iter() { locator.configure(&config); } @@ -805,7 +1064,7 @@ impl ToolchainLister for PythonToolchainProvider { .map_or(Vec::new(), |mut guard| std::mem::take(&mut guard)); let wr = worktree_root; - let wr_venv = get_worktree_venv_declaration(&wr); + let wr_venv = get_worktree_venv_declaration(&wr).await; // Sort detected environments by: // environment name matching activation file (/.venv) // environment project dir matching worktree_root @@ -825,11 +1084,10 @@ impl ToolchainLister for PythonToolchainProvider { }); // Compare project paths against worktree root - let proj_ordering = || match (&lhs.project, &rhs.project) { - (Some(l), Some(r)) => (r == &wr).cmp(&(l == &wr)), - (Some(l), None) if l == &wr => Ordering::Less, - (None, Some(r)) if r == &wr => Ordering::Greater, - _ => Ordering::Equal, + let proj_ordering = || { + let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs)); + let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs)); + wr_distance(&wr, lhs_project.as_ref()).cmp(&wr_distance(&wr, rhs_project.as_ref())) }; // Compare environment priorities @@ -842,7 +1100,7 @@ impl ToolchainLister for PythonToolchainProvider { .get_env_var("CONDA_PREFIX".to_string()) .map(|conda_prefix| { let is_match = |exe: &Option| { - exe.as_ref().map_or(false, |e| e.starts_with(&conda_prefix)) + exe.as_ref().is_some_and(|e| e.starts_with(&conda_prefix)) }; match (is_match(&lhs.executable), is_match(&rhs.executable)) { (true, false) => Ordering::Less, @@ -866,45 +1124,177 @@ impl ToolchainLister for PythonToolchainProvider { .then_with(exe_ordering) }); - let mut toolchains: Vec<_> = toolchains - .into_iter() - .filter_map(|toolchain| { - let mut name = String::from("Python"); - if let Some(ref version) = toolchain.version { - _ = write!(name, " {version}"); - } + let mut out_toolchains = Vec::new(); + for toolchain in toolchains { + let Some(toolchain) = venv_to_toolchain(toolchain, fs).await else { + continue; + }; + out_toolchains.push(toolchain); + } + out_toolchains.dedup(); + ToolchainList { + toolchains: out_toolchains, + default: None, + groups: Default::default(), + } + } + fn meta(&self) -> ToolchainMetadata { + ToolchainMetadata { + term: SharedString::new_static("Virtual Environment"), + new_toolchain_placeholder: SharedString::new_static( + "A path to the python3 executable within a virtual environment, or path to virtual environment itself", + ), + manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")), + } + } + + async fn resolve( + &self, + path: PathBuf, + env: Option>, + fs: &dyn Fs, + ) -> anyhow::Result { + let env = env.unwrap_or_default(); + let environment = EnvironmentApi::from_env(&env); + let locators = pet::locators::create_locators( + Arc::new(pet_conda::Conda::from(&environment)), + Arc::new(pet_poetry::Poetry::from(&environment)), + &environment, + ); + let toolchain = pet::resolve::resolve_environment(&path, &locators, &environment) + .context("Could not find a virtual environment in provided path")?; + let venv = toolchain.resolved.unwrap_or(toolchain.discovered); + venv_to_toolchain(venv, fs) + .await + .context("Could not convert a venv into a toolchain") + } + + fn activation_script(&self, toolchain: &Toolchain, shell: ShellKind) -> Vec { + let Ok(toolchain) = + serde_json::from_value::(toolchain.as_json.clone()) + else { + return vec![]; + }; - let name_and_kind = match (&toolchain.name, &toolchain.kind) { - (Some(name), Some(kind)) => { - Some(format!("({name}; {})", python_env_kind_display(kind))) + log::debug!("(Python) Composing activation script for toolchain {toolchain:?}"); + + let mut activation_script = vec![]; + + match toolchain.environment.kind { + Some(PythonEnvironmentKind::Conda) => { + if let Some(name) = &toolchain.environment.name { + activation_script.push(format!("conda activate {name}")); + } else { + activation_script.push("conda activate".to_string()); + } + } + Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => { + if let Some(activation_scripts) = &toolchain.activation_scripts { + if let Some(activate_script_path) = activation_scripts.get(&shell) { + let activate_keyword = shell.activate_keyword(); + if let Some(quoted) = + shell.try_quote(&activate_script_path.to_string_lossy()) + { + activation_script.push(format!("{activate_keyword} {quoted}")); + } } - (Some(name), None) => Some(format!("({name})")), - (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))), - (None, None) => None, + } + } + Some(PythonEnvironmentKind::Pyenv) => { + let Some(manager) = &toolchain.environment.manager else { + return vec![]; }; + let version = toolchain.environment.version.as_deref().unwrap_or("system"); + let pyenv = &manager.executable; + let pyenv = pyenv.display(); + activation_script.extend(match shell { + ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")), + ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")), + ShellKind::Nushell => Some(format!("\"{pyenv}\" shell - nu {version}")), + ShellKind::PowerShell => None, + ShellKind::Csh => None, + ShellKind::Tcsh => None, + ShellKind::Cmd => None, + ShellKind::Rc => None, + ShellKind::Xonsh => None, + }) + } + _ => {} + } + activation_script + } +} + +async fn venv_to_toolchain(venv: PythonEnvironment, fs: &dyn Fs) -> Option { + let mut name = String::from("Python"); + if let Some(ref version) = venv.version { + _ = write!(name, " {version}"); + } + + let name_and_kind = match (&venv.name, &venv.kind) { + (Some(name), Some(kind)) => Some(format!("({name}; {})", python_env_kind_display(kind))), + (Some(name), None) => Some(format!("({name})")), + (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))), + (None, None) => None, + }; + + if let Some(nk) = name_and_kind { + _ = write!(name, " {nk}"); + } - if let Some(nk) = name_and_kind { - _ = write!(name, " {nk}"); - } + let mut activation_scripts = HashMap::default(); + match venv.kind { + Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => { + resolve_venv_activation_scripts(&venv, fs, &mut activation_scripts).await + } + _ => {} + } + let data = PythonToolchainData { + environment: venv, + activation_scripts: Some(activation_scripts), + }; + + Some(Toolchain { + name: name.into(), + path: data + .environment + .executable + .as_ref()? + .to_str()? + .to_owned() + .into(), + language_name: LanguageName::new("Python"), + as_json: serde_json::to_value(data).ok()?, + }) +} - Some(Toolchain { - name: name.into(), - path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(), - language_name: LanguageName::new("Python"), - as_json: serde_json::to_value(toolchain).ok()?, - }) - }) - .collect(); - toolchains.dedup(); - ToolchainList { - toolchains, - default: None, - groups: Default::default(), +async fn resolve_venv_activation_scripts( + venv: &PythonEnvironment, + fs: &dyn Fs, + activation_scripts: &mut HashMap, +) { + log::debug!("(Python) Resolving activation scripts for venv toolchain {venv:?}"); + if let Some(prefix) = &venv.prefix { + for (shell_kind, script_name) in &[ + (ShellKind::Posix, "activate"), + (ShellKind::Rc, "activate"), + (ShellKind::Csh, "activate.csh"), + (ShellKind::Tcsh, "activate.csh"), + (ShellKind::Fish, "activate.fish"), + (ShellKind::Nushell, "activate.nu"), + (ShellKind::PowerShell, "activate.ps1"), + (ShellKind::Cmd, "activate.bat"), + (ShellKind::Xonsh, "activate.xsh"), + ] { + let path = prefix.join(BINARY_DIR).join(script_name); + + log::debug!("Trying path: {}", path.display()); + + if fs.is_file(&path).await { + activation_scripts.insert(*shell_kind, path); + } } } - fn term(&self) -> SharedString { - self.term.clone() - } } pub struct EnvironmentApi<'a> { @@ -954,9 +1344,13 @@ impl pet_core::os_environment::Environment for EnvironmentApi<'_> { fn get_know_global_search_locations(&self) -> Vec { if self.global_search_locations.lock().is_empty() { - let mut paths = - std::env::split_paths(&self.get_env_var("PATH".to_string()).unwrap_or_default()) - .collect::>(); + let mut paths = std::env::split_paths( + &self + .get_env_var("PATH".to_string()) + .or_else(|| self.get_env_var("Path".to_string())) + .unwrap_or_default(), + ) + .collect::>(); log::trace!("Env PATH: {:?}", paths); for p in self.pet_env.get_know_global_search_locations() { @@ -989,7 +1383,13 @@ impl PyLspAdapter { async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result> { let python_path = Self::find_base_python(delegate) .await - .context("Could not find Python installation for PyLSP")?; + .with_context(|| { + let mut message = "Could not find Python installation for PyLSP".to_owned(); + if cfg!(windows){ + message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.") + } + message + })?; let work_dir = delegate .language_server_download_dir(&Self::SERVER_NAME) .await @@ -1012,9 +1412,24 @@ impl PyLspAdapter { // Find "baseline", user python version from which we'll create our own venv. async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option { for path in ["python3", "python"] { - if let Some(path) = delegate.which(path.as_ref()).await { - return Some(path); + let Some(path) = delegate.which(path.as_ref()).await else { + continue; + }; + // Try to detect situations where `python3` exists but is not a real Python interpreter. + // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app + // when run with no arguments, and just fails otherwise. + let Some(output) = new_smol_command(&path) + .args(["-c", "print(1 + 2)"]) + .output() + .await + .ok() + else { + continue; + }; + if output.stdout.trim_ascii() != b"3" { + continue; } + return Some(path); } None } @@ -1040,108 +1455,7 @@ const BINARY_DIR: &str = if cfg!(target_os = "windows") { #[async_trait(?Send)] impl LspAdapter for PyLspAdapter { fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } - - async fn check_if_user_installed( - &self, - delegate: &dyn LspAdapterDelegate, - toolchains: Arc, - cx: &AsyncApp, - ) -> Option { - if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: pylsp_bin, - env: Some(env), - arguments: vec![], - }) - } else { - let venv = toolchains - .active_toolchain( - delegate.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - &mut cx.clone(), - ) - .await?; - let pylsp_path = Path::new(venv.path.as_ref()).parent()?.join("pylsp"); - pylsp_path.exists().then(|| LanguageServerBinary { - path: venv.path.to_string().into(), - arguments: vec![pylsp_path.into()], - env: None, - }) - } - } - - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new(()) as Box<_>) - } - - async fn fetch_server_binary( - &self, - _: Box, - _: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?; - let pip_path = venv.join(BINARY_DIR).join("pip3"); - ensure!( - util::command::new_smol_command(pip_path.as_path()) - .arg("install") - .arg("python-lsp-server") - .arg("-U") - .output() - .await? - .status - .success(), - "python-lsp-server installation failed" - ); - ensure!( - util::command::new_smol_command(pip_path.as_path()) - .arg("install") - .arg("python-lsp-server[all]") - .arg("-U") - .output() - .await? - .status - .success(), - "python-lsp-server[all] installation failed" - ); - ensure!( - util::command::new_smol_command(pip_path) - .arg("install") - .arg("pylsp-mypy") - .arg("-U") - .output() - .await? - .status - .success(), - "pylsp-mypy installation failed" - ); - let pylsp = venv.join(BINARY_DIR).join("pylsp"); - Ok(LanguageServerBinary { - path: pylsp, - env: None, - arguments: vec![], - }) - } - - async fn cached_server_binary( - &self, - _: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let venv = self.base_venv(delegate).await.ok()?; - let pylsp = venv.join(BINARY_DIR).join("pylsp"); - Some(LanguageServerBinary { - path: pylsp, - env: None, - arguments: vec![], - }) + Self::SERVER_NAME } async fn process_completions(&self, _items: &mut [lsp::CompletionItem]) {} @@ -1160,16 +1474,11 @@ impl LspAdapter for PyLspAdapter { lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?, _ => return None, }; - let filter_range = item - .filter_text - .as_deref() - .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..label.len()); - Some(language::CodeLabel { - text: label.clone(), - runs: vec![(0..label.len(), highlight_id)], - filter_range, - }) + Some(language::CodeLabel::filtered( + label.clone(), + item.filter_text.as_deref(), + vec![(0..label.len(), highlight_id)], + )) } async fn label_for_symbol( @@ -1199,29 +1508,19 @@ impl LspAdapter for PyLspAdapter { } _ => return None, }; - - Some(language::CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), + Some(language::CodeLabel::new( + text[display_range.clone()].to_string(), filter_range, - }) + language.highlight_text(&text.as_str().into(), display_range), + )) } async fn workspace_configuration( self: Arc, - _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -1241,7 +1540,7 @@ impl LspAdapter for PyLspAdapter { // If user did not explicitly modify their python venv, use one from picker. if let Some(toolchain) = toolchain { - if user_settings.is_null() { + if !user_settings.is_object() { user_settings = Value::Object(serde_json::Map::default()); } let object = user_settings.as_object_mut().unwrap(); @@ -1282,126 +1581,29 @@ impl LspAdapter for PyLspAdapter { user_settings }) } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) - } - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot - } -} - -pub(crate) struct BasedPyrightLspAdapter { - python_venv_base: OnceCell, String>>, -} - -impl BasedPyrightLspAdapter { - const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright"); - const BINARY_NAME: &'static str = "basedpyright-langserver"; - - pub(crate) fn new() -> Self { - Self { - python_venv_base: OnceCell::new(), - } - } - - async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result> { - let python_path = Self::find_base_python(delegate) - .await - .context("Could not find Python installation for basedpyright")?; - let work_dir = delegate - .language_server_download_dir(&Self::SERVER_NAME) - .await - .context("Could not get working directory for basedpyright")?; - let mut path = PathBuf::from(work_dir.as_ref()); - path.push("basedpyright-venv"); - if !path.exists() { - util::command::new_smol_command(python_path) - .arg("-m") - .arg("venv") - .arg("basedpyright-venv") - .current_dir(work_dir) - .spawn()? - .output() - .await?; - } - - Ok(path.into()) - } - - // Find "baseline", user python version from which we'll create our own venv. - async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option { - for path in ["python3", "python"] { - if let Some(path) = delegate.which(path.as_ref()).await { - return Some(path); - } - } - None - } - - async fn base_venv(&self, delegate: &dyn LspAdapterDelegate) -> Result, String> { - self.python_venv_base - .get_or_init(move || async move { - Self::ensure_venv(delegate) - .await - .map_err(|e| format!("{e}")) - }) - .await - .clone() - } } -#[async_trait(?Send)] -impl LspAdapter for BasedPyrightLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } - - async fn initialization_options( - self: Arc, - _: &dyn Fs, - _: &Arc, - ) -> Result> { - // Provide minimal initialization options - // Virtual environment configuration will be handled through workspace configuration - Ok(Some(json!({ - "python": { - "analysis": { - "autoSearchPaths": true, - "useLibraryCodeForTypes": true, - "autoImportCompletions": true - } - } - }))) - } - +impl LspInstaller for PyLspAdapter { + type BinaryVersion = (); async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - toolchains: Arc, - cx: &AsyncApp, + toolchain: Option, + _: &AsyncApp, ) -> Option { - if let Some(bin) = delegate.which(Self::BINARY_NAME.as_ref()).await { + if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { let env = delegate.shell_env().await; Some(LanguageServerBinary { - path: bin, + path: pylsp_bin, env: Some(env), - arguments: vec!["--stdio".into()], + arguments: vec![], }) } else { - let venv = toolchains - .active_toolchain( - delegate.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - &mut cx.clone(), - ) - .await?; - let path = Path::new(venv.path.as_ref()) - .parent()? - .join(Self::BINARY_NAME); - path.exists().then(|| LanguageServerBinary { - path, - arguments: vec!["--stdio".into()], + let toolchain = toolchain?; + let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp"); + pylsp_path.exists().then(|| LanguageServerBinary { + path: toolchain.path.to_string().into(), + arguments: vec![pylsp_path.into()], env: None, }) } @@ -1410,14 +1612,16 @@ impl LspAdapter for BasedPyrightLspAdapter { async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new(()) as Box<_>) + _: bool, + _: &mut AsyncApp, + ) -> Result<()> { + Ok(()) } async fn fetch_server_binary( &self, - _latest_version: Box, - _container_dir: PathBuf, + _: (), + _: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?; @@ -1425,57 +1629,110 @@ impl LspAdapter for BasedPyrightLspAdapter { ensure!( util::command::new_smol_command(pip_path.as_path()) .arg("install") - .arg("basedpyright") - .arg("-U") + .arg("python-lsp-server[all]") + .arg("--upgrade") + .output() + .await? + .status + .success(), + "python-lsp-server[all] installation failed" + ); + ensure!( + util::command::new_smol_command(pip_path) + .arg("install") + .arg("pylsp-mypy") + .arg("--upgrade") .output() .await? .status .success(), - "basedpyright installation failed" + "pylsp-mypy installation failed" + ); + let pylsp = venv.join(BINARY_DIR).join("pylsp"); + ensure!( + delegate.which(pylsp.as_os_str()).await.is_some(), + "pylsp installation was incomplete" ); - let pylsp = venv.join(BINARY_DIR).join(Self::BINARY_NAME); Ok(LanguageServerBinary { path: pylsp, env: None, - arguments: vec!["--stdio".into()], + arguments: vec![], + }) + } + + async fn cached_server_binary( + &self, + _: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let venv = self.base_venv(delegate).await.ok()?; + let pylsp = venv.join(BINARY_DIR).join("pylsp"); + delegate.which(pylsp.as_os_str()).await?; + Some(LanguageServerBinary { + path: pylsp, + env: None, + arguments: vec![], }) } +} + +pub(crate) struct BasedPyrightLspAdapter { + node: NodeRuntime, +} + +impl BasedPyrightLspAdapter { + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright"); + const BINARY_NAME: &'static str = "basedpyright-langserver"; + const SERVER_PATH: &str = "node_modules/basedpyright/langserver.index.js"; + const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "basedpyright/langserver.index.js"; + + pub(crate) fn new(node: NodeRuntime) -> Self { + BasedPyrightLspAdapter { node } + } + + async fn get_cached_server_binary( + container_dir: PathBuf, + node: &NodeRuntime, + ) -> Option { + let server_path = container_dir.join(Self::SERVER_PATH); + if server_path.exists() { + Some(LanguageServerBinary { + path: node.binary_path().await.log_err()?, + env: None, + arguments: vec![server_path.into(), "--stdio".into()], + }) + } else { + log::error!("missing executable in directory {:?}", server_path); + None + } + } +} + +#[async_trait(?Send)] +impl LspAdapter for BasedPyrightLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } - async fn cached_server_binary( - &self, - _container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let venv = self.base_venv(delegate).await.ok()?; - let pylsp = venv.join(BINARY_DIR).join(Self::BINARY_NAME); - Some(LanguageServerBinary { - path: pylsp, - env: None, - arguments: vec!["--stdio".into()], - }) + async fn initialization_options( + self: Arc, + _: &Arc, + ) -> Result> { + // Provide minimal initialization options + // Virtual environment configuration will be handled through workspace configuration + Ok(Some(json!({ + "python": { + "analysis": { + "autoSearchPaths": true, + "useLibraryCodeForTypes": true, + "autoImportCompletions": true + } + } + }))) } async fn process_completions(&self, items: &mut [lsp::CompletionItem]) { - // Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`. - // Where `XX` is the sorting category, `YYYY` is based on most recent usage, - // and `name` is the symbol name itself. - // - // Because the symbol name is included, there generally are not ties when - // sorting by the `sortText`, so the symbol's fuzzy match score is not taken - // into account. Here, we remove the symbol name from the sortText in order - // to allow our own fuzzy score to be used to break ties. - // - // see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873 - for item in items { - let Some(sort_text) = &mut item.sort_text else { - continue; - }; - let mut parts = sort_text.split('.'); - let Some(first) = parts.next() else { continue }; - let Some(second) = parts.next() else { continue }; - let Some(_) = parts.next() else { continue }; - sort_text.replace_range(first.len() + second.len() + 1.., ""); - } + process_pyright_completions(items); } async fn label_for_completion( @@ -1486,22 +1743,31 @@ impl LspAdapter for BasedPyrightLspAdapter { let label = &item.label; let grammar = language.grammar()?; let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?, - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function")?, - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type")?, - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?, - _ => return None, + lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), + lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), + lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), + lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), + lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), + _ => { + return None; + } }; - let filter_range = item - .filter_text - .as_deref() - .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..label.len()); - Some(language::CodeLabel { - text: label.clone(), - runs: vec![(0..label.len(), highlight_id)], - filter_range, - }) + let mut text = label.clone(); + if let Some(completion_details) = item + .label_details + .as_ref() + .and_then(|details| details.description.as_ref()) + { + write!(&mut text, " {}", completion_details).ok(); + } + Some(language::CodeLabel::filtered( + text, + item.filter_text.as_deref(), + highlight_id + .map(|id| (0..label.len(), id)) + .into_iter() + .collect(), + )) } async fn label_for_symbol( @@ -1531,29 +1797,19 @@ impl LspAdapter for BasedPyrightLspAdapter { } _ => return None, }; - - Some(language::CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), + Some(language::CodeLabel::new( + text[display_range.clone()].to_string(), filter_range, - }) + language.highlight_text(&text.as_str().into(), display_range), + )) } async fn workspace_configuration( self: Arc, - _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -1561,80 +1817,417 @@ impl LspAdapter for BasedPyrightLspAdapter { .unwrap_or_default(); // If we have a detected toolchain, configure Pyright to use it - if let Some(toolchain) = toolchain { - if user_settings.is_null() { + if let Some(toolchain) = toolchain + && let Ok(env) = serde_json::from_value::< + pet_core::python_environment::PythonEnvironment, + >(toolchain.as_json.clone()) + { + if !user_settings.is_object() { user_settings = Value::Object(serde_json::Map::default()); } let object = user_settings.as_object_mut().unwrap(); let interpreter_path = toolchain.path.to_string(); + if let Some(venv_dir) = env.prefix { + // Set venvPath and venv at the root level + // This matches the format of a pyrightconfig.json file + if let Some(parent) = venv_dir.parent() { + // Use relative path if the venv is inside the workspace + let venv_path = if parent == adapter.worktree_root_path() { + ".".to_string() + } else { + parent.to_string_lossy().into_owned() + }; + object.insert("venvPath".to_string(), Value::String(venv_path)); + } - // Detect if this is a virtual environment - if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() { - if let Some(venv_dir) = interpreter_dir.parent() { - // Check if this looks like a virtual environment - if venv_dir.join("pyvenv.cfg").exists() - || venv_dir.join("bin/activate").exists() - || venv_dir.join("Scripts/activate.bat").exists() - { - // Set venvPath and venv at the root level - // This matches the format of a pyrightconfig.json file - if let Some(parent) = venv_dir.parent() { - // Use relative path if the venv is inside the workspace - let venv_path = if parent == adapter.worktree_root_path() { - ".".to_string() - } else { - parent.to_string_lossy().into_owned() - }; - object.insert("venvPath".to_string(), Value::String(venv_path)); - } - - if let Some(venv_name) = venv_dir.file_name() { - object.insert( - "venv".to_owned(), - Value::String(venv_name.to_string_lossy().into_owned()), - ); - } - } + if let Some(venv_name) = venv_dir.file_name() { + object.insert( + "venv".to_owned(), + Value::String(venv_name.to_string_lossy().into_owned()), + ); } } - // Always set the python interpreter path - // Get or create the python section - let python = object + // Set both pythonPath and defaultInterpreterPath for compatibility + if let Some(python) = object .entry("python") .or_insert(Value::Object(serde_json::Map::default())) .as_object_mut() - .unwrap(); - - // Set both pythonPath and defaultInterpreterPath for compatibility - python.insert( - "pythonPath".to_owned(), - Value::String(interpreter_path.clone()), - ); - python.insert( - "defaultInterpreterPath".to_owned(), - Value::String(interpreter_path), - ); + { + python.insert( + "pythonPath".to_owned(), + Value::String(interpreter_path.clone()), + ); + python.insert( + "defaultInterpreterPath".to_owned(), + Value::String(interpreter_path), + ); + } + // Basedpyright by default uses `strict` type checking, we tone it down as to not surpris users + maybe!({ + let analysis = object + .entry("basedpyright.analysis") + .or_insert(Value::Object(serde_json::Map::default())); + if let serde_json::map::Entry::Vacant(v) = + analysis.as_object_mut()?.entry("typeCheckingMode") + { + v.insert(Value::String("standard".to_owned())); + } + Some(()) + }); } user_settings }) } +} + +impl LspInstaller for BasedPyrightLspAdapter { + type BinaryVersion = String; + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version(Self::SERVER_NAME.as_ref()) + .await + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + if let Some(path) = delegate.which(Self::BINARY_NAME.as_ref()).await { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path, + env: Some(env), + arguments: vec!["--stdio".into()], + }) + } else { + // TODO shouldn't this be self.node.binary_path()? + let node = delegate.which("node".as_ref()).await?; + let (node_modules_path, _) = delegate + .npm_package_installed_version(Self::SERVER_NAME.as_ref()) + .await + .log_err()??; + + let path = node_modules_path.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node, + env: Some(env), + arguments: vec![path.into(), "--stdio".into()], + }) + } + } + + async fn fetch_server_binary( + &self, + latest_version: Self::BinaryVersion, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let server_path = container_dir.join(Self::SERVER_PATH); + + self.node + .npm_install_packages( + &container_dir, + &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], + ) + .await?; + + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: self.node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } + + async fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = self + .node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + container_dir, + VersionStrategy::Latest(version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: self.node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } + } + + async fn cached_server_binary( + &self, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let mut binary = Self::get_cached_server_binary(container_dir, &self.node).await?; + binary.env = Some(delegate.shell_env().await); + Some(binary) + } +} + +pub(crate) struct RuffLspAdapter { + fs: Arc, +} + +#[cfg(target_os = "macos")] +impl RuffLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "apple-darwin"; +} + +#[cfg(target_os = "linux")] +impl RuffLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "unknown-linux-gnu"; +} + +#[cfg(target_os = "freebsd")] +impl RuffLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz; + const ARCH_SERVER_NAME: &str = "unknown-freebsd"; +} + +#[cfg(target_os = "windows")] +impl RuffLspAdapter { + const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip; + const ARCH_SERVER_NAME: &str = "pc-windows-msvc"; +} + +impl RuffLspAdapter { + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("ruff"); + + pub fn new(fs: Arc) -> RuffLspAdapter { + RuffLspAdapter { fs } + } + + fn build_asset_name() -> Result<(String, String)> { + let arch = match consts::ARCH { + "x86" => "i686", + _ => consts::ARCH, + }; + let os = Self::ARCH_SERVER_NAME; + let suffix = match consts::OS { + "windows" => "zip", + _ => "tar.gz", + }; + let asset_name = format!("ruff-{arch}-{os}.{suffix}"); + let asset_stem = format!("ruff-{arch}-{os}"); + Ok((asset_stem, asset_name)) + } +} + +#[async_trait(?Send)] +impl LspAdapter for RuffLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } +} + +impl LspInstaller for RuffLspAdapter { + type BinaryVersion = GitHubLspBinaryVersion; + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + toolchain: Option, + _: &AsyncApp, + ) -> Option { + let ruff_in_venv = if let Some(toolchain) = toolchain + && toolchain.language_name.as_ref() == "Python" + { + Path::new(toolchain.path.as_str()) + .parent() + .map(|path| path.join("ruff")) + } else { + None + }; + + for path in ruff_in_venv.into_iter().chain(["ruff".into()]) { + if let Some(ruff_bin) = delegate.which(path.as_os_str()).await { + let env = delegate.shell_env().await; + return Some(LanguageServerBinary { + path: ruff_bin, + env: Some(env), + arguments: vec!["server".into()], + }); + } + } + + None + } + + async fn fetch_latest_server_version( + &self, + delegate: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + let release = + latest_github_release("astral-sh/ruff", true, false, delegate.http_client()).await?; + let (_, asset_name) = Self::build_asset_name()?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == asset_name) + .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; + Ok(GitHubLspBinaryVersion { + name: release.tag_name, + url: asset.browser_download_url, + digest: asset.digest, + }) + } + + async fn fetch_server_binary( + &self, + latest_version: GitHubLspBinaryVersion, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = latest_version; + let destination_path = container_dir.join(format!("ruff-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ruff"), + AssetKind::Zip => destination_path.clone().join("ruff.exe"), + }; + + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: vec!["server".into()], + }) } - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { + maybe!(async { + let mut last = None; + let mut entries = self.fs.read_dir(&container_dir).await?; + while let Some(entry) = entries.next().await { + let path = entry?; + if path.extension().is_some_and(|ext| ext == "metadata") { + continue; + } + last = Some(path); + } + + let path = last.context("no cached binary")?; + let path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::Gz => { + path.join(Self::build_asset_name()?.0).join("ruff") + } + AssetKind::Zip => path.join("ruff.exe"), + }; + + anyhow::Ok(LanguageServerBinary { + path, + env: None, + arguments: vec!["server".into()], + }) + }) + .await + .log_err() } } #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext}; - use language::{AutoindentMode, Buffer, language_settings::AllLanguageSettings}; + use language::{AutoindentMode, Buffer}; use settings::SettingsStore; use std::num::NonZeroU32; @@ -1647,8 +2240,8 @@ mod tests { cx.set_global(test_settings); language::init(cx); cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |s| { - s.defaults.tab_size = NonZeroU32::new(2); + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2); }); }); }); @@ -1737,6 +2330,26 @@ mod tests { "def a():\n \n if a:\n b()\n else:\n foo(\n )\n\n" ); + // reset to a for loop statement + let statement = "for i in range(10):\n print(i)\n"; + buffer.edit([(0..buffer.len(), statement)], None, cx); + + // insert single line comment after each line + let eol_ixs = statement + .char_indices() + .filter_map(|(ix, c)| if c == '\n' { Some(ix) } else { None }) + .collect::>(); + let editions = eol_ixs + .iter() + .enumerate() + .map(|(i, &eol_ix)| (eol_ix..eol_ix, format!(" # comment {}", i + 1))) + .collect::, String)>>(); + buffer.edit(editions, Some(AutoindentMode::EachLine), cx); + assert_eq!( + buffer.text(), + "for i in range(10): # comment 1\n print(i) # comment 2\n" + ); + // reset to a simple if statement buffer.edit([(0..buffer.len(), "if a:\n b(\n )")], None, cx); diff --git a/crates/languages/src/python/config.toml b/crates/languages/src/python/config.toml index 8728dfeaf138a97a7d9d7e9e2e3ca4b6b6db1820..c58a54fc1cae78cfb3722e74008fe42c7a883851 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/languages/src/python/config.toml @@ -28,10 +28,11 @@ brackets = [ auto_indent_using_last_non_empty_line = false debuggers = ["Debugpy"] -increase_indent_pattern = "^[^#].*:\\s*$" +increase_indent_pattern = "^[^#].*:\\s*(#.*)?$" decrease_indent_patterns = [ - { pattern = "^\\s*elif\\b.*:", valid_after = ["if", "elif"] }, - { pattern = "^\\s*else\\b.*:", valid_after = ["if", "elif", "for", "while", "except"] }, - { pattern = "^\\s*except\\b.*:", valid_after = ["try", "except"] }, - { pattern = "^\\s*finally\\b.*:", valid_after = ["try", "except", "else"] }, + { pattern = "^\\s*elif\\b.*:\\s*(#.*)?", valid_after = ["if", "elif"] }, + { pattern = "^\\s*else\\b.*:\\s*(#.*)?", valid_after = ["if", "elif", "for", "while", "except"] }, + { pattern = "^\\s*except\\b.*:\\s*(#.*)?", valid_after = ["try", "except"] }, + { pattern = "^\\s*finally\\b.*:\\s*(#.*)?", valid_after = ["try", "except", "else"] }, ] +import_path_strip_regex = "/__init__\\.py$" diff --git a/crates/languages/src/python/highlights.scm b/crates/languages/src/python/highlights.scm index 77db9b2f4c17519e966b68c44fede2aa9bc4c29f..f15b3a0e2b03d9c913627b319aff9bca6bb8708e 100644 --- a/crates/languages/src/python/highlights.scm +++ b/crates/languages/src/python/highlights.scm @@ -257,7 +257,6 @@ "elif" "else" "except" - "except*" "exec" "finally" "for" diff --git a/crates/languages/src/python/imports.scm b/crates/languages/src/python/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..7a1e2b225b9e310098f316c29fe6b1a27634bf12 --- /dev/null +++ b/crates/languages/src/python/imports.scm @@ -0,0 +1,32 @@ +(import_statement + name: [ + (dotted_name + ((identifier) @namespace ".")* + (identifier) @namespace .) + (aliased_import + name: (dotted_name + ((identifier) @namespace ".")* + (identifier) @namespace .)) + ]) @wildcard @import + +(import_from_statement + module_name: [ + (dotted_name + ((identifier) @namespace ".")* + (identifier) @namespace .) + (relative_import + (dotted_name + ((identifier) @namespace ".")* + (identifier) @namespace .)?) + ] + (wildcard_import)? @wildcard + name: [ + (dotted_name + ((identifier) @namespace ".")* + (identifier) @name .) + (aliased_import + name: (dotted_name + ((identifier) @namespace ".")* + (identifier) @name .) + alias: (identifier) @alias) + ]?) @import diff --git a/crates/languages/src/python/injections.scm b/crates/languages/src/python/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..9117c713b98fdd2896b13e4949a77c6489b9ee36 --- /dev/null +++ b/crates/languages/src/python/injections.scm @@ -0,0 +1,3 @@ +((comment) @injection.content + (#set! injection.language "comment") +) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 3baaec18421f10cfd83aff44c348e7635e295acf..4b56a617735ab1a5932a56a4f6e51397721d8a86 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -5,9 +5,9 @@ use futures::StreamExt; use gpui::{App, AppContext, AsyncApp, SharedString, Task}; use http_client::github::AssetKind; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; +use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary}; -use project::Fs; use project::lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME; use project::project_settings::ProjectSettings; use regex::Regex; @@ -17,7 +17,6 @@ use smol::fs::{self}; use std::fmt::Display; use std::ops::Range; use std::{ - any::Any, borrow::Cow, path::{Path, PathBuf}, sync::{Arc, LazyLock}, @@ -25,9 +24,9 @@ use std::{ use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; use util::fs::{make_file_executable, remove_matching}; use util::merge_json_value_into; +use util::rel_path::RelPath; use util::{ResultExt, maybe}; -use crate::github_download::{GithubBinaryMetadata, download_server_binary}; use crate::language_settings::language_settings; pub struct RustLspAdapter; @@ -41,7 +40,7 @@ impl RustLspAdapter { #[cfg(target_os = "linux")] impl RustLspAdapter { const GITHUB_ASSET_KIND: AssetKind = AssetKind::Gz; - const ARCH_SERVER_NAME: &str = "unknown-linux-gnu"; + const ARCH_SERVER_NAME: &str = "unknown-linux"; } #[cfg(target_os = "freebsd")] @@ -58,19 +57,89 @@ impl RustLspAdapter { const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("rust-analyzer"); +#[cfg(target_os = "linux")] +enum LibcType { + Gnu, + Musl, +} + impl RustLspAdapter { - fn build_asset_name() -> String { + #[cfg(target_os = "linux")] + async fn determine_libc_type() -> LibcType { + use futures::pin_mut; + use smol::process::Command; + + async fn from_ldd_version() -> Option { + let ldd_output = Command::new("ldd").arg("--version").output().await.ok()?; + let ldd_version = String::from_utf8_lossy(&ldd_output.stdout); + + if ldd_version.contains("GNU libc") || ldd_version.contains("GLIBC") { + Some(LibcType::Gnu) + } else if ldd_version.contains("musl") { + Some(LibcType::Musl) + } else { + None + } + } + + if let Some(libc_type) = from_ldd_version().await { + return libc_type; + } + + let Ok(dir_entries) = smol::fs::read_dir("/lib").await else { + // defaulting to gnu because nix doesn't have /lib files due to not following FHS + return LibcType::Gnu; + }; + let dir_entries = dir_entries.filter_map(async move |e| e.ok()); + pin_mut!(dir_entries); + + let mut has_musl = false; + let mut has_gnu = false; + + while let Some(entry) = dir_entries.next().await { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if file_name.starts_with("ld-musl-") { + has_musl = true; + } else if file_name.starts_with("ld-linux-") { + has_gnu = true; + } + } + + match (has_musl, has_gnu) { + (true, _) => LibcType::Musl, + (_, true) => LibcType::Gnu, + _ => LibcType::Gnu, + } + } + + #[cfg(target_os = "linux")] + async fn build_arch_server_name_linux() -> String { + let libc = match Self::determine_libc_type().await { + LibcType::Musl => "musl", + LibcType::Gnu => "gnu", + }; + + format!("{}-{}", Self::ARCH_SERVER_NAME, libc) + } + + async fn build_asset_name() -> String { let extension = match Self::GITHUB_ASSET_KIND { AssetKind::TarGz => "tar.gz", AssetKind::Gz => "gz", AssetKind::Zip => "zip", }; + #[cfg(target_os = "linux")] + let arch_server_name = Self::build_arch_server_name_linux().await; + #[cfg(not(target_os = "linux"))] + let arch_server_name = Self::ARCH_SERVER_NAME.to_string(); + format!( "{}-{}-{}.{}", SERVER_NAME, std::env::consts::ARCH, - Self::ARCH_SERVER_NAME, + &arch_server_name, extension ) } @@ -90,10 +159,10 @@ impl ManifestProvider for CargoManifestProvider { depth, delegate, }: ManifestQuery, - ) -> Option> { + ) -> Option> { let mut outermost_cargo_toml = None; for path in path.ancestors().take(depth) { - let p = path.join("Cargo.toml"); + let p = path.join(RelPath::unix("Cargo.toml").unwrap()); if delegate.exists(&p, Some(false)) { outermost_cargo_toml = Some(Arc::from(path)); } @@ -106,161 +175,7 @@ impl ManifestProvider for CargoManifestProvider { #[async_trait(?Send)] impl LspAdapter for RustLspAdapter { fn name(&self) -> LanguageServerName { - SERVER_NAME.clone() - } - - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("Cargo.toml").into()) - } - - async fn check_if_user_installed( - &self, - delegate: &dyn LspAdapterDelegate, - _: Arc, - _: &AsyncApp, - ) -> Option { - let path = delegate.which("rust-analyzer".as_ref()).await?; - let env = delegate.shell_env().await; - - // It is surprisingly common for ~/.cargo/bin/rust-analyzer to be a symlink to - // /usr/bin/rust-analyzer that fails when you run it; so we need to test it. - log::info!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`"); - let result = delegate - .try_exec(LanguageServerBinary { - path: path.clone(), - arguments: vec!["--help".into()], - env: Some(env.clone()), - }) - .await; - if let Err(err) = result { - log::debug!( - "failed to run rust-analyzer after detecting it in PATH: binary: {:?}: {}", - path, - err - ); - return None; - } - - Some(LanguageServerBinary { - path, - env: Some(env), - arguments: vec![], - }) - } - - async fn fetch_latest_server_version( - &self, - delegate: &dyn LspAdapterDelegate, - ) -> Result> { - let release = latest_github_release( - "rust-lang/rust-analyzer", - true, - false, - delegate.http_client(), - ) - .await?; - let asset_name = Self::build_asset_name(); - let asset = release - .assets - .into_iter() - .find(|asset| asset.name == asset_name) - .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; - Ok(Box::new(GitHubLspBinaryVersion { - name: release.tag_name, - url: asset.browser_download_url, - digest: asset.digest, - })) - } - - async fn fetch_server_binary( - &self, - version: Box, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = *version.downcast::().unwrap(); - let destination_path = container_dir.join(format!("rust-analyzer-{name}")); - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. - AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe - }; - - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: Default::default(), - }; - - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) - }) - }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); - } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); - } - } else if validity_check().await.is_ok() { - return Ok(binary); - } - } - - download_server_binary( - delegate, - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - &metadata_path, - ) - .await?; - - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: Default::default(), - }) - } - - async fn cached_server_binary( - &self, - container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - get_cached_server_binary(container_dir).await + SERVER_NAME } fn disk_based_diagnostic_sources(&self) -> Vec { @@ -330,11 +245,7 @@ impl LspAdapter for RustLspAdapter { }) .unwrap_or_else(filter_range); - CodeLabel { - text, - runs, - filter_range, - } + CodeLabel::new(text, filter_range, runs) }; let mut label = match (detail_right, completion.kind) { (Some(signature), Some(lsp::CompletionItemKind::FIELD)) => { @@ -407,7 +318,7 @@ impl LspAdapter for RustLspAdapter { } else if completion .detail .as_ref() - .map_or(false, |detail| detail.starts_with("macro_rules! ")) + .is_some_and(|detail| detail.starts_with("macro_rules! ")) { let text = completion.label.clone(); let len = text.len(); @@ -485,11 +396,11 @@ impl LspAdapter for RustLspAdapter { let filter_range = prefix.len()..prefix.len() + name.len(); let display_range = 0..filter_range.end; - Some(CodeLabel { - runs: language.highlight_text(&Rope::from_iter([prefix, name, suffix]), display_range), - text: format!("{prefix}{name}"), + Some(CodeLabel::new( + format!("{prefix}{name}"), filter_range, - }) + language.highlight_text(&Rope::from_iter([prefix, name, suffix]), display_range), + )) } fn prepare_initialize_params( @@ -500,7 +411,7 @@ impl LspAdapter for RustLspAdapter { let enable_lsp_tasks = ProjectSettings::get_global(cx) .lsp .get(&SERVER_NAME) - .map_or(false, |s| s.enable_lsp_tasks); + .is_some_and(|s| s.enable_lsp_tasks); if enable_lsp_tasks { let experimental = json!({ "runnables": { @@ -514,21 +425,162 @@ impl LspAdapter for RustLspAdapter { } } - let cargo_diagnostics_fetched_separately = ProjectSettings::get_global(cx) - .diagnostics - .fetch_cargo_diagnostics(); - if cargo_diagnostics_fetched_separately { - let disable_check_on_save = json!({ - "checkOnSave": false, - }); - if let Some(initialization_options) = &mut original.initialization_options { - merge_json_value_into(disable_check_on_save, initialization_options); - } else { - original.initialization_options = Some(disable_check_on_save); + Ok(original) + } +} + +impl LspInstaller for RustLspAdapter { + type BinaryVersion = GitHubLspBinaryVersion; + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &AsyncApp, + ) -> Option { + let path = delegate.which("rust-analyzer".as_ref()).await?; + let env = delegate.shell_env().await; + + // It is surprisingly common for ~/.cargo/bin/rust-analyzer to be a symlink to + // /usr/bin/rust-analyzer that fails when you run it; so we need to test it. + log::info!("found rust-analyzer in PATH. trying to run `rust-analyzer --help`"); + let result = delegate + .try_exec(LanguageServerBinary { + path: path.clone(), + arguments: vec!["--help".into()], + env: Some(env.clone()), + }) + .await; + if let Err(err) = result { + log::debug!( + "failed to run rust-analyzer after detecting it in PATH: binary: {:?}: {}", + path, + err + ); + return None; + } + + Some(LanguageServerBinary { + path, + env: Some(env), + arguments: vec![], + }) + } + + async fn fetch_latest_server_version( + &self, + delegate: &dyn LspAdapterDelegate, + pre_release: bool, + _: &mut AsyncApp, + ) -> Result { + let release = latest_github_release( + "rust-lang/rust-analyzer", + true, + pre_release, + delegate.http_client(), + ) + .await?; + let asset_name = Self::build_asset_name().await; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == asset_name) + .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; + Ok(GitHubLspBinaryVersion { + name: release.tag_name, + url: asset.browser_download_url, + digest: asset.digest, + }) + } + + async fn fetch_server_binary( + &self, + version: GitHubLspBinaryVersion, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let GitHubLspBinaryVersion { + name, + url, + digest: expected_digest, + } = version; + let destination_path = container_dir.join(format!("rust-analyzer-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. + AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe + }; + + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: Default::default(), + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("Unable to run {server_path:?} asset, redownloading: {err}",) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); } } - Ok(original) + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: Default::default(), + }) + } + + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { + get_cached_server_binary(container_dir).await } } @@ -585,7 +637,7 @@ impl ContextProvider for RustContextProvider { if let (Some(path), Some(stem)) = (&local_abs_path, task_variables.get(&VariableName::Stem)) { - let fragment = test_fragment(&variables, &path, stem); + let fragment = test_fragment(&variables, path, stem); variables.insert(RUST_TEST_FRAGMENT_TASK_VARIABLE, fragment); }; if let Some(test_name) = @@ -602,16 +654,14 @@ impl ContextProvider for RustContextProvider { if let Some(path) = local_abs_path .as_deref() .and_then(|local_abs_path| local_abs_path.parent()) - { - if let Some(package_name) = + && let Some(package_name) = human_readable_package_name(path, project_env.as_ref()).await - { - variables.insert(RUST_PACKAGE_TASK_VARIABLE.clone(), package_name); - } + { + variables.insert(RUST_PACKAGE_TASK_VARIABLE.clone(), package_name); } if let Some(path) = local_abs_path.as_ref() && let Some((target, manifest_path)) = - target_info_from_abs_path(&path, project_env.as_ref()).await + target_info_from_abs_path(path, project_env.as_ref()).await { if let Some(target) = target { variables.extend(TaskVariables::from_iter([ @@ -647,7 +697,6 @@ impl ContextProvider for RustContextProvider { fn associated_tasks( &self, - _: Arc, file: Option>, cx: &App, ) -> Task> { @@ -665,7 +714,7 @@ impl ContextProvider for RustContextProvider { .variables .get(CUSTOM_TARGET_DIR) .cloned(); - let run_task_args = if let Some(package_to_run) = package_to_run.clone() { + let run_task_args = if let Some(package_to_run) = package_to_run { vec!["run".into(), "-p".into(), package_to_run] } else { vec!["run".into()] @@ -1025,8 +1074,8 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option path.clone(), // Tar and gzip extract in place. - AssetKind::Zip => path.clone().join("rust-analyzer.exe"), // zip contains a .exe + AssetKind::TarGz | AssetKind::Gz => path, // Tar and gzip extract in place. + AssetKind::Zip => path.join("rust-analyzer.exe"), // zip contains a .exe }; anyhow::Ok(LanguageServerBinary { @@ -1046,7 +1095,7 @@ fn test_fragment(variables: &TaskVariables, path: &Path, stem: &str) -> String { // filter out just that module. Some("--lib".to_owned()) } else if stem == "mod" { - maybe!({ Some(path.parent()?.file_name()?.to_string_lossy().to_string()) }) + maybe!({ Some(path.parent()?.file_name()?.to_string_lossy().into_owned()) }) } else if stem == "main" { if let (Some(bin_name), Some(bin_kind)) = ( variables.get(&RUST_BIN_NAME_TASK_VARIABLE), @@ -1069,7 +1118,6 @@ mod tests { use super::*; use crate::language; use gpui::{BorrowAppContext, Hsla, TestAppContext}; - use language::language_settings::AllLanguageSettings; use lsp::CompletionItemLabelDetails; use settings::SettingsStore; use theme::SyntaxTheme; @@ -1078,7 +1126,7 @@ mod tests { #[gpui::test] async fn test_process_rust_diagnostics() { let mut params = lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path(path!("/a")).unwrap(), + uri: lsp::Uri::from_file_path(path!("/a")).unwrap(), version: None, diagnostics: vec![ // no newlines @@ -1150,10 +1198,10 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "hello(&mut Option) -> Vec (use crate::foo)".to_string(), - filter_range: 0..5, - runs: vec![ + Some(CodeLabel::new( + "hello(&mut Option) -> Vec (use crate::foo)".to_string(), + 0..5, + vec![ (0..5, highlight_function), (7..10, highlight_keyword), (11..17, highlight_type), @@ -1161,7 +1209,7 @@ mod tests { (25..28, highlight_type), (29..30, highlight_type), ], - }) + )) ); assert_eq!( adapter @@ -1178,10 +1226,10 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "hello(&mut Option) -> Vec (use crate::foo)".to_string(), - filter_range: 0..5, - runs: vec![ + Some(CodeLabel::new( + "hello(&mut Option) -> Vec (use crate::foo)".to_string(), + 0..5, + vec![ (0..5, highlight_function), (7..10, highlight_keyword), (11..17, highlight_type), @@ -1189,7 +1237,7 @@ mod tests { (25..28, highlight_type), (29..30, highlight_type), ], - }) + )) ); assert_eq!( adapter @@ -1203,11 +1251,11 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "len: usize".to_string(), - filter_range: 0..3, - runs: vec![(0..3, highlight_field), (5..10, highlight_type),], - }) + Some(CodeLabel::new( + "len: usize".to_string(), + 0..3, + vec![(0..3, highlight_field), (5..10, highlight_type),], + )) ); assert_eq!( @@ -1226,10 +1274,10 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "hello(&mut Option) -> Vec (use crate::foo)".to_string(), - filter_range: 0..5, - runs: vec![ + Some(CodeLabel::new( + "hello(&mut Option) -> Vec (use crate::foo)".to_string(), + 0..5, + vec![ (0..5, highlight_function), (7..10, highlight_keyword), (11..17, highlight_type), @@ -1237,7 +1285,7 @@ mod tests { (25..28, highlight_type), (29..30, highlight_type), ], - }) + )) ); assert_eq!( @@ -1255,10 +1303,10 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "hello(&mut Option) -> Vec (use crate::foo)".to_string(), - filter_range: 0..5, - runs: vec![ + Some(CodeLabel::new( + "hello(&mut Option) -> Vec (use crate::foo)".to_string(), + 0..5, + vec![ (0..5, highlight_function), (7..10, highlight_keyword), (11..17, highlight_type), @@ -1266,7 +1314,7 @@ mod tests { (25..28, highlight_type), (29..30, highlight_type), ], - }) + )) ); assert_eq!( @@ -1285,16 +1333,16 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "await.as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), - filter_range: 6..18, - runs: vec![ + Some(CodeLabel::new( + "await.as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), + 6..18, + vec![ (6..18, HighlightId(2)), (20..23, HighlightId(1)), (33..40, HighlightId(0)), (45..46, HighlightId(0)) ], - }) + )) ); assert_eq!( @@ -1315,10 +1363,10 @@ mod tests { &language ) .await, - Some(CodeLabel { - text: "pub fn as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), - filter_range: 7..19, - runs: vec![ + Some(CodeLabel::new( + "pub fn as_deref_mut(&mut self) -> IterMut<'_, T>".to_string(), + 7..19, + vec![ (0..3, HighlightId(1)), (4..6, HighlightId(1)), (7..19, HighlightId(2)), @@ -1326,7 +1374,7 @@ mod tests { (34..41, HighlightId(0)), (46..47, HighlightId(0)) ], - }) + )) ); assert_eq!( @@ -1342,11 +1390,11 @@ mod tests { &language, ) .await, - Some(CodeLabel { - text: "inner_value: String".to_string(), - filter_range: 6..11, - runs: vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))], - }) + Some(CodeLabel::new( + "inner_value: String".to_string(), + 6..11, + vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))], + )) ); } @@ -1372,22 +1420,22 @@ mod tests { adapter .label_for_symbol("hello", lsp::SymbolKind::FUNCTION, &language) .await, - Some(CodeLabel { - text: "fn hello".to_string(), - filter_range: 3..8, - runs: vec![(0..2, highlight_keyword), (3..8, highlight_function)], - }) + Some(CodeLabel::new( + "fn hello".to_string(), + 3..8, + vec![(0..2, highlight_keyword), (3..8, highlight_function)], + )) ); assert_eq!( adapter .label_for_symbol("World", lsp::SymbolKind::TYPE_PARAMETER, &language) .await, - Some(CodeLabel { - text: "type World".to_string(), - filter_range: 5..10, - runs: vec![(0..4, highlight_keyword), (5..10, highlight_type)], - }) + Some(CodeLabel::new( + "type World".to_string(), + 5..10, + vec![(0..4, highlight_keyword), (5..10, highlight_type)], + )) ); } @@ -1399,8 +1447,8 @@ mod tests { cx.set_global(test_settings); language::init(cx); cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |s| { - s.defaults.tab_size = NonZeroU32::new(2); + store.update_user_settings(cx, |s| { + s.project.all_languages.defaults.tab_size = NonZeroU32::new(2); }); }); }); @@ -1574,7 +1622,7 @@ mod tests { let found = test_fragment( &TaskVariables::from_iter(variables.into_iter().map(|(k, v)| (k, v.to_owned()))), path, - &path.file_stem().unwrap().to_str().unwrap(), + path.file_stem().unwrap().to_str().unwrap(), ); assert_eq!(expected, found); } diff --git a/crates/languages/src/rust/config.toml b/crates/languages/src/rust/config.toml index fe8b4ffdcba4f8b7949b6fe9187d16c8504d6688..826a219e9868a3f76a063efe8c91cec0be14c2da 100644 --- a/crates/languages/src/rust/config.toml +++ b/crates/languages/src/rust/config.toml @@ -17,3 +17,5 @@ brackets = [ collapsed_placeholder = " /* ... */ " debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +ignored_import_segments = ["crate", "super"] +import_path_strip_regex = "/(lib|mod)\\.rs$" diff --git a/crates/languages/src/rust/highlights.scm b/crates/languages/src/rust/highlights.scm index 1c46061827cd504df669aadacd0a489172d1ce5a..36f638e825b117673bd88b3abaf75d0fc433f4e7 100644 --- a/crates/languages/src/rust/highlights.scm +++ b/crates/languages/src/rust/highlights.scm @@ -5,6 +5,7 @@ (primitive_type) @type.builtin (self) @variable.special (field_identifier) @property +(shorthand_field_identifier) @property (trait_item name: (type_identifier) @type.interface) (impl_item trait: (type_identifier) @type.interface) @@ -82,29 +83,20 @@ "as" "async" "await" - "break" "const" - "continue" "default" "dyn" - "else" "enum" "extern" "fn" - "for" - "if" "impl" - "in" "let" - "loop" "macro_rules!" - "match" "mod" "move" "pub" "raw" "ref" - "return" "static" "struct" "trait" @@ -113,13 +105,25 @@ "unsafe" "use" "where" - "while" - "yield" (crate) (mutable_specifier) (super) ] @keyword +[ + "break" + "continue" + "else" + "for" + "if" + "in" + "loop" + "match" + "return" + "while" + "yield" +] @keyword.control + [ (string_literal) (raw_string_literal) @@ -195,12 +199,13 @@ operator: "/" @operator (attribute_item (attribute [ (identifier) @attribute (scoped_identifier name: (identifier) @attribute) + (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) + (token_tree (identifier) @none "::" (#match? @none "^[a-z\\d_]*$")) ])) + (inner_attribute_item (attribute [ (identifier) @attribute (scoped_identifier name: (identifier) @attribute) + (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) + (token_tree (identifier) @none "::" (#match? @none "^[a-z\\d_]*$")) ])) -; Match nested snake case identifiers in attribute items. -(token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$")) -; Override the attribute match for paths in scoped type/enum identifiers. -(token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]")) diff --git a/crates/languages/src/rust/imports.scm b/crates/languages/src/rust/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..3ce6a4f073506dd4d27320a7fd5bb547927f9c1a --- /dev/null +++ b/crates/languages/src/rust/imports.scm @@ -0,0 +1,27 @@ +(use_declaration) @import + +(scoped_use_list + path: (_) @namespace + list: (_) @list) + +(scoped_identifier + path: (_) @namespace + name: (identifier) @name) + +(use_list (identifier) @name) + +(use_declaration (identifier) @name) + +(use_as_clause + path: (scoped_identifier + path: (_) @namespace + name: (_) @name) + alias: (_) @alias) + +(use_as_clause + path: (identifier) @name + alias: (_) @alias) + +(use_wildcard + (_)? @namespace + "*" @wildcard) diff --git a/crates/languages/src/rust/injections.scm b/crates/languages/src/rust/injections.scm index 1d346ac36bbfc0c015b73798202984714d954e58..20d4cf83541f9241b2e296f8dbc4a5cb7a3a5fe7 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/languages/src/rust/injections.scm @@ -1,6 +1,11 @@ +((line_comment) @injection.content + (#set! injection.language "comment")) + (macro_invocation - macro: (identifier) @_macro_name - (#not-any-of? @_macro_name "view" "html") + macro: [ + ((identifier) @_macro_name) + (scoped_identifier (identifier) @_macro_name .) + ] (token_tree) @injection.content (#set! injection.language "rust")) @@ -8,8 +13,48 @@ ; it wants to inject inside of rust, instead of modifying the rust ; injections to support leptos injections (macro_invocation - macro: (identifier) @_macro_name + macro: [ + ((identifier) @_macro_name) + (scoped_identifier (identifier) @_macro_name .) + ] (#any-of? @_macro_name "view" "html") (token_tree) @injection.content (#set! injection.language "rstml") ) + +(macro_invocation + macro: [ + ((identifier) @_macro_name) + (scoped_identifier (identifier) @_macro_name .) + ] + (#any-of? @_macro_name "sql") + (_) @injection.content + (#set! injection.language "sql") + ) + +; lazy_regex +(macro_invocation + macro: [ + ((identifier) @_macro_name) + (scoped_identifier (identifier) @_macro_name .) + ] + (token_tree [ + (string_literal (string_content) @injection.content) + (raw_string_literal (string_content) @injection.content) + ]) + (#set! injection.language "regex") + (#any-of? @_macro_name "regex" "bytes_regex") +) + +(call_expression + function: (scoped_identifier) @_fn_path + arguments: (arguments + [ + (string_literal (string_content) @injection.content) + (raw_string_literal (string_content) @injection.content) + ] + ) + + (#match? @_fn_path ".*Regex(Builder)?::new") + (#set! injection.language "regex") +) diff --git a/crates/languages/src/rust/outline.scm b/crates/languages/src/rust/outline.scm index 3012995e2a7f23f66b0c1a891789f8fbc3524e6c..a99f53dd2b3154aa3717f67fd683da4a8b57d31b 100644 --- a/crates/languages/src/rust/outline.scm +++ b/crates/languages/src/rust/outline.scm @@ -20,7 +20,7 @@ trait: (_)? @name "for"? @context type: (_) @name - body: (_ "{" @open (_)* "}" @close)) @item + body: (_ . "{" @open "}" @close .)) @item (trait_item (visibility_modifier)? @context @@ -31,7 +31,8 @@ (visibility_modifier)? @context (function_modifiers)? @context "fn" @context - name: (_) @name) @item + name: (_) @name + body: (_ . "{" @open "}" @close .)) @item (function_signature_item (visibility_modifier)? @context diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 0d647f07cf0c97969928d5f292a5101127368016..e1b50a5ccaabb7770d13abc79fbac1da5fa4cbbe 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -3,14 +3,13 @@ use async_trait::async_trait; use collections::HashMap; use futures::StreamExt; use gpui::AsyncApp; -use language::{LanguageName, LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; -use project::{Fs, lsp_store::language_server_settings}; +use project::lsp_store::language_server_settings; use serde_json::{Value, json}; use smol::fs; use std::{ - any::Any, ffi::OsString, path::{Path, PathBuf}, sync::Arc, @@ -41,16 +40,24 @@ impl TailwindLspAdapter { } } -#[async_trait(?Send)] -impl LspAdapter for TailwindLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() +impl LspInstaller for TailwindLspAdapter { + type BinaryVersion = String; + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version(Self::PACKAGE_NAME) + .await } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -63,24 +70,12 @@ impl LspAdapter for TailwindLspAdapter { }) } - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new( - self.node - .npm_package_latest_version(Self::PACKAGE_NAME) - .await?, - ) as Box<_>) - } - async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: String, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(SERVER_PATH); self.node @@ -99,11 +94,10 @@ impl LspAdapter for TailwindLspAdapter { async fn check_if_version_installed( &self, - version: &(dyn 'static + Send + Any), + version: &String, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); let server_path = container_dir.join(SERVER_PATH); let should_install_language_server = self @@ -111,7 +105,7 @@ impl LspAdapter for TailwindLspAdapter { .should_install_npm_package( Self::PACKAGE_NAME, &server_path, - &container_dir, + container_dir, VersionStrategy::Latest(version), ) .await; @@ -134,10 +128,16 @@ impl LspAdapter for TailwindLspAdapter { ) -> Option { get_cached_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for TailwindLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } async fn initialization_options( self: Arc, - _: &dyn Fs, _: &Arc, ) -> Result> { Ok(Some(json!({ @@ -146,6 +146,7 @@ impl LspAdapter for TailwindLspAdapter { "html": "html", "css": "css", "javascript": "javascript", + "typescript": "typescript", "typescriptreact": "typescriptreact", }, }))) @@ -153,9 +154,8 @@ impl LspAdapter for TailwindLspAdapter { async fn workspace_configuration( self: Arc, - _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let mut tailwind_user_settings = cx.update(|cx| { @@ -179,11 +179,13 @@ impl LspAdapter for TailwindLspAdapter { (LanguageName::new("HTML"), "html".to_string()), (LanguageName::new("CSS"), "css".to_string()), (LanguageName::new("JavaScript"), "javascript".to_string()), + (LanguageName::new("TypeScript"), "typescript".to_string()), (LanguageName::new("TSX"), "typescriptreact".to_string()), (LanguageName::new("Svelte"), "svelte".to_string()), (LanguageName::new("Elixir"), "phoenix-heex".to_string()), (LanguageName::new("HEEX"), "phoenix-heex".to_string()), (LanguageName::new("ERB"), "erb".to_string()), + (LanguageName::new("HTML+ERB"), "erb".to_string()), (LanguageName::new("HTML/ERB"), "erb".to_string()), (LanguageName::new("PHP"), "php".to_string()), (LanguageName::new("Vue.js"), "vue".to_string()), diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index 5849b9842fd7f3483f89bbedbdb7b74b3fc1572d..d0a4eb6532db621d741df2fbc99125e1c037ccdf 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -4,6 +4,7 @@ path_suffixes = ["tsx"] line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -28,6 +29,9 @@ jsx_element_node_name = "jsx_element" tag_name_node_name = "identifier" tag_name_node_name_alternates = ["member_expression"] +[overrides.default] +linked_edit_characters = ["."] + [overrides.element] line_comments = { remove = true } block_comment = { start = "{/*", prefix = "", end = "*/}", tab_size = 0 } diff --git a/crates/languages/src/tsx/debugger.scm b/crates/languages/src/tsx/debugger.scm new file mode 100644 index 0000000000000000000000000000000000000000..3e73dc839e4e5fc5ccc1654e96b327bc8181a2e8 --- /dev/null +++ b/crates/languages/src/tsx/debugger.scm @@ -0,0 +1,25 @@ +(lexical_declaration (variable_declarator name: (identifier) @debug-variable)) + +(for_in_statement left: (identifier) @debug-variable) +(for_statement initializer: (lexical_declaration (variable_declarator name: (identifier) @debug-variable))) + +(binary_expression left: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(binary_expression right: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(unary_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(update_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(return_statement (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(parenthesized_expression (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(jsx_expression (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(array (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(pair value: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(member_expression object: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(statement_block) @debug-scope +(program) @debug-scope diff --git a/crates/languages/src/tsx/highlights.scm b/crates/languages/src/tsx/highlights.scm index 5e2fbbf63ac9bce667599955c90bb5416dc29ec5..ef12b3d7913e07109e32bb5bf41909511aa2b555 100644 --- a/crates/languages/src/tsx/highlights.scm +++ b/crates/languages/src/tsx/highlights.scm @@ -171,25 +171,16 @@ "as" "async" "await" - "break" - "case" - "catch" "class" "const" - "continue" "debugger" "default" "delete" - "do" - "else" "export" "extends" - "finally" - "for" "from" "function" "get" - "if" "import" "in" "instanceof" @@ -197,23 +188,37 @@ "let" "new" "of" - "return" "satisfies" "set" "static" - "switch" "target" - "throw" - "try" "typeof" "using" "var" "void" - "while" "with" - "yield" ] @keyword +[ + "break" + "case" + "catch" + "continue" + "do" + "else" + "finally" + "for" + "if" + "return" + "switch" + "throw" + "try" + "while" + "yield" +] @keyword.control + +(switch_default "default" @keyword.control) + (template_substitution "${" @punctuation.special "}" @punctuation.special) @embedded @@ -237,6 +242,7 @@ "implements" "interface" "keyof" + "module" "namespace" "private" "protected" @@ -256,4 +262,4 @@ (jsx_closing_element ([""]) @punctuation.bracket.jsx) (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx) (jsx_attribute "=" @punctuation.delimiter.jsx) -(jsx_text) @text.jsx \ No newline at end of file +(jsx_text) @text.jsx diff --git a/crates/languages/src/tsx/imports.scm b/crates/languages/src/tsx/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..e26b97aeef9cb62395e7030f3173208d79187bd6 --- /dev/null +++ b/crates/languages/src/tsx/imports.scm @@ -0,0 +1,14 @@ +(import_statement + import_clause: (import_clause + [ + (identifier) @name + (named_imports + (import_specifier + name: (_) @name + alias: (_)? @alias)) + ]) + source: (string (string_fragment) @source)) @import + +(import_statement + !import_clause + source: (string (string_fragment) @source @wildcard)) @import diff --git a/crates/languages/src/tsx/injections.scm b/crates/languages/src/tsx/injections.scm index 48da80995bba86765e3dc78748eea6b4d5811bed..3cca9e8e81c31d3565554595456fa62be89bc81f 100644 --- a/crates/languages/src/tsx/injections.scm +++ b/crates/languages/src/tsx/injections.scm @@ -1,3 +1,7 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (((comment) @_jsdoc_comment (#match? @_jsdoc_comment "(?s)^/[*][*][^*].*[*]/$")) @injection.content (#set! injection.language "jsdoc")) @@ -11,6 +15,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string (string_fragment) @injection.content @@ -58,3 +77,9 @@ arguments: (arguments (template_string (string_fragment) @injection.content (#set! injection.language "graphql"))) ) + +(call_expression + function: (identifier) @_name(#match? @_name "^iso$") + arguments: (arguments (template_string (string_fragment) @injection.content + (#set! injection.language "isograph"))) +) diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 1877c86dc5278c7d8b5b2721125d50ae84ebbd01..334fd4c4a717d2b0a9890611ff5cc21f3d898aeb 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -5,9 +5,11 @@ use collections::HashMap; use futures::future::join_all; use gpui::{App, AppContext, AsyncApp, Task}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url}; +use http_client::github_download::download_server_binary; +use itertools::Itertools as _; use language::{ ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter, - LspAdapterDelegate, + LspAdapterDelegate, LspInstaller, Toolchain, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; @@ -15,20 +17,19 @@ use project::{Fs, lsp_store::language_server_settings}; use serde_json::{Value, json}; use smol::{fs, lock::RwLock, stream::StreamExt}; use std::{ - any::Any, borrow::Cow, ffi::OsString, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, LazyLock}, }; use task::{TaskTemplate, TaskTemplates, VariableName}; -use util::merge_json_value_into; use util::{ResultExt, fs::remove_matching, maybe}; +use util::{merge_json_value_into, rel_path::RelPath}; -use crate::{PackageJson, PackageJsonData, github_download::download_server_binary}; +use crate::{PackageJson, PackageJsonData}; -#[derive(Debug)] pub(crate) struct TypeScriptContextProvider { + fs: Arc, last_package_json: PackageJsonContents, } @@ -53,6 +54,12 @@ const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName = const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH")); +const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH")); + +const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH")); + #[derive(Clone, Debug, Default)] struct PackageJsonContents(Arc>>); @@ -219,6 +226,65 @@ impl PackageJsonData { }); } + if self.bun_package_path.is_some() { + task_templates.0.push(TaskTemplate { + label: format!("{} file test", "bun test".to_owned()), + command: "bun".to_owned(), + args: vec!["test".to_owned(), VariableName::File.template_value()], + cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()), + ..TaskTemplate::default() + }); + task_templates.0.push(TaskTemplate { + label: format!("bun test {}", VariableName::Symbol.template_value(),), + command: "bun".to_owned(), + args: vec![ + "test".to_owned(), + "--test-name-pattern".to_owned(), + format!("\"{}\"", VariableName::Symbol.template_value()), + VariableName::File.template_value(), + ], + tags: vec![ + "ts-test".to_owned(), + "js-test".to_owned(), + "tsx-test".to_owned(), + ], + cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()), + ..TaskTemplate::default() + }); + } + + if self.node_package_path.is_some() { + task_templates.0.push(TaskTemplate { + label: format!("{} file test", "node test".to_owned()), + command: "node".to_owned(), + args: vec!["--test".to_owned(), VariableName::File.template_value()], + tags: vec![ + "ts-test".to_owned(), + "js-test".to_owned(), + "tsx-test".to_owned(), + ], + cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()), + ..TaskTemplate::default() + }); + task_templates.0.push(TaskTemplate { + label: format!("node test {}", VariableName::Symbol.template_value()), + command: "node".to_owned(), + args: vec![ + "--test".to_owned(), + "--test-name-pattern".to_owned(), + format!("\"{}\"", VariableName::Symbol.template_value()), + VariableName::File.template_value(), + ], + tags: vec![ + "ts-test".to_owned(), + "js-test".to_owned(), + "tsx-test".to_owned(), + ], + cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()), + ..TaskTemplate::default() + }); + } + let script_name_counts: HashMap<_, usize> = self.scripts .iter() @@ -253,8 +319,9 @@ impl PackageJsonData { } impl TypeScriptContextProvider { - pub fn new() -> Self { + pub fn new(fs: Arc) -> Self { Self { + fs, last_package_json: PackageJsonContents::default(), } } @@ -263,12 +330,12 @@ impl TypeScriptContextProvider { &self, fs: Arc, worktree_root: &Path, - file_relative_path: &Path, + file_relative_path: &RelPath, cx: &App, ) -> Task> { let new_json_data = file_relative_path .ancestors() - .map(|path| worktree_root.join(path)) + .map(|path| worktree_root.join(path.as_std_path())) .map(|parent_path| { self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx) }) @@ -341,10 +408,10 @@ async fn detect_package_manager( fs: Arc, package_json_data: Option, ) -> &'static str { - if let Some(package_json_data) = package_json_data { - if let Some(package_manager) = package_json_data.package_manager { - return package_manager; - } + if let Some(package_json_data) = package_json_data + && let Some(package_manager) = package_json_data.package_manager + { + return package_manager; } if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await { return "pnpm"; @@ -358,7 +425,6 @@ async fn detect_package_manager( impl ContextProvider for TypeScriptContextProvider { fn associated_tasks( &self, - fs: Arc, file: Option>, cx: &App, ) -> Task> { @@ -369,8 +435,12 @@ impl ContextProvider for TypeScriptContextProvider { return Task::ready(None); }; let file_relative_path = file.path().clone(); - let package_json_data = - self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx); + let package_json_data = self.combined_package_json_data( + self.fs.clone(), + &worktree_root, + &file_relative_path, + cx, + ); cx.background_spawn(async move { let mut task_templates = TaskTemplates(Vec::new()); @@ -488,6 +558,26 @@ impl ContextProvider for TypeScriptContextProvider { .to_string(), ); } + + if let Some(path) = package_json_data.bun_package_path { + vars.insert( + TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } + + if let Some(path) = package_json_data.node_package_path { + vars.insert( + TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } } } Ok(vars) @@ -508,12 +598,13 @@ fn eslint_server_binary_arguments(server_path: &Path) -> Vec { } fn replace_test_name_parameters(test_name: &str) -> String { - let pattern = regex::Regex::new(r"(%|\$)[0-9a-zA-Z]+").unwrap(); - - regex::escape(&pattern.replace_all(test_name, "(.+?)")) + static PATTERN: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap()); + PATTERN.split(test_name).map(regex::escape).join("(.+?)") } pub struct TypeScriptLspAdapter { + fs: Arc, node: NodeRuntime, } @@ -523,12 +614,12 @@ impl TypeScriptLspAdapter { const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("typescript-language-server"); const PACKAGE_NAME: &str = "typescript"; - pub fn new(node: NodeRuntime) -> Self { - TypeScriptLspAdapter { node } + pub fn new(node: NodeRuntime, fs: Arc) -> Self { + TypeScriptLspAdapter { fs, node } } - async fn tsdk_path(fs: &dyn Fs, adapter: &Arc) -> Option<&'static str> { + async fn tsdk_path(&self, adapter: &Arc) -> Option<&'static str> { let is_yarn = adapter - .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js")) + .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap()) .await .is_ok(); @@ -538,7 +629,8 @@ impl TypeScriptLspAdapter { "node_modules/typescript/lib" }; - if fs + if self + .fs .is_dir(&adapter.worktree_root_path().join(tsdk_path)) .await { @@ -549,37 +641,35 @@ impl TypeScriptLspAdapter { } } -struct TypeScriptVersions { +pub struct TypeScriptVersions { typescript_version: String, server_version: String, } -#[async_trait(?Send)] -impl LspAdapter for TypeScriptLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } +impl LspInstaller for TypeScriptLspAdapter { + type BinaryVersion = TypeScriptVersions; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new(TypeScriptVersions { + _: bool, + _: &mut AsyncApp, + ) -> Result { + Ok(TypeScriptVersions { typescript_version: self.node.npm_package_latest_version("typescript").await?, server_version: self .node .npm_package_latest_version("typescript-language-server") .await?, - }) as Box<_>) + }) } async fn check_if_version_installed( &self, - version: &(dyn 'static + Send + Any), + version: &TypeScriptVersions, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); let server_path = container_dir.join(Self::NEW_SERVER_PATH); let should_install_language_server = self @@ -587,7 +677,7 @@ impl LspAdapter for TypeScriptLspAdapter { .should_install_npm_package( Self::PACKAGE_NAME, &server_path, - &container_dir, + container_dir, VersionStrategy::Latest(version.typescript_version.as_str()), ) .await; @@ -605,11 +695,10 @@ impl LspAdapter for TypeScriptLspAdapter { async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: TypeScriptVersions, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(Self::NEW_SERVER_PATH); self.node @@ -642,6 +731,13 @@ impl LspAdapter for TypeScriptLspAdapter { ) -> Option { get_cached_ts_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for TypeScriptLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } fn code_action_kinds(&self) -> Option> { Some(vec![ @@ -681,24 +777,18 @@ impl LspAdapter for TypeScriptLspAdapter { } else { item.label.clone() }; - let filter_range = item - .filter_text - .as_deref() - .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..len); - Some(language::CodeLabel { + Some(language::CodeLabel::filtered( text, - runs: vec![(0..len, highlight_id)], - filter_range, - }) + item.filter_text.as_deref(), + vec![(0..len, highlight_id)], + )) } async fn initialization_options( self: Arc, - fs: &dyn Fs, adapter: &Arc, ) -> Result> { - let tsdk_path = Self::tsdk_path(fs, adapter).await; + let tsdk_path = self.tsdk_path(adapter).await; Ok(Some(json!({ "provideFormatter": true, "hostInfo": "zed", @@ -720,9 +810,9 @@ impl LspAdapter for TypeScriptLspAdapter { async fn workspace_configuration( self: Arc, - _: &dyn Fs, + delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let override_options = cx.update(|cx| { @@ -809,111 +899,42 @@ impl EsLintLspAdapter { } } -#[async_trait(?Send)] -impl LspAdapter for EsLintLspAdapter { - fn code_action_kinds(&self) -> Option> { - Some(vec![ - CodeActionKind::QUICKFIX, - CodeActionKind::new("source.fixAll.eslint"), - ]) - } - - async fn workspace_configuration( - self: Arc, - _: &dyn Fs, - delegate: &Arc, - _: Arc, - cx: &mut AsyncApp, - ) -> Result { - let workspace_root = delegate.worktree_root_path(); - let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES - .iter() - .any(|file| workspace_root.join(file).is_file()); - - let mut default_workspace_configuration = json!({ - "validate": "on", - "rulesCustomizations": [], - "run": "onType", - "nodePath": null, - "workingDirectory": { - "mode": "auto" - }, - "workspaceFolder": { - "uri": workspace_root, - "name": workspace_root.file_name() - .unwrap_or(workspace_root.as_os_str()) - .to_string_lossy(), - }, - "problems": {}, - "codeActionOnSave": { - // We enable this, but without also configuring code_actions_on_format - // in the Zed configuration, it doesn't have an effect. - "enable": true, - }, - "codeAction": { - "disableRuleComment": { - "enable": true, - "location": "separateLine", - }, - "showDocumentation": { - "enable": true - } - }, - "experimental": { - "useFlatConfig": use_flat_config, - } - }); - - let override_options = cx.update(|cx| { - language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx) - .and_then(|s| s.settings.clone()) - })?; - - if let Some(override_options) = override_options { - merge_json_value_into(override_options, &mut default_workspace_configuration); - } - - Ok(json!({ - "": default_workspace_configuration - })) - } - - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } +impl LspInstaller for EsLintLspAdapter { + type BinaryVersion = GitHubLspBinaryVersion; async fn fetch_latest_server_version( &self, _delegate: &dyn LspAdapterDelegate, - ) -> Result> { + _: bool, + _: &mut AsyncApp, + ) -> Result { let url = build_asset_url( "zed-industries/vscode-eslint", Self::CURRENT_VERSION_TAG_NAME, Self::GITHUB_ASSET_KIND, )?; - Ok(Box::new(GitHubLspBinaryVersion { + Ok(GitHubLspBinaryVersion { name: Self::CURRENT_VERSION.into(), digest: None, url, - })) + }) } async fn fetch_server_binary( &self, - version: Box, + version: GitHubLspBinaryVersion, container_dir: PathBuf, delegate: &dyn LspAdapterDelegate, ) -> Result { - let version = version.downcast::().unwrap(); let destination_path = Self::build_destination_path(&container_dir); let server_path = destination_path.join(Self::SERVER_PATH); if fs::metadata(&server_path).await.is_err() { - remove_matching(&container_dir, |entry| entry != destination_path).await; + remove_matching(&container_dir, |_| true).await; download_server_binary( - delegate, + &*delegate.http_client(), &version.url, None, &destination_path, @@ -971,6 +992,79 @@ impl LspAdapter for EsLintLspAdapter { } } +#[async_trait(?Send)] +impl LspAdapter for EsLintLspAdapter { + fn code_action_kinds(&self) -> Option> { + Some(vec![ + CodeActionKind::QUICKFIX, + CodeActionKind::new("source.fixAll.eslint"), + ]) + } + + async fn workspace_configuration( + self: Arc, + delegate: &Arc, + _: Option, + cx: &mut AsyncApp, + ) -> Result { + let workspace_root = delegate.worktree_root_path(); + let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES + .iter() + .any(|file| workspace_root.join(file).is_file()); + + let mut default_workspace_configuration = json!({ + "validate": "on", + "rulesCustomizations": [], + "run": "onType", + "nodePath": null, + "workingDirectory": { + "mode": "auto" + }, + "workspaceFolder": { + "uri": workspace_root, + "name": workspace_root.file_name() + .unwrap_or(workspace_root.as_os_str()) + .to_string_lossy(), + }, + "problems": {}, + "codeActionOnSave": { + // We enable this, but without also configuring code_actions_on_format + // in the Zed configuration, it doesn't have an effect. + "enable": true, + }, + "codeAction": { + "disableRuleComment": { + "enable": true, + "location": "separateLine", + }, + "showDocumentation": { + "enable": true + } + }, + "experimental": { + "useFlatConfig": use_flat_config, + } + }); + + let override_options = cx.update(|cx| { + language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx) + .and_then(|s| s.settings.clone()) + })?; + + if let Some(override_options) = override_options { + merge_json_value_into(override_options, &mut default_workspace_configuration); + } + + Ok(json!({ + "": default_workspace_configuration + })) + } + + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } +} + #[cfg(target_os = "windows")] async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> { anyhow::ensure!( @@ -1001,9 +1095,11 @@ mod tests { use serde_json::json; use task::TaskTemplates; use unindent::Unindent; - use util::path; + use util::{path, rel_path::rel_path}; - use crate::typescript::{PackageJsonData, TypeScriptContextProvider}; + use crate::typescript::{ + PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters, + }; #[gpui::test] async fn test_outline(cx: &mut TestAppContext) { @@ -1014,7 +1110,7 @@ mod tests { let text = r#" function a() { - // local variables are omitted + // local variables are included let a1 = 1; // all functions are included async function a2() {} @@ -1028,7 +1124,7 @@ mod tests { .unindent(); let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); - let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap()); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline .items @@ -1037,6 +1133,7 @@ mod tests { .collect::>(), &[ ("function a()", 0), + ("let a1", 1), ("async function a2()", 1), ("let b", 0), ("function getB()", 0), @@ -1045,6 +1142,223 @@ mod tests { ); } + #[gpui::test] + async fn test_outline_with_destructuring(cx: &mut TestAppContext) { + let language = crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ); + + let text = r#" + // Top-level destructuring + const { a1, a2 } = a; + const [b1, b2] = b; + + // Defaults and rest + const [c1 = 1, , c2, ...rest1] = c; + const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d; + + function processData() { + // Nested object destructuring + const { c1, c2 } = c; + // Nested array destructuring + const [d1, d2, d3] = d; + // Destructuring with renaming + const { f1: g1 } = f; + // With defaults + const [x = 10, y] = xy; + } + + class DataHandler { + method() { + // Destructuring in class method + const { a1, a2 } = a; + const [b1, ...b2] = b; + } + } + "# + .unindent(); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); + assert_eq!( + outline + .items + .iter() + .map(|item| (item.text.as_str(), item.depth)) + .collect::>(), + &[ + ("const a1", 0), + ("const a2", 0), + ("const b1", 0), + ("const b2", 0), + ("const c1", 0), + ("const c2", 0), + ("const rest1", 0), + ("const d1", 0), + ("const e1", 0), + ("const h1", 0), + ("const rest2", 0), + ("function processData()", 0), + ("const c1", 1), + ("const c2", 1), + ("const d1", 1), + ("const d2", 1), + ("const d3", 1), + ("const g1", 1), + ("const x", 1), + ("const y", 1), + ("class DataHandler", 0), + ("method()", 1), + ("const a1", 2), + ("const a2", 2), + ("const b1", 2), + ("const b2", 2), + ] + ); + } + + #[gpui::test] + async fn test_outline_with_object_properties(cx: &mut TestAppContext) { + let language = crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ); + + let text = r#" + // Object with function properties + const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} }; + + // Object with primitive properties + const p = { p1: 1, p2: "hello", p3: true }; + + // Nested objects + const q = { + r: { + // won't be included due to one-level depth limit + s: 1 + }, + t: 2 + }; + + function getData() { + const local = { x: 1, y: 2 }; + return local; + } + "# + .unindent(); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); + assert_eq!( + outline + .items + .iter() + .map(|item| (item.text.as_str(), item.depth)) + .collect::>(), + &[ + ("const o", 0), + ("m()", 1), + ("async n()", 1), + ("g", 1), + ("h", 1), + ("k", 1), + ("const p", 0), + ("p1", 1), + ("p2", 1), + ("p3", 1), + ("const q", 0), + ("r", 1), + ("s", 2), + ("t", 1), + ("function getData()", 0), + ("const local", 1), + ("x", 2), + ("y", 2), + ] + ); + } + + #[gpui::test] + async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) { + let language = crate::language( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ); + + let text = r#" + // Symbols as object keys + const sym = Symbol("test"); + const obj1 = { + [sym]: 1, + [Symbol("inline")]: 2, + normalKey: 3 + }; + + // Enums as object keys + enum Color { Red, Blue, Green } + + const obj2 = { + [Color.Red]: "red value", + [Color.Blue]: "blue value", + regularProp: "normal" + }; + + // Mixed computed properties + const key = "dynamic"; + const obj3 = { + [key]: 1, + ["string" + "concat"]: 2, + [1 + 1]: 3, + static: 4 + }; + + // Nested objects with computed properties + const obj4 = { + [sym]: { + nested: 1 + }, + regular: { + [key]: 2 + } + }; + "# + .unindent(); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); + assert_eq!( + outline + .items + .iter() + .map(|item| (item.text.as_str(), item.depth)) + .collect::>(), + &[ + ("const sym", 0), + ("const obj1", 0), + ("[sym]", 1), + ("[Symbol(\"inline\")]", 1), + ("normalKey", 1), + ("enum Color", 0), + ("const obj2", 0), + ("[Color.Red]", 1), + ("[Color.Blue]", 1), + ("regularProp", 1), + ("const key", 0), + ("const obj3", 0), + ("[key]", 1), + ("[\"string\" + \"concat\"]", 1), + ("[1 + 1]", 1), + ("static", 1), + ("const obj4", 0), + ("[sym]", 1), + ("nested", 2), + ("regular", 1), + ("[key]", 2), + ] + ); + } + #[gpui::test] async fn test_generator_function_outline(cx: &mut TestAppContext) { let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()); @@ -1082,7 +1396,7 @@ mod tests { .unindent(); let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx)); - let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap()); + let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( outline .items @@ -1143,13 +1457,13 @@ mod tests { ) .await; - let provider = TypeScriptContextProvider::new(); + let provider = TypeScriptContextProvider::new(fs.clone()); let package_json_data = cx .update(|cx| { provider.combined_package_json_data( fs.clone(), path!("/root").as_ref(), - "sub/file1.js".as_ref(), + rel_path("sub/file1.js"), cx, ) }) @@ -1162,6 +1476,8 @@ mod tests { mocha_package_path: Some(Path::new(path!("/root/package.json")).into()), vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()), jasmine_package_path: None, + bun_package_path: None, + node_package_path: None, scripts: [ ( Path::new(path!("/root/package.json")).into(), @@ -1215,4 +1531,144 @@ mod tests { ] ); } + + #[test] + fn test_escaping_name() { + let cases = [ + ("plain test name", "plain test name"), + ("test name with $param_name", "test name with (.+?)"), + ("test name with $nested.param.name", "test name with (.+?)"), + ("test name with $#", "test name with (.+?)"), + ("test name with $##", "test name with (.+?)\\#"), + ("test name with %p", "test name with (.+?)"), + ("test name with %s", "test name with (.+?)"), + ("test name with %d", "test name with (.+?)"), + ("test name with %i", "test name with (.+?)"), + ("test name with %f", "test name with (.+?)"), + ("test name with %j", "test name with (.+?)"), + ("test name with %o", "test name with (.+?)"), + ("test name with %#", "test name with (.+?)"), + ("test name with %$", "test name with (.+?)"), + ("test name with %%", "test name with (.+?)"), + ("test name with %q", "test name with %q"), + ( + "test name with regex chars .*+?^${}()|[]\\", + "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\", + ), + ( + "test name with multiple $params and %pretty and %b and (.+?)", + "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)", + ), + ]; + + for (input, expected) in cases { + assert_eq!(replace_test_name_parameters(input), expected); + } + } + + // The order of test runner tasks is based on inferred user preference: + // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized. + // 2. Bun's built-in test runner (`bun test`) comes next. + // 3. Node.js's built-in test runner (`node --test`) is last. + // This hierarchy assumes that if a dedicated test framework is installed, it is the + // preferred testing mechanism. Between runtime-specific options, `bun test` is + // typically preferred over `node --test` when @types/bun is present. + #[gpui::test] + async fn test_task_ordering_with_multiple_test_runners( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + settings::init(cx); + Project::init_settings(cx); + language_settings::init(cx); + }); + + // Test case with all test runners present + let package_json_all_runners = json!({ + "devDependencies": { + "@types/bun": "1.0.0", + "@types/node": "^20.0.0", + "jest": "29.0.0", + "mocha": "10.0.0", + "vitest": "1.0.0", + "jasmine": "5.0.0", + }, + "scripts": { + "test": "jest" + } + }) + .to_string(); + + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/root"), + json!({ + "package.json": package_json_all_runners, + "file.js": "", + }), + ) + .await; + + let provider = TypeScriptContextProvider::new(fs.clone()); + + let package_json_data = cx + .update(|cx| { + provider.combined_package_json_data( + fs.clone(), + path!("/root").as_ref(), + rel_path("file.js"), + cx, + ) + }) + .await + .unwrap(); + + assert!(package_json_data.jest_package_path.is_some()); + assert!(package_json_data.mocha_package_path.is_some()); + assert!(package_json_data.vitest_package_path.is_some()); + assert!(package_json_data.jasmine_package_path.is_some()); + assert!(package_json_data.bun_package_path.is_some()); + assert!(package_json_data.node_package_path.is_some()); + + let mut task_templates = TaskTemplates::default(); + package_json_data.fill_task_templates(&mut task_templates); + + let test_tasks: Vec<_> = task_templates + .0 + .iter() + .filter(|template| { + template.tags.contains(&"ts-test".to_owned()) + || template.tags.contains(&"js-test".to_owned()) + }) + .map(|template| &template.label) + .collect(); + + let node_test_index = test_tasks + .iter() + .position(|label| label.contains("node test")); + let jest_test_index = test_tasks.iter().position(|label| label.contains("jest")); + let bun_test_index = test_tasks + .iter() + .position(|label| label.contains("bun test")); + + assert!( + node_test_index.is_some(), + "Node test tasks should be present" + ); + assert!( + jest_test_index.is_some(), + "Jest test tasks should be present" + ); + assert!(bun_test_index.is_some(), "Bun test tasks should be present"); + + assert!( + jest_test_index.unwrap() < bun_test_index.unwrap(), + "Jest should come before Bun" + ); + assert!( + bun_test_index.unwrap() < node_test_index.unwrap(), + "Bun should come before Node" + ); + } } diff --git a/crates/languages/src/typescript/config.toml b/crates/languages/src/typescript/config.toml index d7e3e4bd3d1569f96636b7f7572deea306b46df7..67656e6a538da6c8860e9ab1b08fd6e6ee9cabbd 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/languages/src/typescript/config.toml @@ -5,6 +5,7 @@ first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } +wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "" } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -20,9 +21,12 @@ word_characters = ["#", "$"] prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] +scope_opt_in_language_servers = ["tailwindcss-language-server"] +import_path_strip_regex = "(?:/index)?\\.[jt]s$" [overrides.string] -completion_query_characters = ["."] +completion_query_characters = ["-", "."] +opt_into_language_servers = ["tailwindcss-language-server"] prefer_label_for_snippet = true [overrides.function_name_before_type_arguments] diff --git a/crates/languages/src/typescript/debugger.scm b/crates/languages/src/typescript/debugger.scm new file mode 100644 index 0000000000000000000000000000000000000000..a99f194a4a4130210b47f8170fca039acc163411 --- /dev/null +++ b/crates/languages/src/typescript/debugger.scm @@ -0,0 +1,23 @@ +(lexical_declaration (variable_declarator name: (identifier) @debug-variable)) + +(for_in_statement left: (identifier) @debug-variable) +(for_statement initializer: (lexical_declaration (variable_declarator name: (identifier) @debug-variable))) + +(binary_expression left: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(binary_expression right: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(unary_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) +(update_expression argument: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(return_statement (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(parenthesized_expression (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(array (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(pair value: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(member_expression object: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]")) + +(statement_block) @debug-scope +(program) @debug-scope diff --git a/crates/languages/src/typescript/highlights.scm b/crates/languages/src/typescript/highlights.scm index af37ef6415ba501c1623977c04b7a7b7d110eeb5..8a85dfea07fe4f50cb271f65ec1bdeeaf2ea150c 100644 --- a/crates/languages/src/typescript/highlights.scm +++ b/crates/languages/src/typescript/highlights.scm @@ -218,27 +218,18 @@ "as" "async" "await" - "break" - "case" - "catch" "class" "const" - "continue" "debugger" "declare" "default" "delete" - "do" - "else" "enum" "export" "extends" - "finally" - "for" "from" "function" "get" - "if" "implements" "import" "in" @@ -248,6 +239,7 @@ "is" "keyof" "let" + "module" "namespace" "new" "of" @@ -256,20 +248,34 @@ "protected" "public" "readonly" - "return" "satisfies" "set" "static" - "switch" "target" - "throw" - "try" "type" "typeof" "using" "var" "void" - "while" "with" +] @keyword + +[ + "break" + "case" + "catch" + "continue" + "do" + "else" + "finally" + "for" + "if" + "return" + "switch" + "throw" + "try" + "while" "yield" -] @keyword \ No newline at end of file +] @keyword.control + +(switch_default "default" @keyword.control) diff --git a/crates/languages/src/typescript/imports.scm b/crates/languages/src/typescript/imports.scm new file mode 100644 index 0000000000000000000000000000000000000000..68ca25b2c15b7e312edbc3eeb9b2f0e493ca2d6f --- /dev/null +++ b/crates/languages/src/typescript/imports.scm @@ -0,0 +1,20 @@ +(import_statement + import_clause: (import_clause + [ + (identifier) @name + (named_imports + (import_specifier + name: (_) @name + alias: (_)? @alias)) + (namespace_import) @wildcard + ]) + source: (string (string_fragment) @source)) @import + +(import_statement + !source + import_clause: (import_require_clause + source: (string (string_fragment) @source))) @wildcard @import + +(import_statement + !import_clause + source: (string (string_fragment) @source)) @wildcard @import diff --git a/crates/languages/src/typescript/injections.scm b/crates/languages/src/typescript/injections.scm index 7affdc5b758deb5ff717476f0de934a1786469aa..5321e606c118a41df127c8aa37c7c2811dc8bd23 100644 --- a/crates/languages/src/typescript/injections.scm +++ b/crates/languages/src/typescript/injections.scm @@ -1,9 +1,13 @@ +((comment) @injection.content + (#set! injection.language "comment") +) + (((comment) @_jsdoc_comment (#match? @_jsdoc_comment "(?s)^/[*][*][^*].*[*]/$")) @injection.content (#set! injection.language "jsdoc")) -(((comment) @reference - (#match? @reference "^///\\s+\\s*$")) @injection.content +(((comment) @_reference + (#match? @_reference "^///\\s+\\s*$")) @injection.content (#set! injection.language "html")) ((regex) @injection.content @@ -15,6 +19,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string) @injection.content @@ -63,6 +82,12 @@ (#set! injection.language "graphql"))) ) +(call_expression + function: (identifier) @_name(#match? @_name "^iso$") + arguments: (arguments (template_string (string_fragment) @injection.content + (#set! injection.language "isograph"))) +) + ;; Angular Component template injection (call_expression function: [ diff --git a/crates/languages/src/typescript/outline.scm b/crates/languages/src/typescript/outline.scm index f4261b9697d376f517b717bc942387190e0b6dde..54d29007c7b7eb57c0bcaefc2c1e0ab75e4d9a6c 100644 --- a/crates/languages/src/typescript/outline.scm +++ b/crates/languages/src/typescript/outline.scm @@ -34,18 +34,64 @@ (export_statement (lexical_declaration ["let" "const"] @context - ; Multiple names may be exported - @item is on the declarator to keep - ; ranges distinct. (variable_declarator - name: (_) @name) @item)) + name: (identifier) @name) @item)) +; Exported array destructuring +(export_statement + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ])))) + +; Exported object destructuring +(export_statement + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern + value: (identifier) @name @item) + (pair_pattern + value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)])))) + +(program + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (identifier) @name) @item)) + +; Top-level array destructuring (program (lexical_declaration ["let" "const"] @context - ; Multiple names may be defined - @item is on the declarator to keep - ; ranges distinct. (variable_declarator - name: (_) @name) @item)) + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ])))) + +; Top-level object destructuring +(program + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern + value: (identifier) @name @item) + (pair_pattern + value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)])))) (class_declaration "class" @context @@ -56,21 +102,38 @@ "class" @context name: (_) @name) @item -(method_definition - [ - "get" - "set" - "async" - "*" - "readonly" - "static" - (override_modifier) - (accessibility_modifier) - ]* @context - name: (_) @name - parameters: (formal_parameters - "(" @context - ")" @context)) @item +; Method definitions in classes (not in object literals) +(class_body + (method_definition + [ + "get" + "set" + "async" + "*" + "readonly" + "static" + (override_modifier) + (accessibility_modifier) + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item) + +; Object literal methods +(variable_declarator + value: (object + (method_definition + [ + "get" + "set" + "async" + "*" + ]* @context + name: (_) @name + parameters: (formal_parameters + "(" @context + ")" @context)) @item)) (public_field_definition [ @@ -124,4 +187,44 @@ ) ) @item +; Object properties +(pair + key: [ + (property_identifier) @name + (string (string_fragment) @name) + (number) @name + (computed_property_name) @name + ]) @item + + +; Nested variables in function bodies +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (identifier) @name) @item)) + +; Nested array destructuring in functions +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (array_pattern + [ + (identifier) @name @item + (assignment_pattern left: (identifier) @name @item) + (rest_pattern (identifier) @name @item) + ])))) + +; Nested object destructuring in functions +(statement_block + (lexical_declaration + ["let" "const"] @context + (variable_declarator + name: (object_pattern + [(shorthand_property_identifier_pattern) @name @item + (pair_pattern value: (identifier) @name @item) + (pair_pattern value: (assignment_pattern left: (identifier) @name @item)) + (rest_pattern (identifier) @name @item)])))) + (comment) @annotation diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 90faf883ba8b20016ec5b614d03de39ccb3a94e8..8cbb9f307f6f4222e0e9a65fe2a6954f97fc7f21 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -2,13 +2,12 @@ use anyhow::Result; use async_trait::async_trait; use collections::HashMap; use gpui::AsyncApp; -use language::{LanguageName, LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; use serde_json::Value; use std::{ - any::Any, ffi::OsString, path::{Path, PathBuf}, sync::Arc, @@ -21,6 +20,7 @@ fn typescript_server_binary_arguments(server_path: &Path) -> Vec { pub struct VtslsLspAdapter { node: NodeRuntime, + fs: Arc, } impl VtslsLspAdapter { @@ -29,24 +29,25 @@ impl VtslsLspAdapter { const TYPESCRIPT_PACKAGE_NAME: &'static str = "typescript"; const TYPESCRIPT_TSDK_PATH: &'static str = "node_modules/typescript/lib"; + const TYPESCRIPT_YARN_TSDK_PATH: &'static str = ".yarn/sdks/typescript/lib"; - pub fn new(node: NodeRuntime) -> Self { - VtslsLspAdapter { node } + pub fn new(node: NodeRuntime, fs: Arc) -> Self { + VtslsLspAdapter { node, fs } } - async fn tsdk_path(fs: &dyn Fs, adapter: &Arc) -> Option<&'static str> { - let is_yarn = adapter - .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js")) - .await - .is_ok(); + async fn tsdk_path(&self, adapter: &Arc) -> Option<&'static str> { + let yarn_sdk = adapter + .worktree_root_path() + .join(Self::TYPESCRIPT_YARN_TSDK_PATH); - let tsdk_path = if is_yarn { - ".yarn/sdks/typescript/lib" + let tsdk_path = if self.fs.is_dir(&yarn_sdk).await { + Self::TYPESCRIPT_YARN_TSDK_PATH } else { Self::TYPESCRIPT_TSDK_PATH }; - if fs + if self + .fs .is_dir(&adapter.worktree_root_path().join(tsdk_path)) .await { @@ -57,36 +58,35 @@ impl VtslsLspAdapter { } } -struct TypeScriptVersions { +pub struct TypeScriptVersions { typescript_version: String, server_version: String, } const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vtsls"); -#[async_trait(?Send)] -impl LspAdapter for VtslsLspAdapter { - fn name(&self) -> LanguageServerName { - SERVER_NAME.clone() - } +impl LspInstaller for VtslsLspAdapter { + type BinaryVersion = TypeScriptVersions; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new(TypeScriptVersions { + _: bool, + _: &mut AsyncApp, + ) -> Result { + Ok(TypeScriptVersions { typescript_version: self.node.npm_package_latest_version("typescript").await?, server_version: self .node .npm_package_latest_version("@vtsls/language-server") .await?, - }) as Box<_>) + }) } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let env = delegate.shell_env().await; @@ -100,11 +100,10 @@ impl LspAdapter for VtslsLspAdapter { async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: TypeScriptVersions, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(Self::SERVER_PATH); let mut packages_to_install = Vec::new(); @@ -156,6 +155,13 @@ impl LspAdapter for VtslsLspAdapter { ) -> Option { get_cached_ts_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for VtslsLspAdapter { + fn name(&self) -> LanguageServerName { + SERVER_NAME + } fn code_action_kinds(&self) -> Option> { Some(vec![ @@ -195,26 +201,20 @@ impl LspAdapter for VtslsLspAdapter { } else { item.label.clone() }; - let filter_range = item - .filter_text - .as_deref() - .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..len); - Some(language::CodeLabel { + Some(language::CodeLabel::filtered( text, - runs: vec![(0..len, highlight_id)], - filter_range, - }) + item.filter_text.as_deref(), + vec![(0..len, highlight_id)], + )) } async fn workspace_configuration( self: Arc, - fs: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { - let tsdk_path = Self::tsdk_path(fs, delegate).await; + let tsdk_path = self.tsdk_path(delegate).await; let config = serde_json::json!({ "tsdk": tsdk_path, "suggest": { diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 15a4d590bc2fcd13f611b8afdbf189b1b76b1eb9..45faa142369e6c08817deebfbf8774f228bf70d5 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -3,21 +3,20 @@ use async_trait::async_trait; use futures::StreamExt; use gpui::AsyncApp; use language::{ - LanguageToolchainStore, LspAdapter, LspAdapterDelegate, language_settings::AllLanguageSettings, + LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain, language_settings::AllLanguageSettings, }; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; -use project::{Fs, lsp_store::language_server_settings}; +use project::lsp_store::language_server_settings; use serde_json::Value; use settings::{Settings, SettingsLocation}; use smol::fs; use std::{ - any::Any, ffi::OsString, path::{Path, PathBuf}, sync::Arc, }; -use util::{ResultExt, maybe, merge_json_value_into}; +use util::{ResultExt, maybe, merge_json_value_into, rel_path::RelPath}; const SERVER_PATH: &str = "node_modules/yaml-language-server/bin/yaml-language-server"; @@ -37,27 +36,24 @@ impl YamlLspAdapter { } } -#[async_trait(?Send)] -impl LspAdapter for YamlLspAdapter { - fn name(&self) -> LanguageServerName { - Self::SERVER_NAME.clone() - } +impl LspInstaller for YamlLspAdapter { + type BinaryVersion = String; async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new( - self.node - .npm_package_latest_version("yaml-language-server") - .await?, - ) as Box<_>) + _: bool, + _: &mut AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version("yaml-language-server") + .await } async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -72,11 +68,10 @@ impl LspAdapter for YamlLspAdapter { async fn fetch_server_binary( &self, - latest_version: Box, + latest_version: String, container_dir: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { - let latest_version = latest_version.downcast::().unwrap(); let server_path = container_dir.join(SERVER_PATH); self.node @@ -95,11 +90,10 @@ impl LspAdapter for YamlLspAdapter { async fn check_if_version_installed( &self, - version: &(dyn 'static + Send + Any), + version: &String, container_dir: &PathBuf, _: &dyn LspAdapterDelegate, ) -> Option { - let version = version.downcast_ref::().unwrap(); let server_path = container_dir.join(SERVER_PATH); let should_install_language_server = self @@ -107,7 +101,7 @@ impl LspAdapter for YamlLspAdapter { .should_install_npm_package( Self::PACKAGE_NAME, &server_path, - &container_dir, + container_dir, VersionStrategy::Latest(version), ) .await; @@ -130,17 +124,24 @@ impl LspAdapter for YamlLspAdapter { ) -> Option { get_cached_server_binary(container_dir, &self.node).await } +} + +#[async_trait(?Send)] +impl LspAdapter for YamlLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME + } async fn workspace_configuration( self: Arc, - _: &dyn Fs, + delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let location = SettingsLocation { worktree_id: delegate.worktree_id(), - path: delegate.worktree_root_path(), + path: RelPath::empty(), }; let tab_size = cx.update(|cx| { diff --git a/crates/languages/src/yaml/injections.scm b/crates/languages/src/yaml/injections.scm new file mode 100644 index 0000000000000000000000000000000000000000..9117c713b98fdd2896b13e4949a77c6489b9ee36 --- /dev/null +++ b/crates/languages/src/yaml/injections.scm @@ -0,0 +1,3 @@ +((comment) @injection.content + (#set! injection.language "comment") +) diff --git a/crates/languages/src/yaml/overrides.scm b/crates/languages/src/yaml/overrides.scm new file mode 100644 index 0000000000000000000000000000000000000000..9503051a62080eb2fdfca3416ef9e5286464dd17 --- /dev/null +++ b/crates/languages/src/yaml/overrides.scm @@ -0,0 +1,5 @@ +(comment) @comment.inclusive +[ + (single_quote_scalar) + (double_quote_scalar) +] @string diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/languages/src/yaml/textobjects.scm index 5262b7e232edcd7e44b02730163c675d3d84415c..81fd20245b93cddfd2aaa899462f9afa4dc87046 100644 --- a/crates/languages/src/yaml/textobjects.scm +++ b/crates/languages/src/yaml/textobjects.scm @@ -1 +1 @@ -(comment)+ @comment +(comment)+ @comment.around diff --git a/crates/jj_ui/Cargo.toml b/crates/line_ending_selector/Cargo.toml similarity index 56% rename from crates/jj_ui/Cargo.toml rename to crates/line_ending_selector/Cargo.toml index 34dac76db11bf9cae6a4277ae8fff58d073e19be..462404b150b4e9862662fc76a5b7170def19f404 100644 --- a/crates/jj_ui/Cargo.toml +++ b/crates/line_ending_selector/Cargo.toml @@ -1,25 +1,23 @@ [package] -name = "jj_ui" +name = "line_ending_selector" version = "0.1.0" -publish.workspace = true edition.workspace = true +publish.workspace = true license = "GPL-3.0-or-later" [lints] workspace = true [lib] -path = "src/jj_ui.rs" +path = "src/line_ending_selector.rs" +doctest = false [dependencies] -command_palette_hooks.workspace = true -feature_flags.workspace = true -fuzzy.workspace = true +editor.workspace = true gpui.workspace = true -jj.workspace = true +language.workspace = true picker.workspace = true +project.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true workspace.workspace = true -zed_actions.workspace = true diff --git a/crates/line_ending_selector/LICENSE-GPL b/crates/line_ending_selector/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/line_ending_selector/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/line_ending_selector/src/line_ending_indicator.rs b/crates/line_ending_selector/src/line_ending_indicator.rs new file mode 100644 index 0000000000000000000000000000000000000000..ee858d706b3a8152c868a5bd629c112a4d1b225f --- /dev/null +++ b/crates/line_ending_selector/src/line_ending_indicator.rs @@ -0,0 +1,68 @@ +use editor::Editor; +use gpui::{Entity, Subscription, WeakEntity}; +use language::LineEnding; +use ui::{Tooltip, prelude::*}; +use workspace::{StatusBarSettings, StatusItemView, item::ItemHandle, item::Settings}; + +use crate::{LineEndingSelector, Toggle}; + +#[derive(Default)] +pub struct LineEndingIndicator { + line_ending: Option, + active_editor: Option>, + _observe_active_editor: Option, +} + +impl LineEndingIndicator { + fn update(&mut self, editor: Entity, _: &mut Window, cx: &mut Context) { + self.line_ending = None; + self.active_editor = None; + + if let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx) { + let line_ending = buffer.read(cx).line_ending(); + self.line_ending = Some(line_ending); + self.active_editor = Some(editor.downgrade()); + } + + cx.notify(); + } +} + +impl Render for LineEndingIndicator { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !StatusBarSettings::get_global(cx).line_endings_button { + return div(); + } + + div().when_some(self.line_ending.as_ref(), |el, line_ending| { + el.child( + Button::new("change-line-ending", line_ending.label()) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(editor) = this.active_editor.as_ref() { + LineEndingSelector::toggle(editor, window, cx); + } + })) + .tooltip(|_window, cx| Tooltip::for_action("Select Line Ending", &Toggle, cx)), + ) + }) + } +} + +impl StatusItemView for LineEndingIndicator { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { + self._observe_active_editor = Some(cx.observe_in(&editor, window, Self::update)); + self.update(editor, window, cx); + } else { + self.line_ending = None; + self._observe_active_editor = None; + } + cx.notify(); + } +} diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs new file mode 100644 index 0000000000000000000000000000000000000000..504c327a349c97214e801f6bd375d61c7847f2be --- /dev/null +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -0,0 +1,192 @@ +mod line_ending_indicator; + +use editor::Editor; +use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, actions}; +use language::{Buffer, LineEnding}; +pub use line_ending_indicator::LineEndingIndicator; +use picker::{Picker, PickerDelegate}; +use project::Project; +use std::sync::Arc; +use ui::{ListItem, ListItemSpacing, prelude::*}; +use util::ResultExt; +use workspace::ModalView; + +actions!( + line_ending_selector, + [ + /// Toggles the line ending selector modal. + Toggle + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(LineEndingSelector::register).detach(); +} + +pub struct LineEndingSelector { + picker: Entity>, +} + +impl LineEndingSelector { + fn register(editor: &mut Editor, _window: Option<&mut Window>, cx: &mut Context) { + let editor_handle = cx.weak_entity(); + editor + .register_action(move |_: &Toggle, window, cx| { + Self::toggle(&editor_handle, window, cx); + }) + .detach(); + } + + fn toggle(editor: &WeakEntity, window: &mut Window, cx: &mut App) { + let Some((workspace, buffer)) = editor + .update(cx, |editor, cx| { + Some((editor.workspace()?, editor.active_excerpt(cx)?.1)) + }) + .ok() + .flatten() + else { + return; + }; + + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + workspace.toggle_modal(window, cx, move |window, cx| { + LineEndingSelector::new(buffer, project, window, cx) + }); + }) + } + + fn new( + buffer: Entity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let line_ending = buffer.read(cx).line_ending(); + let delegate = + LineEndingSelectorDelegate::new(cx.entity().downgrade(), buffer, project, line_ending); + let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx)); + Self { picker } + } +} + +impl Render for LineEndingSelector { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + v_flex().w(rems(34.)).child(self.picker.clone()) + } +} + +impl Focusable for LineEndingSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for LineEndingSelector {} +impl ModalView for LineEndingSelector {} + +struct LineEndingSelectorDelegate { + line_ending_selector: WeakEntity, + buffer: Entity, + project: Entity, + line_ending: LineEnding, + matches: Vec, + selected_index: usize, +} + +impl LineEndingSelectorDelegate { + fn new( + line_ending_selector: WeakEntity, + buffer: Entity, + project: Entity, + line_ending: LineEnding, + ) -> Self { + Self { + line_ending_selector, + buffer, + project, + line_ending, + matches: vec![LineEnding::Unix, LineEnding::Windows], + selected_index: 0, + } + } +} + +impl PickerDelegate for LineEndingSelectorDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a line ending…".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + if let Some(line_ending) = self.matches.get(self.selected_index) { + self.buffer.update(cx, |this, cx| { + this.set_line_ending(*line_ending, cx); + }); + let buffer = self.buffer.clone(); + let project = self.project.clone(); + cx.defer(move |cx| { + project.update(cx, |this, cx| { + this.save_buffer(buffer, cx).detach(); + }); + }); + } + self.dismissed(window, cx); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.line_ending_selector + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + _query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> gpui::Task<()> { + return Task::ready(()); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let line_ending = self.matches.get(ix)?; + let label = line_ending.label(); + + let mut list_item = ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(Label::new(label)); + + if &self.line_ending == line_ending { + list_item = list_item.end_slot(Icon::new(IconName::Check).color(Color::Muted)); + } + + Some(list_item) + } +} diff --git a/crates/livekit_api/Cargo.toml b/crates/livekit_api/Cargo.toml index 6835ec4d561546c30ded4bb3d1b8c8e027ccf139..421deee113e7d1e4967c268f58ccc132d0284b01 100644 --- a/crates/livekit_api/Cargo.toml +++ b/crates/livekit_api/Cargo.toml @@ -22,7 +22,6 @@ prost.workspace = true prost-types.workspace = true reqwest.workspace = true serde.workspace = true -workspace-hack.workspace = true [build-dependencies] prost-build.workspace = true diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index 58059967b7ab509fd91209a4a0f9873bbbb6b87d..a7766b5ba5b857e0ec46733efb1105c938f63719 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -22,6 +22,7 @@ test-support = ["collections/test-support", "gpui/test-support"] [dependencies] anyhow.workspace = true async-trait.workspace = true +audio.workspace = true collections.workspace = true cpal.workspace = true futures.workspace = true @@ -34,12 +35,14 @@ log.workspace = true nanoid.workspace = true parking_lot.workspace = true postage.workspace = true +rodio.workspace = true +serde.workspace = true +serde_urlencoded.workspace = true +settings.workspace = true smallvec.workspace = true tokio-tungstenite.workspace = true +ui.workspace = true util.workspace = true -workspace-hack.workspace = true - -rodio = { workspace = true, features = ["wav_output"] } [target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies] libwebrtc = { rev = "5f04705ac3f356350ae31534ffbc476abc9ea83d", git = "https://github.com/zed-industries/livekit-rust-sdks" } diff --git a/crates/livekit_client/examples/test_app.rs b/crates/livekit_client/examples/test_app.rs index e1d01df534e142502abb5f17392e19299f8ae158..c99abb292ef6d99e8adc3ab9007f4c49eeb05be2 100644 --- a/crates/livekit_client/examples/test_app.rs +++ b/crates/livekit_client/examples/test_app.rs @@ -159,14 +159,14 @@ impl LivekitWindow { if output .audio_output_stream .as_ref() - .map_or(false, |(track, _)| track.sid() == unpublish_sid) + .is_some_and(|(track, _)| track.sid() == unpublish_sid) { output.audio_output_stream.take(); } if output .screen_share_output_view .as_ref() - .map_or(false, |(track, _)| track.sid() == unpublish_sid) + .is_some_and(|(track, _)| track.sid() == unpublish_sid) { output.screen_share_output_view.take(); } @@ -183,7 +183,7 @@ impl LivekitWindow { match track { livekit_client::RemoteTrack::Audio(track) => { output.audio_output_stream = Some(( - publication.clone(), + publication, room.play_remote_audio_track(&track, cx).unwrap(), )); } @@ -255,7 +255,10 @@ impl LivekitWindow { } else { let room = self.room.clone(); cx.spawn_in(window, async move |this, cx| { - let (publication, stream) = room.publish_local_microphone_track(cx).await.unwrap(); + let (publication, stream) = room + .publish_local_microphone_track("test_user".to_string(), false, cx) + .await + .unwrap(); this.update(cx, |this, cx| { this.microphone_track = Some(publication); this.microphone_stream = Some(stream); diff --git a/crates/livekit_client/src/lib.rs b/crates/livekit_client/src/lib.rs index e3934410e1e59a110d634585003a97c587f80912..055aa3704e06f25a21c69294343539289d8acb49 100644 --- a/crates/livekit_client/src/lib.rs +++ b/crates/livekit_client/src/lib.rs @@ -24,8 +24,11 @@ mod livekit_client; )))] pub use livekit_client::*; -// If you need proper LSP in livekit_client you've got to comment out -// the mocks and test +// If you need proper LSP in livekit_client you've got to comment +// - the cfg blocks above +// - the mods: mock_client & test and their conditional blocks +// - the pub use mock_client::* and their conditional blocks + #[cfg(any( test, feature = "test-support", diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs index adeea4f51279ece93160d604672eab3962c7a6d7..30a13bd910d52d82a394804e25371f41685437bf 100644 --- a/crates/livekit_client/src/livekit_client.rs +++ b/crates/livekit_client/src/livekit_client.rs @@ -1,17 +1,21 @@ use std::sync::Arc; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; +use audio::AudioSettings; use collections::HashMap; use futures::{SinkExt, channel::mpsc}; use gpui::{App, AsyncApp, ScreenCaptureSource, ScreenCaptureStream, Task}; use gpui_tokio::Tokio; +use log::info; use playback::capture_local_video_track; +use settings::Settings; mod playback; -#[cfg(feature = "record-microphone")] -mod record; -use crate::{LocalTrack, Participant, RemoteTrack, RoomEvent, TrackPublication}; +use crate::{ + LocalTrack, Participant, RemoteTrack, RoomEvent, TrackPublication, + livekit_client::playback::Speaker, +}; pub use playback::AudioStream; pub(crate) use playback::{RemoteVideoFrame, play_remote_video_track}; @@ -96,9 +100,13 @@ impl Room { pub async fn publish_local_microphone_track( &self, + user_name: String, + is_staff: bool, cx: &mut AsyncApp, ) -> Result<(LocalTrackPublication, playback::AudioStream)> { - let (track, stream) = self.playback.capture_local_microphone_track()?; + let (track, stream) = self + .playback + .capture_local_microphone_track(user_name, is_staff, &cx)?; let publication = self .local_participant() .publish_track( @@ -125,9 +133,23 @@ impl Room { pub fn play_remote_audio_track( &self, track: &RemoteAudioTrack, - _cx: &App, + cx: &mut App, ) -> Result { - Ok(self.playback.play_remote_audio_track(&track.0)) + let speaker: Speaker = + serde_urlencoded::from_str(&track.0.name()).unwrap_or_else(|_| Speaker { + name: track.0.name(), + is_staff: false, + sends_legacy_audio: true, + }); + + if AudioSettings::get_global(cx).rodio_audio { + info!("Using experimental.rodio_audio audio pipeline for output"); + playback::play_remote_audio_track(&track.0, speaker, cx) + } else if speaker.sends_legacy_audio { + Ok(self.playback.play_remote_audio_track(&track.0)) + } else { + Err(anyhow!("Client version too old to play audio in call")) + } } } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index d1eec42f8f0df92e78ef63244c7cde400a1f19a6..cdd766453c58ad57460f7ac27aa72930c7015bce 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -1,10 +1,12 @@ use anyhow::{Context as _, Result}; +use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; use cpal::traits::{DeviceTrait, StreamTrait as _}; use futures::channel::mpsc::UnboundedSender; use futures::{Stream, StreamExt as _}; use gpui::{ - BackgroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, Task, + AsyncApp, BackgroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, + Task, }; use libwebrtc::native::{apm, audio_mixer, audio_resampler}; use livekit::track; @@ -17,14 +19,20 @@ use livekit::webrtc::{ video_source::{RtcVideoSource, VideoResolution, native::NativeVideoSource}, video_stream::native::NativeVideoStream, }; +use log::info; use parking_lot::Mutex; +use rodio::Source; +use serde::{Deserialize, Serialize}; +use settings::Settings; use std::cell::RefCell; use std::sync::Weak; -use std::sync::atomic::{self, AtomicI32}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::time::Duration; use std::{borrow::Cow, collections::VecDeque, sync::Arc, thread}; use util::{ResultExt as _, maybe}; +mod source; + pub(crate) struct AudioStack { executor: BackgroundExecutor, apm: Arc>, @@ -33,12 +41,37 @@ pub(crate) struct AudioStack { next_ssrc: AtomicI32, } -// NOTE: We use WebRTC's mixer which only supports -// 16kHz, 32kHz and 48kHz. As 48 is the most common "next step up" -// for audio output devices like speakers/bluetooth, we just hard-code -// this; and downsample when we need to. -const SAMPLE_RATE: u32 = 48000; -const NUM_CHANNELS: u32 = 2; +pub(crate) fn play_remote_audio_track( + track: &livekit::track::RemoteAudioTrack, + speaker: Speaker, + cx: &mut gpui::App, +) -> Result { + info!("speaker: {speaker:?}"); + let stream = + source::LiveKitStream::new(cx.background_executor(), track, speaker.sends_legacy_audio); + + let stop_handle = Arc::new(AtomicBool::new(false)); + let stop_handle_clone = stop_handle.clone(); + let stream = stream + .stoppable() + .periodic_access(Duration::from_millis(50), move |s| { + if stop_handle.load(Ordering::Relaxed) { + s.stop(); + } + }); + + info!("sample_rate: {:?}", stream.sample_rate()); + info!("channel_count: {:?}", stream.channels()); + audio::Audio::play_voip_stream(stream, speaker.name, speaker.is_staff, cx) + .context("Could not play audio")?; + + let on_drop = util::defer(move || { + stop_handle_clone.store(true, Ordering::Relaxed); + }); + Ok(AudioStream::Output { + _drop: Box::new(on_drop), + }) +} impl AudioStack { pub(crate) fn new(executor: BackgroundExecutor) -> Self { @@ -61,11 +94,11 @@ impl AudioStack { ) -> AudioStream { let output_task = self.start_output(); - let next_ssrc = self.next_ssrc.fetch_add(1, atomic::Ordering::Relaxed); + let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed); let source = AudioMixerSource { ssrc: next_ssrc, - sample_rate: SAMPLE_RATE, - num_channels: NUM_CHANNELS, + sample_rate: LEGACY_SAMPLE_RATE.get(), + num_channels: LEGACY_CHANNEL_COUNT.get() as u32, buffer: Arc::default(), }; self.mixer.lock().add_source(source.clone()); @@ -97,19 +130,67 @@ impl AudioStack { } } + fn start_output(&self) -> Arc> { + if let Some(task) = self._output_task.borrow().upgrade() { + return task; + } + let task = Arc::new(self.executor.spawn({ + let apm = self.apm.clone(); + let mixer = self.mixer.clone(); + async move { + Self::play_output( + apm, + mixer, + LEGACY_SAMPLE_RATE.get(), + LEGACY_CHANNEL_COUNT.get().into(), + ) + .await + .log_err(); + } + })); + *self._output_task.borrow_mut() = Arc::downgrade(&task); + task + } + pub(crate) fn capture_local_microphone_track( &self, + user_name: String, + is_staff: bool, + cx: &AsyncApp, ) -> Result<(crate::LocalAudioTrack, AudioStream)> { - let source = NativeAudioSource::new( - // n.b. this struct's options are always ignored, noise cancellation is provided by apm. - AudioSourceOptions::default(), - SAMPLE_RATE, - NUM_CHANNELS, - 10, - ); + let legacy_audio_compatible = + AudioSettings::try_read_global(cx, |setting| setting.legacy_audio_compatible) + .unwrap_or(true); + + let source = if legacy_audio_compatible { + NativeAudioSource::new( + // n.b. this struct's options are always ignored, noise cancellation is provided by apm. + AudioSourceOptions::default(), + LEGACY_SAMPLE_RATE.get(), + LEGACY_CHANNEL_COUNT.get().into(), + 10, + ) + } else { + NativeAudioSource::new( + // n.b. this struct's options are always ignored, noise cancellation is provided by apm. + AudioSourceOptions::default(), + SAMPLE_RATE.get(), + CHANNEL_COUNT.get().into(), + 10, + ) + }; + + let speaker = Speaker { + name: user_name, + is_staff, + sends_legacy_audio: legacy_audio_compatible, + }; + log::info!("Microphone speaker: {speaker:?}"); + let track_name = serde_urlencoded::to_string(speaker) + .context("Could not encode user information in track name")?; let track = track::LocalAudioTrack::create_audio_track( - "microphone", + &track_name, RtcAudioSource::Native(source.clone()), ); @@ -117,44 +198,56 @@ impl AudioStack { let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); let transmit_task = self.executor.spawn({ - let source = source.clone(); async move { while let Some(frame) = frame_rx.next().await { source.capture_frame(&frame).await.log_err(); } } }); - let capture_task = self.executor.spawn(async move { - Self::capture_input(apm, frame_tx, SAMPLE_RATE, NUM_CHANNELS).await - }); + let rodio_pipeline = + AudioSettings::try_read_global(cx, |setting| setting.rodio_audio).unwrap_or_default(); + let capture_task = if rodio_pipeline { + info!("Using experimental.rodio_audio audio pipeline"); + let voip_parts = audio::VoipParts::new(cx)?; + // Audio needs to run real-time and should never be paused. That is + // why we are using a normal std::thread and not a background task + thread::Builder::new() + .name("MicrophoneToLivekit".to_string()) + .spawn(move || { + // microphone is non send on mac + let microphone = match audio::Audio::open_microphone(voip_parts) { + Ok(m) => m, + Err(e) => { + log::error!("Could not open microphone: {e}"); + return; + } + }; + send_to_livekit(frame_tx, microphone); + }) + .expect("should be able to spawn threads"); + Task::ready(Ok(())) + } else { + self.executor.spawn(async move { + Self::capture_input( + apm, + frame_tx, + LEGACY_SAMPLE_RATE.get(), + LEGACY_CHANNEL_COUNT.get().into(), + ) + .await + }) + }; let on_drop = util::defer(|| { drop(transmit_task); drop(capture_task); }); - return Ok(( + Ok(( super::LocalAudioTrack(track), AudioStream::Output { _drop: Box::new(on_drop), }, - )); - } - - fn start_output(&self) -> Arc> { - if let Some(task) = self._output_task.borrow().upgrade() { - return task; - } - let task = Arc::new(self.executor.spawn({ - let apm = self.apm.clone(); - let mixer = self.mixer.clone(); - async move { - Self::play_output(apm, mixer, SAMPLE_RATE, NUM_CHANNELS) - .await - .log_err(); - } - })); - *self._output_task.borrow_mut() = Arc::downgrade(&task); - task + )) } async fn play_output( @@ -172,57 +265,60 @@ impl AudioStack { let mut resampler = audio_resampler::AudioResampler::default(); let mut buf = Vec::new(); - thread::spawn(move || { - let output_stream = output_device.build_output_stream( - &output_config.config(), - { - move |mut data, _info| { - while data.len() > 0 { - if data.len() <= buf.len() { - let rest = buf.split_off(data.len()); - data.copy_from_slice(&buf); - buf = rest; - return; - } - if buf.len() > 0 { - let (prefix, suffix) = data.split_at_mut(buf.len()); - prefix.copy_from_slice(&buf); - data = suffix; - } + thread::Builder::new() + .name("AudioPlayback".to_owned()) + .spawn(move || { + let output_stream = output_device.build_output_stream( + &output_config.config(), + { + move |mut data, _info| { + while data.len() > 0 { + if data.len() <= buf.len() { + let rest = buf.split_off(data.len()); + data.copy_from_slice(&buf); + buf = rest; + return; + } + if buf.len() > 0 { + let (prefix, suffix) = data.split_at_mut(buf.len()); + prefix.copy_from_slice(&buf); + data = suffix; + } - let mut mixer = mixer.lock(); - let mixed = mixer.mix(output_config.channels() as usize); - let sampled = resampler.remix_and_resample( - mixed, - sample_rate / 100, - num_channels, - sample_rate, - output_config.channels() as u32, - output_config.sample_rate().0, - ); - buf = sampled.to_vec(); - apm.lock() - .process_reverse_stream( - &mut buf, - output_config.sample_rate().0 as i32, - output_config.channels() as i32, - ) - .ok(); + let mut mixer = mixer.lock(); + let mixed = mixer.mix(output_config.channels() as usize); + let sampled = resampler.remix_and_resample( + mixed, + sample_rate / 100, + num_channels, + sample_rate, + output_config.channels() as u32, + output_config.sample_rate().0, + ); + buf = sampled.to_vec(); + apm.lock() + .process_reverse_stream( + &mut buf, + output_config.sample_rate().0 as i32, + output_config.channels() as i32, + ) + .ok(); + } } - } - }, - |error| log::error!("error playing audio track: {:?}", error), - Some(Duration::from_millis(100)), - ); + }, + |error| log::error!("error playing audio track: {:?}", error), + Some(Duration::from_millis(100)), + ); - let Some(output_stream) = output_stream.log_err() else { - return; - }; + let Some(output_stream) = output_stream.log_err() else { + return; + }; - output_stream.play().log_err(); - // Block forever to keep the output stream alive - end_on_drop_rx.recv().ok(); - }); + output_stream.play().log_err(); + // Block forever to keep the output stream alive + end_on_drop_rx.recv().ok(); + }) + .unwrap(); device_change_listener.next().await; drop(end_on_drop_tx) @@ -243,77 +339,81 @@ impl AudioStack { let frame_tx = frame_tx.clone(); let mut resampler = audio_resampler::AudioResampler::default(); - thread::spawn(move || { - maybe!({ - if let Some(name) = device.name().ok() { - log::info!("Using microphone: {}", name) - } else { - log::info!("Using microphone: "); - } - - let ten_ms_buffer_size = - (config.channels() as u32 * config.sample_rate().0 / 100) as usize; - let mut buf: Vec = Vec::with_capacity(ten_ms_buffer_size); - - let stream = device - .build_input_stream_raw( - &config.config(), - config.sample_format(), - move |data, _: &_| { - let data = - crate::get_sample_data(config.sample_format(), data).log_err(); - let Some(data) = data else { - return; - }; - let mut data = data.as_slice(); + thread::Builder::new() + .name("AudioCapture".to_owned()) + .spawn(move || { + maybe!({ + if let Some(name) = device.name().ok() { + log::info!("Using microphone: {}", name) + } else { + log::info!("Using microphone: "); + } - while data.len() > 0 { - let remainder = (buf.capacity() - buf.len()).min(data.len()); - buf.extend_from_slice(&data[..remainder]); - data = &data[remainder..]; - - if buf.capacity() == buf.len() { - let mut sampled = resampler - .remix_and_resample( - buf.as_slice(), - config.sample_rate().0 / 100, - config.channels() as u32, - config.sample_rate().0, - num_channels, - sample_rate, - ) - .to_owned(); - apm.lock() - .process_stream( - &mut sampled, - sample_rate as i32, - num_channels as i32, - ) - .log_err(); - buf.clear(); - frame_tx - .unbounded_send(AudioFrame { - data: Cow::Owned(sampled), - sample_rate, - num_channels, - samples_per_channel: sample_rate / 100, - }) - .ok(); + let ten_ms_buffer_size = + (config.channels() as u32 * config.sample_rate().0 / 100) as usize; + let mut buf: Vec = Vec::with_capacity(ten_ms_buffer_size); + + let stream = device + .build_input_stream_raw( + &config.config(), + config.sample_format(), + move |data, _: &_| { + let data = crate::get_sample_data(config.sample_format(), data) + .log_err(); + let Some(data) = data else { + return; + }; + let mut data = data.as_slice(); + + while data.len() > 0 { + let remainder = + (buf.capacity() - buf.len()).min(data.len()); + buf.extend_from_slice(&data[..remainder]); + data = &data[remainder..]; + + if buf.capacity() == buf.len() { + let mut sampled = resampler + .remix_and_resample( + buf.as_slice(), + config.sample_rate().0 / 100, + config.channels() as u32, + config.sample_rate().0, + num_channels, + sample_rate, + ) + .to_owned(); + apm.lock() + .process_stream( + &mut sampled, + sample_rate as i32, + num_channels as i32, + ) + .log_err(); + buf.clear(); + frame_tx + .unbounded_send(AudioFrame { + data: Cow::Owned(sampled), + sample_rate, + num_channels, + samples_per_channel: sample_rate / 100, + }) + .ok(); + } } - } - }, - |err| log::error!("error capturing audio track: {:?}", err), - Some(Duration::from_millis(100)), - ) - .context("failed to build input stream")?; - - stream.play()?; - // Keep the thread alive and holding onto the `stream` - end_on_drop_rx.recv().ok(); - anyhow::Ok(Some(())) + }, + |err| log::error!("error capturing audio track: {:?}", err), + Some(Duration::from_millis(100)), + ) + .context("failed to build input stream")?; + + stream.play()?; + // Keep the thread alive and holding onto the `stream` + end_on_drop_rx.recv().ok(); + anyhow::Ok(Some(())) + }) + .log_err(); }) - .log_err(); - }); + .unwrap(); device_change_listener.next().await; drop(end_on_drop_tx) @@ -321,6 +421,41 @@ impl AudioStack { } } +#[derive(Serialize, Deserialize, Debug)] +pub struct Speaker { + pub name: String, + pub is_staff: bool, + pub sends_legacy_audio: bool, +} + +fn send_to_livekit(frame_tx: UnboundedSender>, mut microphone: impl Source) { + use cpal::Sample; + let sample_rate = microphone.sample_rate().get(); + let num_channels = microphone.channels().get() as u32; + let buffer_size = sample_rate / 100 * num_channels; + + loop { + let sampled: Vec<_> = microphone + .by_ref() + .take(buffer_size as usize) + .map(|s| s.to_sample()) + .collect(); + + if frame_tx + .unbounded_send(AudioFrame { + sample_rate, + num_channels, + samples_per_channel: sampled.len() as u32 / num_channels, + data: Cow::Owned(sampled), + }) + .is_err() + { + // must rx has dropped or is not consuming + break; + } + } +} + use super::LocalVideoTrack; pub enum AudioStream { diff --git a/crates/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs new file mode 100644 index 0000000000000000000000000000000000000000..cde4b19fda2e053346ad535e7c75b2abda60431a --- /dev/null +++ b/crates/livekit_client/src/livekit_client/playback/source.rs @@ -0,0 +1,92 @@ +use std::num::NonZero; + +use futures::StreamExt; +use libwebrtc::{audio_stream::native::NativeAudioStream, prelude::AudioFrame}; +use livekit::track::RemoteAudioTrack; +use rodio::{ + ChannelCount, SampleRate, Source, buffer::SamplesBuffer, conversions::SampleTypeConverter, +}; + +use audio::{CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; + +fn frame_to_samplesbuffer(frame: AudioFrame) -> SamplesBuffer { + let samples = frame.data.iter().copied(); + let samples = SampleTypeConverter::<_, _>::new(samples); + let samples: Vec = samples.collect(); + SamplesBuffer::new( + NonZero::new(frame.num_channels as u16).expect("zero channels is nonsense"), + NonZero::new(frame.sample_rate).expect("samplerate zero is nonsense"), + samples, + ) +} + +pub struct LiveKitStream { + // shared_buffer: SharedBuffer, + inner: rodio::queue::SourcesQueueOutput, + _receiver_task: gpui::Task<()>, + channel_count: ChannelCount, + sample_rate: SampleRate, +} + +impl LiveKitStream { + pub fn new( + executor: &gpui::BackgroundExecutor, + track: &RemoteAudioTrack, + legacy: bool, + ) -> Self { + let (channel_count, sample_rate) = if legacy { + (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) + } else { + (CHANNEL_COUNT, SAMPLE_RATE) + }; + + let mut stream = NativeAudioStream::new( + track.rtc_track(), + sample_rate.get() as i32, + channel_count.get().into(), + ); + let (queue_input, queue_output) = rodio::queue::queue(true); + // spawn rtc stream + let receiver_task = executor.spawn({ + async move { + while let Some(frame) = stream.next().await { + let samples = frame_to_samplesbuffer(frame); + queue_input.append(samples); + } + } + }); + + LiveKitStream { + _receiver_task: receiver_task, + inner: queue_output, + sample_rate, + channel_count, + } + } +} + +impl Iterator for LiveKitStream { + type Item = rodio::Sample; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +impl Source for LiveKitStream { + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + + fn channels(&self) -> rodio::ChannelCount { + self.channel_count + } + + fn sample_rate(&self) -> rodio::SampleRate { + self.sample_rate + } + + fn total_duration(&self) -> Option { + self.inner.total_duration() + } +} diff --git a/crates/livekit_client/src/record.rs b/crates/livekit_client/src/record.rs index 925c0d4c67f91bcb147a9fb8d0d99b0aa1ab1810..24e260e71665704c1010d07e082a03fbe6306a30 100644 --- a/crates/livekit_client/src/record.rs +++ b/crates/livekit_client/src/record.rs @@ -1,5 +1,6 @@ use std::{ env, + num::NonZero, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, @@ -83,8 +84,12 @@ fn write_out( .expect("Stream has ended, callback cant hold the lock"), ); let samples: Vec = SampleTypeConverter::<_, f32>::new(samples.into_iter()).collect(); - let mut samples = SamplesBuffer::new(config.channels(), config.sample_rate().0, samples); - match rodio::output_to_wav(&mut samples, path) { + let mut samples = SamplesBuffer::new( + NonZero::new(config.channels()).expect("config channel is never zero"), + NonZero::new(config.sample_rate().0).expect("config sample_rate is never zero"), + samples, + ); + match rodio::wav_to_file(&mut samples, path) { Ok(_) => Ok(()), Err(e) => Err(anyhow::anyhow!("Failed to write wav file: {}", e)), } diff --git a/crates/livekit_client/src/remote_video_track_view.rs b/crates/livekit_client/src/remote_video_track_view.rs index 9073b8729a1d72ef59fe6ed77fd727cdf6acae00..189806f2138e401e62ad46336e95d8468e3b3732 100644 --- a/crates/livekit_client/src/remote_video_track_view.rs +++ b/crates/livekit_client/src/remote_video_track_view.rs @@ -97,8 +97,10 @@ impl Render for RemoteVideoTrackView { self.previous_rendered_frame = Some(current_rendered_frame) } self.current_rendered_frame = Some(latest_frame.clone()); - return gpui::img(latest_frame.clone()) + use gpui::ParentElement; + return ui::h_flex() .size_full() + .child(gpui::img(latest_frame.clone()).size_full()) .into_any_element(); } diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs index e02c4d876fbe3411cf1730f3d97aaf8db3e208b6..fd3163598203ac26443cae1b733372b6c3bdf1d1 100644 --- a/crates/livekit_client/src/test.rs +++ b/crates/livekit_client/src/test.rs @@ -421,7 +421,7 @@ impl TestServer { track_sid: &TrackSid, muted: bool, ) -> Result<()> { - let claims = livekit_api::token::validate(&token, &self.secret_key)?; + let claims = livekit_api::token::validate(token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let mut server_rooms = self.rooms.lock(); @@ -475,7 +475,7 @@ impl TestServer { } pub(crate) fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option { - let claims = livekit_api::token::validate(&token, &self.secret_key).ok()?; + let claims = livekit_api::token::validate(token, &self.secret_key).ok()?; let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -728,6 +728,8 @@ impl Room { pub async fn publish_local_microphone_track( &self, + _track_name: String, + _is_staff: bool, cx: &mut AsyncApp, ) -> Result<(LocalTrackPublication, AudioStream)> { self.local_participant().publish_microphone_track(cx).await @@ -736,14 +738,14 @@ impl Room { impl Drop for RoomState { fn drop(&mut self) { - if self.connection_state == ConnectionState::Connected { - if let Ok(server) = TestServer::get(&self.url) { - let executor = server.executor.clone(); - let token = self.token.clone(); - executor - .spawn(async move { server.leave_room(token).await.ok() }) - .detach(); - } + if self.connection_state == ConnectionState::Connected + && let Ok(server) = TestServer::get(&self.url) + { + let executor = server.executor.clone(); + let token = self.token.clone(); + executor + .spawn(async move { server.leave_room(token).await.ok() }) + .detach(); } } } diff --git a/crates/lmstudio/Cargo.toml b/crates/lmstudio/Cargo.toml index da5e5c5e46172c92622ab2260935c20352f931b8..825507b9152bc099f23fe66ade235b8b11601875 100644 --- a/crates/lmstudio/Cargo.toml +++ b/crates/lmstudio/Cargo.toml @@ -22,4 +22,3 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index 43c78115cdd4f517a51052991121620a0a93c363..ef2f7b6208f62e079609049b8eff83a80034741e 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -86,11 +86,12 @@ impl Model { } #[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] +#[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, Required, None, + #[serde(untagged)] Other(ToolDefinition), } diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index bc1f8b341b76b1be3e23824033f057a3a00201b3..39a86547f29c90f507bbd908f3af3a2c1a0cdec8 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -31,7 +31,6 @@ schemars.workspace = true smol.workspace = true util.workspace = true release_channel.workspace = true -workspace-hack.workspace = true [dev-dependencies] async-pipe.workspace = true diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index ce9e2fe229c0aded6fac31c260e334445f987f03..84e5a95ed80e75bf7d338b589f5b1c1c6495a616 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -45,7 +45,7 @@ use util::{ConnectionResult, ResultExt, TryFutureExt, redact}; const JSON_RPC_VERSION: &str = "2.0"; const CONTENT_LEN_HEADER: &str = "Content-Length: "; -const LSP_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 2); +pub const LSP_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 2); const SERVER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); type NotificationHandler = Box, Value, &mut AsyncApp)>; @@ -62,7 +62,7 @@ pub enum IoKind { /// Represents a launchable language server. This can either be a standalone binary or the path /// to a runtime with arguments to instruct it to launch the actual language server file. -#[derive(Clone, Deserialize)] +#[derive(Clone)] pub struct LanguageServerBinary { pub path: PathBuf, pub arguments: Vec, @@ -70,19 +70,24 @@ pub struct LanguageServerBinary { } /// Configures the search (and installation) of language servers. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct LanguageServerBinaryOptions { /// Whether the adapter should look at the users system pub allow_path_lookup: bool, /// Whether the adapter should download its own version pub allow_binary_download: bool, + /// Whether the adapter should download a pre-release version + pub pre_release: bool, } +struct NotificationSerializer(Box String + Send + Sync>); + /// A running language server process. pub struct LanguageServer { server_id: LanguageServerId, next_id: AtomicI32, outbound_tx: channel::Sender, + notification_tx: channel::Sender, name: LanguageServerName, process_name: Arc, binary: LanguageServerBinary, @@ -100,8 +105,8 @@ pub struct LanguageServer { io_tasks: Mutex>, Task>)>>, output_done_rx: Mutex>, server: Arc>>, - workspace_folders: Option>>>, - root_uri: Url, + workspace_folders: Option>>>, + root_uri: Uri, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -166,6 +171,12 @@ impl<'a> From<&'a str> for LanguageServerName { } } +impl PartialEq for LanguageServerName { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + /// Handle to a language server RPC activity subscription. pub enum Subscription { Notification { @@ -310,7 +321,7 @@ impl LanguageServer { binary: LanguageServerBinary, root_path: &Path, code_action_kinds: Option>, - workspace_folders: Option>>>, + workspace_folders: Option>>>, cx: &mut AsyncApp, ) -> Result { let working_dir = if root_path.is_dir() { @@ -318,7 +329,7 @@ impl LanguageServer { } else { root_path.parent().unwrap_or_else(|| Path::new("/")) }; - let root_uri = Url::from_file_path(&working_dir) + let root_uri = Uri::from_file_path(&working_dir) .map_err(|()| anyhow!("{working_dir:?} is not a valid URI"))?; log::info!( @@ -328,21 +339,18 @@ impl LanguageServer { &binary.arguments ); - let mut server = util::command::new_smol_command(&binary.path) + let mut command = util::command::new_smol_command(&binary.path); + command .current_dir(working_dir) .args(&binary.arguments) .envs(binary.env.clone().unwrap_or_default()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .kill_on_drop(true) + .kill_on_drop(true); + let mut server = command .spawn() - .with_context(|| { - format!( - "failed to spawn command. path: {:?}, working directory: {:?}, args: {:?}", - binary.path, working_dir, &binary.arguments - ) - })?; + .with_context(|| format!("failed to spawn command {command:?}",))?; let stdin = server.stdin.take().unwrap(); let stdout = server.stdout.take().unwrap(); @@ -384,8 +392,8 @@ impl LanguageServer { server: Option, code_action_kinds: Option>, binary: LanguageServerBinary, - root_uri: Url, - workspace_folders: Option>>>, + root_uri: Uri, + workspace_folders: Option>>>, cx: &mut AsyncApp, on_unhandled_notification: F, ) -> Self @@ -472,9 +480,24 @@ impl LanguageServer { } .into(); + let (notification_tx, notification_rx) = channel::unbounded::(); + cx.background_spawn({ + let outbound_tx = outbound_tx.clone(); + async move { + while let Ok(serializer) = notification_rx.recv().await { + let serialized = (serializer.0)(); + let Ok(_) = outbound_tx.send(serialized).await else { + return; + }; + } + outbound_tx.close(); + } + }) + .detach(); Self { server_id, notification_handlers, + notification_tx, response_handlers, io_handlers, name: server_name, @@ -901,7 +924,7 @@ impl LanguageServer { self.capabilities = RwLock::new(response.capabilities); self.configuration = configuration; - self.notify::(&InitializedParams {})?; + self.notify::(InitializedParams {})?; Ok(Arc::new(self)) }) } @@ -913,11 +936,13 @@ impl LanguageServer { let next_id = AtomicI32::new(self.next_id.load(SeqCst)); let outbound_tx = self.outbound_tx.clone(); let executor = self.executor.clone(); + let notification_serializers = self.notification_tx.clone(); let mut output_done = self.output_done_rx.lock().take().unwrap(); let shutdown_request = Self::request_internal::( &next_id, &response_handlers, &outbound_tx, + ¬ification_serializers, &executor, (), ); @@ -951,8 +976,8 @@ impl LanguageServer { } response_handlers.lock().take(); - Self::notify_internal::(&outbound_tx, &()).ok(); - outbound_tx.close(); + Self::notify_internal::(¬ification_serializers, ()).ok(); + notification_serializers.close(); output_done.recv().await; server.lock().take().map(|mut child| child.kill()); drop(tasks); @@ -1174,6 +1199,7 @@ impl LanguageServer { &self.next_id, &self.response_handlers, &self.outbound_tx, + &self.notification_tx, &self.executor, params, ) @@ -1195,6 +1221,7 @@ impl LanguageServer { &self.next_id, &self.response_handlers, &self.outbound_tx, + &self.notification_tx, &self.executor, timer, params, @@ -1205,6 +1232,7 @@ impl LanguageServer { next_id: &AtomicI32, response_handlers: &Mutex>>, outbound_tx: &channel::Sender, + notification_serializers: &channel::Sender, executor: &BackgroundExecutor, timer: U, params: T::Params, @@ -1256,7 +1284,7 @@ impl LanguageServer { .try_send(message) .context("failed to write to language server's stdin"); - let outbound_tx = outbound_tx.downgrade(); + let notification_serializers = notification_serializers.downgrade(); let started = Instant::now(); LspRequest::new(id, async move { if let Err(e) = handle_response { @@ -1267,10 +1295,10 @@ impl LanguageServer { } let cancel_on_drop = util::defer(move || { - if let Some(outbound_tx) = outbound_tx.upgrade() { + if let Some(notification_serializers) = notification_serializers.upgrade() { Self::notify_internal::( - &outbound_tx, - &CancelParams { + ¬ification_serializers, + CancelParams { id: NumberOrString::Number(id), }, ) @@ -1305,6 +1333,7 @@ impl LanguageServer { next_id: &AtomicI32, response_handlers: &Mutex>>, outbound_tx: &channel::Sender, + notification_serializers: &channel::Sender, executor: &BackgroundExecutor, params: T::Params, ) -> impl LspRequestFuture + use @@ -1316,6 +1345,7 @@ impl LanguageServer { next_id, response_handlers, outbound_tx, + notification_serializers, executor, Self::default_request_timer(executor.clone()), params, @@ -1331,26 +1361,30 @@ impl LanguageServer { /// Sends a RPC notification to the language server. /// /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) - pub fn notify(&self, params: &T::Params) -> Result<()> { - Self::notify_internal::(&self.outbound_tx, params) + pub fn notify(&self, params: T::Params) -> Result<()> { + let outbound = self.notification_tx.clone(); + Self::notify_internal::(&outbound, params) } fn notify_internal( - outbound_tx: &channel::Sender, - params: &T::Params, + outbound_tx: &channel::Sender, + params: T::Params, ) -> Result<()> { - let message = serde_json::to_string(&Notification { - jsonrpc: JSON_RPC_VERSION, - method: T::METHOD, - params, - }) - .unwrap(); - outbound_tx.try_send(message)?; + let serializer = NotificationSerializer(Box::new(move || { + serde_json::to_string(&Notification { + jsonrpc: JSON_RPC_VERSION, + method: T::METHOD, + params, + }) + .unwrap() + })); + + outbound_tx.send_blocking(serializer)?; Ok(()) } /// Add new workspace folder to the list. - pub fn add_workspace_folder(&self, uri: Url) { + pub fn add_workspace_folder(&self, uri: Uri) { if self .capabilities() .workspace @@ -1380,11 +1414,12 @@ impl LanguageServer { removed: vec![], }, }; - self.notify::(¶ms).ok(); + self.notify::(params).ok(); } } - /// Add new workspace folder to the list. - pub fn remove_workspace_folder(&self, uri: Url) { + + /// Remove existing workspace folder from the list. + pub fn remove_workspace_folder(&self, uri: Uri) { if self .capabilities() .workspace @@ -1413,10 +1448,10 @@ impl LanguageServer { }], }, }; - self.notify::(¶ms).ok(); + self.notify::(params).ok(); } } - pub fn set_workspace_folders(&self, folders: BTreeSet) { + pub fn set_workspace_folders(&self, folders: BTreeSet) { let Some(workspace_folders) = self.workspace_folders.as_ref() else { return; }; @@ -1445,11 +1480,11 @@ impl LanguageServer { let params = DidChangeWorkspaceFoldersParams { event: WorkspaceFoldersChangeEvent { added, removed }, }; - self.notify::(¶ms).ok(); + self.notify::(params).ok(); } } - pub fn workspace_folders(&self) -> BTreeSet { + pub fn workspace_folders(&self) -> BTreeSet { self.workspace_folders.as_ref().map_or_else( || BTreeSet::from_iter([self.root_uri.clone()]), |folders| folders.lock().clone(), @@ -1458,19 +1493,19 @@ impl LanguageServer { pub fn register_buffer( &self, - uri: Url, + uri: Uri, language_id: String, version: i32, initial_text: String, ) { - self.notify::(&DidOpenTextDocumentParams { + self.notify::(DidOpenTextDocumentParams { text_document: TextDocumentItem::new(uri, language_id, version, initial_text), }) .ok(); } - pub fn unregister_buffer(&self, uri: Url) { - self.notify::(&DidCloseTextDocumentParams { + pub fn unregister_buffer(&self, uri: Uri) { + self.notify::(DidCloseTextDocumentParams { text_document: TextDocumentIdentifier::new(uri), }) .ok(); @@ -1586,7 +1621,7 @@ impl FakeLanguageServer { let server_name = LanguageServerName(name.clone().into()); let process_name = Arc::from(name.as_str()); let root = Self::root_path(); - let workspace_folders: Arc>> = Default::default(); + let workspace_folders: Arc>> = Default::default(); let mut server = LanguageServer::new_internal( server_id, server_name.clone(), @@ -1656,13 +1691,13 @@ impl FakeLanguageServer { (server, fake) } #[cfg(target_os = "windows")] - fn root_path() -> Url { - Url::from_file_path("C:/").unwrap() + fn root_path() -> Uri { + Uri::from_file_path("C:/").unwrap() } #[cfg(not(target_os = "windows"))] - fn root_path() -> Url { - Url::from_file_path("/").unwrap() + fn root_path() -> Uri { + Uri::from_file_path("/").unwrap() } } @@ -1686,7 +1721,7 @@ impl LanguageServer { #[cfg(any(test, feature = "test-support"))] impl FakeLanguageServer { /// See [`LanguageServer::notify`]. - pub fn notify(&self, params: &T::Params) { + pub fn notify(&self, params: T::Params) { self.server.notify::(params).ok(); } @@ -1795,7 +1830,7 @@ impl FakeLanguageServer { .await .into_response() .unwrap(); - self.notify::(&ProgressParams { + self.notify::(ProgressParams { token: NumberOrString::String(token), value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(progress)), }); @@ -1803,7 +1838,7 @@ impl FakeLanguageServer { /// Simulate that the server has completed work and notifies about that with the specified token. pub fn end_progress(&self, token: impl Into) { - self.notify::(&ProgressParams { + self.notify::(ProgressParams { token: NumberOrString::String(token.into()), value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(Default::default())), }); @@ -1862,9 +1897,9 @@ mod tests { .await .unwrap(); server - .notify::(&DidOpenTextDocumentParams { + .notify::(DidOpenTextDocumentParams { text_document: TextDocumentItem::new( - Url::from_str("file://a/b").unwrap(), + Uri::from_str("file://a/b").unwrap(), "rust".to_string(), 0, "".to_string(), @@ -1880,12 +1915,12 @@ mod tests { "file://a/b" ); - fake.notify::(&ShowMessageParams { + fake.notify::(ShowMessageParams { typ: MessageType::ERROR, message: "ok".to_string(), }); - fake.notify::(&PublishDiagnosticsParams { - uri: Url::from_str("file://b/c").unwrap(), + fake.notify::(PublishDiagnosticsParams { + uri: Uri::from_str("file://b/c").unwrap(), version: Some(5), diagnostics: vec![], }); @@ -1898,6 +1933,7 @@ mod tests { fake.set_request_handler::(|_, _| async move { Ok(()) }); drop(server); + cx.run_until_parked(); fake.receive_notification::().await; } diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index b278ef1cd41817e7f442c1a904f5e8e0e8d3771a..9e852d8074add0f835dafd6bcfb4245eaa52214c 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -20,6 +20,7 @@ test-support = [ [dependencies] base64.workspace = true +collections.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true @@ -30,11 +31,11 @@ sum_tree.workspace = true theme.workspace = true ui.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] assets.workspace = true env_logger.workspace = true +fs = {workspace = true, features = ["test-support"]} gpui = { workspace = true, features = ["test-support"] } languages = { workspace = true, features = ["load-grammars"] } node_runtime.workspace = true diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index c651c7921d4d92562e68bfea6cb1954132c215bc..b4cb2a2503dcb6e097c34e4c8aad718f89e30272 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -1,6 +1,6 @@ use assets::Assets; use gpui::{Application, Entity, KeyBinding, StyleRefinement, WindowOptions, prelude::*, rgb}; -use language::{LanguageRegistry, language_settings::AllLanguageSettings}; +use language::LanguageRegistry; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use node_runtime::NodeRuntime; use settings::SettingsStore; @@ -39,18 +39,16 @@ pub fn main() { let store = SettingsStore::test(cx); cx.set_global(store); language::init(cx); - SettingsStore::update(cx, |store, cx| { - store.update_user_settings::(cx, |_| {}); - }); cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); let node_runtime = NodeRuntime::unavailable(); theme::init(LoadThemes::JustBase, cx); + let fs = fs::FakeFs::new(cx.background_executor().clone()); let language_registry = LanguageRegistry::new(cx.background_executor().clone()); language_registry.set_theme(cx.theme().clone()); let language_registry = Arc::new(language_registry); - languages::init(language_registry.clone(), node_runtime, cx); + languages::init(language_registry.clone(), fs, node_runtime, cx); Assets.load_fonts(cx).unwrap(); cx.activate(true); diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index 862b657c8c50c7adc88642f1af21a4c075ff77f2..3e731506f545dd2166336241cb82742435784fea 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -1,6 +1,6 @@ use assets::Assets; use gpui::{Application, Entity, KeyBinding, Length, StyleRefinement, WindowOptions, rgb}; -use language::{LanguageRegistry, language_settings::AllLanguageSettings}; +use language::LanguageRegistry; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use node_runtime::NodeRuntime; use settings::SettingsStore; @@ -23,14 +23,12 @@ pub fn main() { let store = SettingsStore::test(cx); cx.set_global(store); language::init(cx); - SettingsStore::update(cx, |store, cx| { - store.update_user_settings::(cx, |_| {}); - }); cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); let node_runtime = NodeRuntime::unavailable(); let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); - languages::init(language_registry.clone(), node_runtime, cx); + let fs = fs::FakeFs::new(cx.background_executor().clone()); + languages::init(language_registry, fs, node_runtime, cx); theme::init(LoadThemes::JustBase, cx); Assets.load_fonts(cx).unwrap(); @@ -99,7 +97,7 @@ impl Render for HelloWorld { div() .flex() .bg(rgb(0x2e7d32)) - .size(Length::Definite(Pixels(700.0).into())) + .size(Length::Definite(px(700.0).into())) .justify_center() .items_center() .shadow_lg() diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index a3235a977359270a9c1db0850ad7bb096a90d02d..c34ed69288e39c26d105877d76ee76c01c864c72 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -9,8 +9,6 @@ use log::Level; pub use path_range::{LineCol, PathWithRange}; use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; use std::iter; use std::mem; use std::ops::Range; @@ -19,6 +17,7 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; +use collections::{HashMap, HashSet}; use gpui::{ AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, @@ -69,6 +68,7 @@ pub struct MarkdownStyle { pub heading_level_styles: Option, pub table_overflow_x_scroll: bool, pub height_is_multiple_of_line_height: bool, + pub prevent_mouse_interaction: bool, } impl Default for MarkdownStyle { @@ -89,6 +89,7 @@ impl Default for MarkdownStyle { heading_level_styles: None, table_overflow_x_scroll: false, height_is_multiple_of_line_height: false, + prevent_mouse_interaction: false, } } } @@ -174,7 +175,7 @@ impl Markdown { options: Options { parse_links_only: false, }, - copied_code_blocks: HashSet::new(), + copied_code_blocks: HashSet::default(), }; this.parse(cx); this @@ -197,7 +198,7 @@ impl Markdown { options: Options { parse_links_only: true, }, - copied_code_blocks: HashSet::new(), + copied_code_blocks: HashSet::default(), }; this.parse(cx); this @@ -333,34 +334,36 @@ impl Markdown { } for path in paths { - if let Ok(language) = registry.language_for_file_path(&path).await { + if let Ok(language) = registry + .load_language_for_file_path(Path::new(path.as_ref())) + .await + { languages_by_path.insert(path, language); } } } for (range, event) in &events { - if let MarkdownEvent::Start(MarkdownTag::Image { dest_url, .. }) = event { - if let Some(data_url) = dest_url.strip_prefix("data:") { - let Some((mime_info, data)) = data_url.split_once(',') else { - continue; - }; - let Some((mime_type, encoding)) = mime_info.split_once(';') else { - continue; - }; - let Some(format) = ImageFormat::from_mime_type(mime_type) else { - continue; - }; - let is_base64 = encoding == "base64"; - if is_base64 { - if let Some(bytes) = base64::prelude::BASE64_STANDARD - .decode(data) - .log_with_level(Level::Debug) - { - let image = Arc::new(Image::from_bytes(format, bytes)); - images_by_source_offset.insert(range.start, image); - } - } + if let MarkdownEvent::Start(MarkdownTag::Image { dest_url, .. }) = event + && let Some(data_url) = dest_url.strip_prefix("data:") + { + let Some((mime_info, data)) = data_url.split_once(',') else { + continue; + }; + let Some((mime_type, encoding)) = mime_info.split_once(';') else { + continue; + }; + let Some(format) = ImageFormat::from_mime_type(mime_type) else { + continue; + }; + let is_base64 = encoding == "base64"; + if is_base64 + && let Some(bytes) = base64::prelude::BASE64_STANDARD + .decode(data) + .log_with_level(Level::Debug) + { + let image = Arc::new(Image::from_bytes(format, bytes)); + images_by_source_offset.insert(range.start, image); } } } @@ -434,7 +437,7 @@ pub struct ParsedMarkdown { pub source: SharedString, pub events: Arc<[(Range, MarkdownEvent)]>, pub languages_by_name: TreeMap>, - pub languages_by_path: TreeMap, Arc>, + pub languages_by_path: TreeMap, Arc>, } impl ParsedMarkdown { @@ -576,16 +579,22 @@ impl MarkdownElement { window: &mut Window, cx: &mut App, ) { + if self.style.prevent_mouse_interaction { + return; + } + let is_hovering_link = hitbox.is_hovered(window) && !self.markdown.read(cx).selection.pending && rendered_text .link_for_position(window.mouse_position()) .is_some(); - if is_hovering_link { - window.set_cursor_style(CursorStyle::PointingHand, hitbox); - } else { - window.set_cursor_style(CursorStyle::IBeam, hitbox); + if !self.style.prevent_mouse_interaction { + if is_hovering_link { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } else { + window.set_cursor_style(CursorStyle::IBeam, hitbox); + } } let on_open_url = self.on_url_click.take(); @@ -659,13 +668,13 @@ impl MarkdownElement { let rendered_text = rendered_text.clone(); move |markdown, event: &MouseUpEvent, phase, window, cx| { if phase.bubble() { - if let Some(pressed_link) = markdown.pressed_link.take() { - if Some(&pressed_link) == rendered_text.link_for_position(event.position) { - if let Some(open_url) = on_open_url.as_ref() { - open_url(pressed_link.destination_url, window, cx); - } else { - cx.open_url(&pressed_link.destination_url); - } + if let Some(pressed_link) = markdown.pressed_link.take() + && Some(&pressed_link) == rendered_text.link_for_position(event.position) + { + if let Some(open_url) = on_open_url.as_ref() { + open_url(pressed_link.destination_url, window, cx); + } else { + cx.open_url(&pressed_link.destination_url); } } } else if markdown.selection.pending { @@ -758,10 +767,10 @@ impl Element for MarkdownElement { let mut current_img_block_range: Option> = None; for (range, event) in parsed_markdown.events.iter() { // Skip alt text for images that rendered - if let Some(current_img_block_range) = ¤t_img_block_range { - if current_img_block_range.end > range.end { - continue; - } + if let Some(current_img_block_range) = ¤t_img_block_range + && current_img_block_range.end > range.end + { + continue; } match event { @@ -875,7 +884,7 @@ impl Element for MarkdownElement { (CodeBlockRenderer::Custom { render, .. }, _) => { let parent_container = render( kind, - &parsed_markdown, + parsed_markdown, range.clone(), metadata.clone(), window, @@ -1072,7 +1081,7 @@ impl Element for MarkdownElement { { builder.modify_current_div(|el| { let content_range = parser::extract_code_block_content_range( - parsed_markdown.source()[range.clone()].trim(), + &parsed_markdown.source()[range.clone()], ); let content_range = content_range.start + range.start ..content_range.end + range.start; @@ -1085,7 +1094,13 @@ impl Element for MarkdownElement { cx, ); el.child( - div().absolute().top_1().right_0p5().w_5().child(codeblock), + h_flex() + .w_4() + .absolute() + .top_1p5() + .right_1p5() + .justify_end() + .child(codeblock), ) }); } @@ -1097,7 +1112,7 @@ impl Element for MarkdownElement { { builder.modify_current_div(|el| { let content_range = parser::extract_code_block_content_range( - parsed_markdown.source()[range.clone()].trim(), + &parsed_markdown.source()[range.clone()], ); let content_range = content_range.start + range.start ..content_range.end + range.start; @@ -1110,11 +1125,12 @@ impl Element for MarkdownElement { cx, ); el.child( - div() + h_flex() + .w_4() .absolute() .top_0() .right_0() - .w_5() + .justify_end() .visible_on_hover("code_block") .child(codeblock), ) @@ -1315,11 +1331,11 @@ fn render_copy_code_block_button( ) .icon_color(Color::Muted) .icon_size(IconSize::Small) + .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) + .tooltip(Tooltip::text("Copy")) .on_click({ - let id = id.clone(); - let markdown = markdown.clone(); + let markdown = markdown; move |_event, _window, cx| { let id = id.clone(); markdown.update(cx, |this, cx| { @@ -1696,10 +1712,10 @@ impl RenderedText { while let Some(line) = lines.next() { let line_bounds = line.layout.bounds(); if position.y > line_bounds.bottom() { - if let Some(next_line) = lines.peek() { - if position.y < next_line.layout.bounds().top() { - return Err(line.source_end); - } + if let Some(next_line) = lines.peek() + && position.y < next_line.layout.bounds().top() + { + return Err(line.source_end); } continue; diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index 1035335ccb40f63133c727b5a5be8930d42b818f..62b210f9e33a90a44790c521591ba6f94e8baaef 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -4,7 +4,9 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd; use pulldown_cmark::{ Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser, }; -use std::{collections::HashSet, ops::Range, path::Path, sync::Arc}; +use std::{ops::Range, sync::Arc}; + +use collections::HashSet; use crate::path_range::PathWithRange; @@ -23,11 +25,11 @@ pub fn parse_markdown( ) -> ( Vec<(Range, MarkdownEvent)>, HashSet, - HashSet>, + HashSet>, ) { let mut events = Vec::new(); - let mut language_names = HashSet::new(); - let mut language_paths = HashSet::new(); + let mut language_names = HashSet::default(); + let mut language_paths = HashSet::default(); let mut within_link = false; let mut within_metadata = false; let mut parser = Parser::new_ext(text, PARSE_OPTIONS) @@ -67,7 +69,7 @@ pub fn parse_markdown( MarkdownTag::CodeBlock { kind: CodeBlockKind::Indented, metadata: CodeBlockMetadata { - content_range: range.start + 1..range.end + 1, + content_range: range.clone(), line_count: 1, }, } @@ -247,7 +249,7 @@ pub fn parse_markdown( events.push(event_for( text, range.source_range.start..range.source_range.start + prefix_len, - &head, + head, )); range.parsed = CowStr::Boxed(tail.into()); range.merged_range.start += prefix_len; @@ -579,8 +581,8 @@ mod tests { (30..37, Text), (30..37, End(MarkdownTagEnd::Paragraph)) ], - HashSet::new(), - HashSet::new() + HashSet::default(), + HashSet::default() ) ) } @@ -613,8 +615,8 @@ mod tests { (46..51, Text), (0..51, End(MarkdownTagEnd::Paragraph)) ], - HashSet::new(), - HashSet::new() + HashSet::default(), + HashSet::default() ) ); } @@ -670,8 +672,8 @@ mod tests { (43..53, SubstitutedText("–––––".into())), (0..53, End(MarkdownTagEnd::Paragraph)) ], - HashSet::new(), - HashSet::new() + HashSet::default(), + HashSet::default() ) ) } @@ -695,10 +697,35 @@ mod tests { (8..34, Text), (0..37, End(MarkdownTagEnd::CodeBlock)), ], - HashSet::from(["rust".into()]), - HashSet::new() + { + let mut h = HashSet::default(); + h.insert("rust".into()); + h + }, + HashSet::default() ) - ) + ); + assert_eq!( + parse_markdown(" fn main() {}"), + ( + vec![ + ( + 4..16, + Start(CodeBlock { + kind: CodeBlockKind::Indented, + metadata: CodeBlockMetadata { + content_range: 4..16, + line_count: 1 + } + }) + ), + (4..16, Text), + (4..16, End(MarkdownTagEnd::CodeBlock)) + ], + HashSet::default(), + HashSet::default() + ) + ); } #[test] diff --git a/crates/markdown/src/path_range.rs b/crates/markdown/src/path_range.rs index 19cfda0d9dfb30c550852f64edcad29e3d1e1de9..f98325c9b5aa45420d4e1990d55888675d160d5f 100644 --- a/crates/markdown/src/path_range.rs +++ b/crates/markdown/src/path_range.rs @@ -1,8 +1,8 @@ -use std::{ops::Range, path::Path, sync::Arc}; +use std::{ops::Range, sync::Arc}; #[derive(Debug, Clone, PartialEq)] pub struct PathWithRange { - pub path: Arc, + pub path: Arc, pub range: Option>, } @@ -78,12 +78,12 @@ impl PathWithRange { }; Self { - path: Path::new(path).into(), + path: path.into(), range, } } None => Self { - path: Path::new(str).into(), + path: str.into(), range: None, }, } @@ -123,7 +123,7 @@ mod tests { #[test] fn test_pathrange_parsing() { let path_range = PathWithRange::new("file.rs#L10-L20"); - assert_eq!(path_range.path.as_ref(), Path::new("file.rs")); + assert_eq!(path_range.path.as_ref(), "file.rs"); assert!(path_range.range.is_some()); if let Some(range) = path_range.range { assert_eq!(range.start.line, 10); @@ -133,7 +133,7 @@ mod tests { } let single_line = PathWithRange::new("file.rs#L15"); - assert_eq!(single_line.path.as_ref(), Path::new("file.rs")); + assert_eq!(single_line.path.as_ref(), "file.rs"); assert!(single_line.range.is_some()); if let Some(range) = single_line.range { assert_eq!(range.start.line, 15); @@ -141,11 +141,11 @@ mod tests { } let no_range = PathWithRange::new("file.rs"); - assert_eq!(no_range.path.as_ref(), Path::new("file.rs")); + assert_eq!(no_range.path.as_ref(), "file.rs"); assert!(no_range.range.is_none()); let lowercase = PathWithRange::new("file.rs#l5-l10"); - assert_eq!(lowercase.path.as_ref(), Path::new("file.rs")); + assert_eq!(lowercase.path.as_ref(), "file.rs"); assert!(lowercase.range.is_some()); if let Some(range) = lowercase.range { assert_eq!(range.start.line, 5); @@ -153,7 +153,7 @@ mod tests { } let complex = PathWithRange::new("src/path/to/file.rs#L100"); - assert_eq!(complex.path.as_ref(), Path::new("src/path/to/file.rs")); + assert_eq!(complex.path.as_ref(), "src/path/to/file.rs"); assert!(complex.range.is_some()); } @@ -161,7 +161,7 @@ mod tests { fn test_pathrange_from_str() { let with_range = PathWithRange::new("file.rs#L10-L20"); assert!(with_range.range.is_some()); - assert_eq!(with_range.path.as_ref(), Path::new("file.rs")); + assert_eq!(with_range.path.as_ref(), "file.rs"); let without_range = PathWithRange::new("file.rs"); assert!(without_range.range.is_none()); @@ -173,18 +173,18 @@ mod tests { #[test] fn test_pathrange_leading_text_trimming() { let with_language = PathWithRange::new("```rust file.rs#L10"); - assert_eq!(with_language.path.as_ref(), Path::new("file.rs")); + assert_eq!(with_language.path.as_ref(), "file.rs"); assert!(with_language.range.is_some()); if let Some(range) = with_language.range { assert_eq!(range.start.line, 10); } let with_spaces = PathWithRange::new("``` file.rs#L10-L20"); - assert_eq!(with_spaces.path.as_ref(), Path::new("file.rs")); + assert_eq!(with_spaces.path.as_ref(), "file.rs"); assert!(with_spaces.range.is_some()); let with_words = PathWithRange::new("```rust code example file.rs#L15:10"); - assert_eq!(with_words.path.as_ref(), Path::new("file.rs")); + assert_eq!(with_words.path.as_ref(), "file.rs"); assert!(with_words.range.is_some()); if let Some(range) = with_words.range { assert_eq!(range.start.line, 15); @@ -192,18 +192,18 @@ mod tests { } let with_whitespace = PathWithRange::new(" file.rs#L5"); - assert_eq!(with_whitespace.path.as_ref(), Path::new("file.rs")); + assert_eq!(with_whitespace.path.as_ref(), "file.rs"); assert!(with_whitespace.range.is_some()); let no_leading = PathWithRange::new("file.rs#L10"); - assert_eq!(no_leading.path.as_ref(), Path::new("file.rs")); + assert_eq!(no_leading.path.as_ref(), "file.rs"); assert!(no_leading.range.is_some()); } #[test] fn test_pathrange_with_line_and_column() { let line_and_col = PathWithRange::new("file.rs#L10:5"); - assert_eq!(line_and_col.path.as_ref(), Path::new("file.rs")); + assert_eq!(line_and_col.path.as_ref(), "file.rs"); assert!(line_and_col.range.is_some()); if let Some(range) = line_and_col.range { assert_eq!(range.start.line, 10); @@ -213,7 +213,7 @@ mod tests { } let full_range = PathWithRange::new("file.rs#L10:5-L20:15"); - assert_eq!(full_range.path.as_ref(), Path::new("file.rs")); + assert_eq!(full_range.path.as_ref(), "file.rs"); assert!(full_range.range.is_some()); if let Some(range) = full_range.range { assert_eq!(range.start.line, 10); @@ -223,7 +223,7 @@ mod tests { } let mixed_range1 = PathWithRange::new("file.rs#L10:5-L20"); - assert_eq!(mixed_range1.path.as_ref(), Path::new("file.rs")); + assert_eq!(mixed_range1.path.as_ref(), "file.rs"); assert!(mixed_range1.range.is_some()); if let Some(range) = mixed_range1.range { assert_eq!(range.start.line, 10); @@ -233,7 +233,7 @@ mod tests { } let mixed_range2 = PathWithRange::new("file.rs#L10-L20:15"); - assert_eq!(mixed_range2.path.as_ref(), Path::new("file.rs")); + assert_eq!(mixed_range2.path.as_ref(), "file.rs"); assert!(mixed_range2.range.is_some()); if let Some(range) = mixed_range2.range { assert_eq!(range.start.line, 10); diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index ebdd8a9eb6c0ffbe99f7c14d1e97b13b3a95d8a3..c351ad8634be45f3c7b845eecdbc24e89d1fd190 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -19,10 +19,13 @@ anyhow.workspace = true async-recursion.workspace = true collections.workspace = true editor.workspace = true +fs.workspace = true gpui.workspace = true +html5ever.workspace = true language.workspace = true linkify.workspace = true log.workspace = true +markup5ever_rcdom.workspace = true pretty_assertions.workspace = true pulldown-cmark.workspace = true settings.workspace = true @@ -30,8 +33,6 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true -fs.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index a570e79f5344d0f35693072f82f947004e24ac65..8d2175ab98621fed7e989bbb121cade3afcdf894 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -1,5 +1,6 @@ use gpui::{ - FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, UnderlineStyle, px, + DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, + UnderlineStyle, px, }; use language::HighlightId; use std::{fmt::Display, ops::Range, path::PathBuf}; @@ -15,6 +16,7 @@ pub enum ParsedMarkdownElement { /// A paragraph of text and other inline elements. Paragraph(MarkdownParagraph), HorizontalRule(Range), + Image(Image), } impl ParsedMarkdownElement { @@ -30,6 +32,7 @@ impl ParsedMarkdownElement { MarkdownParagraphChunk::Image(image) => image.source_range.clone(), }, Self::HorizontalRule(range) => range.clone(), + Self::Image(image) => image.source_range.clone(), }) } @@ -61,6 +64,8 @@ pub struct ParsedMarkdownListItem { pub depth: u16, pub item_type: ParsedMarkdownListItemType, pub content: Vec, + /// Whether we can expect nested list items inside of this items `content`. + pub nested: bool, } #[derive(Debug)] @@ -101,25 +106,34 @@ pub enum HeadingLevel { #[derive(Debug)] pub struct ParsedMarkdownTable { pub source_range: Range, - pub header: ParsedMarkdownTableRow, + pub header: Vec, pub body: Vec, - pub column_alignments: Vec, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] #[cfg_attr(test, derive(PartialEq))] pub enum ParsedMarkdownTableAlignment { - /// Default text alignment. + #[default] None, Left, Center, Right, } +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownTableColumn { + pub col_span: usize, + pub row_span: usize, + pub is_header: bool, + pub children: MarkdownParagraph, + pub alignment: ParsedMarkdownTableAlignment, +} + #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct ParsedMarkdownTableRow { - pub children: Vec, + pub columns: Vec, } impl Default for ParsedMarkdownTableRow { @@ -131,12 +145,12 @@ impl Default for ParsedMarkdownTableRow { impl ParsedMarkdownTableRow { pub fn new() -> Self { Self { - children: Vec::new(), + columns: Vec::new(), } } - pub fn with_children(children: Vec) -> Self { - Self { children } + pub fn with_columns(columns: Vec) -> Self { + Self { columns } } } @@ -152,7 +166,7 @@ pub struct ParsedMarkdownText { /// Where the text is located in the source Markdown document. pub source_range: Range, /// The text content stripped of any formatting symbols. - pub contents: String, + pub contents: SharedString, /// The list of highlights contained in the Markdown document. pub highlights: Vec<(Range, MarkdownHighlight)>, /// The regions of the various ranges in the Markdown document. @@ -199,6 +213,13 @@ impl MarkdownHighlight { highlight.font_weight = Some(style.weight); } + if style.link { + highlight.underline = Some(UnderlineStyle { + thickness: px(1.), + ..Default::default() + }); + } + Some(highlight) } @@ -218,6 +239,8 @@ pub struct MarkdownHighlightStyle { pub strikethrough: bool, /// The weight of the text. pub weight: FontWeight, + /// Whether the text should be stylized as link. + pub link: bool, } /// A parsed region in a Markdown document. @@ -290,6 +313,8 @@ pub struct Image { pub link: Link, pub source_range: Range, pub alt_text: Option, + pub width: Option, + pub height: Option, } impl Image { @@ -303,10 +328,20 @@ impl Image { source_range, link, alt_text: None, + width: None, + height: None, }) } pub fn set_alt_text(&mut self, alt_text: SharedString) { self.alt_text = Some(alt_text); } + + pub fn set_width(&mut self, width: DefiniteLength) { + self.width = Some(width); + } + + pub fn set_height(&mut self, height: DefiniteLength) { + self.height = Some(height); + } } diff --git a/crates/markdown_preview/src/markdown_minifier.rs b/crates/markdown_preview/src/markdown_minifier.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7d5ad0be0d9e65617bb45c66eb9748123d43067 --- /dev/null +++ b/crates/markdown_preview/src/markdown_minifier.rs @@ -0,0 +1,829 @@ +use html5ever::{ + Attribute, ParseOpts, QualName, parse_document, + tendril::{Tendril, TendrilSink, fmt::UTF8}, +}; +use markup5ever_rcdom::{Node, NodeData, RcDom}; +use std::{cell::RefCell, io, rc::Rc, str}; + +#[derive(Default)] +pub(crate) struct MinifierOptions { + pub omit_doctype: bool, + pub preserve_comments: bool, + pub collapse_whitespace: bool, +} + +pub(crate) struct Minifier<'a, W: io::Write> { + w: &'a mut W, + options: MinifierOptions, + preceding_whitespace: bool, +} + +impl<'a, W> Minifier<'a, W> +where + W: io::Write, +{ + /// Creates a new `Minifier` instance. + #[inline] + pub fn new(w: &'a mut W, options: MinifierOptions) -> Self { + Self { + w, + options, + preceding_whitespace: false, + } + } + + /// Minifies the given reader input. + /// + /// # Errors + /// + /// Will return `Err` if unable to write to the output writer. + #[inline] + pub fn minify(&mut self, mut r: &mut R) -> io::Result<()> { + let dom = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut r)?; + + if !self.options.omit_doctype { + self.w.write_all(b"")?; + } + + self.minify_node(&None, &dom.document) + } + + fn minify_node<'b>(&mut self, ctx: &'b Option, node: &'b Node) -> io::Result<()> { + match &node.data { + NodeData::Text { contents } => { + // Check if whitespace collapsing disabled + let contents = contents.borrow(); + let contents = contents.as_ref(); + + if !self.options.collapse_whitespace { + return self.w.write_all(contents.as_bytes()); + } + + // Check if parent is whitespace preserving element or contains code ( ", + "", + true, + false, + ), + ( + " ", + "", + true, + false, + ), + ("

A", "

A", true, false), + ("

A", "

A", true, false), + // Retain whitespace, whitespace before

+ ( + "

A ", + "

A ", + false, + false, + ), + // Retain whitespace, touching

+ ("

A", "

A", false, false), + // Comments ignored + ("

A", "

A", false, false), + // Comments preserved + ( + "

A", + "

A", + false, + true, + ), + // Retain end tag if touching inline element + ( + "

Some text

", + "

Some text

", + false, + false, + ), + ] { + let mut w = Vec::new(); + let mut minifier = Minifier::new( + &mut w, + MinifierOptions { + omit_doctype: true, + preserve_comments, + collapse_whitespace, + }, + ); + minifier.minify(&mut input.as_bytes()).unwrap(); + + let s = str::from_utf8(&w).unwrap(); + + assert_eq!(expected, s); + } + } +} diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 27691f2ecffadb7a7df1e9647e7d1d6487135974..8f2203c25b9a7193759668a35016c2d3203310b6 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -1,10 +1,17 @@ -use crate::markdown_elements::*; +use crate::{ + markdown_elements::*, + markdown_minifier::{Minifier, MinifierOptions}, +}; use async_recursion::async_recursion; use collections::FxHashMap; -use gpui::FontWeight; +use gpui::{DefiniteLength, FontWeight, px, relative}; +use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink}; use language::LanguageRegistry; +use markup5ever_rcdom::RcDom; use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; -use std::{ops::Range, path::PathBuf, sync::Arc, vec}; +use std::{ + cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, +}; pub async fn parse_markdown( markdown_input: &str, @@ -26,6 +33,24 @@ pub async fn parse_markdown( } } +fn cleanup_html(source: &str) -> Vec { + let mut writer = std::io::Cursor::new(Vec::new()); + let mut reader = std::io::Cursor::new(source); + let mut minify = Minifier::new( + &mut writer, + MinifierOptions { + omit_doctype: true, + collapse_whitespace: true, + ..Default::default() + }, + ); + if let Ok(()) = minify.minify(&mut reader) { + writer.into_inner() + } else { + source.bytes().collect() + } +} + struct MarkdownParser<'a> { tokens: Vec<(Event<'a>, Range)>, /// The current index in the tokens array @@ -36,6 +61,17 @@ struct MarkdownParser<'a> { language_registry: Option>, } +#[derive(Debug)] +struct ParseHtmlNodeContext { + list_item_depth: u16, +} + +impl Default for ParseHtmlNodeContext { + fn default() -> Self { + Self { list_item_depth: 1 } + } +} + struct MarkdownListItem { content: Vec, item_type: ParsedMarkdownListItemType, @@ -76,22 +112,22 @@ impl<'a> MarkdownParser<'a> { if self.eof() || (steps + self.cursor) >= self.tokens.len() { return self.tokens.last(); } - return self.tokens.get(self.cursor + steps); + self.tokens.get(self.cursor + steps) } fn previous(&self) -> Option<&(Event<'_>, Range)> { if self.cursor == 0 || self.cursor > self.tokens.len() { return None; } - return self.tokens.get(self.cursor - 1); + self.tokens.get(self.cursor - 1) } fn current(&self) -> Option<&(Event<'_>, Range)> { - return self.peek(0); + self.peek(0) } fn current_event(&self) -> Option<&Event<'_>> { - return self.current().map(|(event, _)| event); + self.current().map(|(event, _)| event) } fn is_text_like(event: &Event) -> bool { @@ -172,13 +208,17 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; - let code_block = self.parse_code_block(language).await; + let code_block = self.parse_code_block(language).await?; Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) } + Tag::HtmlBlock => { + self.cursor += 1; + + Some(self.parse_html_block().await) + } _ => None, }, Event::Rule => { - let source_range = source_range.clone(); self.cursor += 1; Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) } @@ -255,7 +295,7 @@ impl<'a> MarkdownParser<'a> { code: false, link: Some(link), }); - style.underline = true; + style.link = true; prev_len } else { // Manually scan for links @@ -263,18 +303,16 @@ impl<'a> MarkdownParser<'a> { finder.kinds(&[linkify::LinkKind::Url]); let mut last_link_len = prev_len; for link in finder.links(t) { - let start = link.start(); - let end = link.end(); - let range = (prev_len + start)..(prev_len + end); + let start = prev_len + link.start(); + let end = prev_len + link.end(); + let range = start..end; link_ranges.push(range.clone()); link_urls.push(link.as_str().to_string()); // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != MarkdownHighlightStyle::default() - && last_link_len < link.start() - { + if style != MarkdownHighlightStyle::default() && last_link_len < start { highlights.push(( - last_link_len..link.start(), + last_link_len..start, MarkdownHighlight::Style(style.clone()), )); } @@ -300,13 +338,12 @@ impl<'a> MarkdownParser<'a> { if style != MarkdownHighlightStyle::default() && last_run_len < text.len() { let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() { - if last_range.end == last_run_len - && last_style == &MarkdownHighlight::Style(style.clone()) - { - last_range.end = text.len(); - new_highlight = false; - } + if let Some((last_range, last_style)) = highlights.last_mut() + && last_range.end == last_run_len + && last_style == &MarkdownHighlight::Style(style.clone()) + { + last_range.end = text.len(); + new_highlight = false; } if new_highlight { highlights.push(( @@ -324,7 +361,7 @@ impl<'a> MarkdownParser<'a> { highlights.push(( prev_len..text.len(), MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, + link: true, ..Default::default() }), )); @@ -348,15 +385,11 @@ impl<'a> MarkdownParser<'a> { if !text.is_empty() { let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { source_range: source_range.clone(), - contents: text.clone(), - highlights: highlights.clone(), - region_ranges: region_ranges.clone(), - regions: regions.clone(), + contents: mem::take(&mut text).into(), + highlights: mem::take(&mut highlights), + region_ranges: mem::take(&mut region_ranges), + regions: mem::take(&mut regions), }); - text = String::new(); - highlights = vec![]; - region_ranges = vec![]; - regions = vec![]; markdown_text_like.push(parsed_regions); } image = Image::identify( @@ -380,7 +413,10 @@ impl<'a> MarkdownParser<'a> { TagEnd::Image => { if let Some(mut image) = image.take() { if !text.is_empty() { - image.alt_text = Some(std::mem::take(&mut text).into()); + image.set_alt_text(std::mem::take(&mut text).into()); + mem::take(&mut highlights); + mem::take(&mut region_ranges); + mem::take(&mut regions); } markdown_text_like.push(MarkdownParagraphChunk::Image(image)); } @@ -402,8 +438,8 @@ impl<'a> MarkdownParser<'a> { } if !text.is_empty() { markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: source_range.clone(), - contents: text, + source_range, + contents: text.into(), highlights, regions, region_ranges, @@ -421,7 +457,7 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; ParsedMarkdownHeading { - source_range: source_range.clone(), + source_range, level: match level { pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1, pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2, @@ -437,11 +473,14 @@ impl<'a> MarkdownParser<'a> { fn parse_table(&mut self, alignment: Vec) -> ParsedMarkdownTable { let (_event, source_range) = self.previous().unwrap(); let source_range = source_range.clone(); - let mut header = ParsedMarkdownTableRow::new(); + let mut header = vec![]; let mut body = vec![]; - let mut current_row = vec![]; + let mut row_columns = vec![]; let mut in_header = true; - let column_alignments = alignment.iter().map(Self::convert_alignment).collect(); + let column_alignments = alignment + .iter() + .map(Self::convert_alignment) + .collect::>(); loop { if self.eof() { @@ -459,17 +498,25 @@ impl<'a> MarkdownParser<'a> { Event::Start(Tag::TableCell) => { self.cursor += 1; let cell_contents = self.parse_text(false, Some(source_range)); - current_row.push(cell_contents); + row_columns.push(ParsedMarkdownTableColumn { + col_span: 1, + row_span: 1, + is_header: in_header, + children: cell_contents, + alignment: column_alignments + .get(row_columns.len()) + .copied() + .unwrap_or_default(), + }); } Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { self.cursor += 1; - let new_row = std::mem::take(&mut current_row); + let columns = std::mem::take(&mut row_columns); if in_header { - header.children = new_row; + header.push(ParsedMarkdownTableRow { columns: columns }); in_header = false; } else { - let row = ParsedMarkdownTableRow::with_children(new_row); - body.push(row); + body.push(ParsedMarkdownTableRow::with_columns(columns)); } } Event::End(TagEnd::Table) => { @@ -486,7 +533,6 @@ impl<'a> MarkdownParser<'a> { source_range, header, body, - column_alignments, } } @@ -579,10 +625,10 @@ impl<'a> MarkdownParser<'a> { } } else { let block = self.parse_block().await; - if let Some(block) = block { - if let Some(list_item) = items_stack.last_mut() { - list_item.content.extend(block); - } + if let Some(block) = block + && let Some(list_item) = items_stack.last_mut() + { + list_item.content.extend(block); } } } @@ -611,6 +657,7 @@ impl<'a> MarkdownParser<'a> { content: list_item.content, depth, item_type: list_item.item_type, + nested: false, }); if let Some(index) = insertion_indices.get(&depth) { @@ -697,13 +744,22 @@ impl<'a> MarkdownParser<'a> { } } - async fn parse_code_block(&mut self, language: Option) -> ParsedMarkdownCodeBlock { - let (_event, source_range) = self.previous().unwrap(); + async fn parse_code_block( + &mut self, + language: Option, + ) -> Option { + let Some((_event, source_range)) = self.previous() else { + return None; + }; + let source_range = source_range.clone(); let mut code = String::new(); while !self.eof() { - let (current, _source_range) = self.current().unwrap(); + let Some((current, _source_range)) = self.current() else { + break; + }; + match current { Event::Text(text) => { code.push_str(text); @@ -736,23 +792,489 @@ impl<'a> MarkdownParser<'a> { None }; - ParsedMarkdownCodeBlock { + Some(ParsedMarkdownCodeBlock { source_range, contents: code.into(), language, highlights, + }) + } + + async fn parse_html_block(&mut self) -> Vec { + let mut elements = Vec::new(); + let Some((_event, _source_range)) = self.previous() else { + return elements; + }; + + let mut html_source_range_start = None; + let mut html_source_range_end = None; + let mut html_buffer = String::new(); + + while !self.eof() { + let Some((current, source_range)) = self.current() else { + break; + }; + let source_range = source_range.clone(); + match current { + Event::Html(html) => { + html_source_range_start.get_or_insert(source_range.start); + html_source_range_end = Some(source_range.end); + html_buffer.push_str(html); + self.cursor += 1; + } + Event::End(TagEnd::CodeBlock) => { + self.cursor += 1; + break; + } + _ => { + break; + } + } + } + + let bytes = cleanup_html(&html_buffer); + + let mut cursor = std::io::Cursor::new(bytes); + if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut cursor) + && let Some((start, end)) = html_source_range_start.zip(html_source_range_end) + { + self.parse_html_node( + start..end, + &dom.document, + &mut elements, + &ParseHtmlNodeContext::default(), + ); + } + + elements + } + + fn parse_html_node( + &self, + source_range: Range, + node: &Rc, + elements: &mut Vec, + context: &ParseHtmlNodeContext, + ) { + match &node.data { + markup5ever_rcdom::NodeData::Document => { + self.consume_children(source_range, node, elements, context); + } + markup5ever_rcdom::NodeData::Text { contents } => { + elements.push(ParsedMarkdownElement::Paragraph(vec![ + MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range, + regions: Vec::default(), + region_ranges: Vec::default(), + highlights: Vec::default(), + contents: contents.borrow().to_string().into(), + }), + ])); + } + markup5ever_rcdom::NodeData::Comment { .. } => {} + markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { + if local_name!("img") == name.local { + if let Some(image) = self.extract_image(source_range, attrs) { + elements.push(ParsedMarkdownElement::Image(image)); + } + } else if local_name!("p") == name.local { + let mut paragraph = MarkdownParagraph::new(); + self.parse_paragraph(source_range, node, &mut paragraph); + + if !paragraph.is_empty() { + elements.push(ParsedMarkdownElement::Paragraph(paragraph)); + } + } else if matches!( + name.local, + local_name!("h1") + | local_name!("h2") + | local_name!("h3") + | local_name!("h4") + | local_name!("h5") + | local_name!("h6") + ) { + let mut paragraph = MarkdownParagraph::new(); + self.consume_paragraph(source_range.clone(), node, &mut paragraph); + + if !paragraph.is_empty() { + elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + source_range, + level: match name.local { + local_name!("h1") => HeadingLevel::H1, + local_name!("h2") => HeadingLevel::H2, + local_name!("h3") => HeadingLevel::H3, + local_name!("h4") => HeadingLevel::H4, + local_name!("h5") => HeadingLevel::H5, + local_name!("h6") => HeadingLevel::H6, + _ => unreachable!(), + }, + contents: paragraph, + })); + } + } else if local_name!("ul") == name.local || local_name!("ol") == name.local { + if let Some(list_items) = self.extract_html_list( + node, + local_name!("ol") == name.local, + context.list_item_depth, + source_range, + ) { + elements.extend(list_items); + } + } else if local_name!("blockquote") == name.local { + if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { + elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); + } + } else if local_name!("table") == name.local { + if let Some(table) = self.extract_html_table(node, source_range) { + elements.push(ParsedMarkdownElement::Table(table)); + } + } else { + self.consume_children(source_range, node, elements, context); + } + } + _ => {} + } + } + + fn parse_paragraph( + &self, + source_range: Range, + node: &Rc, + paragraph: &mut MarkdownParagraph, + ) { + match &node.data { + markup5ever_rcdom::NodeData::Text { contents } => { + paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range, + regions: Vec::default(), + region_ranges: Vec::default(), + highlights: Vec::default(), + contents: contents.borrow().to_string().into(), + })); + } + markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { + if local_name!("img") == name.local { + if let Some(image) = self.extract_image(source_range, attrs) { + paragraph.push(MarkdownParagraphChunk::Image(image)); + } + } else { + self.consume_paragraph(source_range, node, paragraph); + } + } + _ => {} + } + } + + fn consume_paragraph( + &self, + source_range: Range, + node: &Rc, + paragraph: &mut MarkdownParagraph, + ) { + for node in node.children.borrow().iter() { + self.parse_paragraph(source_range.clone(), node, paragraph); + } + } + + fn parse_table_row( + &self, + source_range: Range, + node: &Rc, + ) -> Option { + let mut columns = Vec::new(); + + match &node.data { + markup5ever_rcdom::NodeData::Element { name, .. } => { + if local_name!("tr") != name.local { + return None; + } + + for node in node.children.borrow().iter() { + if let Some(column) = self.parse_table_column(source_range.clone(), node) { + columns.push(column); + } + } + } + _ => {} + } + + if columns.is_empty() { + None + } else { + Some(ParsedMarkdownTableRow { columns }) + } + } + + fn parse_table_column( + &self, + source_range: Range, + node: &Rc, + ) -> Option { + match &node.data { + markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { + if !matches!(name.local, local_name!("th") | local_name!("td")) { + return None; + } + + let mut children = MarkdownParagraph::new(); + self.consume_paragraph(source_range, node, &mut children); + + let is_header = matches!(name.local, local_name!("th")); + + Some(ParsedMarkdownTableColumn { + col_span: std::cmp::max( + Self::attr_value(attrs, local_name!("colspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + row_span: std::cmp::max( + Self::attr_value(attrs, local_name!("rowspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + is_header, + children, + alignment: Self::attr_value(attrs, local_name!("align")) + .and_then(|align| match align.as_str() { + "left" => Some(ParsedMarkdownTableAlignment::Left), + "center" => Some(ParsedMarkdownTableAlignment::Center), + "right" => Some(ParsedMarkdownTableAlignment::Right), + _ => None, + }) + .unwrap_or_else(|| { + if is_header { + ParsedMarkdownTableAlignment::Center + } else { + ParsedMarkdownTableAlignment::default() + } + }), + }) + } + _ => None, + } + } + + fn consume_children( + &self, + source_range: Range, + node: &Rc, + elements: &mut Vec, + context: &ParseHtmlNodeContext, + ) { + for node in node.children.borrow().iter() { + self.parse_html_node(source_range.clone(), node, elements, context); + } + } + + fn attr_value( + attrs: &RefCell>, + name: html5ever::LocalName, + ) -> Option { + attrs.borrow().iter().find_map(|attr| { + if attr.name.local == name { + Some(attr.value.to_string()) + } else { + None + } + }) + } + + fn extract_styles_from_attributes( + attrs: &RefCell>, + ) -> HashMap { + let mut styles = HashMap::new(); + + if let Some(style) = Self::attr_value(attrs, local_name!("style")) { + for decl in style.split(';') { + let mut parts = decl.splitn(2, ':'); + if let Some((key, value)) = parts.next().zip(parts.next()) { + styles.insert( + key.trim().to_lowercase().to_string(), + value.trim().to_string(), + ); + } + } + } + + styles + } + + fn extract_image( + &self, + source_range: Range, + attrs: &RefCell>, + ) -> Option { + let src = Self::attr_value(attrs, local_name!("src"))?; + + let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?; + + if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) { + image.set_alt_text(alt.into()); + } + + let styles = Self::extract_styles_from_attributes(attrs); + + if let Some(width) = Self::attr_value(attrs, local_name!("width")) + .or_else(|| styles.get("width").cloned()) + .and_then(|width| Self::parse_html_element_dimension(&width)) + { + image.set_width(width); + } + + if let Some(height) = Self::attr_value(attrs, local_name!("height")) + .or_else(|| styles.get("height").cloned()) + .and_then(|height| Self::parse_html_element_dimension(&height)) + { + image.set_height(height); + } + + Some(image) + } + + fn extract_html_list( + &self, + node: &Rc, + ordered: bool, + depth: u16, + source_range: Range, + ) -> Option> { + let mut list_items = Vec::with_capacity(node.children.borrow().len()); + + for (index, node) in node.children.borrow().iter().enumerate() { + match &node.data { + markup5ever_rcdom::NodeData::Element { name, .. } => { + if local_name!("li") != name.local { + continue; + } + + let mut content = Vec::new(); + self.consume_children( + source_range.clone(), + node, + &mut content, + &ParseHtmlNodeContext { + list_item_depth: depth + 1, + }, + ); + + if !content.is_empty() { + list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + depth, + source_range: source_range.clone(), + item_type: if ordered { + ParsedMarkdownListItemType::Ordered(index as u64 + 1) + } else { + ParsedMarkdownListItemType::Unordered + }, + content, + nested: true, + })); + } + } + _ => {} + } + } + + if list_items.is_empty() { + None + } else { + Some(list_items) + } + } + + fn parse_html_element_dimension(value: &str) -> Option { + if value.ends_with("%") { + value + .trim_end_matches("%") + .parse::() + .ok() + .map(|value| relative(value / 100.)) + } else { + value + .trim_end_matches("px") + .parse() + .ok() + .map(|value| px(value).into()) + } + } + + fn extract_html_blockquote( + &self, + node: &Rc, + source_range: Range, + ) -> Option { + let mut children = Vec::new(); + self.consume_children( + source_range.clone(), + node, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + if children.is_empty() { + None + } else { + Some(ParsedMarkdownBlockQuote { + children, + source_range, + }) + } + } + + fn extract_html_table( + &self, + node: &Rc, + source_range: Range, + ) -> Option { + let mut header_rows = Vec::new(); + let mut body_rows = Vec::new(); + + // node should be a thead or tbody element + for node in node.children.borrow().iter() { + match &node.data { + markup5ever_rcdom::NodeData::Element { name, .. } => { + if local_name!("thead") == name.local { + // node should be a tr element + for node in node.children.borrow().iter() { + if let Some(row) = self.parse_table_row(source_range.clone(), node) { + header_rows.push(row); + } + } + } else if local_name!("tbody") == name.local { + // node should be a tr element + for node in node.children.borrow().iter() { + if let Some(row) = self.parse_table_row(source_range.clone(), node) { + body_rows.push(row); + } + } + } + } + _ => {} + } + } + + if !header_rows.is_empty() || !body_rows.is_empty() { + Some(ParsedMarkdownTable { + source_range, + body: body_rows, + header: header_rows, + }) + } else { + None } } } #[cfg(test)] mod tests { - use core::panic; - use super::*; - use ParsedMarkdownListItemType::*; - use gpui::BackgroundExecutor; + use core::panic; + use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength}; use language::{ HighlightId, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, tree_sitter_rust, }; @@ -829,7 +1351,7 @@ mod tests { ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text( ParsedMarkdownText { source_range: 0..35, - contents: "Some bostrikethroughld text".to_string(), + contents: "Some bostrikethroughld text".into(), highlights: Vec::new(), region_ranges: Vec::new(), regions: Vec::new(), @@ -920,15 +1442,40 @@ mod tests { panic!("Expected a paragraph"); }; assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..111, - link: Link::Web { - url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), - }, - alt_text: Some("test".into()), - },) - ); + paragraph[0], + MarkdownParagraphChunk::Image(Image { + source_range: 0..111, + link: Link::Web { + url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), + }, + alt_text: Some("test".into()), + height: None, + width: None, + },) + ); + } + + #[gpui::test] + async fn test_image_alt_text() { + let parsed = parse("[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)\n ").await; + + let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { + text + } else { + panic!("Expected a paragraph"); + }; + assert_eq!( + paragraph[0], + MarkdownParagraphChunk::Image(Image { + source_range: 0..142, + link: Link::Web { + url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(), + }, + alt_text: Some("Zed".into()), + height: None, + width: None, + },) + ); } #[gpui::test] @@ -948,6 +1495,8 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: None, + height: None, + width: None, },) ); } @@ -967,6 +1516,8 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: Some("foo bar baz".into()), + height: None, + width: None, }),], ); } @@ -992,10 +1543,12 @@ mod tests { url: "http://example.com/foo.png".to_string(), }, alt_text: Some("foo".into()), + height: None, + width: None, }), MarkdownParagraphChunk::Text(ParsedMarkdownText { source_range: 0..81, - contents: " Lorem Ipsum ".to_string(), + contents: " Lorem Ipsum ".into(), highlights: Vec::new(), region_ranges: Vec::new(), regions: Vec::new(), @@ -1006,11 +1559,689 @@ mod tests { url: "http://example.com/bar.png".to_string(), }, alt_text: Some("bar".into()), + height: None, + width: None, }) ] ); } + #[test] + fn test_parse_html_element_dimension() { + // Test percentage values + assert_eq!( + MarkdownParser::parse_html_element_dimension("50%"), + Some(DefiniteLength::Fraction(0.5)) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("100%"), + Some(DefiniteLength::Fraction(1.0)) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("25%"), + Some(DefiniteLength::Fraction(0.25)) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("0%"), + Some(DefiniteLength::Fraction(0.0)) + ); + + // Test pixel values + assert_eq!( + MarkdownParser::parse_html_element_dimension("100px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("50px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0)))) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("0px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0)))) + ); + + // Test values without units (should be treated as pixels) + assert_eq!( + MarkdownParser::parse_html_element_dimension("100"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("42"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) + ); + + // Test invalid values + assert_eq!( + MarkdownParser::parse_html_element_dimension("invalid"), + None + ); + assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None); + assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None); + assert_eq!(MarkdownParser::parse_html_element_dimension(""), None); + assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None); + assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None); + + // Test decimal values + assert_eq!( + MarkdownParser::parse_html_element_dimension("50.5%"), + Some(DefiniteLength::Fraction(0.505)) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("100.25px"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25)))) + ); + assert_eq!( + MarkdownParser::parse_html_element_dimension("42.0"), + Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) + ); + } + + #[gpui::test] + async fn test_html_unordered_list() { + let parsed = parse( + "
    +
  • Item 1
  • +
  • Item 2
  • +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] + ), + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_ordered_list() { + let parsed = parse( + "
    +
  1. Item 1
  2. +
  3. Item 2
  4. +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] + ), + nested_list_item( + 0..82, + 1, + ParsedMarkdownListItemType::Ordered(2), + vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_nested_ordered_list() { + let parsed = parse( + "
    +
  1. Item 1
  2. +
  3. Item 2 +
      +
    1. Sub-Item 1
    2. +
    3. Sub-Item 2
    4. +
    +
  4. +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Ordered(2), + vec![ + ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Ordered(1), + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Ordered(2), + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] + ), + ] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_nested_unordered_list() { + let parsed = parse( + "
    +
  • Item 1
  • +
  • Item 2 +
      +
    • Sub-Item 1
    • +
    • Sub-Item 2
    • +
    +
  • +
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 1, + ParsedMarkdownListItemType::Unordered, + vec![ + ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] + ), + nested_list_item( + 0..216, + 2, + ParsedMarkdownListItemType::Unordered, + vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] + ), + ] + ), + ] + }, + parsed + ); + } + + #[gpui::test] + async fn test_inline_html_image_tag() { + let parsed = + parse("

Some text some more text

") + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Paragraph(vec![ + MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..71, + contents: "Some text".into(), + highlights: Default::default(), + region_ranges: Default::default(), + regions: Default::default() + }), + MarkdownParagraphChunk::Image(Image { + source_range: 0..71, + link: Link::Web { + url: "http://example.com/foo.png".to_string(), + }, + alt_text: None, + height: None, + width: None, + }), + MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..71, + contents: " some more text".into(), + highlights: Default::default(), + region_ranges: Default::default(), + regions: Default::default() + }), + ])] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_block_quote() { + let parsed = parse( + "
+

some description

+
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![block_quote( + vec![ParsedMarkdownElement::Paragraph(text( + "some description", + 0..78 + ))], + 0..78, + )] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_nested_block_quote() { + let parsed = parse( + "
+

some description

+
+

second description

+
+
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![block_quote( + vec![ + ParsedMarkdownElement::Paragraph(text("some description", 0..179)), + block_quote( + vec![ParsedMarkdownElement::Paragraph(text( + "second description", + 0..179 + ))], + 0..179, + ) + ], + 0..179, + )] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_table() { + let parsed = parse( + " + + + + + + + + + + + + + + + + +
IdName
1Chris
2Dennis
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Table(table( + 0..366, + vec![row(vec![ + column( + 1, + 1, + true, + text("Id", 0..366), + ParsedMarkdownTableAlignment::Center + ), + column( + 1, + 1, + true, + text("Name ", 0..366), + ParsedMarkdownTableAlignment::Center + ) + ])], + vec![ + row(vec![ + column( + 1, + 1, + false, + text("1", 0..366), + ParsedMarkdownTableAlignment::None + ), + column( + 1, + 1, + false, + text("Chris", 0..366), + ParsedMarkdownTableAlignment::None + ) + ]), + row(vec![ + column( + 1, + 1, + false, + text("2", 0..366), + ParsedMarkdownTableAlignment::None + ), + column( + 1, + 1, + false, + text("Dennis", 0..366), + ParsedMarkdownTableAlignment::None + ) + ]), + ], + ))], + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_table_without_headings() { + let parsed = parse( + " + + + + + + + + + + +
1Chris
2Dennis
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Table(table( + 0..240, + vec![], + vec![ + row(vec![ + column( + 1, + 1, + false, + text("1", 0..240), + ParsedMarkdownTableAlignment::None + ), + column( + 1, + 1, + false, + text("Chris", 0..240), + ParsedMarkdownTableAlignment::None + ) + ]), + row(vec![ + column( + 1, + 1, + false, + text("2", 0..240), + ParsedMarkdownTableAlignment::None + ), + column( + 1, + 1, + false, + text("Dennis", 0..240), + ParsedMarkdownTableAlignment::None + ) + ]), + ], + ))], + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_table_without_body() { + let parsed = parse( + " + + + + + + +
IdName
", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Table(table( + 0..150, + vec![row(vec![ + column( + 1, + 1, + true, + text("Id", 0..150), + ParsedMarkdownTableAlignment::Center + ), + column( + 1, + 1, + true, + text("Name", 0..150), + ParsedMarkdownTableAlignment::Center + ) + ])], + vec![], + ))], + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_heading_tags() { + let parsed = parse("

Heading

Heading

Heading

Heading

Heading
Heading
").await; + + assert_eq!( + ParsedMarkdown { + children: vec![ + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H1, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H2, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H3, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H4, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H5, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + level: HeadingLevel::H6, + source_range: 0..96, + contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..96, + contents: "Heading".into(), + highlights: Vec::default(), + region_ranges: Vec::default(), + regions: Vec::default() + })], + }), + ], + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_image_tag() { + let parsed = parse("").await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Image(Image { + source_range: 0..40, + link: Link::Web { + url: "http://example.com/foo.png".to_string(), + }, + alt_text: None, + height: None, + width: None, + })] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_image_tag_with_alt_text() { + let parsed = parse("\"Foo\"").await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Image(Image { + source_range: 0..50, + link: Link::Web { + url: "http://example.com/foo.png".to_string(), + }, + alt_text: Some("Foo".into()), + height: None, + width: None, + })] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_image_tag_with_height_and_width() { + let parsed = + parse("").await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Image(Image { + source_range: 0..65, + link: Link::Web { + url: "http://example.com/foo.png".to_string(), + }, + alt_text: None, + height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), + width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), + })] + }, + parsed + ); + } + + #[gpui::test] + async fn test_html_image_style_tag_with_height_and_width() { + let parsed = parse( + "", + ) + .await; + + assert_eq!( + ParsedMarkdown { + children: vec![ParsedMarkdownElement::Image(Image { + source_range: 0..75, + link: Link::Web { + url: "http://example.com/foo.png".to_string(), + }, + alt_text: None, + height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), + width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), + })] + }, + parsed + ); + } + #[gpui::test] async fn test_header_only_table() { let markdown = "\ @@ -1022,7 +2253,22 @@ Some other content let expected_table = table( 0..48, - row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]), + vec![row(vec![ + column( + 1, + 1, + true, + text("Header 1", 1..11), + ParsedMarkdownTableAlignment::None, + ), + column( + 1, + 1, + true, + text("Header 2", 12..22), + ParsedMarkdownTableAlignment::None, + ), + ])], vec![], ); @@ -1042,10 +2288,55 @@ Some other content let expected_table = table( 0..95, - row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]), + vec![row(vec![ + column( + 1, + 1, + true, + text("Header 1", 1..11), + ParsedMarkdownTableAlignment::None, + ), + column( + 1, + 1, + true, + text("Header 2", 12..22), + ParsedMarkdownTableAlignment::None, + ), + ])], vec![ - row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]), - row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]), + row(vec![ + column( + 1, + 1, + false, + text("Cell 1", 49..59), + ParsedMarkdownTableAlignment::None, + ), + column( + 1, + 1, + false, + text("Cell 2", 60..70), + ParsedMarkdownTableAlignment::None, + ), + ]), + row(vec![ + column( + 1, + 1, + false, + text("Cell 3", 73..83), + ParsedMarkdownTableAlignment::None, + ), + column( + 1, + 1, + false, + text("Cell 4", 84..94), + ParsedMarkdownTableAlignment::None, + ), + ]), ], ); @@ -1458,7 +2749,7 @@ fn main() { region_ranges: Vec::new(), regions: Vec::new(), source_range, - contents: contents.to_string(), + contents: contents.to_string().into(), })] } @@ -1497,24 +2788,55 @@ fn main() { item_type, depth, content, + nested: false, + }) + } + + fn nested_list_item( + source_range: Range, + depth: u16, + item_type: ParsedMarkdownListItemType, + content: Vec, + ) -> ParsedMarkdownElement { + ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + source_range, + item_type, + depth, + content, + nested: true, }) } fn table( source_range: Range, - header: ParsedMarkdownTableRow, + header: Vec, body: Vec, ) -> ParsedMarkdownTable { ParsedMarkdownTable { - column_alignments: Vec::new(), source_range, header, body, } } - fn row(children: Vec) -> ParsedMarkdownTableRow { - ParsedMarkdownTableRow { children } + fn row(columns: Vec) -> ParsedMarkdownTableRow { + ParsedMarkdownTableRow { columns } + } + + fn column( + col_span: usize, + row_span: usize, + is_header: bool, + children: MarkdownParagraph, + alignment: ParsedMarkdownTableAlignment, + ) -> ParsedMarkdownTableColumn { + ParsedMarkdownTableColumn { + col_span, + row_span, + is_header, + children, + alignment, + } } impl PartialEq for ParsedMarkdownTable { diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 91c0005097d778d4b60f7a8a721ed898f0059ed1..77bad89a629cbb1f660e1cd16158d4dbca03361e 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -2,6 +2,7 @@ use gpui::{App, actions}; use workspace::Workspace; pub mod markdown_elements; +mod markdown_minifier; pub mod markdown_parser; pub mod markdown_preview_view; pub mod markdown_renderer; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index a0c8819991d68336a306af85a4dd709353222fa1..f62ff0874df8079f44868dfeaa1ad2fd0348e474 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -13,7 +13,7 @@ use gpui::{ use language::LanguageRegistry; use settings::Settings; use theme::ThemeSettings; -use ui::prelude::*; +use ui::{WithScrollbar, prelude::*}; use workspace::item::{Item, ItemHandle}; use workspace::{Pane, Workspace}; @@ -115,8 +115,7 @@ impl MarkdownPreviewView { pane.activate_item(existing_follow_view_idx, true, true, window, cx); }); } else { - let view = - Self::create_following_markdown_view(workspace, editor.clone(), window, cx); + let view = Self::create_following_markdown_view(workspace, editor, window, cx); workspace.active_pane().update(cx, |pane, cx| { pane.add_item(Box::new(view.clone()), true, true, None, window, cx) }); @@ -151,10 +150,9 @@ impl MarkdownPreviewView { if let Some(editor) = workspace .active_item(cx) .and_then(|item| item.act_as::(cx)) + && Self::is_markdown_file(&editor, cx) { - if Self::is_markdown_file(&editor, cx) { - return Some(editor); - } + return Some(editor); } None } @@ -243,32 +241,30 @@ impl MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { - if let Some(item) = active_item { - if item.item_id() != cx.entity_id() { - if let Some(editor) = item.act_as::(cx) { - if Self::is_markdown_file(&editor, cx) { - self.set_editor(editor, window, cx); - } - } - } + if let Some(item) = active_item + && item.item_id() != cx.entity_id() + && let Some(editor) = item.act_as::(cx) + && Self::is_markdown_file(&editor, cx) + { + self.set_editor(editor, window, cx); } } pub fn is_markdown_file(editor: &Entity, cx: &mut Context) -> bool { let buffer = editor.read(cx).buffer().read(cx); - if let Some(buffer) = buffer.as_singleton() { - if let Some(language) = buffer.read(cx).language() { - return language.name() == "Markdown".into(); - } + if let Some(buffer) = buffer.as_singleton() + && let Some(language) = buffer.read(cx).language() + { + return language.name() == "Markdown".into(); } false } fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { - if let Some(active) = &self.active_editor { - if active.editor == editor { - return; - } + if let Some(active) = &self.active_editor + && active.editor == editor + { + return; } let subscription = cx.subscribe_in( @@ -282,8 +278,12 @@ impl MarkdownPreviewView { this.parse_markdown_from_active_editor(true, window, cx); } EditorEvent::SelectionsChanged { .. } => { - let selection_range = editor - .update(cx, |editor, cx| editor.selections.last::(cx).range()); + let selection_range = editor.update(cx, |editor, cx| { + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + }); this.selected_block = this.get_block_index_under_cursor(selection_range); this.list_state.scroll_to_reveal_item(this.selected_block); cx.notify(); @@ -485,7 +485,7 @@ impl Item for MarkdownPreviewView { } impl Render for MarkdownPreviewView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx); let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height; @@ -552,21 +552,20 @@ impl Render for MarkdownPreviewView { .group("markdown-block") .on_click(cx.listener( move |this, event: &ClickEvent, window, cx| { - if event.click_count() == 2 { - if let Some(source_range) = this + if event.click_count() == 2 + && let Some(source_range) = this .contents .as_ref() .and_then(|c| c.children.get(ix)) .and_then(|block: &ParsedMarkdownElement| { block.source_range() }) - { - this.move_cursor_to_block( - window, - cx, - source_range.start..source_range.start, - ); - } + { + this.move_cursor_to_block( + window, + cx, + source_range.start..source_range.start, + ); } }, )) @@ -603,5 +602,6 @@ impl Render for MarkdownPreviewView { .size_full(), ) })) + .vertical_scrollbar_for(self.list_state.clone(), window, cx) } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 37d2ca21105566f1e2e3271f49c75a3ce1d7846b..38b38466394cf8073b26b1ca5728eecc8230d9c1 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,15 +1,15 @@ use crate::markdown_elements::{ - HeadingLevel, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, + HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, }; use fs::normalize_path; use gpui::{ - AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div, - Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, - Keystroke, Length, Modifiers, ParentElement, Render, Resource, SharedString, Styled, - StyledText, TextStyle, WeakEntity, Window, div, img, rems, + AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element, + ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, + Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, + WeakEntity, Window, div, img, rems, }; use settings::Settings; use std::{ @@ -22,7 +22,7 @@ use ui::{ ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, IconSize, InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, Pixels, Rems, StatefulInteractiveElement, StyledExt, StyledImage, ToggleState, Tooltip, VisibleOnHover, - h_flex, relative, tooltip_container, v_flex, + h_flex, tooltip_container, v_flex, }; use workspace::{OpenOptions, OpenVisible, Workspace}; @@ -51,7 +51,10 @@ pub struct RenderContext { buffer_text_style: TextStyle, text_style: TextStyle, border_color: Hsla, + title_bar_background_color: Hsla, + panel_background_color: Hsla, text_color: Hsla, + link_color: Hsla, window_rem_size: Pixels, text_muted_color: Hsla, code_block_background_color: Hsla, @@ -59,6 +62,7 @@ pub struct RenderContext { syntax_theme: Arc, indent: usize, checkbox_clicked_callback: Option, + is_last_child: bool, } impl RenderContext { @@ -84,12 +88,16 @@ impl RenderContext { text_style: window.text_style(), syntax_theme: theme.syntax().clone(), border_color: theme.colors().border, + title_bar_background_color: theme.colors().title_bar_background, + panel_background_color: theme.colors().panel_background, text_color: theme.colors().text, + link_color: theme.colors().text_accent, window_rem_size: window.rem_size(), text_muted_color: theme.colors().text_muted, code_block_background_color: theme.colors().surface_background, code_span_background_color: theme.colors().editor_document_highlight_read_background, checkbox_clicked_callback: None, + is_last_child: false, } } @@ -111,11 +119,10 @@ impl RenderContext { /// buffer font size changes. The callees of this function should be reimplemented to use real /// relative sizing once that is implemented in GPUI pub fn scaled_rems(&self, rems: f32) -> Rems { - return self - .buffer_text_style + self.buffer_text_style .font_size .to_rems(self.window_rem_size) - .mul(rems); + .mul(rems) } /// This ensures that children inside of block quotes @@ -132,12 +139,25 @@ impl RenderContext { /// We give padding between "This is a block quote." /// and "And this is the next paragraph." fn with_common_p(&self, element: Div) -> Div { - if self.indent > 0 { + if self.indent > 0 && !self.is_last_child { element.pb(self.scaled_rems(0.75)) } else { element } } + + /// The is used to indicate that the current element is the last child or not of its parent. + /// + /// Then we can avoid adding padding to the bottom of the last child. + fn with_last_child(&mut self, is_last: bool, render: R) -> AnyElement + where + R: FnOnce(&mut Self) -> AnyElement, + { + self.is_last_child = is_last; + let element = render(self); + self.is_last_child = false; + element + } } pub fn render_parsed_markdown( @@ -165,6 +185,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), CodeBlock(code_block) => render_markdown_code_block(code_block, cx), HorizontalRule(_) => render_markdown_rule(cx), + Image(image) => render_markdown_image(image, cx), } } @@ -212,12 +233,11 @@ fn render_markdown_list_item( cx: &mut RenderContext, ) -> AnyElement { use ParsedMarkdownListItemType::*; - - let padding = cx.scaled_rems((parsed.depth - 1) as f32); + let depth = parsed.depth.saturating_sub(1) as usize; let bullet = match &parsed.item_type { - Ordered(order) => format!("{}.", order).into_any_element(), - Unordered => "•".into_any_element(), + Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(), + Unordered => list_item_prefix(1, false, depth).into_any_element(), Task(checked, range) => div() .id(cx.next_id(range)) .mt(cx.scaled_rems(3.0 / 16.0)) @@ -273,11 +293,16 @@ fn render_markdown_list_item( .collect(); let item = h_flex() - .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding))) + .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32))) + .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5()) .items_start() .children(vec![ bullet, - div().children(contents).pr(cx.scaled_rems(1.0)).w_full(), + v_flex() + .children(contents) + .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0))) + .pr(cx.scaled_rems(1.0)) + .w_full(), ]); cx.with_common_p(item).into_any() @@ -444,112 +469,105 @@ impl gpui::RenderOnce for MarkdownCheckbox { } } -fn paragraph_len(paragraphs: &MarkdownParagraph) -> usize { - paragraphs - .iter() - .map(|paragraph| match paragraph { - MarkdownParagraphChunk::Text(text) => text.contents.len(), - // TODO: Scale column width based on image size - MarkdownParagraphChunk::Image(_) => 1, - }) - .sum() +fn calculate_table_columns_count(rows: &Vec) -> usize { + let mut actual_column_count = 0; + for row in rows { + actual_column_count = actual_column_count.max( + row.columns + .iter() + .map(|column| column.col_span) + .sum::(), + ); + } + actual_column_count } fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { - let mut max_lengths: Vec = vec![0; parsed.header.children.len()]; + let actual_header_column_count = calculate_table_columns_count(&parsed.header); + let actual_body_column_count = calculate_table_columns_count(&parsed.body); + let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count); - for (index, cell) in parsed.header.children.iter().enumerate() { - let length = paragraph_len(&cell); - max_lengths[index] = length; - } + let total_rows = parsed.header.len() + parsed.body.len(); - for row in &parsed.body { - for (index, cell) in row.children.iter().enumerate() { - let length = paragraph_len(&cell); + // Track which grid cells are occupied by spanning cells + let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; - if length > max_lengths[index] { - max_lengths[index] = length; - } - } - } + let mut cells = Vec::with_capacity(total_rows * max_column_count); - let total_max_length: usize = max_lengths.iter().sum(); - let max_column_widths: Vec = max_lengths - .iter() - .map(|&length| length as f32 / total_max_length as f32) - .collect(); + for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() { + let mut col_idx = 0; - let header = render_markdown_table_row( - &parsed.header, - &parsed.column_alignments, - &max_column_widths, - true, - cx, - ); + for cell in row.columns.iter() { + // Skip columns occupied by row-spanning cells from previous rows + while col_idx < max_column_count && grid_occupied[row_idx][col_idx] { + col_idx += 1; + } - let body: Vec = parsed - .body - .iter() - .map(|row| { - render_markdown_table_row( - row, - &parsed.column_alignments, - &max_column_widths, - false, - cx, - ) - }) - .collect(); + if col_idx >= max_column_count { + break; + } - cx.with_common_p(v_flex()) - .w_full() - .child(header) - .children(body) - .into_any() -} + let container = match cell.alignment { + ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), + ParsedMarkdownTableAlignment::Center => v_flex().items_center(), + ParsedMarkdownTableAlignment::Right => v_flex().items_end(), + }; -fn render_markdown_table_row( - parsed: &ParsedMarkdownTableRow, - alignments: &Vec, - max_column_widths: &Vec, - is_header: bool, - cx: &mut RenderContext, -) -> AnyElement { - let mut items = vec![]; + let cell_element = container + .col_span(cell.col_span.min(max_column_count - col_idx) as u16) + .row_span(cell.row_span.min(total_rows - row_idx) as u16) + .children(render_markdown_text(&cell.children, cx)) + .px_2() + .py_1() + .border_1() + .size_full() + .border_color(cx.border_color) + .when(cell.is_header, |this| { + this.bg(cx.title_bar_background_color) + }) + .when(cell.row_span > 1, |this| this.justify_center()) + .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); + + cells.push(cell_element); + + // Mark grid positions as occupied for row-spanning cells + for r in 0..cell.row_span { + for c in 0..cell.col_span { + if row_idx + r < total_rows && col_idx + c < max_column_count { + grid_occupied[row_idx + r][col_idx + c] = true; + } + } + } - for (index, cell) in parsed.children.iter().enumerate() { - let alignment = alignments - .get(index) - .copied() - .unwrap_or(ParsedMarkdownTableAlignment::None); + col_idx += cell.col_span; + } - let contents = render_markdown_text(cell, cx); + // Fill remaining columns with empty cells if needed + while col_idx < max_column_count { + if grid_occupied[row_idx][col_idx] { + col_idx += 1; + continue; + } - let container = match alignment { - ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), - ParsedMarkdownTableAlignment::Center => v_flex().items_center(), - ParsedMarkdownTableAlignment::Right => v_flex().items_end(), - }; + let empty_cell = div() + .border_1() + .size_full() + .border_color(cx.border_color) + .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - let max_width = max_column_widths.get(index).unwrap_or(&0.0); - let mut cell = container - .w(Length::Definite(relative(*max_width))) - .h_full() - .children(contents) - .px_2() - .py_1() - .border_color(cx.border_color); - - if is_header { - cell = cell.border_2() - } else { - cell = cell.border_1() + cells.push(empty_cell); + col_idx += 1; } - - items.push(cell); } - h_flex().children(items).into_any_element() + cx.with_common_p(div()) + .grid() + .size_full() + .grid_cols(max_column_count as u16) + .border_1() + .border_color(cx.border_color) + .children(cells) + .into_any() } fn render_markdown_block_quote( @@ -561,7 +579,12 @@ fn render_markdown_block_quote( let children: Vec = parsed .children .iter() - .map(|child| render_markdown_block(child, cx)) + .enumerate() + .map(|(ix, child)| { + cx.with_last_child(ix + 1 == parsed.children.len(), |cx| { + render_markdown_block(child, cx) + }) + }) .collect(); cx.indent -= 1; @@ -632,12 +655,13 @@ fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) } fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec { - let mut any_element = vec![]; + let mut any_element = Vec::with_capacity(parsed_new.len()); // these values are cloned in-order satisfy borrow checker let syntax_theme = cx.syntax_theme.clone(); let workspace_clone = cx.workspace.clone(); let code_span_bg_color = cx.code_span_background_color; let text_style = cx.text_style.clone(); + let link_color = cx.link_color; for parsed_region in parsed_new { match parsed_region { @@ -660,6 +684,14 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) ..Default::default() }, )) + } else if region.link.is_some() { + Some(( + range.clone(), + HighlightStyle { + color: Some(link_color), + ..Default::default() + }, + )) } else { None } @@ -723,65 +755,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) } MarkdownParagraphChunk::Image(image) => { - let image_resource = match image.link.clone() { - Link::Web { url } => Resource::Uri(url.into()), - Link::Path { path, .. } => Resource::Path(Arc::from(path)), - }; - - let element_id = cx.next_id(&image.source_range); - - let image_element = div() - .id(element_id) - .cursor_pointer() - .child( - img(ImageSource::Resource(image_resource)) - .max_w_full() - .with_fallback({ - let alt_text = image.alt_text.clone(); - move || div().children(alt_text.clone()).into_any_element() - }), - ) - .tooltip({ - let link = image.link.clone(); - move |_, cx| { - InteractiveMarkdownElementTooltip::new( - Some(link.to_string()), - "open image", - cx, - ) - .into() - } - }) - .on_click({ - let workspace = workspace_clone.clone(); - let link = image.link.clone(); - move |_, window, cx| { - if window.modifiers().secondary() { - match &link { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - } - } - } - }) - .into_any(); - any_element.push(image_element); + any_element.push(render_markdown_image(image, cx)); } } } @@ -794,25 +768,93 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { div().py(cx.scaled_rems(0.5)).child(rule).into_any() } +fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement { + let image_resource = match image.link.clone() { + Link::Web { url } => Resource::Uri(url.into()), + Link::Path { path, .. } => Resource::Path(Arc::from(path)), + }; + + let element_id = cx.next_id(&image.source_range); + let workspace = cx.workspace.clone(); + + div() + .id(element_id) + .cursor_pointer() + .child( + img(ImageSource::Resource(image_resource)) + .max_w_full() + .with_fallback({ + let alt_text = image.alt_text.clone(); + move || div().children(alt_text.clone()).into_any_element() + }) + .when_some(image.height, |this, height| this.h(height)) + .when_some(image.width, |this, width| this.w(width)), + ) + .tooltip({ + let link = image.link.clone(); + let alt_text = image.alt_text.clone(); + move |_, cx| { + InteractiveMarkdownElementTooltip::new( + Some(alt_text.clone().unwrap_or(link.to_string().into())), + "open image", + cx, + ) + .into() + } + }) + .on_click({ + let link = image.link.clone(); + move |_, window, cx| { + if window.modifiers().secondary() { + match &link { + Link::Web { url } => cx.open_url(url), + Link::Path { path, .. } => { + if let Some(workspace) = &workspace { + _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + path.clone(), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + .detach(); + }); + } + } + } + } + } + }) + .into_any() +} + struct InteractiveMarkdownElementTooltip { tooltip_text: Option, - action_text: String, + action_text: SharedString, } impl InteractiveMarkdownElementTooltip { - pub fn new(tooltip_text: Option, action_text: &str, cx: &mut App) -> Entity { + pub fn new( + tooltip_text: Option, + action_text: impl Into, + cx: &mut App, + ) -> Entity { let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); cx.new(|_cx| Self { tooltip_text, - action_text: action_text.to_string(), + action_text: action_text.into(), }) } } impl Render for InteractiveMarkdownElementTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(window, cx, |el, _, _| { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + tooltip_container(cx, |el, _| { let secondary_modifier = Keystroke { modifiers: Modifiers::secondary_key(), ..Default::default() @@ -836,3 +878,198 @@ impl Render for InteractiveMarkdownElementTooltip { }) } } + +/// Returns the prefix for a list item. +fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { + let ix = order.saturating_sub(1); + const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; + const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; + + if ordered { + match depth { + 0 => format!("{}. ", order), + 1 => format!( + "{}. ", + NUMBERED_PREFIXES_1 + .chars() + .nth(ix % NUMBERED_PREFIXES_1.len()) + .unwrap() + ), + _ => format!( + "{}. ", + NUMBERED_PREFIXES_2 + .chars() + .nth(ix % NUMBERED_PREFIXES_2.len()) + .unwrap() + ), + } + } else { + let depth = depth.min(BULLETS.len() - 1); + let bullet = BULLETS[depth]; + return format!("{} ", bullet); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::markdown_elements::ParsedMarkdownTableColumn; + use crate::markdown_elements::ParsedMarkdownText; + + fn text(text: &str) -> MarkdownParagraphChunk { + MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: 0..text.len(), + contents: SharedString::new(text), + highlights: Default::default(), + region_ranges: Default::default(), + regions: Default::default(), + }) + } + + fn column( + col_span: usize, + row_span: usize, + children: Vec, + ) -> ParsedMarkdownTableColumn { + ParsedMarkdownTableColumn { + col_span, + row_span, + is_header: false, + children, + alignment: ParsedMarkdownTableAlignment::None, + } + } + + fn column_with_row_span( + col_span: usize, + row_span: usize, + children: Vec, + ) -> ParsedMarkdownTableColumn { + ParsedMarkdownTableColumn { + col_span, + row_span, + is_header: false, + children, + alignment: ParsedMarkdownTableAlignment::None, + } + } + + #[test] + fn test_calculate_table_columns_count() { + assert_eq!(0, calculate_table_columns_count(&vec![])); + + assert_eq!( + 1, + calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ + column(1, 1, vec![text("column1")]) + ])]) + ); + + assert_eq!( + 2, + calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ + column(1, 1, vec![text("column1")]), + column(1, 1, vec![text("column2")]), + ])]) + ); + + assert_eq!( + 2, + calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ + column(2, 1, vec![text("column1")]) + ])]) + ); + + assert_eq!( + 3, + calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ + column(1, 1, vec![text("column1")]), + column(2, 1, vec![text("column2")]), + ])]) + ); + + assert_eq!( + 2, + calculate_table_columns_count(&vec![ + ParsedMarkdownTableRow::with_columns(vec![ + column(1, 1, vec![text("column1")]), + column(1, 1, vec![text("column2")]), + ]), + ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),]) + ]) + ); + + assert_eq!( + 3, + calculate_table_columns_count(&vec![ + ParsedMarkdownTableRow::with_columns(vec![ + column(1, 1, vec![text("column1")]), + column(1, 1, vec![text("column2")]), + ]), + ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),]) + ]) + ); + } + + #[test] + fn test_row_span_support() { + assert_eq!( + 3, + calculate_table_columns_count(&vec![ + ParsedMarkdownTableRow::with_columns(vec![ + column_with_row_span(1, 2, vec![text("spans 2 rows")]), + column(1, 1, vec![text("column2")]), + column(1, 1, vec![text("column3")]), + ]), + ParsedMarkdownTableRow::with_columns(vec![ + // First column is covered by row span from above + column(1, 1, vec![text("column2 row2")]), + column(1, 1, vec![text("column3 row2")]), + ]) + ]) + ); + + assert_eq!( + 4, + calculate_table_columns_count(&vec![ + ParsedMarkdownTableRow::with_columns(vec![ + column_with_row_span(1, 3, vec![text("spans 3 rows")]), + column_with_row_span(2, 1, vec![text("spans 2 cols")]), + column(1, 1, vec![text("column4")]), + ]), + ParsedMarkdownTableRow::with_columns(vec![ + // First column covered by row span + column(1, 1, vec![text("column2")]), + column(1, 1, vec![text("column3")]), + column(1, 1, vec![text("column4")]), + ]), + ParsedMarkdownTableRow::with_columns(vec![ + // First column still covered by row span + column(3, 1, vec![text("spans 3 cols")]), + ]) + ]) + ); + } + + #[test] + fn test_list_item_prefix() { + assert_eq!(list_item_prefix(1, true, 0), "1. "); + assert_eq!(list_item_prefix(2, true, 0), "2. "); + assert_eq!(list_item_prefix(3, true, 0), "3. "); + assert_eq!(list_item_prefix(11, true, 0), "11. "); + assert_eq!(list_item_prefix(1, true, 1), "A. "); + assert_eq!(list_item_prefix(2, true, 1), "B. "); + assert_eq!(list_item_prefix(3, true, 1), "C. "); + assert_eq!(list_item_prefix(1, true, 2), "a. "); + assert_eq!(list_item_prefix(2, true, 2), "b. "); + assert_eq!(list_item_prefix(7, true, 2), "g. "); + assert_eq!(list_item_prefix(1, true, 1), "A. "); + assert_eq!(list_item_prefix(1, true, 2), "a. "); + assert_eq!(list_item_prefix(1, false, 0), "• "); + assert_eq!(list_item_prefix(1, false, 1), "◦ "); + assert_eq!(list_item_prefix(1, false, 2), "▪ "); + assert_eq!(list_item_prefix(1, false, 3), "‣ "); + assert_eq!(list_item_prefix(1, false, 4), "⁃ "); + } +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0dd5b8e0a4dabccdffbd3b3fe304f72dae77a25e..90a3d938333d66a258bca1bafec92f338c0374b6 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -2,8 +2,9 @@ name = "media" version = "0.1.0" edition.workspace = true -publish.workspace = true +publish = false license = "Apache-2.0" +description = "Bindings to macos media handling APIs for Zed" [lints] workspace = true @@ -14,7 +15,6 @@ doctest = false [dependencies] anyhow.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true diff --git a/crates/media/build.rs b/crates/media/build.rs index 75639e30b43b909619f32bdb92b5f75de13cff1f..090002e9e925951383166282dcbabbec79838546 100644 --- a/crates/media/build.rs +++ b/crates/media/build.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] #[cfg(target_os = "macos")] fn main() { use std::{env, path::PathBuf, process::Command}; diff --git a/crates/menu/Cargo.toml b/crates/menu/Cargo.toml index bbe69903ce32af5cc8e48c052a1ff9d728d42754..fcb209df8892bde42f50b1f7e90f1097ebd10905 100644 --- a/crates/menu/Cargo.toml +++ b/crates/menu/Cargo.toml @@ -14,4 +14,3 @@ doctest = false [dependencies] gpui.workspace = true -workspace-hack.workspace = true diff --git a/crates/migrator/Cargo.toml b/crates/migrator/Cargo.toml index aeb50e582e2cec34821ba9094514cd32c21204d8..e0a75784749c2d3a2a981b44cbbe449a7685c605 100644 --- a/crates/migrator/Cargo.toml +++ b/crates/migrator/Cargo.toml @@ -20,7 +20,10 @@ log.workspace = true streaming-iterator.workspace = true tree-sitter-json.workspace = true tree-sitter.workspace = true -workspace-hack.workspace = true +serde_json_lenient.workspace = true +serde_json.workspace = true +settings_json.workspace = true [dev-dependencies] pretty_assertions.workspace = true +unindent.workspace = true diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 9db597e9643866ba2fcbad00ea77d4de0c244fd1..084a3348b54acd9d2fc6ba043e1fb1648bbb3f8b 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -99,3 +99,33 @@ pub(crate) mod m_2025_07_08 { pub(crate) use settings::SETTINGS_PATTERNS; } + +pub(crate) mod m_2025_10_01 { + mod settings; + + pub(crate) use settings::flatten_code_actions_formatters; +} + +pub(crate) mod m_2025_10_02 { + mod settings; + + pub(crate) use settings::remove_formatters_on_save; +} + +pub(crate) mod m_2025_10_03 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} + +pub(crate) mod m_2025_10_16 { + mod settings; + + pub(crate) use settings::restore_code_actions_on_format; +} + +pub(crate) mod m_2025_10_17 { + mod settings; + + pub(crate) use settings::make_file_finder_include_ignored_an_enum; +} diff --git a/crates/migrator/src/migrations/m_2025_01_02/settings.rs b/crates/migrator/src/migrations/m_2025_01_02/settings.rs index 3ce85e6b2611b69dfaac5479ee3404eeda9c0ebc..a35b1ebd2e9d8e2c658de0623b7c2e8377662b18 100644 --- a/crates/migrator/src/migrations/m_2025_01_02/settings.rs +++ b/crates/migrator/src/migrations/m_2025_01_02/settings.rs @@ -20,14 +20,14 @@ fn replace_deprecated_settings_values( .nodes_for_capture_index(parent_object_capture_ix) .next()? .byte_range(); - let parent_object_name = contents.get(parent_object_range.clone())?; + let parent_object_name = contents.get(parent_object_range)?; let setting_name_ix = query.capture_index_for_name("setting_name")?; let setting_name_range = mat .nodes_for_capture_index(setting_name_ix) .next()? .byte_range(); - let setting_name = contents.get(setting_name_range.clone())?; + let setting_name = contents.get(setting_name_range)?; let setting_value_ix = query.capture_index_for_name("setting_value")?; let setting_value_range = mat diff --git a/crates/migrator/src/migrations/m_2025_01_29/keymap.rs b/crates/migrator/src/migrations/m_2025_01_29/keymap.rs index 646af8f63dc90b6ebe3faef9432eecc54140b438..222ad9716b71757245015b99e808d92d146151a8 100644 --- a/crates/migrator/src/migrations/m_2025_01_29/keymap.rs +++ b/crates/migrator/src/migrations/m_2025_01_29/keymap.rs @@ -156,6 +156,16 @@ static TRANSFORM_ARRAY: LazyLock> = LazyLock::new(|| (("vim::ResizePane", "Narrow"), "vim::ResizePaneLeft"), (("vim::ResizePane", "Shorten"), "vim::ResizePaneDown"), (("vim::ResizePane", "Lengthen"), "vim::ResizePaneUp"), + // fold at level + (("editor::FoldAtLevel", "1"), "editor::FoldAtLevel1"), + (("editor::FoldAtLevel", "2"), "editor::FoldAtLevel2"), + (("editor::FoldAtLevel", "3"), "editor::FoldAtLevel3"), + (("editor::FoldAtLevel", "4"), "editor::FoldAtLevel4"), + (("editor::FoldAtLevel", "5"), "editor::FoldAtLevel5"), + (("editor::FoldAtLevel", "6"), "editor::FoldAtLevel6"), + (("editor::FoldAtLevel", "7"), "editor::FoldAtLevel7"), + (("editor::FoldAtLevel", "8"), "editor::FoldAtLevel8"), + (("editor::FoldAtLevel", "9"), "editor::FoldAtLevel9"), ]) }); @@ -242,22 +252,22 @@ static STRING_REPLACE: LazyLock> = LazyLock::new(|| { "inline_completion::ToggleMenu", "edit_prediction::ToggleMenu", ), - ("editor::NextEditPrediction", "editor::NextEditPrediction"), + ("editor::NextInlineCompletion", "editor::NextEditPrediction"), ( - "editor::PreviousEditPrediction", + "editor::PreviousInlineCompletion", "editor::PreviousEditPrediction", ), ( - "editor::AcceptPartialEditPrediction", + "editor::AcceptPartialInlineCompletion", "editor::AcceptPartialEditPrediction", ), - ("editor::ShowEditPrediction", "editor::ShowEditPrediction"), + ("editor::ShowInlineCompletion", "editor::ShowEditPrediction"), ( - "editor::AcceptEditPrediction", + "editor::AcceptInlineCompletion", "editor::AcceptEditPrediction", ), ( - "editor::ToggleEditPredictions", + "editor::ToggleInlineCompletions", "editor::ToggleEditPrediction", ), ]) @@ -279,7 +289,7 @@ fn rename_context_key( new_predicate = new_predicate.replace(old_key, new_key); } if new_predicate != old_predicate { - Some((context_predicate_range, new_predicate.to_string())) + Some((context_predicate_range, new_predicate)) } else { None } diff --git a/crates/migrator/src/migrations/m_2025_01_29/settings.rs b/crates/migrator/src/migrations/m_2025_01_29/settings.rs index 8d3261676b731d00e3dd85f3f5d94737931d74fe..46cfe2f178f1e4416cb404f26b5b77b55663aa29 100644 --- a/crates/migrator/src/migrations/m_2025_01_29/settings.rs +++ b/crates/migrator/src/migrations/m_2025_01_29/settings.rs @@ -57,7 +57,7 @@ pub fn replace_edit_prediction_provider_setting( .nodes_for_capture_index(parent_object_capture_ix) .next()? .byte_range(); - let parent_object_name = contents.get(parent_object_range.clone())?; + let parent_object_name = contents.get(parent_object_range)?; let setting_name_ix = query.capture_index_for_name("setting_name")?; let setting_range = mat diff --git a/crates/migrator/src/migrations/m_2025_01_30/settings.rs b/crates/migrator/src/migrations/m_2025_01_30/settings.rs index 23a3243b827b7d44e673208e56858b6cd2e8f2b7..2d763e4722cb2119f0b2f982b5841aab37e55c12 100644 --- a/crates/migrator/src/migrations/m_2025_01_30/settings.rs +++ b/crates/migrator/src/migrations/m_2025_01_30/settings.rs @@ -25,7 +25,7 @@ fn replace_tab_close_button_setting_key( .nodes_for_capture_index(parent_object_capture_ix) .next()? .byte_range(); - let parent_object_name = contents.get(parent_object_range.clone())?; + let parent_object_name = contents.get(parent_object_range)?; let setting_name_ix = query.capture_index_for_name("setting_name")?; let setting_range = mat @@ -51,14 +51,14 @@ fn replace_tab_close_button_setting_value( .nodes_for_capture_index(parent_object_capture_ix) .next()? .byte_range(); - let parent_object_name = contents.get(parent_object_range.clone())?; + let parent_object_name = contents.get(parent_object_range)?; let setting_name_ix = query.capture_index_for_name("setting_name")?; let setting_name_range = mat .nodes_for_capture_index(setting_name_ix) .next()? .byte_range(); - let setting_name = contents.get(setting_name_range.clone())?; + let setting_name = contents.get(setting_name_range)?; let setting_value_ix = query.capture_index_for_name("setting_value")?; let setting_value_range = mat diff --git a/crates/migrator/src/migrations/m_2025_03_29/settings.rs b/crates/migrator/src/migrations/m_2025_03_29/settings.rs index 47f65b407da2b7079fb68a4877275339d6309433..8f83d8e39ea050de0ec9291199804f0e62dab392 100644 --- a/crates/migrator/src/migrations/m_2025_03_29/settings.rs +++ b/crates/migrator/src/migrations/m_2025_03_29/settings.rs @@ -19,7 +19,7 @@ fn replace_setting_value( .nodes_for_capture_index(setting_capture_ix) .next()? .byte_range(); - let setting_name = contents.get(setting_name_range.clone())?; + let setting_name = contents.get(setting_name_range)?; if setting_name != "hide_mouse_while_typing" { return None; diff --git a/crates/migrator/src/migrations/m_2025_05_05/settings.rs b/crates/migrator/src/migrations/m_2025_05_05/settings.rs index 88c6c338d18bc9c648a6c09e8fe1755bc3f77cd9..77da1b9a077b4acc2e6df6d47713f8e15f0fd090 100644 --- a/crates/migrator/src/migrations/m_2025_05_05/settings.rs +++ b/crates/migrator/src/migrations/m_2025_05_05/settings.rs @@ -24,7 +24,7 @@ fn rename_assistant( .nodes_for_capture_index(key_capture_ix) .next()? .byte_range(); - return Some((key_range, "agent".to_string())); + Some((key_range, "agent".to_string())) } fn rename_edit_prediction_assistant( @@ -37,5 +37,5 @@ fn rename_edit_prediction_assistant( .nodes_for_capture_index(key_capture_ix) .next()? .byte_range(); - return Some((key_range, "enabled_in_text_threads".to_string())); + Some((key_range, "enabled_in_text_threads".to_string())) } diff --git a/crates/migrator/src/migrations/m_2025_05_29/settings.rs b/crates/migrator/src/migrations/m_2025_05_29/settings.rs index 56d72836fa396810db2a220f57b8144c939a872a..37ef0e45cc0730c9861ca4362a4b93f025002c6d 100644 --- a/crates/migrator/src/migrations/m_2025_05_29/settings.rs +++ b/crates/migrator/src/migrations/m_2025_05_29/settings.rs @@ -19,7 +19,7 @@ fn replace_preferred_completion_mode_value( .nodes_for_capture_index(parent_object_capture_ix) .next()? .byte_range(); - let parent_object_name = contents.get(parent_object_range.clone())?; + let parent_object_name = contents.get(parent_object_range)?; if parent_object_name != "agent" { return None; @@ -30,7 +30,7 @@ fn replace_preferred_completion_mode_value( .nodes_for_capture_index(setting_name_capture_ix) .next()? .byte_range(); - let setting_name = contents.get(setting_name_range.clone())?; + let setting_name = contents.get(setting_name_range)?; if setting_name != "preferred_completion_mode" { return None; diff --git a/crates/migrator/src/migrations/m_2025_06_16/settings.rs b/crates/migrator/src/migrations/m_2025_06_16/settings.rs index cce407e21b81bf9064c1261c142b216b622712a8..cd79eae2048ca9809b720b7913eba12b3e6cb1ce 100644 --- a/crates/migrator/src/migrations/m_2025_06_16/settings.rs +++ b/crates/migrator/src/migrations/m_2025_06_16/settings.rs @@ -40,20 +40,20 @@ fn migrate_context_server_settings( // Parse the server settings to check what keys it contains let mut cursor = server_settings.walk(); for child in server_settings.children(&mut cursor) { - if child.kind() == "pair" { - if let Some(key_node) = child.child_by_field_name("key") { - if let (None, Some(quote_content)) = (column, key_node.child(0)) { - column = Some(quote_content.start_position().column); - } - if let Some(string_content) = key_node.child(1) { - let key = &contents[string_content.byte_range()]; - match key { - // If it already has a source key, don't modify it - "source" => return None, - "command" => has_command = true, - "settings" => has_settings = true, - _ => other_keys += 1, - } + if child.kind() == "pair" + && let Some(key_node) = child.child_by_field_name("key") + { + if let (None, Some(quote_content)) = (column, key_node.child(0)) { + column = Some(quote_content.start_position().column); + } + if let Some(string_content) = key_node.child(1) { + let key = &contents[string_content.byte_range()]; + match key { + // If it already has a source key, don't modify it + "source" => return None, + "command" => has_command = true, + "settings" => has_settings = true, + _ => other_keys += 1, } } } diff --git a/crates/migrator/src/migrations/m_2025_06_25/settings.rs b/crates/migrator/src/migrations/m_2025_06_25/settings.rs index 5dd6c3093a43b00acff3db6c1e316a3fc6664175..2bf7658eeb9036c0b1d08d2af446c0aba788d402 100644 --- a/crates/migrator/src/migrations/m_2025_06_25/settings.rs +++ b/crates/migrator/src/migrations/m_2025_06_25/settings.rs @@ -84,10 +84,10 @@ fn remove_pair_with_whitespace( } } else { // If no next sibling, check if there's a comma before - if let Some(prev_sibling) = pair_node.prev_sibling() { - if prev_sibling.kind() == "," { - range_to_remove.start = prev_sibling.start_byte(); - } + if let Some(prev_sibling) = pair_node.prev_sibling() + && prev_sibling.kind() == "," + { + range_to_remove.start = prev_sibling.start_byte(); } } @@ -123,10 +123,10 @@ fn remove_pair_with_whitespace( // Also check if we need to include trailing whitespace up to the next line let text_after = &contents[range_to_remove.end..]; - if let Some(newline_pos) = text_after.find('\n') { - if text_after[..newline_pos].chars().all(|c| c.is_whitespace()) { - range_to_remove.end += newline_pos + 1; - } + if let Some(newline_pos) = text_after.find('\n') + && text_after[..newline_pos].chars().all(|c| c.is_whitespace()) + { + range_to_remove.end += newline_pos + 1; } Some((range_to_remove, String::new())) diff --git a/crates/migrator/src/migrations/m_2025_06_27/settings.rs b/crates/migrator/src/migrations/m_2025_06_27/settings.rs index 6156308fcec05dfb10b5b258d31077e5d4b09adc..e3e951b1a69e39d19e93a152a264750caf51a81e 100644 --- a/crates/migrator/src/migrations/m_2025_06_27/settings.rs +++ b/crates/migrator/src/migrations/m_2025_06_27/settings.rs @@ -56,19 +56,18 @@ fn flatten_context_server_command( let mut cursor = command_object.walk(); for child in command_object.children(&mut cursor) { - if child.kind() == "pair" { - if let Some(key_node) = child.child_by_field_name("key") { - if let Some(string_content) = key_node.child(1) { - let key = &contents[string_content.byte_range()]; - if let Some(value_node) = child.child_by_field_name("value") { - let value_range = value_node.byte_range(); - match key { - "path" => path_value = Some(&contents[value_range]), - "args" => args_value = Some(&contents[value_range]), - "env" => env_value = Some(&contents[value_range]), - _ => {} - } - } + if child.kind() == "pair" + && let Some(key_node) = child.child_by_field_name("key") + && let Some(string_content) = key_node.child(1) + { + let key = &contents[string_content.byte_range()]; + if let Some(value_node) = child.child_by_field_name("value") { + let value_range = value_node.byte_range(); + match key { + "path" => path_value = Some(&contents[value_range]), + "args" => args_value = Some(&contents[value_range]), + "env" => env_value = Some(&contents[value_range]), + _ => {} } } } diff --git a/crates/migrator/src/migrations/m_2025_10_01/settings.rs b/crates/migrator/src/migrations/m_2025_10_01/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..84cf95049154b44048e92982fd00a11a3514bc16 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_10_01/settings.rs @@ -0,0 +1,74 @@ +use crate::patterns::migrate_language_setting; +use anyhow::Result; +use serde_json::Value; + +pub fn flatten_code_actions_formatters(value: &mut Value) -> Result<()> { + migrate_language_setting(value, |value, _path| { + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + for key in ["formatter", "format_on_save"] { + let Some(formatter) = obj.get_mut(key) else { + continue; + }; + let new_formatter = match formatter { + Value::Array(arr) => { + let mut new_arr = Vec::new(); + let mut found_code_actions = false; + for item in arr { + let Some(obj) = item.as_object() else { + new_arr.push(item.clone()); + continue; + }; + let code_actions_obj = obj + .get("code_actions") + .and_then(|code_actions| code_actions.as_object()); + let Some(code_actions) = code_actions_obj else { + new_arr.push(item.clone()); + continue; + }; + found_code_actions = true; + for (name, enabled) in code_actions { + if !enabled.as_bool().unwrap_or(true) { + continue; + } + new_arr.push(serde_json::json!({ + "code_action": name + })); + } + } + if !found_code_actions { + continue; + } + Value::Array(new_arr) + } + Value::Object(obj) => { + let mut new_arr = Vec::new(); + let code_actions_obj = obj + .get("code_actions") + .and_then(|code_actions| code_actions.as_object()); + let Some(code_actions) = code_actions_obj else { + continue; + }; + for (name, enabled) in code_actions { + if !enabled.as_bool().unwrap_or(true) { + continue; + } + new_arr.push(serde_json::json!({ + "code_action": name + })); + } + if new_arr.len() == 1 { + new_arr.pop().unwrap() + } else { + Value::Array(new_arr) + } + } + _ => continue, + }; + + obj.insert(key.to_string(), new_formatter); + } + return Ok(()); + }) +} diff --git a/crates/migrator/src/migrations/m_2025_10_02/settings.rs b/crates/migrator/src/migrations/m_2025_10_02/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb0d63ca8570952818e74e021f5dd2edc2523786 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_10_02/settings.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::patterns::migrate_language_setting; + +pub fn remove_formatters_on_save(value: &mut Value) -> Result<()> { + migrate_language_setting(value, remove_formatters_on_save_inner) +} + +fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<()> { + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + let Some(format_on_save) = obj.get("format_on_save").cloned() else { + return Ok(()); + }; + let is_format_on_save_set_to_formatter = format_on_save + .as_str() + .map_or(true, |s| s != "on" && s != "off"); + if !is_format_on_save_set_to_formatter { + return Ok(()); + } + + fn fmt_path(path: &[&str], key: &str) -> String { + let mut path = path.to_vec(); + path.push(key); + path.join(".") + } + + anyhow::ensure!( + obj.get("formatter").is_none(), + r#"Setting formatters in both "format_on_save" and "formatter" is deprecated. Please migrate the formatters from {} into {}"#, + fmt_path(path, "format_on_save"), + fmt_path(path, "formatter") + ); + + obj.insert("format_on_save".to_string(), serde_json::json!("on")); + obj.insert("formatter".to_string(), format_on_save); + + Ok(()) +} diff --git a/crates/migrator/src/migrations/m_2025_10_03/settings.rs b/crates/migrator/src/migrations/m_2025_10_03/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..47d15e8ddfa1bfd2c73c8e408579c901294903ee --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_10_03/settings.rs @@ -0,0 +1,30 @@ +use std::ops::Range; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::SETTINGS_ROOT_KEY_VALUE_PATTERN; + +pub const SETTINGS_PATTERNS: MigrationPatterns = + &[(SETTINGS_ROOT_KEY_VALUE_PATTERN, rename_agent_font_size)]; + +/// Renames the setting `agent_font_size` to `agent_ui_font_size` +fn rename_agent_font_size( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + let setting_capture_ix = query.capture_index_for_name("name")?; + + let setting_name_range = mat + .nodes_for_capture_index(setting_capture_ix) + .next()? + .byte_range(); + + let setting_name = contents.get(setting_name_range.clone())?; + + if setting_name != "agent_font_size" { + return None; + } + + Some((setting_name_range, "agent_ui_font_size".to_string())) +} diff --git a/crates/migrator/src/migrations/m_2025_10_16/settings.rs b/crates/migrator/src/migrations/m_2025_10_16/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..3fa8c509b1f3910f48603a10a0fd0f448992c151 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_10_16/settings.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::patterns::migrate_language_setting; + +pub fn restore_code_actions_on_format(value: &mut Value) -> Result<()> { + migrate_language_setting(value, restore_code_actions_on_format_inner) +} + +fn restore_code_actions_on_format_inner(value: &mut Value, path: &[&str]) -> Result<()> { + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + let code_actions_on_format = obj + .get("code_actions_on_format") + .cloned() + .unwrap_or_else(|| Value::Object(Default::default())); + + fn fmt_path(path: &[&str], key: &str) -> String { + let mut path = path.to_vec(); + path.push(key); + path.join(".") + } + + let Some(mut code_actions_map) = code_actions_on_format.as_object().cloned() else { + anyhow::bail!( + r#"The `code_actions_on_format` is in an invalid state and cannot be migrated at {}. Please ensure the code_actions_on_format setting is a Map"#, + fmt_path(path, "code_actions_on_format"), + ); + }; + + let Some(formatter) = obj.get("formatter") else { + return Ok(()); + }; + let formatter_array = if let Some(array) = formatter.as_array() { + array.clone() + } else { + vec![formatter.clone()] + }; + if formatter_array.is_empty() { + return Ok(()); + } + let mut code_action_formatters = Vec::new(); + for formatter in formatter_array { + let Some(code_action) = formatter.get("code_action") else { + return Ok(()); + }; + let Some(code_action_name) = code_action.as_str() else { + anyhow::bail!( + r#"The `code_action` is in an invalid state and cannot be migrated at {}. Please ensure the code_action setting is a String"#, + fmt_path(path, "formatter"), + ); + }; + code_action_formatters.push(code_action_name.to_string()); + } + + code_actions_map.extend( + code_action_formatters + .into_iter() + .rev() + .map(|code_action| (code_action, Value::Bool(true))), + ); + + obj.insert("formatter".to_string(), Value::Array(vec![])); + obj.insert( + "code_actions_on_format".into(), + Value::Object(code_actions_map), + ); + + Ok(()) +} diff --git a/crates/migrator/src/migrations/m_2025_10_17/settings.rs b/crates/migrator/src/migrations/m_2025_10_17/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..519ec740346ed5cb954477b2ae4f0cff341a21b2 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_10_17/settings.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use serde_json::Value; + +pub fn make_file_finder_include_ignored_an_enum(value: &mut Value) -> Result<()> { + let Some(file_finder) = value.get_mut("file_finder") else { + return Ok(()); + }; + + let Some(file_finder_obj) = file_finder.as_object_mut() else { + anyhow::bail!("Expected file_finder to be an object"); + }; + + let Some(include_ignored) = file_finder_obj.get_mut("include_ignored") else { + return Ok(()); + }; + *include_ignored = match include_ignored { + Value::Bool(true) => Value::String("all".to_string()), + Value::Bool(false) => Value::String("indexed".to_string()), + Value::Null => Value::String("smart".to_string()), + Value::String(s) if s == "all" || s == "indexed" || s == "smart" => return Ok(()), + _ => anyhow::bail!("Expected include_ignored to be a boolean or null"), + }; + Ok(()) +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index b425f7f1d5dc691ed1501d712ab72556412f7eb6..ff9635dcef7664b17eb02a03b7584ea18ac9a91b 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -15,6 +15,7 @@ //! You only need to write replacement logic for x-1 to x because you can be certain that, internally, every user will be at x-1, regardless of their on disk state. use anyhow::{Context as _, Result}; +use settings_json::{infer_json_indent_size, parse_json_with_comments, update_value_in_json_text}; use std::{cmp::Reverse, ops::Range, sync::LazyLock}; use streaming_iterator::StreamingIterator; use tree_sitter::{Query, QueryMatch}; @@ -28,7 +29,7 @@ fn migrate(text: &str, patterns: MigrationPatterns, query: &Query) -> Result Result Result Result> { +/// Runs the provided migrations on the given text. +/// Will automatically return `Ok(None)` if there's no content to migrate. +fn run_migrations(text: &str, migrations: &[MigrationType]) -> Result> { + if text.is_empty() { + return Ok(None); + } + let mut current_text = text.to_string(); let mut result: Option = None; - for (patterns, query) in migrations.iter() { - if let Some(migrated_text) = migrate(¤t_text, patterns, query)? { + let json_indent_size = infer_json_indent_size(¤t_text); + for migration in migrations.iter() { + let migrated_text = match migration { + MigrationType::TreeSitter(patterns, query) => migrate(¤t_text, patterns, query)?, + MigrationType::Json(callback) => { + if current_text.trim().is_empty() { + return Ok(None); + } + let old_content: serde_json_lenient::Value = + parse_json_with_comments(¤t_text)?; + let old_value = serde_json::to_value(&old_content).unwrap(); + let mut new_value = old_value.clone(); + callback(&mut new_value)?; + if new_value != old_value { + let mut current = current_text.clone(); + let mut edits = vec![]; + update_value_in_json_text( + &mut current, + &mut vec![], + json_indent_size, + &old_value, + &new_value, + &mut edits, + ); + let mut migrated_text = current_text.clone(); + for (range, replacement) in edits.into_iter() { + migrated_text.replace_range(range, &replacement); + } + Some(migrated_text) + } else { + None + } + } + }; + if let Some(migrated_text) = migrated_text { current_text = migrated_text.clone(); result = Some(migrated_text); } @@ -81,24 +118,24 @@ fn run_migrations( } pub fn migrate_keymap(text: &str) -> Result> { - let migrations: &[(MigrationPatterns, &Query)] = &[ - ( + let migrations: &[MigrationType] = &[ + MigrationType::TreeSitter( migrations::m_2025_01_29::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_01_29, ), - ( + MigrationType::TreeSitter( migrations::m_2025_01_30::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_01_30, ), - ( + MigrationType::TreeSitter( migrations::m_2025_03_03::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_03_03, ), - ( + MigrationType::TreeSitter( migrations::m_2025_03_06::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_03_06, ), - ( + MigrationType::TreeSitter( migrations::m_2025_04_15::KEYMAP_PATTERNS, &KEYMAP_QUERY_2025_04_15, ), @@ -106,71 +143,84 @@ pub fn migrate_keymap(text: &str) -> Result> { run_migrations(text, migrations) } +enum MigrationType<'a> { + TreeSitter(MigrationPatterns, &'a Query), + Json(fn(&mut serde_json::Value) -> Result<()>), +} + pub fn migrate_settings(text: &str) -> Result> { - let migrations: &[(MigrationPatterns, &Query)] = &[ - ( + let migrations: &[MigrationType] = &[ + MigrationType::TreeSitter( migrations::m_2025_01_02::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_01_02, ), - ( + MigrationType::TreeSitter( migrations::m_2025_01_29::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_01_29, ), - ( + MigrationType::TreeSitter( migrations::m_2025_01_30::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_01_30, ), - ( + MigrationType::TreeSitter( migrations::m_2025_03_29::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_03_29, ), - ( + MigrationType::TreeSitter( migrations::m_2025_04_15::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_04_15, ), - ( + MigrationType::TreeSitter( migrations::m_2025_04_21::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_04_21, ), - ( + MigrationType::TreeSitter( migrations::m_2025_04_23::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_04_23, ), - ( + MigrationType::TreeSitter( migrations::m_2025_05_05::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_05_05, ), - ( + MigrationType::TreeSitter( migrations::m_2025_05_08::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_05_08, ), - ( + MigrationType::TreeSitter( migrations::m_2025_05_29::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_05_29, ), - ( + MigrationType::TreeSitter( migrations::m_2025_06_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_16, ), - ( + MigrationType::TreeSitter( migrations::m_2025_06_25::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_25, ), - ( + MigrationType::TreeSitter( migrations::m_2025_06_27::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_27, ), - ( + MigrationType::TreeSitter( migrations::m_2025_07_08::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_07_08, ), + MigrationType::Json(migrations::m_2025_10_01::flatten_code_actions_formatters), + MigrationType::Json(migrations::m_2025_10_02::remove_formatters_on_save), + MigrationType::TreeSitter( + migrations::m_2025_10_03::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_10_03, + ), + MigrationType::Json(migrations::m_2025_10_16::restore_code_actions_on_format), + MigrationType::Json(migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum), ]; run_migrations(text, migrations) } pub fn migrate_edit_prediction_provider_settings(text: &str) -> Result> { migrate( - &text, + text, &[( SETTINGS_NESTED_KEY_VALUE_PATTERN, migrations::m_2025_01_29::replace_edit_prediction_provider_setting, @@ -278,6 +328,10 @@ define_query!( SETTINGS_QUERY_2025_07_08, migrations::m_2025_07_08::SETTINGS_PATTERNS ); +define_query!( + SETTINGS_QUERY_2025_10_03, + migrations::m_2025_10_03::SETTINGS_PATTERNS +); // custom query static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new(|| { @@ -291,24 +345,56 @@ static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new #[cfg(test)] mod tests { use super::*; + use unindent::Unindent as _; + + #[track_caller] + fn assert_migrated_correctly(migrated: Option, expected: Option<&str>) { + match (&migrated, &expected) { + (Some(migrated), Some(expected)) => { + pretty_assertions::assert_str_eq!(expected, migrated); + } + _ => { + pretty_assertions::assert_eq!(migrated.as_deref(), expected); + } + } + } fn assert_migrate_keymap(input: &str, output: Option<&str>) { - let migrated = migrate_keymap(&input).unwrap(); + let migrated = migrate_keymap(input).unwrap(); pretty_assertions::assert_eq!(migrated.as_deref(), output); } + #[track_caller] fn assert_migrate_settings(input: &str, output: Option<&str>) { - let migrated = migrate_settings(&input).unwrap(); - pretty_assertions::assert_eq!(migrated.as_deref(), output); + let migrated = migrate_settings(input).unwrap(); + assert_migrated_correctly(migrated.clone(), output); + + // expect that rerunning the migration does not result in another migration + if let Some(migrated) = migrated { + let rerun = migrate_settings(&migrated).unwrap(); + assert_migrated_correctly(rerun, None); + } } + #[track_caller] fn assert_migrate_settings_with_migrations( - migrations: &[(MigrationPatterns, &Query)], + migrations: &[MigrationType], input: &str, output: Option<&str>, ) { let migrated = run_migrations(input, migrations).unwrap(); - pretty_assertions::assert_eq!(migrated.as_deref(), output); + assert_migrated_correctly(migrated.clone(), output); + + // expect that rerunning the migration does not result in another migration + if let Some(migrated) = migrated { + let rerun = run_migrations(&migrated, migrations).unwrap(); + assert_migrated_correctly(rerun, None); + } + } + + #[test] + fn test_empty_content() { + assert_migrate_settings("", None) } #[test] @@ -899,7 +985,7 @@ mod tests { #[test] fn test_mcp_settings_migration() { assert_migrate_settings_with_migrations( - &[( + &[MigrationType::TreeSitter( migrations::m_2025_06_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_16, )], @@ -1088,7 +1174,7 @@ mod tests { } }"#; assert_migrate_settings_with_migrations( - &[( + &[MigrationType::TreeSitter( migrations::m_2025_06_16::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_06_16, )], @@ -1263,4 +1349,847 @@ mod tests { ), ); } + + #[test] + fn test_flatten_code_action_formatters_basic_array() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_01::flatten_code_actions_formatters, + )], + &r#"{ + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + } + ] + }"# + .unindent(), + Some( + &r#"{ + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + } + ] + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_flatten_code_action_formatters_basic_object() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_01::flatten_code_actions_formatters, + )], + &r#"{ + "formatter": { + "code_actions": { + "included-1": true, + "excluded": false, + "included-2": true + } + } + }"# + .unindent(), + Some( + &r#"{ + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + } + ] + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_flatten_code_action_formatters_array_with_multiple_action_blocks() { + assert_migrate_settings( + &r#"{ + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }"# + .unindent(), + Some( + &r#"{ + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_languages() { + assert_migrate_settings( + &r#"{ + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"# + .unindent(), + Some( + &r#"{ + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_defaults_and_multiple_languages() + { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_01::flatten_code_actions_formatters, + )], + &r#"{ + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"# + .unindent(), + Some( + &r#"{ + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_flatten_code_action_formatters_array_with_format_on_save_and_multiple_languages() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_01::flatten_code_actions_formatters, + )], + &r#"{ + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "format_on_save": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "format_on_save": { + "code_actions": { + "on-save-1": true, + "on-save-2": true, + } + }, + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"# + .unindent(), + Some( + &r#" + { + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "format_on_save": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "format_on_save": [ + { + "code_action": "on-save-1" + }, + { + "code_action": "on-save-2" + } + ], + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_format_on_save_formatter_migration_basic() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "format_on_save": "prettier" + }"# + .unindent(), + Some( + &r#"{ + "formatter": "prettier", + "format_on_save": "on" + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_format_on_save_formatter_migration_array() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "format_on_save": ["prettier", {"language_server": "eslint"}] + }"# + .unindent(), + Some( + &r#"{ + "formatter": [ + "prettier", + { + "language_server": "eslint" + } + ], + "format_on_save": "on" + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_format_on_save_on_off_unchanged() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "format_on_save": "on" + }"# + .unindent(), + None, + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "format_on_save": "off" + }"# + .unindent(), + None, + ); + } + + #[test] + fn test_format_on_save_formatter_migration_in_languages() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": ["ruff", "black"] + } + } + }"# + .unindent(), + Some( + &r#"{ + "languages": { + "Rust": { + "formatter": "rust-analyzer", + "format_on_save": "on" + }, + "Python": { + "formatter": [ + "ruff", + "black" + ], + "format_on_save": "on" + } + } + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_format_on_save_formatter_migration_mixed_global_and_languages() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "format_on_save": "prettier", + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": "on" + } + } + }"# + .unindent(), + Some( + &r#"{ + "formatter": "prettier", + "format_on_save": "on", + "languages": { + "Rust": { + "formatter": "rust-analyzer", + "format_on_save": "on" + }, + "Python": { + "format_on_save": "on" + } + } + }"# + .unindent(), + ), + ); + } + + #[test] + fn test_format_on_save_no_migration_when_no_format_on_save() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_02::remove_formatters_on_save, + )], + &r#"{ + "formatter": ["prettier"] + }"# + .unindent(), + None, + ); + } + + #[test] + fn test_restore_code_actions_on_format() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_16::restore_code_actions_on_format, + )], + &r#"{ + "formatter": { + "code_action": "foo" + } + }"# + .unindent(), + Some( + &r#"{ + "code_actions_on_format": { + "foo": true + }, + "formatter": [] + }"# + .unindent(), + ), + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_16::restore_code_actions_on_format, + )], + &r#"{ + "formatter": [ + { "code_action": "foo" }, + "auto" + ] + }"# + .unindent(), + None, + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_16::restore_code_actions_on_format, + )], + &r#"{ + "formatter": { + "code_action": "foo" + }, + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"# + .unindent(), + Some( + &r#"{ + "formatter": [], + "code_actions_on_format": { + "foo": true, + "bar": true, + "baz": false + } + }"# + .unindent(), + ), + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_16::restore_code_actions_on_format, + )], + &r#"{ + "formatter": [ + { "code_action": "foo" }, + { "code_action": "qux" }, + ], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"# + .unindent(), + Some( + &r#"{ + "formatter": [], + "code_actions_on_format": { + "foo": true, + "qux": true, + "bar": true, + "baz": false + } + }"# + .unindent(), + ), + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_16::restore_code_actions_on_format, + )], + &r#"{ + "formatter": [], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"# + .unindent(), + None, + ); + } + + #[test] + fn test_make_file_finder_include_ignored_an_enum() { + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, + )], + &r#"{ }"#.unindent(), + None, + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, + )], + &r#"{ + "file_finder": { + "include_ignored": true + } + }"# + .unindent(), + Some( + &r#"{ + "file_finder": { + "include_ignored": "all" + } + }"# + .unindent(), + ), + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, + )], + &r#"{ + "file_finder": { + "include_ignored": false + } + }"# + .unindent(), + Some( + &r#"{ + "file_finder": { + "include_ignored": "indexed" + } + }"# + .unindent(), + ), + ); + + assert_migrate_settings_with_migrations( + &[MigrationType::Json( + migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, + )], + &r#"{ + "file_finder": { + "include_ignored": null + } + }"# + .unindent(), + Some( + &r#"{ + "file_finder": { + "include_ignored": "smart" + } + }"# + .unindent(), + ), + ); + } } diff --git a/crates/migrator/src/patterns.rs b/crates/migrator/src/patterns.rs index 3848baf23ba0d324995f18e3a53921948291153b..4132c93d9367a8dee200200e03dcc46ee073e67f 100644 --- a/crates/migrator/src/patterns.rs +++ b/crates/migrator/src/patterns.rs @@ -10,4 +10,5 @@ pub(crate) use settings::{ SETTINGS_ASSISTANT_PATTERN, SETTINGS_ASSISTANT_TOOLS_PATTERN, SETTINGS_DUPLICATED_AGENT_PATTERN, SETTINGS_EDIT_PREDICTIONS_ASSISTANT_PATTERN, SETTINGS_LANGUAGES_PATTERN, SETTINGS_NESTED_KEY_VALUE_PATTERN, SETTINGS_ROOT_KEY_VALUE_PATTERN, + migrate_language_setting, }; diff --git a/crates/migrator/src/patterns/settings.rs b/crates/migrator/src/patterns/settings.rs index 72fd02b153a5cf6e3158790f1c5d09a9f643ebf9..a068cce23b013a3435188c03ceebe866883c4e6d 100644 --- a/crates/migrator/src/patterns/settings.rs +++ b/crates/migrator/src/patterns/settings.rs @@ -108,3 +108,24 @@ pub const SETTINGS_DUPLICATED_AGENT_PATTERN: &str = r#"(document (#eq? @agent1 "agent") (#eq? @agent2 "agent") )"#; + +/// Migrate language settings, +/// calls `migrate_fn` with the top level object as well as all language settings under the "languages" key +/// Fails early if `migrate_fn` returns an error at any point +pub fn migrate_language_setting( + value: &mut serde_json::Value, + migrate_fn: fn(&mut serde_json::Value, path: &[&str]) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + migrate_fn(value, &[])?; + let languages = value + .as_object_mut() + .and_then(|obj| obj.get_mut("languages")) + .and_then(|languages| languages.as_object_mut()); + if let Some(languages) = languages { + for (language_name, language) in languages.iter_mut() { + let path = vec!["languages", language_name]; + migrate_fn(language, &path)?; + } + } + Ok(()) +} diff --git a/crates/mistral/Cargo.toml b/crates/mistral/Cargo.toml index 95f44b4f959522617c41e61acc041c43f80d82fe..c4d475f014a035005e4749d7dd7904d0a045cc78 100644 --- a/crates/mistral/Cargo.toml +++ b/crates/mistral/Cargo.toml @@ -23,4 +23,3 @@ schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index 5b4d05377c7132f47828aa6afafbb5c850e940a8..eca4743d0442b9ca169ac966f78af0112565fcbc 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -7,6 +7,7 @@ use std::convert::TryFrom; use strum::EnumIter; pub const MISTRAL_API_URL: &str = "https://api.mistral.ai/v1"; +pub const CODESTRAL_API_URL: &str = "https://codestral.mistral.ai"; #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] @@ -268,30 +269,13 @@ pub struct FunctionDefinition { } #[derive(Debug, Serialize, Deserialize)] -pub struct CompletionRequest { - pub model: String, - pub prompt: String, - pub max_tokens: u32, - pub temperature: f32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prediction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rewrite_speculation: Option, -} - -#[derive(Clone, Deserialize, Serialize, Debug)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Prediction { - Content { content: String }, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, Required, None, Any, + #[serde(untagged)] Function(ToolDefinition), } @@ -396,21 +380,6 @@ pub struct FunctionContent { pub arguments: String, } -#[derive(Serialize, Deserialize, Debug)] -pub struct CompletionChoice { - pub text: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct Response { - pub id: String, - pub object: String, - pub created: u64, - pub model: String, - pub choices: Vec, - pub usage: Usage, -} - #[derive(Serialize, Deserialize, Debug)] pub struct Usage { pub prompt_tokens: u64, @@ -418,13 +387,6 @@ pub struct Usage { pub total_tokens: u64, } -#[derive(Serialize, Deserialize, Debug)] -pub struct Choice { - pub index: u32, - pub message: RequestMessage, - pub finish_reason: Option, -} - #[derive(Serialize, Deserialize, Debug)] pub struct StreamResponse { pub id: String, @@ -482,7 +444,7 @@ pub async fn stream_completion( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index d5a38f539824c4b17f0c654148362ca5f906c8ba..93747140c1960b70b9a9ddffe2a609e8a32a7dc7 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -43,7 +43,6 @@ text.workspace = true theme.workspace = true tree-sitter.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] buffer_diff = { workspace = true, features = ["test-support"] } diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index 1305328d384023517dbb80d25e210b44e632eed8..d5009172084d6d683f722a8ad2aa5b8b21ae0493 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -1,4 +1,4 @@ -use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToOffsetUtf16, ToPoint}; +use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToPoint}; use language::{OffsetUtf16, Point, TextDimension}; use std::{ cmp::Ordering, @@ -16,6 +16,13 @@ pub struct Anchor { } impl Anchor { + pub fn with_diff_base_anchor(self, diff_base_anchor: text::Anchor) -> Self { + Self { + diff_base_anchor: Some(diff_base_anchor), + ..self + } + } + pub fn in_buffer( excerpt_id: ExcerptId, buffer_id: BufferId, @@ -76,27 +83,26 @@ impl Anchor { if text_cmp.is_ne() { return text_cmp; } - if self.diff_base_anchor.is_some() || other.diff_base_anchor.is_some() { - if let Some(base_text) = snapshot + if (self.diff_base_anchor.is_some() || other.diff_base_anchor.is_some()) + && let Some(base_text) = snapshot .diffs .get(&excerpt.buffer_id) .map(|diff| diff.base_text()) - { - let self_anchor = self.diff_base_anchor.filter(|a| base_text.can_resolve(a)); - let other_anchor = other.diff_base_anchor.filter(|a| base_text.can_resolve(a)); - return match (self_anchor, other_anchor) { - (Some(a), Some(b)) => a.cmp(&b, base_text), - (Some(_), None) => match other.text_anchor.bias { - Bias::Left => Ordering::Greater, - Bias::Right => Ordering::Less, - }, - (None, Some(_)) => match self.text_anchor.bias { - Bias::Left => Ordering::Less, - Bias::Right => Ordering::Greater, - }, - (None, None) => Ordering::Equal, - }; - } + { + let self_anchor = self.diff_base_anchor.filter(|a| base_text.can_resolve(a)); + let other_anchor = other.diff_base_anchor.filter(|a| base_text.can_resolve(a)); + return match (self_anchor, other_anchor) { + (Some(a), Some(b)) => a.cmp(&b, base_text), + (Some(_), None) => match other.text_anchor.bias { + Bias::Left => Ordering::Greater, + Bias::Right => Ordering::Less, + }, + (None, Some(_)) => match self.text_anchor.bias { + Bias::Left => Ordering::Less, + Bias::Right => Ordering::Greater, + }, + (None, None) => Ordering::Equal, + }; } } Ordering::Equal @@ -107,51 +113,49 @@ impl Anchor { } pub fn bias_left(&self, snapshot: &MultiBufferSnapshot) -> Anchor { - if self.text_anchor.bias != Bias::Left { - if let Some(excerpt) = snapshot.excerpt(self.excerpt_id) { - return Self { - buffer_id: self.buffer_id, - excerpt_id: self.excerpt_id, - text_anchor: self.text_anchor.bias_left(&excerpt.buffer), - diff_base_anchor: self.diff_base_anchor.map(|a| { - if let Some(base_text) = snapshot - .diffs - .get(&excerpt.buffer_id) - .map(|diff| diff.base_text()) - { - if a.buffer_id == Some(base_text.remote_id()) { - return a.bias_left(base_text); - } - } - a - }), - }; - } + if self.text_anchor.bias != Bias::Left + && let Some(excerpt) = snapshot.excerpt(self.excerpt_id) + { + return Self { + buffer_id: self.buffer_id, + excerpt_id: self.excerpt_id, + text_anchor: self.text_anchor.bias_left(&excerpt.buffer), + diff_base_anchor: self.diff_base_anchor.map(|a| { + if let Some(base_text) = snapshot + .diffs + .get(&excerpt.buffer_id) + .map(|diff| diff.base_text()) + && a.buffer_id == Some(base_text.remote_id()) + { + return a.bias_left(base_text); + } + a + }), + }; } *self } pub fn bias_right(&self, snapshot: &MultiBufferSnapshot) -> Anchor { - if self.text_anchor.bias != Bias::Right { - if let Some(excerpt) = snapshot.excerpt(self.excerpt_id) { - return Self { - buffer_id: self.buffer_id, - excerpt_id: self.excerpt_id, - text_anchor: self.text_anchor.bias_right(&excerpt.buffer), - diff_base_anchor: self.diff_base_anchor.map(|a| { - if let Some(base_text) = snapshot - .diffs - .get(&excerpt.buffer_id) - .map(|diff| diff.base_text()) - { - if a.buffer_id == Some(base_text.remote_id()) { - return a.bias_right(&base_text); - } - } - a - }), - }; - } + if self.text_anchor.bias != Bias::Right + && let Some(excerpt) = snapshot.excerpt(self.excerpt_id) + { + return Self { + buffer_id: self.buffer_id, + excerpt_id: self.excerpt_id, + text_anchor: self.text_anchor.bias_right(&excerpt.buffer), + diff_base_anchor: self.diff_base_anchor.map(|a| { + if let Some(base_text) = snapshot + .diffs + .get(&excerpt.buffer_id) + .map(|diff| diff.base_text()) + && a.buffer_id == Some(base_text.remote_id()) + { + return a.bias_right(base_text); + } + a + }), + }; } *self } @@ -181,9 +185,6 @@ impl ToOffset for Anchor { fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> usize { self.summary(snapshot) } -} - -impl ToOffsetUtf16 for Anchor { fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16 { self.summary(snapshot) } @@ -193,6 +194,9 @@ impl ToPoint for Anchor { fn to_point<'a>(&self, snapshot: &MultiBufferSnapshot) -> Point { self.summary(snapshot) } + fn to_point_utf16(&self, snapshot: &MultiBufferSnapshot) -> rope::PointUtf16 { + self.summary(snapshot) + } } pub trait AnchorRangeExt { @@ -212,7 +216,7 @@ impl AnchorRangeExt for Range { } fn includes(&self, other: &Range, buffer: &MultiBufferSnapshot) -> bool { - self.start.cmp(&other.start, &buffer).is_le() && other.end.cmp(&self.end, &buffer).is_le() + self.start.cmp(&other.start, buffer).is_le() && other.end.cmp(&self.end, buffer).is_le() } fn overlaps(&self, other: &Range, buffer: &MultiBufferSnapshot) -> bool { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index eb12e6929cbc4bf74f44a2cb6eb9970c825d0fe3..bdfc7a7606a4d6e4a77a74ebb3c42a41449f002e 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1,7 +1,11 @@ mod anchor; #[cfg(test)] mod multi_buffer_tests; +mod path_key; mod position; +mod transaction; + +use self::transaction::History; pub use anchor::{Anchor, AnchorRangeExt, Offset}; pub use position::{TypedOffset, TypedPoint, TypedRow}; @@ -13,17 +17,20 @@ use buffer_diff::{ }; use clock::ReplicaId; use collections::{BTreeMap, Bound, HashMap, HashSet}; -use gpui::{App, AppContext as _, Context, Entity, EntityId, EventEmitter, Task}; +use gpui::{App, Context, Entity, EntityId, EventEmitter}; use itertools::Itertools; use language::{ AutoindentMode, Buffer, BufferChunks, BufferRow, BufferSnapshot, Capability, CharClassifier, - CharKind, Chunk, CursorShape, DiagnosticEntry, DiskState, File, IndentSize, Language, - LanguageScope, OffsetRangeExt, OffsetUtf16, Outline, OutlineItem, Point, PointUtf16, Selection, - TextDimension, TextObject, ToOffset as _, ToPoint as _, TransactionId, TreeSitterOptions, - Unclipped, - language_settings::{IndentGuideSettings, LanguageSettings, language_settings}, + CharKind, CharScopeContext, Chunk, CursorShape, DiagnosticEntryRef, DiskState, File, + IndentGuideSettings, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16, Outline, + OutlineItem, Point, PointUtf16, Selection, TextDimension, TextObject, ToOffset as _, + ToPoint as _, TransactionId, TreeSitterOptions, Unclipped, + language_settings::{LanguageSettings, language_settings}, }; +#[cfg(any(test, feature = "test-support"))] +use gpui::AppContext as _; + use rope::DimensionPair; use smallvec::SmallVec; use smol::future::yield_now; @@ -37,11 +44,10 @@ use std::{ iter::{self, FromIterator}, mem, ops::{Range, RangeBounds, Sub}, - path::{Path, PathBuf}, rc::Rc, str, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use sum_tree::{Bias, Cursor, Dimension, Dimensions, SumTree, Summary, TreeMap}; use text::{ @@ -52,10 +58,10 @@ use text::{ use theme::SyntaxTheme; use util::post_inc; -const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; +pub use self::path_key::PathKey; #[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct ExcerptId(usize); +pub struct ExcerptId(u32); /// One or more [`Buffers`](Buffer) being edited in a single view. /// @@ -65,27 +71,26 @@ pub struct MultiBuffer { /// Use [`MultiBuffer::snapshot`] to get a up-to-date snapshot. snapshot: RefCell, /// Contains the state of the buffers being edited - buffers: RefCell>, - // only used by consumers using `set_excerpts_for_buffer` + buffers: HashMap, + /// Mapping from path keys to their excerpts. excerpts_by_path: BTreeMap>, + /// Mapping from excerpt IDs to their path key. paths_by_excerpt: HashMap, + /// Mapping from buffer IDs to their diff states diffs: HashMap, - // all_diff_hunks_expanded: bool, subscriptions: Topic, /// If true, the multi-buffer only contains a single [`Buffer`] and a single [`Excerpt`] singleton: bool, + /// The history of the multi-buffer. history: History, + /// The explicit title of the multi-buffer. + /// If `None`, it will be derived from the underlying path or content. title: Option, + /// The writing capability of the multi-buffer. capability: Capability, buffer_changed_since_sync: Rc>, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MultiOrSingleBufferOffsetRange { - Single(Range), - Multi(Range), -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum Event { ExcerptsAdded { @@ -106,22 +111,16 @@ pub enum Event { }, DiffHunksToggled, Edited { - singleton_buffer_edited: bool, edited_buffer: Option>, }, TransactionUndone { transaction_id: TransactionId, }, Reloaded, - ReloadNeeded, - LanguageChanged(BufferId), - CapabilityChanged, Reparsed(BufferId), Saved, FileHandleChanged, - Closed, - Discarded, DirtyChanged, DiagnosticsUpdated, BufferDiffChanged, @@ -171,30 +170,6 @@ impl MultiBufferDiffHunk { } } -#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)] -pub struct PathKey { - namespace: u32, - path: Arc, -} - -impl PathKey { - pub fn namespaced(namespace: u32, path: Arc) -> Self { - Self { namespace, path } - } - - pub fn for_buffer(buffer: &Entity, cx: &App) -> Self { - if let Some(file) = buffer.read(cx).file() { - Self::namespaced(1, Arc::from(file.full_path(cx))) - } else { - Self::namespaced(0, Arc::from(PathBuf::from(buffer.entity_id().to_string()))) - } - } - - pub fn path(&self) -> &Arc { - &self.path - } -} - pub type MultiBufferPoint = Point; type ExcerptOffset = TypedOffset; type ExcerptPoint = TypedPoint; @@ -216,44 +191,20 @@ impl std::ops::Add for MultiBufferRow { } } -#[derive(Clone)] -struct History { - next_transaction_id: TransactionId, - undo_stack: Vec, - redo_stack: Vec, - transaction_depth: usize, - group_interval: Duration, -} - -#[derive(Clone)] -struct Transaction { - id: TransactionId, - buffer_transactions: HashMap, - first_edit_at: Instant, - last_edit_at: Instant, - suppress_grouping: bool, -} - pub trait ToOffset: 'static + fmt::Debug { fn to_offset(&self, snapshot: &MultiBufferSnapshot) -> usize; -} - -pub trait ToOffsetUtf16: 'static + fmt::Debug { fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16; } pub trait ToPoint: 'static + fmt::Debug { fn to_point(&self, snapshot: &MultiBufferSnapshot) -> Point; -} - -pub trait ToPointUtf16: 'static + fmt::Debug { fn to_point_utf16(&self, snapshot: &MultiBufferSnapshot) -> PointUtf16; } struct BufferState { buffer: Entity, - last_version: clock::Global, - last_non_text_state_update_count: usize, + last_version: RefCell, + last_non_text_state_update_count: Cell, excerpts: Vec, _subscriptions: [gpui::Subscription; 2], } @@ -284,19 +235,20 @@ impl DiffState { /// The contents of a [`MultiBuffer`] at a single point in time. #[derive(Clone, Default)] pub struct MultiBufferSnapshot { - singleton: bool, excerpts: SumTree, - excerpt_ids: SumTree, diffs: TreeMap, diff_transforms: SumTree, - replaced_excerpts: TreeMap, - trailing_excerpt_update_count: usize, - all_diff_hunks_expanded: bool, non_text_state_update_count: usize, edit_count: usize, is_dirty: bool, has_deleted_file: bool, has_conflict: bool, + /// immutable fields + singleton: bool, + excerpt_ids: SumTree, + replaced_excerpts: TreeMap, + trailing_excerpt_update_count: usize, + all_diff_hunks_expanded: bool, show_headers: bool, } @@ -417,7 +369,7 @@ struct Excerpt { #[derive(Clone)] pub struct MultiBufferExcerpt<'a> { excerpt: &'a Excerpt, - diff_transforms: sum_tree::Cursor<'a, DiffTransform, DiffTransforms>, + diff_transforms: sum_tree::Cursor<'a, 'static, DiffTransform, DiffTransforms>, offset: usize, excerpt_offset: ExcerptDimension, buffer_offset: usize, @@ -473,8 +425,8 @@ pub struct MultiBufferRows<'a> { } pub struct MultiBufferChunks<'a> { - excerpts: Cursor<'a, Excerpt, ExcerptOffset>, - diff_transforms: Cursor<'a, DiffTransform, Dimensions>, + excerpts: Cursor<'a, 'static, Excerpt, ExcerptOffset>, + diff_transforms: Cursor<'a, 'static, DiffTransform, Dimensions>, diffs: &'a TreeMap, diff_base_chunks: Option<(BufferId, BufferChunks<'a>)>, buffer_chunk: Option>, @@ -512,7 +464,7 @@ struct DiffTransforms { } impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransforms { - fn zero(cx: &::Context) -> Self { + fn zero(cx: ::Context<'_>) -> Self { Self { output_dimension: OutputDimension::zero(cx), excerpt_dimension: as Dimension<'a, DiffTransformSummary>>::zero( @@ -524,7 +476,7 @@ impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransform fn add_summary( &mut self, summary: &'a DiffTransformSummary, - cx: &::Context, + cx: ::Context<'_>, ) { self.output_dimension.add_summary(summary, cx); self.excerpt_dimension.add_summary(summary, cx); @@ -533,8 +485,8 @@ impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransform #[derive(Clone)] struct MultiBufferCursor<'a, D: TextDimension> { - excerpts: Cursor<'a, Excerpt, ExcerptDimension>, - diff_transforms: Cursor<'a, DiffTransform, DiffTransforms>, + excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension>, + diff_transforms: Cursor<'a, 'static, DiffTransform, DiffTransforms>, diffs: &'a TreeMap, cached_region: Option>, } @@ -553,7 +505,7 @@ struct MultiBufferRegion<'a, D: TextDimension> { struct ExcerptChunks<'a> { excerpt_id: ExcerptId, content_chunks: BufferChunks<'a>, - footer_height: usize, + has_footer: bool, } #[derive(Debug)] @@ -615,56 +567,57 @@ impl IndentGuide { impl MultiBuffer { pub fn new(capability: Capability) -> Self { - Self { - snapshot: RefCell::new(MultiBufferSnapshot { + Self::new_( + capability, + MultiBufferSnapshot { show_headers: true, ..MultiBufferSnapshot::default() - }), - buffers: RefCell::default(), - diffs: HashMap::default(), - subscriptions: Topic::default(), - singleton: false, - capability, - title: None, - excerpts_by_path: Default::default(), - paths_by_excerpt: Default::default(), - buffer_changed_since_sync: Default::default(), - history: History { - next_transaction_id: clock::Lamport::default(), - undo_stack: Vec::new(), - redo_stack: Vec::new(), - transaction_depth: 0, - group_interval: Duration::from_millis(300), }, - } + ) } pub fn without_headers(capability: Capability) -> Self { + Self::new_(capability, Default::default()) + } + + pub fn singleton(buffer: Entity, cx: &mut Context) -> Self { + let mut this = Self::new_( + buffer.read(cx).capability(), + MultiBufferSnapshot { + singleton: true, + ..MultiBufferSnapshot::default() + }, + ); + this.singleton = true; + this.push_excerpts( + buffer, + [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], + cx, + ); + this + } + + #[inline] + pub fn new_(capability: Capability, snapshot: MultiBufferSnapshot) -> Self { Self { - snapshot: Default::default(), + snapshot: RefCell::new(snapshot), buffers: Default::default(), - excerpts_by_path: Default::default(), - paths_by_excerpt: Default::default(), diffs: HashMap::default(), - subscriptions: Default::default(), + subscriptions: Topic::default(), singleton: false, capability, + title: None, + excerpts_by_path: Default::default(), + paths_by_excerpt: Default::default(), buffer_changed_since_sync: Default::default(), - history: History { - next_transaction_id: Default::default(), - undo_stack: Default::default(), - redo_stack: Default::default(), - transaction_depth: 0, - group_interval: Duration::from_millis(300), - }, - title: Default::default(), + history: History::default(), } } pub fn clone(&self, new_cx: &mut Context) -> Self { let mut buffers = HashMap::default(); let buffer_changed_since_sync = Rc::new(Cell::new(false)); - for (buffer_id, buffer_state) in self.buffers.borrow().iter() { + for (buffer_id, buffer_state) in self.buffers.iter() { buffer_state.buffer.update(new_cx, |buffer, _| { buffer.record_changes(Rc::downgrade(&buffer_changed_since_sync)); }); @@ -673,7 +626,9 @@ impl MultiBuffer { BufferState { buffer: buffer_state.buffer.clone(), last_version: buffer_state.last_version.clone(), - last_non_text_state_update_count: buffer_state.last_non_text_state_update_count, + last_non_text_state_update_count: buffer_state + .last_non_text_state_update_count + .clone(), excerpts: buffer_state.excerpts.clone(), _subscriptions: [ new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()), @@ -688,7 +643,7 @@ impl MultiBuffer { } Self { snapshot: RefCell::new(self.snapshot.borrow().clone()), - buffers: RefCell::new(buffers), + buffers: buffers, excerpts_by_path: Default::default(), paths_by_excerpt: Default::default(), diffs: diff_bases, @@ -701,6 +656,10 @@ impl MultiBuffer { } } + pub fn set_group_interval(&mut self, group_interval: Duration) { + self.history.set_group_interval(group_interval); + } + pub fn with_title(mut self, title: String) -> Self { self.title = Some(title); self @@ -710,18 +669,6 @@ impl MultiBuffer { self.capability == Capability::ReadOnly } - pub fn singleton(buffer: Entity, cx: &mut Context) -> Self { - let mut this = Self::new(buffer.read(cx).capability()); - this.singleton = true; - this.push_excerpts( - buffer, - [ExcerptRange::new(text::Anchor::MIN..text::Anchor::MAX)], - cx, - ); - this.snapshot.borrow_mut().singleton = true; - this - } - /// Returns an up-to-date snapshot of the MultiBuffer. pub fn snapshot(&self, cx: &App) -> MultiBufferSnapshot { self.sync(cx); @@ -735,15 +682,7 @@ impl MultiBuffer { pub fn as_singleton(&self) -> Option> { if self.singleton { - return Some( - self.buffers - .borrow() - .values() - .next() - .unwrap() - .buffer - .clone(), - ); + Some(self.buffers.values().next().unwrap().buffer.clone()) } else { None } @@ -776,20 +715,11 @@ impl MultiBuffer { } pub fn is_empty(&self) -> bool { - self.buffers.borrow().is_empty() - } - - pub fn symbols_containing( - &self, - offset: T, - theme: Option<&SyntaxTheme>, - cx: &App, - ) -> Option<(BufferId, Vec>)> { - self.read(cx).symbols_containing(offset, theme) + self.buffers.is_empty() } pub fn edit( - &self, + &mut self, edits: I, autoindent_mode: Option, cx: &mut Context, @@ -798,11 +728,15 @@ impl MultiBuffer { S: ToOffset, T: Into>, { - let snapshot = self.read(cx); + if self.read_only() || self.buffers.is_empty() { + return; + } + self.sync_mut(cx); let edits = edits .into_iter() .map(|(range, new_text)| { - let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + let mut range = range.start.to_offset(self.snapshot.get_mut()) + ..range.end.to_offset(self.snapshot.get_mut()); if range.start > range.end { mem::swap(&mut range.start, &mut range.end); } @@ -810,20 +744,15 @@ impl MultiBuffer { }) .collect::>(); - return edit_internal(self, snapshot, edits, autoindent_mode, cx); + return edit_internal(self, edits, autoindent_mode, cx); // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR. fn edit_internal( - this: &MultiBuffer, - snapshot: Ref, + this: &mut MultiBuffer, edits: Vec<(Range, Arc)>, mut autoindent_mode: Option, cx: &mut Context, ) { - if this.read_only() || this.buffers.borrow().is_empty() { - return; - } - let original_indent_columns = match &mut autoindent_mode { Some(AutoindentMode::Block { original_indent_columns, @@ -831,86 +760,84 @@ impl MultiBuffer { _ => Default::default(), }; - let (buffer_edits, edited_excerpt_ids) = - this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns); - drop(snapshot); + let (buffer_edits, edited_excerpt_ids) = MultiBuffer::convert_edits_to_buffer_edits( + edits, + this.snapshot.get_mut(), + &original_indent_columns, + ); - let mut buffer_ids = Vec::new(); + let mut buffer_ids = Vec::with_capacity(buffer_edits.len()); for (buffer_id, mut edits) in buffer_edits { buffer_ids.push(buffer_id); edits.sort_by_key(|edit| edit.range.start); - this.buffers.borrow()[&buffer_id] - .buffer - .update(cx, |buffer, cx| { - let mut edits = edits.into_iter().peekable(); - let mut insertions = Vec::new(); - let mut original_indent_columns = Vec::new(); - let mut deletions = Vec::new(); - let empty_str: Arc = Arc::default(); + this.buffers[&buffer_id].buffer.update(cx, |buffer, cx| { + let mut edits = edits.into_iter().peekable(); + let mut insertions = Vec::new(); + let mut original_indent_columns = Vec::new(); + let mut deletions = Vec::new(); + let empty_str: Arc = Arc::default(); + while let Some(BufferEdit { + mut range, + mut new_text, + mut is_insertion, + original_indent_column, + excerpt_id, + }) = edits.next() + { while let Some(BufferEdit { - mut range, - mut new_text, - mut is_insertion, - original_indent_column, - excerpt_id, - }) = edits.next() + range: next_range, + is_insertion: next_is_insertion, + new_text: next_new_text, + excerpt_id: next_excerpt_id, + .. + }) = edits.peek() { - while let Some(BufferEdit { - range: next_range, - is_insertion: next_is_insertion, - new_text: next_new_text, - excerpt_id: next_excerpt_id, - .. - }) = edits.peek() - { - if range.end >= next_range.start { - range.end = cmp::max(next_range.end, range.end); - is_insertion |= *next_is_insertion; - if excerpt_id == *next_excerpt_id { - new_text = format!("{new_text}{next_new_text}").into(); - } - edits.next(); - } else { - break; + if range.end >= next_range.start { + range.end = cmp::max(next_range.end, range.end); + is_insertion |= *next_is_insertion; + if excerpt_id == *next_excerpt_id { + new_text = format!("{new_text}{next_new_text}").into(); } + edits.next(); + } else { + break; } + } - if is_insertion { - original_indent_columns.push(original_indent_column); - insertions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - new_text.clone(), - )); - } else if !range.is_empty() { - deletions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - empty_str.clone(), - )); - } + if is_insertion { + original_indent_columns.push(original_indent_column); + insertions.push(( + buffer.anchor_before(range.start)..buffer.anchor_before(range.end), + new_text.clone(), + )); + } else if !range.is_empty() { + deletions.push(( + buffer.anchor_before(range.start)..buffer.anchor_before(range.end), + empty_str.clone(), + )); } + } - let deletion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns: Default::default(), - }) - } else { - autoindent_mode.clone() - }; - let insertion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns, - }) - } else { - autoindent_mode.clone() - }; + let deletion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns: Default::default(), + }) + } else { + autoindent_mode.clone() + }; + let insertion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns, + }) + } else { + autoindent_mode.clone() + }; - buffer.edit(deletions, deletion_autoindent_mode, cx); - buffer.edit(insertions, insertion_autoindent_mode, cx); - }) + buffer.edit(deletions, deletion_autoindent_mode, cx); + buffer.edit(insertions, insertion_autoindent_mode, cx); + }) } cx.emit(Event::ExcerptsEdited { @@ -921,7 +848,6 @@ impl MultiBuffer { } fn convert_edits_to_buffer_edits( - &self, edits: Vec<(Range, Arc)>, snapshot: &MultiBufferSnapshot, original_indent_columns: &[Option], @@ -1041,17 +967,21 @@ impl MultiBuffer { (buffer_edits, edited_excerpt_ids) } - pub fn autoindent_ranges(&self, ranges: I, cx: &mut Context) + pub fn autoindent_ranges(&mut self, ranges: I, cx: &mut Context) where I: IntoIterator>, S: ToOffset, { - let snapshot = self.read(cx); + if self.read_only() || self.buffers.is_empty() { + return; + } + self.sync_mut(cx); let empty = Arc::::from(""); let edits = ranges .into_iter() .map(|range| { - let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + let mut range = range.start.to_offset(self.snapshot.get_mut()) + ..range.end.to_offset(&self.snapshot.get_mut()); if range.start > range.end { mem::swap(&mut range.start, &mut range.end); } @@ -1059,21 +989,15 @@ impl MultiBuffer { }) .collect::>(); - return autoindent_ranges_internal(self, snapshot, edits, cx); + return autoindent_ranges_internal(self, edits, cx); fn autoindent_ranges_internal( - this: &MultiBuffer, - snapshot: Ref, + this: &mut MultiBuffer, edits: Vec<(Range, Arc)>, cx: &mut Context, ) { - if this.read_only() || this.buffers.borrow().is_empty() { - return; - } - let (buffer_edits, edited_excerpt_ids) = - this.convert_edits_to_buffer_edits(edits, &snapshot, &[]); - drop(snapshot); + MultiBuffer::convert_edits_to_buffer_edits(edits, this.snapshot.get_mut(), &[]); let mut buffer_ids = Vec::new(); for (buffer_id, mut edits) in buffer_edits { @@ -1082,20 +1006,18 @@ impl MultiBuffer { let mut ranges: Vec> = Vec::new(); for edit in edits { - if let Some(last_range) = ranges.last_mut() { - if edit.range.start <= last_range.end { - last_range.end = last_range.end.max(edit.range.end); - continue; - } + if let Some(last_range) = ranges.last_mut() + && edit.range.start <= last_range.end + { + last_range.end = last_range.end.max(edit.range.end); + continue; } ranges.push(edit.range); } - this.buffers.borrow()[&buffer_id] - .buffer - .update(cx, |buffer, cx| { - buffer.autoindent_ranges(ranges, cx); - }) + this.buffers[&buffer_id].buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(ranges, cx); + }) } cx.emit(Event::ExcerptsEdited { @@ -1105,9 +1027,9 @@ impl MultiBuffer { } } - // Inserts newlines at the given position to create an empty line, returning the start of the new line. - // You can also request the insertion of empty lines above and below the line starting at the returned point. - // Panics if the given position is invalid. + /// Inserts newlines at the given position to create an empty line, returning the start of the new line. + /// You can also request the insertion of empty lines above and below the line starting at the returned point. + /// Panics if the given position is invalid. pub fn insert_empty_line( &mut self, position: impl ToPoint, @@ -1125,188 +1047,6 @@ impl MultiBuffer { multibuffer_point + (empty_line_start - buffer_point) } - pub fn start_transaction(&mut self, cx: &mut Context) -> Option { - self.start_transaction_at(Instant::now(), cx) - } - - pub fn start_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); - } - - for BufferState { buffer, .. } in self.buffers.borrow().values() { - buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); - } - self.history.start_transaction(now) - } - - pub fn last_transaction_id(&self, cx: &App) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer - .read(cx) - .peek_undo_stack() - .map(|history_entry| history_entry.transaction_id()); - } else { - let last_transaction = self.history.undo_stack.last()?; - return Some(last_transaction.id); - } - } - - pub fn end_transaction(&mut self, cx: &mut Context) -> Option { - self.end_transaction_at(Instant::now(), cx) - } - - pub fn end_transaction_at( - &mut self, - now: Instant, - cx: &mut Context, - ) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)); - } - - let mut buffer_transactions = HashMap::default(); - for BufferState { buffer, .. } in self.buffers.borrow().values() { - if let Some(transaction_id) = - buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) - { - buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); - } - } - - if self.history.end_transaction(now, buffer_transactions) { - let transaction_id = self.history.group().unwrap(); - Some(transaction_id) - } else { - None - } - } - - pub fn edited_ranges_for_transaction( - &self, - transaction_id: TransactionId, - cx: &App, - ) -> Vec> - where - D: TextDimension + Ord + Sub, - { - let Some(transaction) = self.history.transaction(transaction_id) else { - return Vec::new(); - }; - - let mut ranges = Vec::new(); - let snapshot = self.read(cx); - let buffers = self.buffers.borrow(); - let mut cursor = snapshot.excerpts.cursor::(&()); - - for (buffer_id, buffer_transaction) in &transaction.buffer_transactions { - let Some(buffer_state) = buffers.get(buffer_id) else { - continue; - }; - - let buffer = buffer_state.buffer.read(cx); - for range in buffer.edited_ranges_for_transaction_id::(*buffer_transaction) { - for excerpt_id in &buffer_state.excerpts { - cursor.seek(excerpt_id, Bias::Left); - if let Some(excerpt) = cursor.item() { - if excerpt.locator == *excerpt_id { - let excerpt_buffer_start = - excerpt.range.context.start.summary::(buffer); - let excerpt_buffer_end = excerpt.range.context.end.summary::(buffer); - let excerpt_range = excerpt_buffer_start..excerpt_buffer_end; - if excerpt_range.contains(&range.start) - && excerpt_range.contains(&range.end) - { - let excerpt_start = D::from_text_summary(&cursor.start().text); - - let mut start = excerpt_start; - start.add_assign(&(range.start - excerpt_buffer_start)); - let mut end = excerpt_start; - end.add_assign(&(range.end - excerpt_buffer_start)); - - ranges.push(start..end); - break; - } - } - } - } - } - } - - ranges.sort_by_key(|range| range.start); - ranges - } - - pub fn merge_transactions( - &mut self, - transaction: TransactionId, - destination: TransactionId, - cx: &mut Context, - ) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.merge_transactions(transaction, destination) - }); - } else if let Some(transaction) = self.history.forget(transaction) { - if let Some(destination) = self.history.transaction_mut(destination) { - for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { - if let Some(destination_buffer_transaction_id) = - destination.buffer_transactions.get(&buffer_id) - { - if let Some(state) = self.buffers.borrow().get(&buffer_id) { - state.buffer.update(cx, |buffer, _| { - buffer.merge_transactions( - buffer_transaction_id, - *destination_buffer_transaction_id, - ) - }); - } - } else { - destination - .buffer_transactions - .insert(buffer_id, buffer_transaction_id); - } - } - } - } - } - - pub fn finalize_last_transaction(&mut self, cx: &mut Context) { - self.history.finalize_last_transaction(); - for BufferState { buffer, .. } in self.buffers.borrow().values() { - buffer.update(cx, |buffer, _| { - buffer.finalize_last_transaction(); - }); - } - } - - pub fn push_transaction<'a, T>(&mut self, buffer_transactions: T, cx: &Context) - where - T: IntoIterator, &'a language::Transaction)>, - { - self.history - .push_transaction(buffer_transactions, Instant::now(), cx); - self.history.finalize_last_transaction(); - } - - pub fn group_until_transaction( - &mut self, - transaction_id: TransactionId, - cx: &mut Context, - ) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.group_until_transaction(transaction_id) - }); - } else { - self.history.group_until(transaction_id); - } - } - pub fn set_active_selections( &self, selections: &[Selection], @@ -1317,17 +1057,15 @@ impl MultiBuffer { let mut selections_by_buffer: HashMap>> = Default::default(); let snapshot = self.read(cx); - let mut cursor = snapshot.excerpts.cursor::>(&()); + let mut cursor = snapshot.excerpts.cursor::>(()); for selection in selections { let start_locator = snapshot.excerpt_locator_for_id(selection.start.excerpt_id); let end_locator = snapshot.excerpt_locator_for_id(selection.end.excerpt_id); cursor.seek(&Some(start_locator), Bias::Left); - while let Some(excerpt) = cursor.item() { - if excerpt.locator > *end_locator { - break; - } - + while let Some(excerpt) = cursor.item() + && excerpt.locator <= *end_locator + { let mut start = excerpt.range.context.start; let mut end = excerpt.range.context.end; if excerpt.id == selection.start.excerpt_id { @@ -1351,7 +1089,7 @@ impl MultiBuffer { } } - for (buffer_id, buffer_state) in self.buffers.borrow().iter() { + for (buffer_id, buffer_state) in self.buffers.iter() { if !selections_by_buffer.contains_key(buffer_id) { buffer_state .buffer @@ -1360,128 +1098,36 @@ impl MultiBuffer { } for (buffer_id, mut selections) in selections_by_buffer { - self.buffers.borrow()[&buffer_id] - .buffer - .update(cx, |buffer, cx| { - selections.sort_unstable_by(|a, b| a.start.cmp(&b.start, buffer)); - let mut selections = selections.into_iter().peekable(); - let merged_selections = Arc::from_iter(iter::from_fn(|| { - let mut selection = selections.next()?; - while let Some(next_selection) = selections.peek() { - if selection.end.cmp(&next_selection.start, buffer).is_ge() { - let next_selection = selections.next().unwrap(); - if next_selection.end.cmp(&selection.end, buffer).is_ge() { - selection.end = next_selection.end; - } - } else { - break; + self.buffers[&buffer_id].buffer.update(cx, |buffer, cx| { + selections.sort_unstable_by(|a, b| a.start.cmp(&b.start, buffer)); + let mut selections = selections.into_iter().peekable(); + let merged_selections = Arc::from_iter(iter::from_fn(|| { + let mut selection = selections.next()?; + while let Some(next_selection) = selections.peek() { + if selection.end.cmp(&next_selection.start, buffer).is_ge() { + let next_selection = selections.next().unwrap(); + if next_selection.end.cmp(&selection.end, buffer).is_ge() { + selection.end = next_selection.end; } + } else { + break; } - Some(selection) - })); - buffer.set_active_selections(merged_selections, line_mode, cursor_shape, cx); - }); + } + Some(selection) + })); + buffer.set_active_selections(merged_selections, line_mode, cursor_shape, cx); + }); } } pub fn remove_active_selections(&self, cx: &mut Context) { - for buffer in self.buffers.borrow().values() { + for buffer in self.buffers.values() { buffer .buffer .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); } } - pub fn undo(&mut self, cx: &mut Context) -> Option { - let mut transaction_id = None; - if let Some(buffer) = self.as_singleton() { - transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx)); - } else { - while let Some(transaction) = self.history.pop_undo() { - let mut undone = false; - for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { - undone |= buffer.update(cx, |buffer, cx| { - let undo_to = *buffer_transaction_id; - if let Some(entry) = buffer.peek_undo_stack() { - *buffer_transaction_id = entry.transaction_id(); - } - buffer.undo_to_transaction(undo_to, cx) - }); - } - } - - if undone { - transaction_id = Some(transaction.id); - break; - } - } - } - - if let Some(transaction_id) = transaction_id { - cx.emit(Event::TransactionUndone { transaction_id }); - } - - transaction_id - } - - pub fn redo(&mut self, cx: &mut Context) -> Option { - if let Some(buffer) = self.as_singleton() { - return buffer.update(cx, |buffer, cx| buffer.redo(cx)); - } - - while let Some(transaction) = self.history.pop_redo() { - let mut redone = false; - for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { - redone |= buffer.update(cx, |buffer, cx| { - let redo_to = *buffer_transaction_id; - if let Some(entry) = buffer.peek_redo_stack() { - *buffer_transaction_id = entry.transaction_id(); - } - buffer.redo_to_transaction(redo_to, cx) - }); - } - } - - if redone { - return Some(transaction.id); - } - } - - None - } - - pub fn undo_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); - } else if let Some(transaction) = self.history.remove_from_undo(transaction_id) { - for (buffer_id, transaction_id) in &transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.undo_transaction(*transaction_id, cx) - }); - } - } - } - } - - pub fn forget_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, _| { - buffer.forget_transaction(transaction_id); - }); - } else if let Some(transaction) = self.history.forget(transaction_id) { - for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { - if let Some(state) = self.buffers.borrow_mut().get_mut(&buffer_id) { - state.buffer.update(cx, |buffer, _| { - buffer.forget_transaction(buffer_transaction_id); - }); - } - } - } - } - pub fn push_excerpts( &mut self, buffer: Entity, @@ -1494,216 +1140,6 @@ impl MultiBuffer { self.insert_excerpts_after(ExcerptId::max(), buffer, ranges, cx) } - pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { - let excerpt_id = self.excerpts_by_path.get(path)?.first()?; - let snapshot = self.snapshot(cx); - let excerpt = snapshot.excerpt(*excerpt_id)?; - Some(Anchor::in_buffer( - *excerpt_id, - excerpt.buffer_id, - excerpt.range.context.start, - )) - } - - pub fn excerpt_paths(&self) -> impl Iterator { - self.excerpts_by_path.keys() - } - - fn expand_excerpts_with_paths( - &mut self, - ids: impl IntoIterator, - line_count: u32, - direction: ExpandExcerptDirection, - cx: &mut Context, - ) { - let grouped = ids - .into_iter() - .chunk_by(|id| self.paths_by_excerpt.get(id).cloned()) - .into_iter() - .flat_map(|(k, v)| Some((k?, v.into_iter().collect::>()))) - .collect::>(); - let snapshot = self.snapshot(cx); - - for (path, ids) in grouped.into_iter() { - let Some(excerpt_ids) = self.excerpts_by_path.get(&path) else { - continue; - }; - - let ids_to_expand = HashSet::from_iter(ids); - let expanded_ranges = excerpt_ids.iter().filter_map(|excerpt_id| { - let excerpt = snapshot.excerpt(*excerpt_id)?; - - let mut context = excerpt.range.context.to_point(&excerpt.buffer); - if ids_to_expand.contains(excerpt_id) { - match direction { - ExpandExcerptDirection::Up => { - context.start.row = context.start.row.saturating_sub(line_count); - context.start.column = 0; - } - ExpandExcerptDirection::Down => { - context.end.row = - (context.end.row + line_count).min(excerpt.buffer.max_point().row); - context.end.column = excerpt.buffer.line_len(context.end.row); - } - ExpandExcerptDirection::UpAndDown => { - context.start.row = context.start.row.saturating_sub(line_count); - context.start.column = 0; - context.end.row = - (context.end.row + line_count).min(excerpt.buffer.max_point().row); - context.end.column = excerpt.buffer.line_len(context.end.row); - } - } - } - - Some(ExcerptRange { - context, - primary: excerpt.range.primary.to_point(&excerpt.buffer), - }) - }); - let mut merged_ranges: Vec> = Vec::new(); - for range in expanded_ranges { - if let Some(last_range) = merged_ranges.last_mut() { - if last_range.context.end >= range.context.start { - last_range.context.end = range.context.end; - continue; - } - } - merged_ranges.push(range) - } - let Some(excerpt_id) = excerpt_ids.first() else { - continue; - }; - let Some(buffer_id) = &snapshot.buffer_id_for_excerpt(*excerpt_id) else { - continue; - }; - - let Some(buffer) = self - .buffers - .borrow() - .get(buffer_id) - .map(|b| b.buffer.clone()) - else { - continue; - }; - - let buffer_snapshot = buffer.read(cx).snapshot(); - self.update_path_excerpts(path.clone(), buffer, &buffer_snapshot, merged_ranges, cx); - } - } - - /// Sets excerpts, returns `true` if at least one new excerpt was added. - pub fn set_excerpts_for_path( - &mut self, - path: PathKey, - buffer: Entity, - ranges: impl IntoIterator>, - context_line_count: u32, - cx: &mut Context, - ) -> (Vec>, bool) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot); - - let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); - self.set_merged_excerpt_ranges_for_path( - path, - buffer, - excerpt_ranges, - &buffer_snapshot, - new, - counts, - cx, - ) - } - - pub fn set_excerpt_ranges_for_path( - &mut self, - path: PathKey, - buffer: Entity, - buffer_snapshot: &BufferSnapshot, - excerpt_ranges: Vec>, - cx: &mut Context, - ) -> (Vec>, bool) { - let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); - self.set_merged_excerpt_ranges_for_path( - path, - buffer, - excerpt_ranges, - buffer_snapshot, - new, - counts, - cx, - ) - } - - pub fn set_anchored_excerpts_for_path( - &self, - buffer: Entity, - ranges: Vec>, - context_line_count: u32, - cx: &mut Context, - ) -> Task>> { - let buffer_snapshot = buffer.read(cx).snapshot(); - let path_key = PathKey::for_buffer(&buffer, cx); - cx.spawn(async move |multi_buffer, cx| { - let snapshot = buffer_snapshot.clone(); - let (excerpt_ranges, new, counts) = cx - .background_spawn(async move { - let ranges = ranges.into_iter().map(|range| range.to_point(&snapshot)); - let excerpt_ranges = - build_excerpt_ranges(ranges, context_line_count, &snapshot); - let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); - (excerpt_ranges, new, counts) - }) - .await; - - multi_buffer - .update(cx, move |multi_buffer, cx| { - let (ranges, _) = multi_buffer.set_merged_excerpt_ranges_for_path( - path_key, - buffer, - excerpt_ranges, - &buffer_snapshot, - new, - counts, - cx, - ); - ranges - }) - .ok() - .unwrap_or_default() - }) - } - - /// Sets excerpts, returns `true` if at least one new excerpt was added. - fn set_merged_excerpt_ranges_for_path( - &mut self, - path: PathKey, - buffer: Entity, - ranges: Vec>, - buffer_snapshot: &BufferSnapshot, - new: Vec>, - counts: Vec, - cx: &mut Context, - ) -> (Vec>, bool) { - let (excerpt_ids, added_a_new_excerpt) = - self.update_path_excerpts(path, buffer, &buffer_snapshot, new, cx); - - let mut result = Vec::new(); - let mut ranges = ranges.into_iter(); - for (excerpt_id, range_count) in excerpt_ids.into_iter().zip(counts.into_iter()) { - for range in ranges.by_ref().take(range_count) { - let range = Anchor::range_in_buffer( - excerpt_id, - buffer_snapshot.remote_id(), - buffer_snapshot.anchor_before(&range.primary.start) - ..buffer_snapshot.anchor_after(&range.primary.end), - ); - result.push(range) - } - } - (result, added_a_new_excerpt) - } - fn merge_excerpt_ranges<'a>( expanded_ranges: impl IntoIterator> + 'a, ) -> (Vec>, Vec) { @@ -1726,175 +1162,7 @@ impl MultiBuffer { merged_ranges.push(range.clone()); counts.push(1); } - return (merged_ranges, counts); - } - - fn update_path_excerpts( - &mut self, - path: PathKey, - buffer: Entity, - buffer_snapshot: &BufferSnapshot, - new: Vec>, - cx: &mut Context, - ) -> (Vec, bool) { - let mut insert_after = self - .excerpts_by_path - .range(..path.clone()) - .next_back() - .map(|(_, value)| *value.last().unwrap()) - .unwrap_or(ExcerptId::min()); - - let existing = self - .excerpts_by_path - .get(&path) - .cloned() - .unwrap_or_default(); - - let mut new_iter = new.into_iter().peekable(); - let mut existing_iter = existing.into_iter().peekable(); - - let mut excerpt_ids = Vec::new(); - let mut to_remove = Vec::new(); - let mut to_insert: Vec<(ExcerptId, ExcerptRange)> = Vec::new(); - let mut added_a_new_excerpt = false; - let snapshot = self.snapshot(cx); - - let mut next_excerpt_id = - if let Some(last_entry) = self.snapshot.borrow().excerpt_ids.last() { - last_entry.id.0 + 1 - } else { - 1 - }; - - let mut next_excerpt_id = move || ExcerptId(post_inc(&mut next_excerpt_id)); - - let mut excerpts_cursor = snapshot.excerpts.cursor::>(&()); - excerpts_cursor.next(); - - loop { - let new = new_iter.peek(); - let existing = if let Some(existing_id) = existing_iter.peek() { - let locator = snapshot.excerpt_locator_for_id(*existing_id); - excerpts_cursor.seek_forward(&Some(locator), Bias::Left); - if let Some(excerpt) = excerpts_cursor.item() { - if excerpt.buffer_id != buffer_snapshot.remote_id() { - to_remove.push(*existing_id); - existing_iter.next(); - continue; - } - Some(( - *existing_id, - excerpt.range.context.to_point(&buffer_snapshot), - )) - } else { - None - } - } else { - None - }; - - if let Some((last_id, last)) = to_insert.last_mut() { - if let Some(new) = new { - if last.context.end >= new.context.start { - last.context.end = last.context.end.max(new.context.end); - excerpt_ids.push(*last_id); - new_iter.next(); - continue; - } - } - if let Some((existing_id, existing_range)) = &existing { - if last.context.end >= existing_range.start { - last.context.end = last.context.end.max(existing_range.end); - to_remove.push(*existing_id); - self.snapshot - .borrow_mut() - .replaced_excerpts - .insert(*existing_id, *last_id); - existing_iter.next(); - continue; - } - } - } - - match (new, existing) { - (None, None) => break, - (None, Some((existing_id, _))) => { - existing_iter.next(); - to_remove.push(existing_id); - continue; - } - (Some(_), None) => { - added_a_new_excerpt = true; - let new_id = next_excerpt_id(); - excerpt_ids.push(new_id); - to_insert.push((new_id, new_iter.next().unwrap())); - continue; - } - (Some(new), Some((_, existing_range))) => { - if existing_range.end < new.context.start { - let existing_id = existing_iter.next().unwrap(); - to_remove.push(existing_id); - continue; - } else if existing_range.start > new.context.end { - let new_id = next_excerpt_id(); - excerpt_ids.push(new_id); - to_insert.push((new_id, new_iter.next().unwrap())); - continue; - } - - if existing_range.start == new.context.start - && existing_range.end == new.context.end - { - self.insert_excerpts_with_ids_after( - insert_after, - buffer.clone(), - mem::take(&mut to_insert), - cx, - ); - insert_after = existing_iter.next().unwrap(); - excerpt_ids.push(insert_after); - new_iter.next(); - } else { - let existing_id = existing_iter.next().unwrap(); - let new_id = next_excerpt_id(); - self.snapshot - .borrow_mut() - .replaced_excerpts - .insert(existing_id, new_id); - to_remove.push(existing_id); - let mut range = new_iter.next().unwrap(); - range.context.start = range.context.start.min(existing_range.start); - range.context.end = range.context.end.max(existing_range.end); - excerpt_ids.push(new_id); - to_insert.push((new_id, range)); - } - } - }; - } - - self.insert_excerpts_with_ids_after(insert_after, buffer, to_insert, cx); - self.remove_excerpts(to_remove, cx); - if excerpt_ids.is_empty() { - self.excerpts_by_path.remove(&path); - } else { - for excerpt_id in &excerpt_ids { - self.paths_by_excerpt.insert(*excerpt_id, path.clone()); - } - self.excerpts_by_path - .insert(path, excerpt_ids.iter().dedup().cloned().collect()); - } - - (excerpt_ids, added_a_new_excerpt) - } - - pub fn paths(&self) -> impl Iterator + '_ { - self.excerpts_by_path.keys().cloned() - } - - pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { - if let Some(to_remove) = self.excerpts_by_path.remove(&path) { - self.remove_excerpts(to_remove, cx) - } + (merged_ranges, counts) } pub fn insert_excerpts_after( @@ -1936,26 +1204,27 @@ impl MultiBuffer { ) where O: text::ToOffset, { - assert_eq!(self.history.transaction_depth, 0); + assert_eq!(self.history.transaction_depth(), 0); let mut ranges = ranges.into_iter().peekable(); if ranges.peek().is_none() { return Default::default(); } - self.sync(cx); + self.sync_mut(cx); let buffer_snapshot = buffer.read(cx).snapshot(); let buffer_id = buffer_snapshot.remote_id(); - let mut buffers = self.buffers.borrow_mut(); - let buffer_state = buffers.entry(buffer_id).or_insert_with(|| { + let buffer_state = self.buffers.entry(buffer_id).or_insert_with(|| { self.buffer_changed_since_sync.replace(true); buffer.update(cx, |buffer, _| { buffer.record_changes(Rc::downgrade(&self.buffer_changed_since_sync)); }); BufferState { - last_version: buffer_snapshot.version().clone(), - last_non_text_state_update_count: buffer_snapshot.non_text_state_update_count(), + last_version: RefCell::new(buffer_snapshot.version().clone()), + last_non_text_state_update_count: Cell::new( + buffer_snapshot.non_text_state_update_count(), + ), excerpts: Default::default(), _subscriptions: [ cx.observe(&buffer, |_, _, cx| cx.notify()), @@ -1965,11 +1234,11 @@ impl MultiBuffer { } }); - let mut snapshot = self.snapshot.borrow_mut(); + let mut snapshot = self.snapshot.get_mut(); let mut prev_locator = snapshot.excerpt_locator_for_id(prev_excerpt_id).clone(); let mut new_excerpt_ids = mem::take(&mut snapshot.excerpt_ids); - let mut cursor = snapshot.excerpts.cursor::>(&()); + let mut cursor = snapshot.excerpts.cursor::>(()); let mut new_excerpts = cursor.slice(&prev_locator, Bias::Right); prev_locator = cursor.start().unwrap_or(Locator::min_ref()).clone(); @@ -1978,7 +1247,7 @@ impl MultiBuffer { |excerpt| { excerpt.has_trailing_newline = true; }, - &(), + (), ); let next_locator = if let Some(excerpt) = cursor.item() { @@ -2008,20 +1277,20 @@ impl MultiBuffer { range, ranges.peek().is_some() || cursor.item().is_some(), ); - new_excerpts.push(excerpt, &()); + new_excerpts.push(excerpt, ()); prev_locator = locator.clone(); if let Some(last_mapping_entry) = new_excerpt_ids.last() { assert!(id > last_mapping_entry.id, "excerpt ids must be increasing"); } - new_excerpt_ids.push(ExcerptIdMapping { id, locator }, &()); + new_excerpt_ids.push(ExcerptIdMapping { id, locator }, ()); } let edit_end = ExcerptOffset::new(new_excerpts.summary().text.len); let suffix = cursor.suffix(); let changed_trailing_excerpt = suffix.is_empty(); - new_excerpts.append(suffix, &()); + new_excerpts.append(suffix, ()); drop(cursor); snapshot.excerpts = new_excerpts; snapshot.excerpt_ids = new_excerpt_ids; @@ -2029,7 +1298,7 @@ impl MultiBuffer { snapshot.trailing_excerpt_update_count += 1; } - self.sync_diff_transforms( + let edits = Self::sync_diff_transforms( &mut snapshot, vec![Edit { old: edit_start..edit_start, @@ -2037,8 +1306,11 @@ impl MultiBuffer { }], DiffChangeKind::BufferEdited, ); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } + cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); cx.emit(Event::ExcerptsAdded { @@ -2050,36 +1322,48 @@ impl MultiBuffer { } pub fn clear(&mut self, cx: &mut Context) { - self.sync(cx); + self.sync_mut(cx); let ids = self.excerpt_ids(); - let removed_buffer_ids = self - .buffers - .borrow_mut() - .drain() - .map(|(id, _)| id) - .collect(); + let removed_buffer_ids = self.buffers.drain().map(|(id, _)| id).collect(); self.excerpts_by_path.clear(); self.paths_by_excerpt.clear(); - let mut snapshot = self.snapshot.borrow_mut(); + let MultiBufferSnapshot { + excerpts, + diffs: _, + diff_transforms: _, + non_text_state_update_count: _, + edit_count: _, + is_dirty, + has_deleted_file, + has_conflict, + singleton: _, + excerpt_ids: _, + replaced_excerpts, + trailing_excerpt_update_count, + all_diff_hunks_expanded: _, + show_headers: _, + } = self.snapshot.get_mut(); let start = ExcerptOffset::new(0); - let prev_len = ExcerptOffset::new(snapshot.excerpts.summary().text.len); - snapshot.excerpts = Default::default(); - snapshot.trailing_excerpt_update_count += 1; - snapshot.is_dirty = false; - snapshot.has_deleted_file = false; - snapshot.has_conflict = false; - snapshot.replaced_excerpts.clear(); - - self.sync_diff_transforms( - &mut snapshot, + let prev_len = ExcerptOffset::new(excerpts.summary().text.len); + *excerpts = Default::default(); + *trailing_excerpt_update_count += 1; + *is_dirty = false; + *has_deleted_file = false; + *has_conflict = false; + replaced_excerpts.clear(); + + let edits = Self::sync_diff_transforms( + self.snapshot.get_mut(), vec![Edit { old: start..prev_len, new: start..start, }], DiffChangeKind::BufferEdited, ); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); cx.emit(Event::ExcerptsRemoved { @@ -2096,17 +1380,13 @@ impl MultiBuffer { ) -> Vec<(ExcerptId, ExcerptRange)> { let mut excerpts = Vec::new(); let snapshot = self.read(cx); - let buffers = self.buffers.borrow(); - let mut cursor = snapshot.excerpts.cursor::>(&()); - for locator in buffers - .get(&buffer_id) - .map(|state| &state.excerpts) - .into_iter() - .flatten() - { - cursor.seek_forward(&Some(locator), Bias::Left); - if let Some(excerpt) = cursor.item() { - if excerpt.locator == *locator { + let mut cursor = snapshot.excerpts.cursor::>(()); + if let Some(locators) = self.buffers.get(&buffer_id).map(|state| &state.excerpts) { + for locator in locators { + cursor.seek_forward(&Some(locator), Bias::Left); + if let Some(excerpt) = cursor.item() + && excerpt.locator == *locator + { excerpts.push((excerpt.id, excerpt.range.clone())); } } @@ -2117,37 +1397,36 @@ impl MultiBuffer { pub fn excerpt_ranges_for_buffer(&self, buffer_id: BufferId, cx: &App) -> Vec> { let snapshot = self.read(cx); - let buffers = self.buffers.borrow(); let mut excerpts = snapshot .excerpts - .cursor::, ExcerptDimension>>(&()); + .cursor::, ExcerptDimension>>(()); let mut diff_transforms = snapshot .diff_transforms - .cursor::, OutputDimension>>(&()); + .cursor::, OutputDimension>>(()); diff_transforms.next(); - let locators = buffers + let locators = self + .buffers .get(&buffer_id) .into_iter() .flat_map(|state| &state.excerpts); let mut result = Vec::new(); for locator in locators { excerpts.seek_forward(&Some(locator), Bias::Left); - if let Some(excerpt) = excerpts.item() { - if excerpt.locator == *locator { - let excerpt_start = excerpts.start().1.clone(); - let excerpt_end = - ExcerptDimension(excerpt_start.0 + excerpt.text_summary.lines); + if let Some(excerpt) = excerpts.item() + && excerpt.locator == *locator + { + let excerpt_start = excerpts.start().1.clone(); + let excerpt_end = ExcerptDimension(excerpt_start.0 + excerpt.text_summary.lines); - diff_transforms.seek_forward(&excerpt_start, Bias::Left); - let overshoot = excerpt_start.0 - diff_transforms.start().0.0; - let start = diff_transforms.start().1.0 + overshoot; + diff_transforms.seek_forward(&excerpt_start, Bias::Left); + let overshoot = excerpt_start.0 - diff_transforms.start().0.0; + let start = diff_transforms.start().1.0 + overshoot; - diff_transforms.seek_forward(&excerpt_end, Bias::Right); - let overshoot = excerpt_end.0 - diff_transforms.start().0.0; - let end = diff_transforms.start().1.0 + overshoot; + diff_transforms.seek_forward(&excerpt_end, Bias::Right); + let overshoot = excerpt_end.0 - diff_transforms.start().0.0; + let end = diff_transforms.start().1.0 + overshoot; - result.push(start..end) - } + result.push(start..end) } } result @@ -2187,17 +1466,21 @@ impl MultiBuffer { .map(|excerpt| { ( excerpt.id, - self.buffers - .borrow() - .get(&excerpt.buffer_id) - .unwrap() - .buffer - .clone(), + self.buffers.get(&excerpt.buffer_id).unwrap().buffer.clone(), excerpt.range.context.clone(), ) }) } + pub fn buffer_for_anchor(&self, anchor: Anchor, cx: &App) -> Option> { + if let Some(buffer_id) = anchor.buffer_id { + self.buffer(buffer_id) + } else { + let (_, buffer, _) = self.excerpt_containing(anchor, cx)?; + Some(buffer) + } + } + // If point is at the end of the buffer, the last excerpt is returned pub fn point_to_buffer_offset( &self, @@ -2207,11 +1490,7 @@ impl MultiBuffer { let snapshot = self.read(cx); let (buffer, offset) = snapshot.point_to_buffer_offset(point)?; Some(( - self.buffers - .borrow() - .get(&buffer.remote_id())? - .buffer - .clone(), + self.buffers.get(&buffer.remote_id())?.buffer.clone(), offset, )) } @@ -2226,11 +1505,7 @@ impl MultiBuffer { let (buffer, point, is_main_buffer) = snapshot.point_to_buffer_point(point.to_point(&snapshot))?; Some(( - self.buffers - .borrow() - .get(&buffer.remote_id())? - .buffer - .clone(), + self.buffers.get(&buffer.remote_id())?.buffer.clone(), point, is_main_buffer, )) @@ -2265,23 +1540,41 @@ impl MultiBuffer { }) } + pub fn buffer_anchor_to_anchor( + &self, + buffer: &Entity, + anchor: text::Anchor, + cx: &App, + ) -> Option { + let snapshot = buffer.read(cx).snapshot(); + for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { + if range.context.start.cmp(&anchor, &snapshot).is_le() + && range.context.end.cmp(&anchor, &snapshot).is_ge() + { + return Some(Anchor::in_buffer(excerpt_id, snapshot.remote_id(), anchor)); + } + } + + None + } + pub fn remove_excerpts( &mut self, excerpt_ids: impl IntoIterator, cx: &mut Context, ) { - self.sync(cx); + self.sync_mut(cx); let ids = excerpt_ids.into_iter().collect::>(); if ids.is_empty() { return; } + self.buffer_changed_since_sync.replace(true); - let mut buffers = self.buffers.borrow_mut(); - let mut snapshot = self.snapshot.borrow_mut(); + let mut snapshot = self.snapshot.get_mut(); let mut new_excerpts = SumTree::default(); let mut cursor = snapshot .excerpts - .cursor::, ExcerptOffset>>(&()); + .cursor::, ExcerptOffset>>(()); let mut edits = Vec::new(); let mut excerpt_ids = ids.iter().copied().peekable(); let mut removed_buffer_ids = Vec::new(); @@ -2290,7 +1583,7 @@ impl MultiBuffer { self.paths_by_excerpt.remove(&excerpt_id); // Seek to the next excerpt to remove, preserving any preceding excerpts. let locator = snapshot.excerpt_locator_for_id(excerpt_id); - new_excerpts.append(cursor.slice(&Some(locator), Bias::Left), &()); + new_excerpts.append(cursor.slice(&Some(locator), Bias::Left), ()); if let Some(mut excerpt) = cursor.item() { if excerpt.id != excerpt_id { @@ -2300,14 +1593,14 @@ impl MultiBuffer { // Skip over the removed excerpt. 'remove_excerpts: loop { - if let Some(buffer_state) = buffers.get_mut(&excerpt.buffer_id) { + if let Some(buffer_state) = self.buffers.get_mut(&excerpt.buffer_id) { buffer_state.excerpts.retain(|l| l != &excerpt.locator); if buffer_state.excerpts.is_empty() { log::debug!( "removing buffer and diff for buffer {}", excerpt.buffer_id ); - buffers.remove(&excerpt.buffer_id); + self.buffers.remove(&excerpt.buffer_id); removed_buffer_ids.push(excerpt.buffer_id); } } @@ -2316,12 +1609,12 @@ impl MultiBuffer { // Skip over any subsequent excerpts that are also removed. if let Some(&next_excerpt_id) = excerpt_ids.peek() { let next_locator = snapshot.excerpt_locator_for_id(next_excerpt_id); - if let Some(next_excerpt) = cursor.item() { - if next_excerpt.locator == *next_locator { - excerpt_ids.next(); - excerpt = next_excerpt; - continue 'remove_excerpts; - } + if let Some(next_excerpt) = cursor.item() + && next_excerpt.locator == *next_locator + { + excerpt_ids.next(); + excerpt = next_excerpt; + continue 'remove_excerpts; } } @@ -2332,7 +1625,7 @@ impl MultiBuffer { // the previous excerpt. if cursor.item().is_none() && old_start.value > 0 { old_start.value -= 1; - new_excerpts.update_last(|e| e.has_trailing_newline = false, &()); + new_excerpts.update_last(|e| e.has_trailing_newline = false, ()); } // Push an edit for the removal of this run of excerpts. @@ -2346,7 +1639,7 @@ impl MultiBuffer { } let suffix = cursor.suffix(); let changed_trailing_excerpt = suffix.is_empty(); - new_excerpts.append(suffix, &()); + new_excerpts.append(suffix, ()); drop(cursor); snapshot.excerpts = new_excerpts; for buffer_id in &removed_buffer_ids { @@ -2358,10 +1651,11 @@ impl MultiBuffer { snapshot.trailing_excerpt_update_count += 1; } - self.sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); - self.buffer_changed_since_sync.replace(true); + let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); cx.emit(Event::ExcerptsRemoved { @@ -2376,12 +1670,11 @@ impl MultiBuffer { anchors: Anchors, cx: &mut Context, ) -> impl 'static + Future> + use { - let borrow = self.buffers.borrow(); let mut error = None; let mut futures = Vec::new(); for anchor in anchors { if let Some(buffer_id) = anchor.buffer_id { - if let Some(buffer) = borrow.get(&buffer_id) { + if let Some(buffer) = self.buffers.get(&buffer_id) { buffer.buffer.update(cx, |buffer, _| { futures.push(buffer.wait_for_anchors([anchor.text_anchor])) }); @@ -2411,12 +1704,7 @@ impl MultiBuffer { ) -> Option<(Entity, language::Anchor)> { let snapshot = self.read(cx); let anchor = snapshot.anchor_before(position); - let buffer = self - .buffers - .borrow() - .get(&anchor.buffer_id?)? - .buffer - .clone(); + let buffer = self.buffers.get(&anchor.buffer_id?)?.buffer.clone(); Some((buffer, anchor.text_anchor)) } @@ -2426,38 +1714,31 @@ impl MultiBuffer { event: &language::BufferEvent, cx: &mut Context, ) { + use language::BufferEvent; cx.emit(match event { - language::BufferEvent::Edited => Event::Edited { - singleton_buffer_edited: true, - edited_buffer: Some(buffer.clone()), + BufferEvent::Edited => Event::Edited { + edited_buffer: Some(buffer), }, - language::BufferEvent::DirtyChanged => Event::DirtyChanged, - language::BufferEvent::Saved => Event::Saved, - language::BufferEvent::FileHandleChanged => Event::FileHandleChanged, - language::BufferEvent::Reloaded => Event::Reloaded, - language::BufferEvent::ReloadNeeded => Event::ReloadNeeded, - language::BufferEvent::LanguageChanged => { - Event::LanguageChanged(buffer.read(cx).remote_id()) - } - language::BufferEvent::Reparsed => Event::Reparsed(buffer.read(cx).remote_id()), - language::BufferEvent::DiagnosticsUpdated => Event::DiagnosticsUpdated, - language::BufferEvent::Closed => Event::Closed, - language::BufferEvent::Discarded => Event::Discarded, - language::BufferEvent::CapabilityChanged => { + BufferEvent::DirtyChanged => Event::DirtyChanged, + BufferEvent::Saved => Event::Saved, + BufferEvent::FileHandleChanged => Event::FileHandleChanged, + BufferEvent::Reloaded => Event::Reloaded, + BufferEvent::LanguageChanged => Event::LanguageChanged(buffer.read(cx).remote_id()), + BufferEvent::Reparsed => Event::Reparsed(buffer.read(cx).remote_id()), + BufferEvent::DiagnosticsUpdated => Event::DiagnosticsUpdated, + BufferEvent::CapabilityChanged => { self.capability = buffer.read(cx).capability(); - Event::CapabilityChanged + return; } - language::BufferEvent::Operation { .. } => return, + BufferEvent::Operation { .. } | BufferEvent::ReloadNeeded => return, }); } fn buffer_diff_language_changed(&mut self, diff: Entity, cx: &mut Context) { - self.sync(cx); - let mut snapshot = self.snapshot.borrow_mut(); let diff = diff.read(cx); let buffer_id = diff.buffer_id; let diff = diff.snapshot(cx); - snapshot.diffs.insert(buffer_id, diff); + self.snapshot.get_mut().diffs.insert(buffer_id, diff); } fn buffer_diff_changed( @@ -2466,25 +1747,24 @@ impl MultiBuffer { range: Range, cx: &mut Context, ) { - self.sync(cx); - self.buffer_changed_since_sync.replace(true); + self.sync_mut(cx); let diff = diff.read(cx); let buffer_id = diff.buffer_id; - let buffers = self.buffers.borrow(); - let Some(buffer_state) = buffers.get(&buffer_id) else { + let Some(buffer_state) = self.buffers.get(&buffer_id) else { return; }; + self.buffer_changed_since_sync.replace(true); let buffer = buffer_state.buffer.read(cx); let diff_change_range = range.to_offset(buffer); let new_diff = diff.snapshot(cx); - let mut snapshot = self.snapshot.borrow_mut(); + let mut snapshot = self.snapshot.get_mut(); let base_text_changed = snapshot .diffs .get(&buffer_id) - .map_or(true, |old_diff| !new_diff.base_texts_eq(old_diff)); + .is_none_or(|old_diff| !new_diff.base_texts_eq(old_diff)); snapshot.diffs.insert(buffer_id, new_diff); @@ -2492,62 +1772,66 @@ impl MultiBuffer { for locator in &buffer_state.excerpts { let mut cursor = snapshot .excerpts - .cursor::, ExcerptOffset>>(&()); + .cursor::, ExcerptOffset>>(()); cursor.seek_forward(&Some(locator), Bias::Left); - if let Some(excerpt) = cursor.item() { - if excerpt.locator == *locator { - let excerpt_buffer_range = excerpt.range.context.to_offset(&excerpt.buffer); - if diff_change_range.end < excerpt_buffer_range.start - || diff_change_range.start > excerpt_buffer_range.end - { - continue; - } - let excerpt_start = cursor.start().1; - let excerpt_len = ExcerptOffset::new(excerpt.text_summary.len); - let diff_change_start_in_excerpt = ExcerptOffset::new( - diff_change_range - .start - .saturating_sub(excerpt_buffer_range.start), - ); - let diff_change_end_in_excerpt = ExcerptOffset::new( - diff_change_range - .end - .saturating_sub(excerpt_buffer_range.start), - ); - let edit_start = excerpt_start + diff_change_start_in_excerpt.min(excerpt_len); - let edit_end = excerpt_start + diff_change_end_in_excerpt.min(excerpt_len); - excerpt_edits.push(Edit { - old: edit_start..edit_end, - new: edit_start..edit_end, - }); + if let Some(excerpt) = cursor.item() + && excerpt.locator == *locator + { + let excerpt_buffer_range = excerpt.range.context.to_offset(&excerpt.buffer); + if diff_change_range.end < excerpt_buffer_range.start + || diff_change_range.start > excerpt_buffer_range.end + { + continue; } + let excerpt_start = cursor.start().1; + let excerpt_len = ExcerptOffset::new(excerpt.text_summary.len); + let diff_change_start_in_excerpt = ExcerptOffset::new( + diff_change_range + .start + .saturating_sub(excerpt_buffer_range.start), + ); + let diff_change_end_in_excerpt = ExcerptOffset::new( + diff_change_range + .end + .saturating_sub(excerpt_buffer_range.start), + ); + let edit_start = excerpt_start + diff_change_start_in_excerpt.min(excerpt_len); + let edit_end = excerpt_start + diff_change_end_in_excerpt.min(excerpt_len); + excerpt_edits.push(Edit { + old: edit_start..edit_end, + new: edit_start..edit_end, + }); } } - self.sync_diff_transforms( + let edits = Self::sync_diff_transforms( &mut snapshot, excerpt_edits, DiffChangeKind::DiffUpdated { base_changed: base_text_changed, }, ); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); } pub fn all_buffers(&self) -> HashSet> { self.buffers - .borrow() .values() .map(|state| state.buffer.clone()) .collect() } + pub fn all_buffer_ids(&self) -> Vec { + self.buffers.keys().copied().collect() + } + pub fn buffer(&self, buffer_id: BufferId) -> Option> { self.buffers - .borrow() .get(&buffer_id) .map(|state| state.buffer.clone()) } @@ -2589,10 +1873,11 @@ impl MultiBuffer { } pub fn for_each_buffer(&self, mut f: impl FnMut(&Entity)) { - self.buffers - .borrow() - .values() - .for_each(|state| f(&state.buffer)) + self.buffers.values().for_each(|state| f(&state.buffer)) + } + + pub fn explicit_title(&self) -> Option<&str> { + self.title.as_deref() } pub fn title<'a>(&'a self, cx: &'a App) -> Cow<'a, str> { @@ -2604,7 +1889,7 @@ impl MultiBuffer { let buffer = buffer.read(cx); if let Some(file) = buffer.file() { - return file.file_name(cx).to_string_lossy(); + return file.file_name(cx).into(); } if let Some(title) = self.buffer_content_title(buffer) { @@ -2661,7 +1946,7 @@ impl MultiBuffer { /// Preserve preview tabs containing this multibuffer until additional edits occur. pub fn refresh_preview(&self, cx: &mut Context) { - for buffer_state in self.buffers.borrow().values() { + for buffer_state in self.buffers.values() { buffer_state .buffer .update(cx, |buffer, _cx| buffer.refresh_preview()); @@ -2671,7 +1956,6 @@ impl MultiBuffer { /// Whether we should preserve the preview status of a tab containing this multi-buffer. pub fn preserve_preview(&self, cx: &App) -> bool { self.buffers - .borrow() .values() .all(|state| state.buffer.read(cx).preserve_preview()) } @@ -2700,7 +1984,7 @@ impl MultiBuffer { } pub fn set_all_diff_hunks_expanded(&mut self, cx: &mut Context) { - self.snapshot.borrow_mut().all_diff_hunks_expanded = true; + self.snapshot.get_mut().all_diff_hunks_expanded = true; self.expand_or_collapse_diff_hunks(vec![Anchor::min()..Anchor::max()], true, cx); } @@ -2709,7 +1993,7 @@ impl MultiBuffer { } pub fn set_all_diff_hunks_collapsed(&mut self, cx: &mut Context) { - self.snapshot.borrow_mut().all_diff_hunks_expanded = false; + self.snapshot.get_mut().all_diff_hunks_expanded = false; self.expand_or_collapse_diff_hunks(vec![Anchor::min()..Anchor::max()], false, cx); } @@ -2722,7 +2006,7 @@ impl MultiBuffer { pub fn single_hunk_is_expanded(&self, range: Range, cx: &App) -> bool { let snapshot = self.read(cx); - let mut cursor = snapshot.diff_transforms.cursor::(&()); + let mut cursor = snapshot.diff_transforms.cursor::(()); let offset_range = range.to_offset(&snapshot); cursor.seek(&offset_range.start, Bias::Left); while let Some(item) = cursor.item() { @@ -2739,7 +2023,7 @@ impl MultiBuffer { pub fn has_expanded_diff_hunks_in_ranges(&self, ranges: &[Range], cx: &App) -> bool { let snapshot = self.read(cx); - let mut cursor = snapshot.diff_transforms.cursor::(&()); + let mut cursor = snapshot.diff_transforms.cursor::(()); for range in ranges { let range = range.to_point(&snapshot); let start = snapshot.point_to_offset(Point::new(range.start.row, 0)); @@ -2769,8 +2053,8 @@ impl MultiBuffer { if self.snapshot.borrow().all_diff_hunks_expanded && !expand { return; } - self.sync(cx); - let mut snapshot = self.snapshot.borrow_mut(); + self.sync_mut(cx); + let mut snapshot = self.snapshot.get_mut(); let mut excerpt_edits = Vec::new(); let mut last_hunk_row = None; for (range, end_excerpt_id) in ranges { @@ -2778,7 +2062,7 @@ impl MultiBuffer { if diff_hunk.excerpt_id.cmp(&end_excerpt_id, &snapshot).is_gt() { continue; } - if last_hunk_row.map_or(false, |row| row >= diff_hunk.row_range.start) { + if last_hunk_row.is_some_and(|row| row >= diff_hunk.row_range.start) { continue; } let start = Anchor::in_buffer( @@ -2801,14 +2085,16 @@ impl MultiBuffer { } } - self.sync_diff_transforms( + let edits = Self::sync_diff_transforms( &mut snapshot, excerpt_edits, DiffChangeKind::ExpandOrCollapseHunks { expand }, ); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); } @@ -2838,18 +2124,18 @@ impl MultiBuffer { range: Range, cx: &mut Context, ) { - self.sync(cx); + self.sync_mut(cx); - let mut snapshot = self.snapshot.borrow_mut(); + let mut snapshot = self.snapshot.get_mut(); let locator = snapshot.excerpt_locator_for_id(id); let mut new_excerpts = SumTree::default(); let mut cursor = snapshot .excerpts - .cursor::, ExcerptOffset>>(&()); + .cursor::, ExcerptOffset>>(()); let mut edits = Vec::>::new(); let prefix = cursor.slice(&Some(locator), Bias::Left); - new_excerpts.append(prefix, &()); + new_excerpts.append(prefix, ()); let mut excerpt = cursor.item().unwrap().clone(); let old_text_len = ExcerptOffset::new(excerpt.text_summary.len); @@ -2881,18 +2167,20 @@ impl MultiBuffer { edits.push(edit); } - new_excerpts.push(excerpt, &()); + new_excerpts.push(excerpt, ()); cursor.next(); - new_excerpts.append(cursor.suffix(), &()); + new_excerpts.append(cursor.suffix(), ()); drop(cursor); snapshot.excerpts = new_excerpts; - self.sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); cx.emit(Event::ExcerptsExpanded { ids: vec![id] }); @@ -2909,24 +2197,24 @@ impl MultiBuffer { if line_count == 0 { return; } - self.sync(cx); + self.sync_mut(cx); if !self.excerpts_by_path.is_empty() { self.expand_excerpts_with_paths(ids, line_count, direction, cx); return; } - let mut snapshot = self.snapshot.borrow_mut(); + let mut snapshot = self.snapshot.get_mut(); let ids = ids.into_iter().collect::>(); let locators = snapshot.excerpt_locators_for_ids(ids.iter().copied()); let mut new_excerpts = SumTree::default(); let mut cursor = snapshot .excerpts - .cursor::, ExcerptOffset>>(&()); + .cursor::, ExcerptOffset>>(()); let mut edits = Vec::>::new(); for locator in &locators { let prefix = cursor.slice(&Some(locator), Bias::Left); - new_excerpts.append(prefix, &()); + new_excerpts.append(prefix, ()); let mut excerpt = cursor.item().unwrap().clone(); let old_text_len = ExcerptOffset::new(excerpt.text_summary.len); @@ -2985,19 +2273,21 @@ impl MultiBuffer { edits.push(edit); } - new_excerpts.push(excerpt, &()); + new_excerpts.push(excerpt, ()); cursor.next(); } - new_excerpts.append(cursor.suffix(), &()); + new_excerpts.append(cursor.suffix(), ()); drop(cursor); snapshot.excerpts = new_excerpts; - self.sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + let edits = Self::sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } cx.emit(Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }); cx.emit(Event::ExcerptsExpanded { ids }); @@ -3009,26 +2299,71 @@ impl MultiBuffer { if !changed { return; } + let edits = Self::sync_( + &mut self.snapshot.borrow_mut(), + &self.buffers, + &self.diffs, + cx, + ); + if !edits.is_empty() { + self.subscriptions.publish(edits); + } + } + + fn sync_mut(&mut self, cx: &App) { + let changed = self.buffer_changed_since_sync.replace(false); + if !changed { + return; + } + let edits = Self::sync_(self.snapshot.get_mut(), &self.buffers, &self.diffs, cx); + + if !edits.is_empty() { + self.subscriptions.publish(edits); + } + } + + fn sync_( + snapshot: &mut MultiBufferSnapshot, + buffers: &HashMap, + diffs: &HashMap, + cx: &App, + ) -> Vec> { + let MultiBufferSnapshot { + excerpts, + diffs: buffer_diff, + diff_transforms: _, + non_text_state_update_count, + edit_count, + is_dirty, + has_deleted_file, + has_conflict, + singleton: _, + excerpt_ids: _, + replaced_excerpts: _, + trailing_excerpt_update_count: _, + all_diff_hunks_expanded: _, + show_headers: _, + } = snapshot; + *is_dirty = false; + *has_deleted_file = false; + *has_conflict = false; - let mut snapshot = self.snapshot.borrow_mut(); let mut excerpts_to_edit = Vec::new(); let mut non_text_state_updated = false; - let mut is_dirty = false; - let mut has_deleted_file = false; - let mut has_conflict = false; let mut edited = false; - let mut buffers = self.buffers.borrow_mut(); - for buffer_state in buffers.values_mut() { + for buffer_state in buffers.values() { let buffer = buffer_state.buffer.read(cx); let version = buffer.version(); let non_text_state_update_count = buffer.non_text_state_update_count(); - let buffer_edited = version.changed_since(&buffer_state.last_version); + let buffer_edited = version.changed_since(&buffer_state.last_version.borrow()); let buffer_non_text_state_updated = - non_text_state_update_count > buffer_state.last_non_text_state_update_count; + non_text_state_update_count > buffer_state.last_non_text_state_update_count.get(); if buffer_edited || buffer_non_text_state_updated { - buffer_state.last_version = version; - buffer_state.last_non_text_state_update_count = non_text_state_update_count; + *buffer_state.last_version.borrow_mut() = version; + buffer_state + .last_non_text_state_update_count + .set(non_text_state_update_count); excerpts_to_edit.extend( buffer_state .excerpts @@ -3039,25 +2374,22 @@ impl MultiBuffer { edited |= buffer_edited; non_text_state_updated |= buffer_non_text_state_updated; - is_dirty |= buffer.is_dirty(); - has_deleted_file |= buffer + *is_dirty |= buffer.is_dirty(); + *has_deleted_file |= buffer .file() - .map_or(false, |file| file.disk_state() == DiskState::Deleted); - has_conflict |= buffer.has_conflict(); + .is_some_and(|file| file.disk_state() == DiskState::Deleted); + *has_conflict |= buffer.has_conflict(); } if edited { - snapshot.edit_count += 1; + *edit_count += 1; } if non_text_state_updated { - snapshot.non_text_state_update_count += 1; + *non_text_state_update_count += 1; } - snapshot.is_dirty = is_dirty; - snapshot.has_deleted_file = has_deleted_file; - snapshot.has_conflict = has_conflict; - for (id, diff) in self.diffs.iter() { - if snapshot.diffs.get(&id).is_none() { - snapshot.diffs.insert(*id, diff.diff.read(cx).snapshot(cx)); + for (id, diff) in diffs.iter() { + if buffer_diff.get(id).is_none() { + buffer_diff.insert(*id, diff.diff.read(cx).snapshot(cx)); } } @@ -3065,12 +2397,10 @@ impl MultiBuffer { let mut edits = Vec::new(); let mut new_excerpts = SumTree::default(); - let mut cursor = snapshot - .excerpts - .cursor::, ExcerptOffset>>(&()); + let mut cursor = excerpts.cursor::, ExcerptOffset>>(()); for (locator, buffer, buffer_edited) in excerpts_to_edit { - new_excerpts.append(cursor.slice(&Some(locator), Bias::Left), &()); + new_excerpts.append(cursor.slice(&Some(locator), Bias::Left), ()); let old_excerpt = cursor.item().unwrap(); let buffer = buffer.read(cx); let buffer_id = buffer.remote_id(); @@ -3111,31 +2441,29 @@ impl MultiBuffer { new_excerpt.buffer = buffer.snapshot(); } - new_excerpts.push(new_excerpt, &()); + new_excerpts.push(new_excerpt, ()); cursor.next(); } - new_excerpts.append(cursor.suffix(), &()); + new_excerpts.append(cursor.suffix(), ()); drop(cursor); - snapshot.excerpts = new_excerpts; - - self.sync_diff_transforms(&mut snapshot, edits, DiffChangeKind::BufferEdited); + *excerpts = new_excerpts; + Self::sync_diff_transforms(snapshot, edits, DiffChangeKind::BufferEdited) } fn sync_diff_transforms( - &self, snapshot: &mut MultiBufferSnapshot, excerpt_edits: Vec>, change_kind: DiffChangeKind, - ) { + ) -> Vec> { if excerpt_edits.is_empty() { - return; + return vec![]; } - let mut excerpts = snapshot.excerpts.cursor::(&()); + let mut excerpts = snapshot.excerpts.cursor::(()); let mut old_diff_transforms = snapshot .diff_transforms - .cursor::>(&()); + .cursor::>(()); let mut new_diff_transforms = SumTree::default(); let mut old_expanded_hunks = HashSet::default(); let mut output_edits = Vec::new(); @@ -3154,14 +2482,13 @@ impl MultiBuffer { if at_transform_boundary { at_transform_boundary = false; let transforms_before_edit = old_diff_transforms.slice(&edit.old.start, Bias::Left); - self.append_diff_transforms(&mut new_diff_transforms, transforms_before_edit); - if let Some(transform) = old_diff_transforms.item() { - if old_diff_transforms.end().0 == edit.old.start - && old_diff_transforms.start().0 < edit.old.start - { - self.push_diff_transform(&mut new_diff_transforms, transform.clone()); - old_diff_transforms.next(); - } + Self::append_diff_transforms(&mut new_diff_transforms, transforms_before_edit); + if let Some(transform) = old_diff_transforms.item() + && old_diff_transforms.end().0 == edit.old.start + && old_diff_transforms.start().0 < edit.old.start + { + Self::push_diff_transform(&mut new_diff_transforms, transform.clone()); + old_diff_transforms.next(); } } @@ -3170,14 +2497,14 @@ impl MultiBuffer { let edit_old_start = old_diff_transforms.start().1 + edit_start_overshoot; let edit_new_start = (edit_old_start as isize + output_delta) as usize; - let changed_diff_hunks = self.recompute_diff_transforms_for_edit( + let changed_diff_hunks = Self::recompute_diff_transforms_for_edit( &edit, &mut excerpts, &mut old_diff_transforms, &mut new_diff_transforms, &mut end_of_current_insert, &mut old_expanded_hunks, - &snapshot, + snapshot, change_kind, ); @@ -3201,9 +2528,10 @@ impl MultiBuffer { // If this is the last edit that intersects the current diff transform, // then recreate the content up to the end of this transform, to prepare // for reusing additional slices of the old transforms. - if excerpt_edits.peek().map_or(true, |next_edit| { - next_edit.old.start >= old_diff_transforms.end().0 - }) { + if excerpt_edits + .peek() + .is_none_or(|next_edit| next_edit.old.start >= old_diff_transforms.end().0) + { let keep_next_old_transform = (old_diff_transforms.start().0 >= edit.old.end) && match old_diff_transforms.item() { Some(DiffTransform::BufferContent { @@ -3222,8 +2550,8 @@ impl MultiBuffer { } old_expanded_hunks.clear(); - self.push_buffer_content_transform( - &snapshot, + Self::push_buffer_content_transform( + snapshot, &mut new_diff_transforms, excerpt_offset, end_of_current_insert, @@ -3233,7 +2561,7 @@ impl MultiBuffer { } // Keep any transforms that are after the last edit. - self.append_diff_transforms(&mut new_diff_transforms, old_diff_transforms.suffix()); + Self::append_diff_transforms(&mut new_diff_transforms, old_diff_transforms.suffix()); // Ensure there's always at least one buffer content transform. if new_diff_transforms.is_empty() { @@ -3242,11 +2570,10 @@ impl MultiBuffer { summary: Default::default(), inserted_hunk_info: None, }, - &(), + (), ); } - self.subscriptions.publish(output_edits); drop(old_diff_transforms); drop(excerpts); snapshot.diff_transforms = new_diff_transforms; @@ -3254,10 +2581,10 @@ impl MultiBuffer { #[cfg(any(test, feature = "test-support"))] snapshot.check_invariants(); + output_edits } fn recompute_diff_transforms_for_edit( - &self, edit: &Edit>, excerpts: &mut Cursor>, old_diff_transforms: &mut Cursor, usize>>, @@ -3342,7 +2669,7 @@ impl MultiBuffer { + ExcerptOffset::new(hunk_buffer_range.end - excerpt_buffer_start), ); - self.push_buffer_content_transform( + Self::push_buffer_content_transform( snapshot, new_diff_transforms, hunk_excerpt_start, @@ -3400,7 +2727,7 @@ impl MultiBuffer { hunk_info, has_trailing_newline, }, - &(), + (), ); } @@ -3423,7 +2750,6 @@ impl MultiBuffer { } fn append_diff_transforms( - &self, new_transforms: &mut SumTree, subtree: SumTree, ) { @@ -3431,45 +2757,38 @@ impl MultiBuffer { inserted_hunk_info, summary, }) = subtree.first() - { - if self.extend_last_buffer_content_transform( + && Self::extend_last_buffer_content_transform( new_transforms, *inserted_hunk_info, *summary, - ) { - let mut cursor = subtree.cursor::<()>(&()); - cursor.next(); - cursor.next(); - new_transforms.append(cursor.suffix(), &()); - return; - } + ) + { + let mut cursor = subtree.cursor::<()>(()); + cursor.next(); + cursor.next(); + new_transforms.append(cursor.suffix(), ()); + return; } - new_transforms.append(subtree, &()); + new_transforms.append(subtree, ()); } - fn push_diff_transform( - &self, - new_transforms: &mut SumTree, - transform: DiffTransform, - ) { + fn push_diff_transform(new_transforms: &mut SumTree, transform: DiffTransform) { if let DiffTransform::BufferContent { inserted_hunk_info: inserted_hunk_anchor, summary, } = transform - { - if self.extend_last_buffer_content_transform( + && Self::extend_last_buffer_content_transform( new_transforms, inserted_hunk_anchor, summary, - ) { - return; - } + ) + { + return; } - new_transforms.push(transform, &()); + new_transforms.push(transform, ()); } fn push_buffer_content_transform( - &self, old_snapshot: &MultiBufferSnapshot, new_transforms: &mut SumTree, end_offset: ExcerptOffset, @@ -3489,7 +2808,7 @@ impl MultiBuffer { let summary_to_add = old_snapshot .text_summary_for_excerpt_offset_range::(start_offset..end_offset); - if !self.extend_last_buffer_content_transform( + if !Self::extend_last_buffer_content_transform( new_transforms, inserted_hunk_info, summary_to_add, @@ -3499,14 +2818,13 @@ impl MultiBuffer { summary: summary_to_add, inserted_hunk_info, }, - &(), + (), ) } } } fn extend_last_buffer_content_transform( - &self, new_transforms: &mut SumTree, new_inserted_hunk_info: Option, summary_to_add: TextSummary, @@ -3518,14 +2836,13 @@ impl MultiBuffer { summary, inserted_hunk_info: inserted_hunk_anchor, } = last_transform + && *inserted_hunk_anchor == new_inserted_hunk_info { - if *inserted_hunk_anchor == new_inserted_hunk_info { - *summary += summary_to_add; - did_extend = true; - } + *summary += summary_to_add; + did_extend = true; } }, - &(), + (), ); did_extend } @@ -3565,9 +2882,7 @@ impl MultiBuffer { let multi = cx.new(|_| Self::new(Capability::ReadWrite)); for (text, ranges) in excerpts { let buffer = cx.new(|cx| Buffer::local(text, cx)); - let excerpt_ranges = ranges - .into_iter() - .map(|range| ExcerptRange::new(range.clone())); + let excerpt_ranges = ranges.into_iter().map(ExcerptRange::new); multi.update(cx, |multi, cx| { multi.push_excerpts(buffer, excerpt_ranges, cx) }); @@ -3583,7 +2898,7 @@ impl MultiBuffer { pub fn build_random(rng: &mut impl rand::Rng, cx: &mut gpui::App) -> Entity { cx.new(|cx| { let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); - let mutation_count = rng.gen_range(1..=5); + let mutation_count = rng.random_range(1..=5); multibuffer.randomly_edit_excerpts(rng, mutation_count, cx); multibuffer }) @@ -3601,21 +2916,22 @@ impl MultiBuffer { let mut edits: Vec<(Range, Arc)> = Vec::new(); let mut last_end = None; for _ in 0..edit_count { - if last_end.map_or(false, |last_end| last_end >= snapshot.len()) { + if last_end.is_some_and(|last_end| last_end >= snapshot.len()) { break; } let new_start = last_end.map_or(0, |last_end| last_end + 1); - let end = snapshot.clip_offset(rng.gen_range(new_start..=snapshot.len()), Bias::Right); - let start = snapshot.clip_offset(rng.gen_range(new_start..=end), Bias::Right); + let end = + snapshot.clip_offset(rng.random_range(new_start..=snapshot.len()), Bias::Right); + let start = snapshot.clip_offset(rng.random_range(new_start..=end), Bias::Right); last_end = Some(end); let mut range = start..end; - if rng.gen_bool(0.2) { + if rng.random_bool(0.2) { mem::swap(&mut range.start, &mut range.end); } - let new_text_len = rng.gen_range(0..10); + let new_text_len = rng.random_range(0..10); let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect(); edits.push((range, new_text.into())); @@ -3642,18 +2958,18 @@ impl MultiBuffer { let mut buffers = Vec::new(); for _ in 0..mutation_count { - if rng.gen_bool(0.05) { + if rng.random_bool(0.05) { log::info!("Clearing multi-buffer"); self.clear(cx); continue; - } else if rng.gen_bool(0.1) && !self.excerpt_ids().is_empty() { + } else if rng.random_bool(0.1) && !self.excerpt_ids().is_empty() { let ids = self.excerpt_ids(); let mut excerpts = HashSet::default(); - for _ in 0..rng.gen_range(0..ids.len()) { + for _ in 0..rng.random_range(0..ids.len()) { excerpts.extend(ids.choose(rng).copied()); } - let line_count = rng.gen_range(0..5); + let line_count = rng.random_range(0..5); log::info!("Expanding excerpts {excerpts:?} by {line_count} lines"); @@ -3667,8 +2983,8 @@ impl MultiBuffer { } let excerpt_ids = self.excerpt_ids(); - if excerpt_ids.is_empty() || (rng.r#gen() && excerpt_ids.len() < max_excerpts) { - let buffer_handle = if rng.r#gen() || self.buffers.borrow().is_empty() { + if excerpt_ids.is_empty() || (rng.random() && excerpt_ids.len() < max_excerpts) { + let buffer_handle = if rng.random() || self.buffers.is_empty() { let text = RandomCharIter::new(&mut *rng).take(10).collect::(); buffers.push(cx.new(|cx| Buffer::local(text, cx))); let buffer = buffers.last().unwrap().read(cx); @@ -3679,22 +2995,16 @@ impl MultiBuffer { ); buffers.last().unwrap().clone() } else { - self.buffers - .borrow() - .values() - .choose(rng) - .unwrap() - .buffer - .clone() + self.buffers.values().choose(rng).unwrap().buffer.clone() }; let buffer = buffer_handle.read(cx); let buffer_text = buffer.text(); - let ranges = (0..rng.gen_range(0..5)) + let ranges = (0..rng.random_range(0..5)) .map(|_| { let end_ix = - buffer.clip_offset(rng.gen_range(0..=buffer.len()), Bias::Right); - let start_ix = buffer.clip_offset(rng.gen_range(0..=end_ix), Bias::Left); + buffer.clip_offset(rng.random_range(0..=buffer.len()), Bias::Right); + let start_ix = buffer.clip_offset(rng.random_range(0..=end_ix), Bias::Left); ExcerptRange::new(start_ix..end_ix) }) .collect::>(); @@ -3711,7 +3021,7 @@ impl MultiBuffer { let excerpt_id = self.push_excerpts(buffer_handle.clone(), ranges, cx); log::info!("Inserted with ids: {:?}", excerpt_id); } else { - let remove_count = rng.gen_range(1..=excerpt_ids.len()); + let remove_count = rng.random_range(1..=excerpt_ids.len()); let mut excerpts_to_remove = excerpt_ids .choose_multiple(rng, remove_count) .cloned() @@ -3733,17 +3043,16 @@ impl MultiBuffer { ) { use rand::prelude::*; - if rng.gen_bool(0.7) || self.singleton { + if rng.random_bool(0.7) || self.singleton { let buffer = self .buffers - .borrow() .values() .choose(rng) .map(|state| state.buffer.clone()); if let Some(buffer) = buffer { buffer.update(cx, |buffer, cx| { - if rng.r#gen() { + if rng.random() { buffer.randomly_edit(rng, mutation_count, cx); } else { buffer.randomly_undo_redo(rng, cx); @@ -3916,8 +3225,8 @@ impl MultiBufferSnapshot { &self, range: Range, ) -> Vec<(&BufferSnapshot, Range, ExcerptId)> { - let start = range.start.to_offset(&self); - let end = range.end.to_offset(&self); + let start = range.start.to_offset(self); + let end = range.end.to_offset(self); let mut cursor = self.cursor::(); cursor.seek(&start); @@ -3955,8 +3264,8 @@ impl MultiBufferSnapshot { &self, range: Range, ) -> impl Iterator, ExcerptId, Option)> + '_ { - let start = range.start.to_offset(&self); - let end = range.end.to_offset(&self); + let start = range.start.to_offset(self); + let end = range.end.to_offset(self); let mut cursor = self.cursor::(); cursor.seek(&start); @@ -4037,10 +3346,10 @@ impl MultiBufferSnapshot { cursor.seek(&query_range.start); - if let Some(region) = cursor.region().filter(|region| !region.is_main_buffer) { - if region.range.start > D::zero(&()) { - cursor.prev() - } + if let Some(region) = cursor.region().filter(|region| !region.is_main_buffer) + && region.range.start > D::zero(()) + { + cursor.prev() } iter::from_fn(move || { @@ -4070,19 +3379,15 @@ impl MultiBufferSnapshot { buffer_start = cursor.main_buffer_position()?; }; let mut buffer_end = excerpt.range.context.end.summary::(&excerpt.buffer); - if let Some((end_excerpt_id, end_buffer_offset)) = range_end { - if excerpt.id == end_excerpt_id { - buffer_end = buffer_end.min(end_buffer_offset); - } - } - - if let Some(iterator) = - get_buffer_metadata(&excerpt.buffer, buffer_start..buffer_end) + if let Some((end_excerpt_id, end_buffer_offset)) = range_end + && excerpt.id == end_excerpt_id { - Some(&mut current_excerpt_metadata.insert((excerpt.id, iterator)).1) - } else { - None + buffer_end = buffer_end.min(end_buffer_offset); } + + get_buffer_metadata(&excerpt.buffer, buffer_start..buffer_end).map(|iterator| { + &mut current_excerpt_metadata.insert((excerpt.id, iterator)).1 + }) }; // Visit each metadata item. @@ -4144,10 +3449,10 @@ impl MultiBufferSnapshot { // When there are no more metadata items for this excerpt, move to the next excerpt. else { current_excerpt_metadata.take(); - if let Some((end_excerpt_id, _)) = range_end { - if excerpt.id == end_excerpt_id { - return None; - } + if let Some((end_excerpt_id, _)) = range_end + && excerpt.id == end_excerpt_id + { + return None; } cursor.next_excerpt(); } @@ -4186,7 +3491,7 @@ impl MultiBufferSnapshot { } let start = Anchor::in_buffer(excerpt.id, excerpt.buffer_id, hunk.buffer_range.start) - .to_point(&self); + .to_point(self); return Some(MultiBufferRow(start.row)); } } @@ -4204,7 +3509,7 @@ impl MultiBufferSnapshot { continue; }; let start = Anchor::in_buffer(excerpt.id, excerpt.buffer_id, hunk.buffer_range.start) - .to_point(&self); + .to_point(self); return Some(MultiBufferRow(start.row)); } } @@ -4213,11 +3518,15 @@ impl MultiBufferSnapshot { self.diffs.values().any(|diff| !diff.is_empty()) } - pub fn is_inside_word(&self, position: T, for_completion: bool) -> bool { + pub fn is_inside_word( + &self, + position: T, + scope_context: Option, + ) -> bool { let position = position.to_offset(self); let classifier = self .char_classifier_at(position) - .for_completion(for_completion); + .scope_context(scope_context); let next_char_kind = self.chars_at(position).next().map(|c| classifier.kind(c)); let prev_char_kind = self .reversed_chars_at(position) @@ -4229,16 +3538,14 @@ impl MultiBufferSnapshot { pub fn surrounding_word( &self, start: T, - for_completion: bool, + scope_context: Option, ) -> (Range, Option) { let mut start = start.to_offset(self); let mut end = start; let mut next_chars = self.chars_at(start).peekable(); let mut prev_chars = self.reversed_chars_at(start).peekable(); - let classifier = self - .char_classifier_at(start) - .for_completion(for_completion); + let classifier = self.char_classifier_at(start).scope_context(scope_context); let word_kind = cmp::max( prev_chars.peek().copied().map(|c| classifier.kind(c)), @@ -4267,12 +3574,10 @@ impl MultiBufferSnapshot { pub fn char_kind_before( &self, start: T, - for_completion: bool, + scope_context: Option, ) -> Option { let start = start.to_offset(self); - let classifier = self - .char_classifier_at(start) - .for_completion(for_completion); + let classifier = self.char_classifier_at(start).scope_context(scope_context); self.reversed_chars_at(start) .next() .map(|ch| classifier.kind(ch)) @@ -4377,8 +3682,8 @@ impl MultiBufferSnapshot { let mut chunks = MultiBufferChunks { excerpt_offset_range: ExcerptOffset::new(0)..ExcerptOffset::new(0), range: 0..0, - excerpts: self.excerpts.cursor(&()), - diff_transforms: self.diff_transforms.cursor(&()), + excerpts: self.excerpts.cursor(()), + diff_transforms: self.diff_transforms.cursor(()), diffs: &self.diffs, diff_base_chunks: None, excerpt_chunks: None, @@ -4420,10 +3725,18 @@ impl MultiBufferSnapshot { self.convert_dimension(point, text::BufferSnapshot::point_to_point_utf16) } + pub fn point_utf16_to_point(&self, point: PointUtf16) -> Point { + self.convert_dimension(point, text::BufferSnapshot::point_utf16_to_point) + } + pub fn point_to_offset(&self, point: Point) -> usize { self.convert_dimension(point, text::BufferSnapshot::point_to_offset) } + pub fn point_to_offset_utf16(&self, point: Point) -> OffsetUtf16 { + self.convert_dimension(point, text::BufferSnapshot::point_to_offset_utf16) + } + pub fn offset_utf16_to_offset(&self, offset: OffsetUtf16) -> usize { self.convert_dimension(offset, text::BufferSnapshot::offset_utf16_to_offset) } @@ -4436,6 +3749,10 @@ impl MultiBufferSnapshot { self.convert_dimension(point, text::BufferSnapshot::point_utf16_to_offset) } + pub fn point_utf16_to_offset_utf16(&self, point: PointUtf16) -> OffsetUtf16 { + self.convert_dimension(point, text::BufferSnapshot::point_utf16_to_offset_utf16) + } + fn clip_dimension( &self, position: D, @@ -4455,7 +3772,7 @@ impl MultiBufferSnapshot { let mut buffer_position = region.buffer_range.start; buffer_position.add_assign(&overshoot); let clipped_buffer_position = - clip_buffer_position(®ion.buffer, buffer_position, bias); + clip_buffer_position(region.buffer, buffer_position, bias); let mut position = region.range.start; position.add_assign(&(clipped_buffer_position - region.buffer_range.start)); position @@ -4485,7 +3802,7 @@ impl MultiBufferSnapshot { let buffer_start_value = region.buffer_range.start.value.unwrap(); let mut buffer_key = buffer_start_key; buffer_key.add_assign(&(key - start_key)); - let buffer_value = convert_buffer_dimension(®ion.buffer, buffer_key); + let buffer_value = convert_buffer_dimension(region.buffer, buffer_key); let mut result = start_value; result.add_assign(&(buffer_value - buffer_start_value)); result @@ -4508,10 +3825,23 @@ impl MultiBufferSnapshot { && region.has_trailing_newline && !region.is_main_buffer { - return Some((&cursor.excerpt()?.buffer, cursor.main_buffer_position()?)); + let main_buffer_position = cursor.main_buffer_position()?; + let buffer_snapshot = &cursor.excerpt()?.buffer; + // remove this assert once we figure out the cause of the panics for #40453 + buffer_snapshot + .text + .as_rope() + .assert_char_boundary(main_buffer_position); + return Some((buffer_snapshot, main_buffer_position)); } else if buffer_offset > region.buffer.len() { return None; } + // remove this assert once we figure out the cause of the panics for #40453 + region + .buffer + .text + .as_rope() + .assert_char_boundary(buffer_offset); Some((region.buffer, buffer_offset)) } @@ -4622,20 +3952,20 @@ impl MultiBufferSnapshot { pub fn indent_and_comment_for_line(&self, row: MultiBufferRow, cx: &App) -> String { let mut indent = self.indent_size_for_line(row).chars().collect::(); - if self.language_settings(cx).extend_comment_on_newline { - if let Some(language_scope) = self.language_scope_at(Point::new(row.0, 0)) { - let delimiters = language_scope.line_comment_prefixes(); - for delimiter in delimiters { - if *self - .chars_at(Point::new(row.0, indent.len() as u32)) - .take(delimiter.chars().count()) - .collect::() - .as_str() - == **delimiter - { - indent.push_str(&delimiter); - break; - } + if self.language_settings(cx).extend_comment_on_newline + && let Some(language_scope) = self.language_scope_at(Point::new(row.0, 0)) + { + let delimiters = language_scope.line_comment_prefixes(); + for delimiter in delimiters { + if *self + .chars_at(Point::new(row.0, indent.len() as u32)) + .take(delimiter.chars().count()) + .collect::() + .as_str() + == **delimiter + { + indent.push_str(delimiter); + break; } } } @@ -4655,7 +3985,7 @@ impl MultiBufferSnapshot { return true; } } - return true; + true } pub fn prev_non_blank_row(&self, mut row: MultiBufferRow) -> Option { @@ -4715,7 +4045,7 @@ impl MultiBufferSnapshot { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut cursor = self .diff_transforms - .cursor::>(&()); + .cursor::>(()); cursor.seek(&range.start, Bias::Right); let Some(first_transform) = cursor.item() else { @@ -4809,9 +4139,8 @@ impl MultiBufferSnapshot { where D: TextDimension, { - // let mut range = range.start..range.end; - let mut summary = D::zero(&()); - let mut cursor = self.excerpts.cursor::(&()); + let mut summary = D::zero(()); + let mut cursor = self.excerpts.cursor::(()); cursor.seek(&range.start, Bias::Right); if let Some(excerpt) = cursor.item() { let mut end_before_newline = cursor.end(); @@ -4893,25 +4222,22 @@ impl MultiBufferSnapshot { base_text_byte_range, .. }) => { - if let Some(diff_base_anchor) = &anchor.diff_base_anchor { - if let Some(base_text) = + if let Some(diff_base_anchor) = &anchor.diff_base_anchor + && let Some(base_text) = self.diffs.get(buffer_id).map(|diff| diff.base_text()) + && base_text.can_resolve(diff_base_anchor) + { + let base_text_offset = diff_base_anchor.to_offset(base_text); + if base_text_offset >= base_text_byte_range.start + && base_text_offset <= base_text_byte_range.end { - if base_text.can_resolve(&diff_base_anchor) { - let base_text_offset = diff_base_anchor.to_offset(&base_text); - if base_text_offset >= base_text_byte_range.start - && base_text_offset <= base_text_byte_range.end - { - let position_in_hunk = base_text - .text_summary_for_range::( - base_text_byte_range.start..base_text_offset, - ); - position.add_assign(&position_in_hunk); - } else if at_transform_end { - diff_transforms.next(); - continue; - } - } + let position_in_hunk = base_text.text_summary_for_range::( + base_text_byte_range.start..base_text_offset, + ); + position.add_assign(&position_in_hunk); + } else if at_transform_end { + diff_transforms.next(); + continue; } } } @@ -4932,29 +4258,28 @@ impl MultiBufferSnapshot { fn excerpt_offset_for_anchor(&self, anchor: &Anchor) -> ExcerptOffset { let mut cursor = self .excerpts - .cursor::, ExcerptOffset>>(&()); + .cursor::, ExcerptOffset>>(()); let locator = self.excerpt_locator_for_id(anchor.excerpt_id); cursor.seek(&Some(locator), Bias::Left); - if cursor.item().is_none() { - cursor.next(); + if cursor.item().is_none() && anchor.excerpt_id == ExcerptId::max() { + cursor.prev(); } let mut position = cursor.start().1; - if let Some(excerpt) = cursor.item() { - if excerpt.id == anchor.excerpt_id { - let excerpt_buffer_start = excerpt - .buffer - .offset_for_anchor(&excerpt.range.context.start); - let excerpt_buffer_end = - excerpt.buffer.offset_for_anchor(&excerpt.range.context.end); - let buffer_position = cmp::min( - excerpt_buffer_end, - excerpt.buffer.offset_for_anchor(&anchor.text_anchor), - ); - if buffer_position > excerpt_buffer_start { - position.value += buffer_position - excerpt_buffer_start; - } + if let Some(excerpt) = cursor.item() + && (excerpt.id == anchor.excerpt_id || anchor.excerpt_id == ExcerptId::max()) + { + let excerpt_buffer_start = excerpt + .buffer + .offset_for_anchor(&excerpt.range.context.start); + let excerpt_buffer_end = excerpt.buffer.offset_for_anchor(&excerpt.range.context.end); + let buffer_position = cmp::min( + excerpt_buffer_end, + excerpt.buffer.offset_for_anchor(&anchor.text_anchor), + ); + if buffer_position > excerpt_buffer_start { + position.value += buffer_position - excerpt_buffer_start; } } position @@ -4964,7 +4289,7 @@ impl MultiBufferSnapshot { while let Some(replacement) = self.replaced_excerpts.get(&excerpt_id) { excerpt_id = *replacement; } - return excerpt_id; + excerpt_id } pub fn summaries_for_anchors<'a, D, I>(&'a self, anchors: I) -> Vec @@ -4973,33 +4298,29 @@ impl MultiBufferSnapshot { I: 'a + IntoIterator, { let mut anchors = anchors.into_iter().peekable(); - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::(()); let mut diff_transforms_cursor = self .diff_transforms - .cursor::, OutputDimension>>(&()); + .cursor::, OutputDimension>>(()); diff_transforms_cursor.next(); let mut summaries = Vec::new(); while let Some(anchor) = anchors.peek() { let excerpt_id = self.latest_excerpt_id(anchor.excerpt_id); - let excerpt_anchors = iter::from_fn(|| { - let anchor = anchors.peek()?; - if self.latest_excerpt_id(anchor.excerpt_id) == excerpt_id { - Some(anchors.next().unwrap()) - } else { - None - } + + let excerpt_anchors = anchors.peeking_take_while(|anchor| { + self.latest_excerpt_id(anchor.excerpt_id) == excerpt_id }); let locator = self.excerpt_locator_for_id(excerpt_id); cursor.seek_forward(locator, Bias::Left); - if cursor.item().is_none() { - cursor.next(); + if cursor.item().is_none() && excerpt_id == ExcerptId::max() { + cursor.prev(); } let excerpt_start_position = D::from_text_summary(&cursor.start().text); if let Some(excerpt) = cursor.item() { - if excerpt.id != excerpt_id { + if excerpt.id != excerpt_id && excerpt_id != ExcerptId::max() { let position = self.resolve_summary_for_anchor( &Anchor::min(), excerpt_start_position, @@ -5082,9 +4403,9 @@ impl MultiBufferSnapshot { if point == region.range.end.key && region.has_trailing_newline { position.add_assign(&D::from_text_summary(&TextSummary::newline())); } - return Some(position); + Some(position) } else { - return Some(D::from_text_summary(&self.text_summary())); + Some(D::from_text_summary(&self.text_summary())) } }) } @@ -5094,7 +4415,7 @@ impl MultiBufferSnapshot { I: 'a + IntoIterator, { let mut anchors = anchors.into_iter().enumerate().peekable(); - let mut cursor = self.excerpts.cursor::>(&()); + let mut cursor = self.excerpts.cursor::>(()); cursor.next(); let mut result = Vec::new(); @@ -5106,25 +4427,19 @@ impl MultiBufferSnapshot { let old_locator = self.excerpt_locator_for_id(old_excerpt_id); cursor.seek_forward(&Some(old_locator), Bias::Left); - if cursor.item().is_none() { - cursor.next(); - } - let next_excerpt = cursor.item(); let prev_excerpt = cursor.prev_item(); // Process all of the anchors for this excerpt. - while let Some((_, anchor)) = anchors.peek() { - if anchor.excerpt_id != old_excerpt_id { - break; - } - let (anchor_ix, anchor) = anchors.next().unwrap(); - let mut anchor = *anchor; + while let Some((anchor_ix, &anchor)) = + anchors.next_if(|(_, anchor)| anchor.excerpt_id == old_excerpt_id) + { + let mut anchor = anchor; // Leave min and max anchors unchanged if invalid or // if the old excerpt still exists at this location let mut kept_position = next_excerpt - .map_or(false, |e| e.id == old_excerpt_id && e.contains(&anchor)) + .is_some_and(|e| e.id == old_excerpt_id && e.contains(&anchor)) || old_excerpt_id == ExcerptId::max() || old_excerpt_id == ExcerptId::min(); @@ -5155,12 +4470,7 @@ impl MultiBufferSnapshot { { text_anchor = excerpt.range.context.end; } - Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id: excerpt.id, - text_anchor, - diff_base_anchor: None, - } + Anchor::in_buffer(excerpt.id, excerpt.buffer_id, text_anchor) } else if let Some(excerpt) = prev_excerpt { let mut text_anchor = excerpt .range @@ -5173,12 +4483,7 @@ impl MultiBufferSnapshot { { text_anchor = excerpt.range.context.start; } - Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id: excerpt.id, - text_anchor, - diff_base_anchor: None, - } + Anchor::in_buffer(excerpt.id, excerpt.buffer_id, text_anchor) } else if anchor.text_anchor.bias == Bias::Left { Anchor::min() } else { @@ -5208,18 +4513,15 @@ impl MultiBufferSnapshot { // offset in the excerpts, and whether the position is within a deleted hunk. let mut diff_transforms = self .diff_transforms - .cursor::>(&()); + .cursor::>(()); diff_transforms.seek(&offset, Bias::Right); - if offset == diff_transforms.start().0 && bias == Bias::Left { - if let Some(prev_item) = diff_transforms.prev_item() { - match prev_item { - DiffTransform::DeletedHunk { .. } => { - diff_transforms.prev(); - } - _ => {} - } - } + if offset == diff_transforms.start().0 + && bias == Bias::Left + && let Some(prev_item) = diff_transforms.prev_item() + && let DiffTransform::DeletedHunk { .. } = prev_item + { + diff_transforms.prev(); } let offset_in_transform = offset - diff_transforms.start().0; let mut excerpt_offset = diff_transforms.start().1; @@ -5246,18 +4548,9 @@ impl MultiBufferSnapshot { excerpt_offset += ExcerptOffset::new(offset_in_transform); }; - if let Some((excerpt_id, buffer_id, buffer)) = self.as_singleton() { - return Anchor { - buffer_id: Some(buffer_id), - excerpt_id: *excerpt_id, - text_anchor: buffer.anchor_at(excerpt_offset.value, bias), - diff_base_anchor, - }; - } - let mut excerpts = self .excerpts - .cursor::>>(&()); + .cursor::>>(()); excerpts.seek(&excerpt_offset, Bias::Right); if excerpts.item().is_none() && excerpt_offset == excerpts.start().0 && bias == Bias::Left { excerpts.prev(); @@ -5272,11 +4565,10 @@ impl MultiBufferSnapshot { let buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer); let text_anchor = excerpt.clip_anchor(excerpt.buffer.anchor_at(buffer_start + overshoot, bias)); - Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id: excerpt.id, - text_anchor, - diff_base_anchor, + let anchor = Anchor::in_buffer(excerpt.id, excerpt.buffer_id, text_anchor); + match diff_base_anchor { + Some(diff_base_anchor) => anchor.with_diff_base_anchor(diff_base_anchor), + None => anchor, } } else if excerpt_offset.is_zero() && bias == Bias::Left { Anchor::min() @@ -5285,30 +4577,66 @@ impl MultiBufferSnapshot { } } + /// Wraps the [`text::Anchor`] in a [`multi_buffer::Anchor`] if this multi-buffer is a singleton. + pub fn as_singleton_anchor(&self, text_anchor: text::Anchor) -> Option { + let (excerpt, buffer, _) = self.as_singleton()?; + Some(Anchor::in_buffer(*excerpt, buffer, text_anchor)) + } + + /// Returns an anchor for the given excerpt and text anchor, + /// Returns [`None`] if the excerpt_id is no longer valid or the text anchor range is out of excerpt's bounds. + pub fn anchor_range_in_excerpt( + &self, + excerpt_id: ExcerptId, + text_anchor: Range, + ) -> Option> { + let excerpt_id = self.latest_excerpt_id(excerpt_id); + let excerpt = self.excerpt(excerpt_id)?; + + Some( + self.anchor_in_excerpt_(excerpt, text_anchor.start)? + ..self.anchor_in_excerpt_(excerpt, text_anchor.end)?, + ) + } + /// Returns an anchor for the given excerpt and text anchor, - /// returns None if the excerpt_id is no longer valid. + /// Returns [`None`] if the excerpt_id is no longer valid or the text anchor range is out of excerpt's bounds. pub fn anchor_in_excerpt( &self, excerpt_id: ExcerptId, text_anchor: text::Anchor, ) -> Option { let excerpt_id = self.latest_excerpt_id(excerpt_id); - let locator = self.excerpt_locator_for_id(excerpt_id); - let mut cursor = self.excerpts.cursor::>(&()); - cursor.seek(locator, Bias::Left); - if let Some(excerpt) = cursor.item() { - if excerpt.id == excerpt_id { - let text_anchor = excerpt.clip_anchor(text_anchor); - drop(cursor); - return Some(Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id, + let excerpt = self.excerpt(excerpt_id)?; + self.anchor_in_excerpt_(excerpt, text_anchor) + } + + fn anchor_in_excerpt_(&self, excerpt: &Excerpt, text_anchor: text::Anchor) -> Option { + match text_anchor.buffer_id { + Some(buffer_id) if buffer_id == excerpt.buffer_id => (), + Some(_) => return None, + None if text_anchor == text::Anchor::MAX || text_anchor == text::Anchor::MIN => { + return Some(Anchor::in_buffer( + excerpt.id, + excerpt.buffer_id, text_anchor, - diff_base_anchor: None, - }); + )); } + None => return None, } - None + + let context = &excerpt.range.context; + if context.start.cmp(&text_anchor, &excerpt.buffer).is_gt() + || context.end.cmp(&text_anchor, &excerpt.buffer).is_lt() + { + return None; + } + + Some(Anchor::in_buffer( + excerpt.id, + excerpt.buffer_id, + text_anchor, + )) } pub fn context_range_for_excerpt(&self, excerpt_id: ExcerptId) -> Option> { @@ -5334,8 +4662,8 @@ impl MultiBufferSnapshot { } fn cursor(&self) -> MultiBufferCursor<'_, D> { - let excerpts = self.excerpts.cursor(&()); - let diff_transforms = self.diff_transforms.cursor(&()); + let excerpts = self.excerpts.cursor(()); + let diff_transforms = self.diff_transforms.cursor(()); MultiBufferCursor { excerpts, diff_transforms, @@ -5344,15 +4672,15 @@ impl MultiBufferSnapshot { } } - pub fn excerpt_before(&self, id: ExcerptId) -> Option> { - let start_locator = self.excerpt_locator_for_id(id); + pub fn excerpt_before(&self, excerpt_id: ExcerptId) -> Option> { + let start_locator = self.excerpt_locator_for_id(excerpt_id); let mut excerpts = self .excerpts - .cursor::, ExcerptDimension>>(&()); + .cursor::, ExcerptDimension>>(()); excerpts.seek(&Some(start_locator), Bias::Left); excerpts.prev(); - let mut diff_transforms = self.diff_transforms.cursor::>(&()); + let mut diff_transforms = self.diff_transforms.cursor::>(()); diff_transforms.seek(&excerpts.start().1, Bias::Left); if diff_transforms.end().excerpt_dimension < excerpts.start().1 { diff_transforms.next(); @@ -5491,7 +4819,7 @@ impl MultiBufferSnapshot { let range_filter = |open: Range, close: Range| -> bool { excerpt_buffer_range.contains(&open.start) && excerpt_buffer_range.contains(&close.end) - && range_filter.map_or(true, |filter| filter(buffer, open, close)) + && range_filter.is_none_or(|filter| filter(buffer, open, close)) }; let (open, close) = excerpt.buffer().innermost_enclosing_bracket_ranges( @@ -5651,10 +4979,10 @@ impl MultiBufferSnapshot { .buffer .line_indents_in_row_range(buffer_start_row..buffer_end_row); cursor.next(); - return Some(line_indents.map(move |(buffer_row, indent)| { + Some(line_indents.map(move |(buffer_row, indent)| { let row = region.range.start.row + (buffer_row - region.buffer_range.start.row); (MultiBufferRow(row), indent, ®ion.excerpt.buffer) - })); + })) }) .flatten() } @@ -5691,10 +5019,10 @@ impl MultiBufferSnapshot { .buffer .reversed_line_indents_in_row_range(buffer_start_row..buffer_end_row); cursor.prev(); - return Some(line_indents.map(move |(buffer_row, indent)| { + Some(line_indents.map(move |(buffer_row, indent)| { let row = region.range.start.row + (buffer_row - region.buffer_range.start.row); (MultiBufferRow(row), indent, ®ion.excerpt.buffer) - })); + })) }) .flatten() } @@ -5860,10 +5188,10 @@ impl MultiBufferSnapshot { let current_depth = indent_stack.len() as u32; // Avoid retrieving the language settings repeatedly for every buffer row. - if let Some((prev_buffer_id, _)) = &prev_settings { - if prev_buffer_id != &buffer.remote_id() { - prev_settings.take(); - } + if let Some((prev_buffer_id, _)) = &prev_settings + && prev_buffer_id != &buffer.remote_id() + { + prev_settings.take(); } let settings = &prev_settings .get_or_insert_with(|| { @@ -5931,7 +5259,7 @@ impl MultiBufferSnapshot { end_row: last_row, depth: next_depth, tab_size, - settings: settings.indent_guides, + settings: settings.indent_guides.clone(), }); } } @@ -6031,7 +5359,7 @@ impl MultiBufferSnapshot { &self, buffer_id: BufferId, group_id: usize, - ) -> impl Iterator> + '_ { + ) -> impl Iterator> + '_ { self.lift_buffer_metadata(Point::zero()..self.max_point(), move |buffer, range| { if buffer.remote_id() != buffer_id { return None; @@ -6040,16 +5368,16 @@ impl MultiBufferSnapshot { buffer .diagnostics_in_range(range, false) .filter(move |diagnostic| diagnostic.diagnostic.group_id == group_id) - .map(move |DiagnosticEntry { diagnostic, range }| (range, diagnostic)), + .map(move |DiagnosticEntryRef { diagnostic, range }| (range, diagnostic)), ) }) - .map(|(range, diagnostic, _)| DiagnosticEntry { diagnostic, range }) + .map(|(range, diagnostic, _)| DiagnosticEntryRef { diagnostic, range }) } pub fn diagnostics_in_range<'a, T>( &'a self, range: Range, - ) -> impl Iterator> + 'a + ) -> impl Iterator> + 'a where T: 'a + text::ToOffset @@ -6066,13 +5394,13 @@ impl MultiBufferSnapshot { .map(|entry| (entry.range, entry.diagnostic)), ) }) - .map(|(range, diagnostic, _)| DiagnosticEntry { diagnostic, range }) + .map(|(range, diagnostic, _)| DiagnosticEntryRef { diagnostic, range }) } pub fn diagnostics_with_buffer_ids_in_range<'a, T>( &'a self, range: Range, - ) -> impl Iterator)> + 'a + ) -> impl Iterator)> + 'a where T: 'a + text::ToOffset @@ -6089,30 +5417,50 @@ impl MultiBufferSnapshot { .map(|entry| (entry.range, entry.diagnostic)), ) }) - .map(|(range, diagnostic, b)| (b.buffer_id, DiagnosticEntry { diagnostic, range })) + .map(|(range, diagnostic, b)| (b.buffer_id, DiagnosticEntryRef { diagnostic, range })) + } + + pub fn syntax_ancestor( + &self, + range: Range, + ) -> Option<(tree_sitter::Node<'_>, Range)> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut excerpt = self.excerpt_containing(range.clone())?; + let node = excerpt + .buffer() + .syntax_ancestor(excerpt.map_range_to_buffer(range))?; + let node_range = node.byte_range(); + if !excerpt.contains_buffer_range(node_range.clone()) { + return None; + }; + Some((node, excerpt.map_range_from_buffer(node_range))) } - pub fn syntax_ancestor( + pub fn syntax_next_sibling( &self, range: Range, - ) -> Option<(tree_sitter::Node<'_>, MultiOrSingleBufferOffsetRange)> { + ) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut excerpt = self.excerpt_containing(range.clone())?; - let node = excerpt + excerpt .buffer() - .syntax_ancestor(excerpt.map_range_to_buffer(range))?; - let node_range = node.byte_range(); - let range = if excerpt.contains_buffer_range(node_range.clone()) { - MultiOrSingleBufferOffsetRange::Multi(excerpt.map_range_from_buffer(node_range)) - } else { - MultiOrSingleBufferOffsetRange::Single(node_range) - }; - Some((node, range)) + .syntax_next_sibling(excerpt.map_range_to_buffer(range)) + } + + pub fn syntax_prev_sibling( + &self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut excerpt = self.excerpt_containing(range.clone())?; + excerpt + .buffer() + .syntax_prev_sibling(excerpt.map_range_to_buffer(range)) } pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option> { let (excerpt_id, _, buffer) = self.as_singleton()?; - let outline = buffer.outline(theme)?; + let outline = buffer.outline(theme); Some(Outline::new( outline .items @@ -6120,22 +5468,17 @@ impl MultiBufferSnapshot { .flat_map(|item| { Some(OutlineItem { depth: item.depth, - range: self.anchor_in_excerpt(*excerpt_id, item.range.start)? - ..self.anchor_in_excerpt(*excerpt_id, item.range.end)?, + range: self.anchor_range_in_excerpt(*excerpt_id, item.range)?, + source_range_for_text: self + .anchor_range_in_excerpt(*excerpt_id, item.source_range_for_text)?, text: item.text, highlight_ranges: item.highlight_ranges, name_ranges: item.name_ranges, body_range: item.body_range.and_then(|body_range| { - Some( - self.anchor_in_excerpt(*excerpt_id, body_range.start)? - ..self.anchor_in_excerpt(*excerpt_id, body_range.end)?, - ) + self.anchor_range_in_excerpt(*excerpt_id, body_range) }), annotation_range: item.annotation_range.and_then(|annotation_range| { - Some( - self.anchor_in_excerpt(*excerpt_id, annotation_range.start)? - ..self.anchor_in_excerpt(*excerpt_id, annotation_range.end)?, - ) + self.anchor_range_in_excerpt(*excerpt_id, annotation_range) }), }) }) @@ -6151,32 +5494,30 @@ impl MultiBufferSnapshot { let anchor = self.anchor_before(offset); let excerpt_id = anchor.excerpt_id; let excerpt = self.excerpt(excerpt_id)?; + let buffer_id = excerpt.buffer_id; Some(( - excerpt.buffer_id, + buffer_id, excerpt .buffer .symbols_containing(anchor.text_anchor, theme) .into_iter() - .flatten() .flat_map(|item| { Some(OutlineItem { depth: item.depth, - range: self.anchor_in_excerpt(excerpt_id, item.range.start)? - ..self.anchor_in_excerpt(excerpt_id, item.range.end)?, + source_range_for_text: Anchor::range_in_buffer( + excerpt_id, + buffer_id, + item.source_range_for_text, + ), + range: Anchor::range_in_buffer(excerpt_id, buffer_id, item.range), text: item.text, highlight_ranges: item.highlight_ranges, name_ranges: item.name_ranges, - body_range: item.body_range.and_then(|body_range| { - Some( - self.anchor_in_excerpt(excerpt_id, body_range.start)? - ..self.anchor_in_excerpt(excerpt_id, body_range.end)?, - ) + body_range: item.body_range.map(|body_range| { + Anchor::range_in_buffer(excerpt_id, buffer_id, body_range) }), - annotation_range: item.annotation_range.and_then(|body_range| { - Some( - self.anchor_in_excerpt(excerpt_id, body_range.start)? - ..self.anchor_in_excerpt(excerpt_id, body_range.end)?, - ) + annotation_range: item.annotation_range.map(|body_range| { + Anchor::range_in_buffer(excerpt_id, buffer_id, body_range) }), }) }) @@ -6190,12 +5531,11 @@ impl MultiBufferSnapshot { } else if id == ExcerptId::max() { Locator::max_ref() } else { - let mut cursor = self.excerpt_ids.cursor::(&()); - cursor.seek(&id, Bias::Left); - if let Some(entry) = cursor.item() { - if entry.id == id { - return &entry.locator; - } + let (_, _, item) = self.excerpt_ids.find::((), &id, Bias::Left); + if let Some(entry) = item + && entry.id == id + { + return &entry.locator; } panic!("invalid excerpt id {id:?}") } @@ -6208,24 +5548,22 @@ impl MultiBufferSnapshot { ) -> SmallVec<[Locator; 1]> { let mut sorted_ids = ids.into_iter().collect::>(); sorted_ids.sort_unstable(); + sorted_ids.dedup(); let mut locators = SmallVec::new(); while sorted_ids.last() == Some(&ExcerptId::max()) { sorted_ids.pop(); - if let Some(mapping) = self.excerpt_ids.last() { - locators.push(mapping.locator.clone()); - } + locators.push(Locator::max()); } - let mut sorted_ids = sorted_ids.into_iter().dedup().peekable(); - if sorted_ids.peek() == Some(&ExcerptId::min()) { - sorted_ids.next(); - if let Some(mapping) = self.excerpt_ids.first() { - locators.push(mapping.locator.clone()); - } - } + let mut sorted_ids = sorted_ids.into_iter().peekable(); + locators.extend( + sorted_ids + .peeking_take_while(|excerpt| *excerpt == ExcerptId::min()) + .map(|_| Locator::min()), + ); - let mut cursor = self.excerpt_ids.cursor::(&()); + let mut cursor = self.excerpt_ids.cursor::(()); for id in sorted_ids { if cursor.seek_forward(&id, Bias::Left) { locators.push(cursor.item().unwrap().locator.clone()); @@ -6249,14 +5587,21 @@ impl MultiBufferSnapshot { pub fn range_for_excerpt(&self, excerpt_id: ExcerptId) -> Option> { let mut cursor = self .excerpts - .cursor::, ExcerptDimension>>(&()); + .cursor::, ExcerptDimension>>(()); let locator = self.excerpt_locator_for_id(excerpt_id); - if cursor.seek(&Some(locator), Bias::Left) { + let mut sought_exact = cursor.seek(&Some(locator), Bias::Left); + if cursor.item().is_none() && excerpt_id == ExcerptId::max() { + sought_exact = true; + cursor.prev(); + } else if excerpt_id == ExcerptId::min() { + sought_exact = true; + } + if sought_exact { let start = cursor.start().1.clone(); let end = cursor.end().1; let mut diff_transforms = self .diff_transforms - .cursor::, OutputDimension>>(&()); + .cursor::, OutputDimension>>(()); diff_transforms.seek(&start, Bias::Left); let overshoot = start.0 - diff_transforms.start().0.0; let start = diff_transforms.start().1.0 + overshoot; @@ -6269,25 +5614,17 @@ impl MultiBufferSnapshot { } } - pub fn buffer_range_for_excerpt(&self, excerpt_id: ExcerptId) -> Option> { - let mut cursor = self.excerpts.cursor::>(&()); - let locator = self.excerpt_locator_for_id(excerpt_id); - if cursor.seek(&Some(locator), Bias::Left) { - if let Some(excerpt) = cursor.item() { - return Some(excerpt.range.context.clone()); - } - } - None - } - fn excerpt(&self, excerpt_id: ExcerptId) -> Option<&Excerpt> { - let mut cursor = self.excerpts.cursor::>(&()); + let mut cursor = self.excerpts.cursor::>(()); let locator = self.excerpt_locator_for_id(excerpt_id); cursor.seek(&Some(locator), Bias::Left); - if let Some(excerpt) = cursor.item() { - if excerpt.id == excerpt_id { - return Some(excerpt); - } + if let Some(excerpt) = cursor.item() + && excerpt.id == excerpt_id + { + return Some(excerpt); + } else if cursor.item().is_none() && excerpt_id == ExcerptId::max() { + cursor.prev(); + return cursor.item(); } None } @@ -6323,12 +5660,20 @@ impl MultiBufferSnapshot { }) } + pub fn buffer_id_for_anchor(&self, anchor: Anchor) -> Option { + if let Some(id) = anchor.buffer_id { + return Some(id); + } + let excerpt = self.excerpt_containing(anchor..anchor)?; + Some(excerpt.buffer_id()) + } + pub fn selections_in_range<'a>( &'a self, range: &'a Range, include_local: bool, ) -> impl 'a + Iterator)> { - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::(()); let start_locator = self.excerpt_locator_for_id(range.start.excerpt_id); let end_locator = self.excerpt_locator_for_id(range.end.excerpt_id); cursor.seek(start_locator, Bias::Left); @@ -6348,18 +5693,10 @@ impl MultiBufferSnapshot { .selections_in_range(query_range, include_local) .flat_map(move |(replica_id, line_mode, cursor_shape, selections)| { selections.map(move |selection| { - let mut start = Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id: excerpt.id, - text_anchor: selection.start, - diff_base_anchor: None, - }; - let mut end = Anchor { - buffer_id: Some(excerpt.buffer_id), - excerpt_id: excerpt.id, - text_anchor: selection.end, - diff_base_anchor: None, - }; + let mut start = + Anchor::in_buffer(excerpt.id, excerpt.buffer_id, selection.start); + let mut end = + Anchor::in_buffer(excerpt.id, excerpt.buffer_id, selection.end); if range.start.cmp(&start, self).is_gt() { start = range.start; } @@ -6391,20 +5728,59 @@ impl MultiBufferSnapshot { pub fn diff_for_buffer_id(&self, buffer_id: BufferId) -> Option<&BufferDiffSnapshot> { self.diffs.get(&buffer_id) } + + /// Visually annotates a position or range with the `Debug` representation of a value. The + /// callsite of this function is used as a key - previous annotations will be removed. + #[cfg(debug_assertions)] + #[track_caller] + pub fn debug(&self, ranges: &R, value: V) + where + R: debug::ToMultiBufferDebugRanges, + V: std::fmt::Debug, + { + self.debug_with_key(std::panic::Location::caller(), ranges, value); + } + + /// Visually annotates a position or range with the `Debug` representation of a value. Previous + /// debug annotations with the same key will be removed. The key is also used to determine the + /// annotation's color. + #[cfg(debug_assertions)] + #[track_caller] + pub fn debug_with_key(&self, key: &K, ranges: &R, value: V) + where + K: std::hash::Hash + 'static, + R: debug::ToMultiBufferDebugRanges, + V: std::fmt::Debug, + { + let text_ranges = ranges + .to_multi_buffer_debug_ranges(self) + .into_iter() + .flat_map(|range| { + self.range_to_buffer_ranges(range).into_iter().map( + |(buffer, range, _excerpt_id)| { + buffer.anchor_after(range.start)..buffer.anchor_before(range.end) + }, + ) + }) + .collect(); + text::debug::GlobalDebugRanges::with_locked(|debug_ranges| { + debug_ranges.insert(key, text_ranges, format!("{value:?}").into()) + }); + } } #[cfg(any(test, feature = "test-support"))] impl MultiBufferSnapshot { pub fn random_byte_range(&self, start_offset: usize, rng: &mut impl rand::Rng) -> Range { - let end = self.clip_offset(rng.gen_range(start_offset..=self.len()), Bias::Right); - let start = self.clip_offset(rng.gen_range(start_offset..=end), Bias::Right); + let end = self.clip_offset(rng.random_range(start_offset..=self.len()), Bias::Right); + let start = self.clip_offset(rng.random_range(start_offset..=end), Bias::Right); start..end } #[cfg(any(test, feature = "test-support"))] fn check_invariants(&self) { - let excerpts = self.excerpts.items(&()); - let excerpt_ids = self.excerpt_ids.items(&()); + let excerpts = self.excerpts.items(()); + let excerpt_ids = self.excerpt_ids.items(()); for (ix, excerpt) in excerpts.iter().enumerate() { if ix == 0 { @@ -6418,7 +5794,7 @@ impl MultiBufferSnapshot { for (ix, entry) in excerpt_ids.iter().enumerate() { if ix == 0 { - if entry.id.cmp(&ExcerptId::min(), &self).is_le() { + if entry.id.cmp(&ExcerptId::min(), self).is_le() { panic!("invalid first excerpt id {:?}", entry.id); } } else if entry.id <= excerpt_ids[ix - 1].id { @@ -6431,7 +5807,7 @@ impl MultiBufferSnapshot { "incorrect input summary. expected {:?}, got {:?}. transforms: {:+?}", self.excerpts.summary().text.len, self.diff_transforms.summary().input, - self.diff_transforms.items(&()), + self.diff_transforms.items(()), ); } @@ -6446,13 +5822,12 @@ impl MultiBufferSnapshot { inserted_hunk_info: prev_inserted_hunk_info, .. }) = prev_transform + && *inserted_hunk_info == *prev_inserted_hunk_info { - if *inserted_hunk_info == *prev_inserted_hunk_info { - panic!( - "multiple adjacent buffer content transforms with is_inserted_hunk = {inserted_hunk_info:?}. transforms: {:+?}", - self.diff_transforms.items(&()) - ); - } + panic!( + "multiple adjacent buffer content transforms with is_inserted_hunk = {inserted_hunk_info:?}. transforms: {:+?}", + self.diff_transforms.items(()) + ); } if summary.len == 0 && !self.is_empty() { panic!("empty buffer content transform"); @@ -6552,14 +5927,12 @@ where self.excerpts.next(); } else if let Some(DiffTransform::DeletedHunk { hunk_info, .. }) = self.diff_transforms.item() - { - if self + && self .excerpts .item() - .map_or(false, |excerpt| excerpt.id != hunk_info.excerpt_id) - { - self.excerpts.next(); - } + .is_some_and(|excerpt| excerpt.id != hunk_info.excerpt_id) + { + self.excerpts.next(); } } } @@ -6604,7 +5977,7 @@ where let prev_transform = self.diff_transforms.item(); self.diff_transforms.next(); - prev_transform.map_or(true, |next_transform| { + prev_transform.is_none_or(|next_transform| { matches!(next_transform, DiffTransform::BufferContent { .. }) }) } @@ -6619,12 +5992,12 @@ where } let next_transform = self.diff_transforms.next_item(); - next_transform.map_or(true, |next_transform| match next_transform { + next_transform.is_none_or(|next_transform| match next_transform { DiffTransform::BufferContent { .. } => true, DiffTransform::DeletedHunk { hunk_info, .. } => self .excerpts .item() - .map_or(false, |excerpt| excerpt.id != hunk_info.excerpt_id), + .is_some_and(|excerpt| excerpt.id != hunk_info.excerpt_id), }) } @@ -6648,7 +6021,7 @@ where hunk_info, .. } => { - let diff = self.diffs.get(&buffer_id)?; + let diff = self.diffs.get(buffer_id)?; let buffer = diff.base_text(); let mut rope_cursor = buffer.as_rope().cursor(0); let buffer_start = rope_cursor.summary::(base_text_byte_range.start); @@ -6657,7 +6030,7 @@ where buffer_end.add_assign(&buffer_range_len); let start = self.diff_transforms.start().output_dimension.0; let end = self.diff_transforms.end().output_dimension.0; - return Some(MultiBufferRegion { + Some(MultiBufferRegion { buffer, excerpt, has_trailing_newline: *has_trailing_newline, @@ -6667,7 +6040,7 @@ where )), buffer_range: buffer_start..buffer_end, range: start..end, - }); + }) } DiffTransform::BufferContent { inserted_hunk_info, .. @@ -6725,208 +6098,6 @@ where } } -impl History { - fn start_transaction(&mut self, now: Instant) -> Option { - self.transaction_depth += 1; - if self.transaction_depth == 1 { - let id = self.next_transaction_id.tick(); - self.undo_stack.push(Transaction { - id, - buffer_transactions: Default::default(), - first_edit_at: now, - last_edit_at: now, - suppress_grouping: false, - }); - Some(id) - } else { - None - } - } - - fn end_transaction( - &mut self, - now: Instant, - buffer_transactions: HashMap, - ) -> bool { - assert_ne!(self.transaction_depth, 0); - self.transaction_depth -= 1; - if self.transaction_depth == 0 { - if buffer_transactions.is_empty() { - self.undo_stack.pop(); - false - } else { - self.redo_stack.clear(); - let transaction = self.undo_stack.last_mut().unwrap(); - transaction.last_edit_at = now; - for (buffer_id, transaction_id) in buffer_transactions { - transaction - .buffer_transactions - .entry(buffer_id) - .or_insert(transaction_id); - } - true - } - } else { - false - } - } - - fn push_transaction<'a, T>( - &mut self, - buffer_transactions: T, - now: Instant, - cx: &Context, - ) where - T: IntoIterator, &'a language::Transaction)>, - { - assert_eq!(self.transaction_depth, 0); - let transaction = Transaction { - id: self.next_transaction_id.tick(), - buffer_transactions: buffer_transactions - .into_iter() - .map(|(buffer, transaction)| (buffer.read(cx).remote_id(), transaction.id)) - .collect(), - first_edit_at: now, - last_edit_at: now, - suppress_grouping: false, - }; - if !transaction.buffer_transactions.is_empty() { - self.undo_stack.push(transaction); - self.redo_stack.clear(); - } - } - - fn finalize_last_transaction(&mut self) { - if let Some(transaction) = self.undo_stack.last_mut() { - transaction.suppress_grouping = true; - } - } - - fn forget(&mut self, transaction_id: TransactionId) -> Option { - if let Some(ix) = self - .undo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id) - { - Some(self.undo_stack.remove(ix)) - } else if let Some(ix) = self - .redo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id) - { - Some(self.redo_stack.remove(ix)) - } else { - None - } - } - - fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> { - self.undo_stack - .iter() - .find(|transaction| transaction.id == transaction_id) - .or_else(|| { - self.redo_stack - .iter() - .find(|transaction| transaction.id == transaction_id) - }) - } - - fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { - self.undo_stack - .iter_mut() - .find(|transaction| transaction.id == transaction_id) - .or_else(|| { - self.redo_stack - .iter_mut() - .find(|transaction| transaction.id == transaction_id) - }) - } - - fn pop_undo(&mut self) -> Option<&mut Transaction> { - assert_eq!(self.transaction_depth, 0); - if let Some(transaction) = self.undo_stack.pop() { - self.redo_stack.push(transaction); - self.redo_stack.last_mut() - } else { - None - } - } - - fn pop_redo(&mut self) -> Option<&mut Transaction> { - assert_eq!(self.transaction_depth, 0); - if let Some(transaction) = self.redo_stack.pop() { - self.undo_stack.push(transaction); - self.undo_stack.last_mut() - } else { - None - } - } - - fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&Transaction> { - let ix = self - .undo_stack - .iter() - .rposition(|transaction| transaction.id == transaction_id)?; - let transaction = self.undo_stack.remove(ix); - self.redo_stack.push(transaction); - self.redo_stack.last() - } - - fn group(&mut self) -> Option { - let mut count = 0; - let mut transactions = self.undo_stack.iter(); - if let Some(mut transaction) = transactions.next_back() { - while let Some(prev_transaction) = transactions.next_back() { - if !prev_transaction.suppress_grouping - && transaction.first_edit_at - prev_transaction.last_edit_at - <= self.group_interval - { - transaction = prev_transaction; - count += 1; - } else { - break; - } - } - } - self.group_trailing(count) - } - - fn group_until(&mut self, transaction_id: TransactionId) { - let mut count = 0; - for transaction in self.undo_stack.iter().rev() { - if transaction.id == transaction_id { - self.group_trailing(count); - break; - } else if transaction.suppress_grouping { - break; - } else { - count += 1; - } - } - } - - fn group_trailing(&mut self, n: usize) -> Option { - let new_len = self.undo_stack.len() - n; - let (transactions_to_keep, transactions_to_merge) = self.undo_stack.split_at_mut(new_len); - if let Some(last_transaction) = transactions_to_keep.last_mut() { - if let Some(transaction) = transactions_to_merge.last() { - last_transaction.last_edit_at = transaction.last_edit_at; - } - for to_merge in transactions_to_merge { - for (buffer_id, transaction_id) in &to_merge.buffer_transactions { - last_transaction - .buffer_transactions - .entry(*buffer_id) - .or_insert(*transaction_id); - } - } - } - - self.undo_stack.truncate(new_len); - self.undo_stack.last().map(|t| t.id) - } -} - impl Excerpt { fn new( id: ExcerptId, @@ -6954,21 +6125,16 @@ impl Excerpt { let chunks_start = content_start + range.start; let chunks_end = content_start + cmp::min(range.end, self.text_summary.len); - let footer_height = if self.has_trailing_newline + let has_footer = self.has_trailing_newline && range.start <= self.text_summary.len - && range.end > self.text_summary.len - { - 1 - } else { - 0 - }; + && range.end > self.text_summary.len; let content_chunks = self.buffer.chunks(chunks_start..chunks_end, language_aware); ExcerptChunks { excerpt_id: self.id, content_chunks, - footer_height, + has_footer, } } @@ -6977,14 +6143,9 @@ impl Excerpt { let chunks_start = content_start + range.start; let chunks_end = content_start + cmp::min(range.end, self.text_summary.len); excerpt_chunks.content_chunks.seek(chunks_start..chunks_end); - excerpt_chunks.footer_height = if self.has_trailing_newline + excerpt_chunks.has_footer = self.has_trailing_newline && range.start <= self.text_summary.len - && range.end > self.text_summary.len - { - 1 - } else { - 0 - }; + && range.end > self.text_summary.len; } fn clip_anchor(&self, text_anchor: text::Anchor) -> text::Anchor { @@ -7004,7 +6165,7 @@ impl Excerpt { } fn contains(&self, anchor: &Anchor) -> bool { - Some(self.buffer_id) == anchor.buffer_id + (anchor.buffer_id == None || anchor.buffer_id == Some(self.buffer_id)) && self .range .context @@ -7040,21 +6201,19 @@ impl<'a> MultiBufferExcerpt<'a> { } pub fn start_anchor(&self) -> Anchor { - Anchor { - buffer_id: Some(self.excerpt.buffer_id), - excerpt_id: self.excerpt.id, - text_anchor: self.excerpt.range.context.start, - diff_base_anchor: None, - } + Anchor::in_buffer( + self.excerpt.id, + self.excerpt.buffer_id, + self.excerpt.range.context.start, + ) } pub fn end_anchor(&self) -> Anchor { - Anchor { - buffer_id: Some(self.excerpt.buffer_id), - excerpt_id: self.excerpt.id, - text_anchor: self.excerpt.range.context.end, - diff_base_anchor: None, - } + Anchor::in_buffer( + self.excerpt.id, + self.excerpt.buffer_id, + self.excerpt.range.context.end, + ) } pub fn buffer(&self) -> &'a BufferSnapshot { @@ -7161,10 +6320,10 @@ impl ExcerptId { } pub fn max() -> Self { - Self(usize::MAX) + Self(u32::MAX) } - pub fn to_proto(&self) -> u64 { + pub fn to_proto(self) -> u64 { self.0 as _ } @@ -7181,7 +6340,7 @@ impl ExcerptId { impl From for usize { fn from(val: ExcerptId) -> Self { - val.0 + val.0 as usize } } @@ -7201,7 +6360,7 @@ impl fmt::Debug for Excerpt { impl sum_tree::Item for Excerpt { type Summary = ExcerptSummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { let mut text = self.text_summary; if self.has_trailing_newline { text += TextSummary::from("\n"); @@ -7218,7 +6377,7 @@ impl sum_tree::Item for Excerpt { impl sum_tree::Item for ExcerptIdMapping { type Summary = ExcerptId; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { self.id } } @@ -7245,7 +6404,7 @@ impl DiffTransform { impl sum_tree::Item for DiffTransform { type Summary = DiffTransformSummary; - fn summary(&self, _: &::Context) -> Self::Summary { + fn summary(&self, _: ::Context<'_>) -> Self::Summary { match self { DiffTransform::BufferContent { summary, .. } => DiffTransformSummary { input: *summary, @@ -7265,83 +6424,77 @@ impl DiffTransformSummary { } } -impl sum_tree::Summary for DiffTransformSummary { - type Context = (); - - fn zero(_: &Self::Context) -> Self { +impl sum_tree::ContextLessSummary for DiffTransformSummary { + fn zero() -> Self { DiffTransformSummary { input: TextSummary::default(), output: TextSummary::default(), } } - fn add_summary(&mut self, summary: &Self, _: &Self::Context) { - self.input += &summary.input; - self.output += &summary.output; + fn add_summary(&mut self, other: &Self) { + self.input += other.input; + self.output += other.output; } } -impl sum_tree::Summary for ExcerptId { - type Context = (); - - fn zero(_cx: &()) -> Self { - Default::default() +impl sum_tree::ContextLessSummary for ExcerptId { + fn zero() -> Self { + Self(0) } - fn add_summary(&mut self, other: &Self, _: &()) { - *self = *other; + fn add_summary(&mut self, summary: &Self) { + *self = cmp::max(*self, *summary); } } -impl sum_tree::Summary for ExcerptSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { - Default::default() +impl sum_tree::ContextLessSummary for ExcerptSummary { + fn zero() -> Self { + Self::default() } - fn add_summary(&mut self, summary: &Self, _: &()) { + fn add_summary(&mut self, summary: &Self) { debug_assert!(summary.excerpt_locator > self.excerpt_locator); self.excerpt_locator = summary.excerpt_locator.clone(); - Summary::add_summary(&mut self.text, &summary.text, &()); + Summary::add_summary(&mut self.text, &summary.text, ()); self.widest_line_number = cmp::max(self.widest_line_number, summary.widest_line_number); } } impl<'a> sum_tree::Dimension<'a, ExcerptSummary> for ExcerptOffset { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ExcerptSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ExcerptSummary, _: ()) { self.value += summary.text.len; } } impl sum_tree::SeekTarget<'_, ExcerptSummary, ExcerptSummary> for ExcerptOffset { - fn cmp(&self, cursor_location: &ExcerptSummary, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &ExcerptSummary, _: ()) -> cmp::Ordering { Ord::cmp(&self.value, &cursor_location.text.len) } } impl<'a> sum_tree::SeekTarget<'a, ExcerptSummary, Option<&'a Locator>> for Locator { - fn cmp(&self, cursor_location: &Option<&'a Locator>, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &Option<&'a Locator>, _: ()) -> cmp::Ordering { Ord::cmp(&Some(self), cursor_location) } } impl sum_tree::SeekTarget<'_, ExcerptSummary, ExcerptSummary> for Locator { - fn cmp(&self, cursor_location: &ExcerptSummary, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &ExcerptSummary, _: ()) -> cmp::Ordering { Ord::cmp(self, &cursor_location.excerpt_locator) } } impl<'a> sum_tree::Dimension<'a, ExcerptSummary> for ExcerptPoint { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ExcerptSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ExcerptSummary, _: ()) { self.value += summary.text.lines; } } @@ -7349,31 +6502,31 @@ impl<'a> sum_tree::Dimension<'a, ExcerptSummary> for ExcerptPoint { impl<'a, D: TextDimension + Default> sum_tree::Dimension<'a, ExcerptSummary> for ExcerptDimension { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { ExcerptDimension(D::default()) } - fn add_summary(&mut self, summary: &'a ExcerptSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ExcerptSummary, _: ()) { self.0.add_assign(&D::from_text_summary(&summary.text)) } } impl<'a> sum_tree::Dimension<'a, ExcerptSummary> for Option<&'a Locator> { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ExcerptSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ExcerptSummary, _: ()) { *self = Some(&summary.excerpt_locator); } } impl<'a> sum_tree::Dimension<'a, ExcerptSummary> for Option { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a ExcerptSummary, _: &()) { + fn add_summary(&mut self, summary: &'a ExcerptSummary, _: ()) { *self = Some(summary.excerpt_id); } } @@ -7385,21 +6538,21 @@ struct ExcerptDimension(T); struct OutputDimension(T); impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for ExcerptOffset { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { ExcerptOffset::new(0) } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { self.value += summary.input.len; } } impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for ExcerptPoint { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { ExcerptPoint::new(0, 0) } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { self.value += summary.input.lines; } } @@ -7407,7 +6560,7 @@ impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for ExcerptPoint { impl sum_tree::SeekTarget<'_, DiffTransformSummary, DiffTransformSummary> for ExcerptDimension { - fn cmp(&self, cursor_location: &DiffTransformSummary, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &DiffTransformSummary, _: ()) -> cmp::Ordering { Ord::cmp(&self.0, &D::from_text_summary(&cursor_location.input)) } } @@ -7415,17 +6568,17 @@ impl sum_tree::SeekTarget<'_, DiffTransformSummary, Diff impl sum_tree::SeekTarget<'_, DiffTransformSummary, DiffTransforms> for ExcerptDimension { - fn cmp(&self, cursor_location: &DiffTransforms, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &DiffTransforms, _: ()) -> cmp::Ordering { Ord::cmp(&self.0, &cursor_location.excerpt_dimension.0) } } impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for ExcerptDimension { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { ExcerptDimension(D::default()) } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { self.0.add_assign(&D::from_text_summary(&summary.input)) } } @@ -7433,47 +6586,47 @@ impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for Exc impl sum_tree::SeekTarget<'_, DiffTransformSummary, DiffTransforms> for OutputDimension { - fn cmp(&self, cursor_location: &DiffTransforms, _: &()) -> cmp::Ordering { + fn cmp(&self, cursor_location: &DiffTransforms, _: ()) -> cmp::Ordering { Ord::cmp(&self.0, &cursor_location.output_dimension.0) } } impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for OutputDimension { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { OutputDimension(D::default()) } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { self.0.add_assign(&D::from_text_summary(&summary.output)) } } impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for TextSummary { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { TextSummary::default() } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { *self += summary.output } } impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for usize { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { 0 } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { *self += summary.output.len } } impl<'a> sum_tree::Dimension<'a, DiffTransformSummary> for Point { - fn zero(_: &()) -> Self { + fn zero(_: ()) -> Self { Point::new(0, 0) } - fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: &()) { + fn add_summary(&mut self, summary: &'a DiffTransformSummary, _: ()) { *self += summary.output.lines } } @@ -7505,61 +6658,59 @@ impl Iterator for MultiBufferRows<'_> { self.cursor.next(); if let Some(next_region) = self.cursor.region() { region = next_region; - } else { - if self.point == self.cursor.diff_transforms.end().output_dimension.0 { - let multibuffer_row = MultiBufferRow(self.point.row); - let last_excerpt = self - .cursor - .excerpts - .item() - .or(self.cursor.excerpts.prev_item())?; - let last_row = last_excerpt - .range - .context - .end - .to_point(&last_excerpt.buffer) - .row; + } else if self.point == self.cursor.diff_transforms.end().output_dimension.0 { + let multibuffer_row = MultiBufferRow(self.point.row); + let last_excerpt = self + .cursor + .excerpts + .item() + .or(self.cursor.excerpts.prev_item())?; + let last_row = last_excerpt + .range + .context + .end + .to_point(&last_excerpt.buffer) + .row; - let first_row = last_excerpt - .range - .context - .start - .to_point(&last_excerpt.buffer) - .row; + let first_row = last_excerpt + .range + .context + .start + .to_point(&last_excerpt.buffer) + .row; - let expand_info = if self.is_singleton { - None - } else { - let needs_expand_up = first_row == last_row - && last_row > 0 - && !region.diff_hunk_status.is_some_and(|d| d.is_deleted()); - let needs_expand_down = last_row < last_excerpt.buffer.max_point().row; - - if needs_expand_up && needs_expand_down { - Some(ExpandExcerptDirection::UpAndDown) - } else if needs_expand_up { - Some(ExpandExcerptDirection::Up) - } else if needs_expand_down { - Some(ExpandExcerptDirection::Down) - } else { - None - } - .map(|direction| ExpandInfo { - direction, - excerpt_id: last_excerpt.id, - }) - }; - self.point += Point::new(1, 0); - return Some(RowInfo { - buffer_id: Some(last_excerpt.buffer_id), - buffer_row: Some(last_row), - multibuffer_row: Some(multibuffer_row), - diff_status: None, - expand_info, - }); + let expand_info = if self.is_singleton { + None } else { - return None; - } + let needs_expand_up = first_row == last_row + && last_row > 0 + && !region.diff_hunk_status.is_some_and(|d| d.is_deleted()); + let needs_expand_down = last_row < last_excerpt.buffer.max_point().row; + + if needs_expand_up && needs_expand_down { + Some(ExpandExcerptDirection::UpAndDown) + } else if needs_expand_up { + Some(ExpandExcerptDirection::Up) + } else if needs_expand_down { + Some(ExpandExcerptDirection::Down) + } else { + None + } + .map(|direction| ExpandInfo { + direction, + excerpt_id: last_excerpt.id, + }) + }; + self.point += Point::new(1, 0); + return Some(RowInfo { + buffer_id: Some(last_excerpt.buffer_id), + buffer_row: Some(last_row), + multibuffer_row: Some(multibuffer_row), + diff_status: None, + expand_info, + }); + } else { + return None; }; } @@ -7731,12 +6882,21 @@ impl<'a> Iterator for MultiBufferChunks<'a> { let diff_transform_end = diff_transform_end.min(self.range.end); if diff_transform_end < chunk_end { - let (before, after) = - chunk.text.split_at(diff_transform_end - self.range.start); + let split_idx = diff_transform_end - self.range.start; + let (before, after) = chunk.text.split_at(split_idx); self.range.start = diff_transform_end; + let mask = 1u128.unbounded_shl(split_idx as u32).wrapping_sub(1); + let chars = chunk.chars & mask; + let tabs = chunk.tabs & mask; + chunk.text = after; + chunk.chars = chunk.chars >> split_idx; + chunk.tabs = chunk.tabs >> split_idx; + Some(Chunk { text: before, + chars, + tabs, ..chunk.clone() }) } else { @@ -7767,7 +6927,7 @@ impl<'a> Iterator for MultiBufferChunks<'a> { } chunks } else { - let base_buffer = &self.diffs.get(&buffer_id)?.base_text(); + let base_buffer = &self.diffs.get(buffer_id)?.base_text(); base_buffer.chunks(base_text_start..base_text_end, self.language_aware) }; @@ -7780,6 +6940,7 @@ impl<'a> Iterator for MultiBufferChunks<'a> { self.range.start += "\n".len(); Chunk { text: "\n", + chars: 1u128, ..Default::default() } }; @@ -7855,10 +7016,11 @@ impl io::Read for ReversedMultiBufferBytes<'_> { if len > 0 { self.range.end -= len; self.chunk = &self.chunk[..self.chunk.len() - len]; - if !self.range.is_empty() && self.chunk.is_empty() { - if let Some(chunk) = self.chunks.next() { - self.chunk = chunk.as_bytes(); - } + if !self.range.is_empty() + && self.chunk.is_empty() + && let Some(chunk) = self.chunks.next() + { + self.chunk = chunk.as_bytes(); } } Ok(len) @@ -7873,11 +7035,13 @@ impl<'a> Iterator for ExcerptChunks<'a> { return Some(chunk); } - if self.footer_height > 0 { - let text = unsafe { str::from_utf8_unchecked(&NEWLINES[..self.footer_height]) }; - self.footer_height = 0; + if self.has_footer { + let text = "\n"; + let chars = 0b1; + self.has_footer = false; return Some(Chunk { text, + chars, ..Default::default() }); } @@ -7890,6 +7054,9 @@ impl ToOffset for Point { fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { snapshot.point_to_offset(*self) } + fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16 { + snapshot.point_to_offset_utf16(*self) + } } impl ToOffset for usize { @@ -7903,29 +7070,27 @@ impl ToOffset for usize { ); *self } + fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16 { + snapshot.offset_to_offset_utf16(*self) + } } impl ToOffset for OffsetUtf16 { fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { snapshot.offset_utf16_to_offset(*self) } -} - -impl ToOffset for PointUtf16 { - fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { - snapshot.point_utf16_to_offset(*self) - } -} -impl ToOffsetUtf16 for OffsetUtf16 { fn to_offset_utf16(&self, _snapshot: &MultiBufferSnapshot) -> OffsetUtf16 { *self } } -impl ToOffsetUtf16 for usize { +impl ToOffset for PointUtf16 { + fn to_offset<'a>(&self, snapshot: &MultiBufferSnapshot) -> usize { + snapshot.point_utf16_to_offset(*self) + } fn to_offset_utf16(&self, snapshot: &MultiBufferSnapshot) -> OffsetUtf16 { - snapshot.offset_to_offset_utf16(*self) + snapshot.point_utf16_to_offset_utf16(*self) } } @@ -7933,27 +7098,24 @@ impl ToPoint for usize { fn to_point<'a>(&self, snapshot: &MultiBufferSnapshot) -> Point { snapshot.offset_to_point(*self) } + fn to_point_utf16<'a>(&self, snapshot: &MultiBufferSnapshot) -> PointUtf16 { + snapshot.offset_to_point_utf16(*self) + } } impl ToPoint for Point { fn to_point<'a>(&self, _: &MultiBufferSnapshot) -> Point { *self } -} - -impl ToPointUtf16 for usize { - fn to_point_utf16<'a>(&self, snapshot: &MultiBufferSnapshot) -> PointUtf16 { - snapshot.offset_to_point_utf16(*self) - } -} - -impl ToPointUtf16 for Point { fn to_point_utf16<'a>(&self, snapshot: &MultiBufferSnapshot) -> PointUtf16 { snapshot.point_to_point_utf16(*self) } } -impl ToPointUtf16 for PointUtf16 { +impl ToPoint for PointUtf16 { + fn to_point<'a>(&self, snapshot: &MultiBufferSnapshot) -> Point { + snapshot.point_utf16_to_point(*self) + } fn to_point_utf16<'a>(&self, _: &MultiBufferSnapshot) -> PointUtf16 { *self } @@ -7964,3 +7126,75 @@ impl From for EntityId { EntityId::from(id.0 as u64) } } + +#[cfg(debug_assertions)] +pub mod debug { + use super::*; + + pub trait ToMultiBufferDebugRanges { + fn to_multi_buffer_debug_ranges(&self, snapshot: &MultiBufferSnapshot) + -> Vec>; + } + + impl ToMultiBufferDebugRanges for T { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + [self.to_offset(snapshot)].to_multi_buffer_debug_ranges(snapshot) + } + } + + impl ToMultiBufferDebugRanges for Range { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + [self.start.to_offset(snapshot)..self.end.to_offset(snapshot)] + .to_multi_buffer_debug_ranges(snapshot) + } + } + + impl ToMultiBufferDebugRanges for Vec { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + self.as_slice().to_multi_buffer_debug_ranges(snapshot) + } + } + + impl ToMultiBufferDebugRanges for Vec> { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + self.as_slice().to_multi_buffer_debug_ranges(snapshot) + } + } + + impl ToMultiBufferDebugRanges for [T] { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + self.iter() + .map(|item| { + let offset = item.to_offset(snapshot); + offset..offset + }) + .collect() + } + } + + impl ToMultiBufferDebugRanges for [Range] { + fn to_multi_buffer_debug_ranges( + &self, + snapshot: &MultiBufferSnapshot, + ) -> Vec> { + self.iter() + .map(|range| range.start.to_offset(snapshot)..range.end.to_offset(snapshot)) + .collect() + } + } +} diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 824efa559f6d52bf654d8f6c6ff9655eaf4a0e52..a9121b9104400d88d5f22801db1bfebaeeb060d6 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -7,6 +7,9 @@ use parking_lot::RwLock; use rand::prelude::*; use settings::SettingsStore; use std::env; +use std::time::{Duration, Instant}; +use util::RandomCharIter; +use util::rel_path::rel_path; use util::test::sample_text; #[ctor::ctor] @@ -76,7 +79,9 @@ fn test_remote(cx: &mut App) { let ops = cx .background_executor() .block(host_buffer.read(cx).serialize_ops(None, cx)); - let mut buffer = Buffer::from_proto(1, Capability::ReadWrite, state, None).unwrap(); + let mut buffer = + Buffer::from_proto(ReplicaId::REMOTE_SERVER, Capability::ReadWrite, state, None) + .unwrap(); buffer.apply_ops( ops.into_iter() .map(|op| language::proto::deserialize_operation(op).unwrap()), @@ -155,15 +160,12 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { events.read().as_slice(), &[ Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }, Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, }, Event::Edited { - singleton_buffer_edited: false, edited_buffer: None, } ] @@ -473,7 +475,7 @@ fn test_editing_text_in_diff_hunks(cx: &mut TestAppContext) { let base_text = "one\ntwo\nfour\nfive\nsix\nseven\n"; let text = "one\ntwo\nTHREE\nfour\nfive\nseven\n"; let buffer = cx.new(|cx| Buffer::local(text, cx)); - let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx)); + let diff = cx.new(|cx| BufferDiff::new_with_base_text(base_text, &buffer, cx)); let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); let (mut snapshot, mut subscription) = multibuffer.update(cx, |multibuffer, cx| { @@ -798,7 +800,13 @@ async fn test_set_anchored_excerpts_for_path(cx: &mut TestAppContext) { let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); let anchor_ranges_1 = multibuffer .update(cx, |multibuffer, cx| { - multibuffer.set_anchored_excerpts_for_path(buffer_1.clone(), ranges_1, 2, cx) + multibuffer.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer_1, cx), + buffer_1.clone(), + ranges_1, + 2, + cx, + ) }) .await; let snapshot_1 = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx)); @@ -815,7 +823,13 @@ async fn test_set_anchored_excerpts_for_path(cx: &mut TestAppContext) { ); let anchor_ranges_2 = multibuffer .update(cx, |multibuffer, cx| { - multibuffer.set_anchored_excerpts_for_path(buffer_2.clone(), ranges_2, 2, cx) + multibuffer.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer_2, cx), + buffer_2.clone(), + ranges_2, + 2, + cx, + ) }) .await; let snapshot_2 = multibuffer.update(cx, |multibuffer, cx| multibuffer.snapshot(cx)); @@ -1523,7 +1537,7 @@ fn test_set_excerpts_for_buffer_ordering(cx: &mut TestAppContext) { cx, ) }); - let path1: PathKey = PathKey::namespaced(0, Path::new("/").into()); + let path1: PathKey = PathKey::with_sort_prefix(0, rel_path("root").into_arc()); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); multibuffer.update(cx, |multibuffer, cx| { @@ -1618,7 +1632,7 @@ fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) { cx, ) }); - let path1: PathKey = PathKey::namespaced(0, Path::new("/").into()); + let path1: PathKey = PathKey::with_sort_prefix(0, rel_path("root").into_arc()); let buf2 = cx.new(|cx| { Buffer::local( indoc! { @@ -1637,7 +1651,7 @@ fn test_set_excerpts_for_buffer(cx: &mut TestAppContext) { cx, ) }); - let path2 = PathKey::namespaced(1, Path::new("/").into()); + let path2 = PathKey::with_sort_prefix(1, rel_path("root").into_arc()); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); multibuffer.update(cx, |multibuffer, cx| { @@ -1814,7 +1828,7 @@ fn test_set_excerpts_for_buffer_rename(cx: &mut TestAppContext) { cx, ) }); - let path: PathKey = PathKey::namespaced(0, Path::new("/").into()); + let path: PathKey = PathKey::with_sort_prefix(0, rel_path("root").into_arc()); let buf2 = cx.new(|cx| { Buffer::local( indoc! { @@ -2250,11 +2264,11 @@ impl ReferenceMultibuffer { let base_buffer = diff.base_text(); let mut offset = buffer_range.start; - let mut hunks = diff + let hunks = diff .hunks_intersecting_range(excerpt.range.clone(), buffer, cx) .peekable(); - while let Some(hunk) = hunks.next() { + for hunk in hunks { // Ignore hunks that are outside the excerpt range. let mut hunk_range = hunk.buffer_range.to_offset(buffer); @@ -2265,14 +2279,14 @@ impl ReferenceMultibuffer { } if !excerpt.expanded_diff_hunks.iter().any(|expanded_anchor| { - expanded_anchor.to_offset(&buffer).max(buffer_range.start) + expanded_anchor.to_offset(buffer).max(buffer_range.start) == hunk_range.start.max(buffer_range.start) }) { log::trace!("skipping a hunk that's not marked as expanded"); continue; } - if !hunk.buffer_range.start.is_valid(&buffer) { + if !hunk.buffer_range.start.is_valid(buffer) { log::trace!("skipping hunk with deleted start: {:?}", hunk.range); continue; } @@ -2449,7 +2463,7 @@ impl ReferenceMultibuffer { return false; } while let Some(hunk) = hunks.peek() { - match hunk.buffer_range.start.cmp(&hunk_anchor, &buffer) { + match hunk.buffer_range.start.cmp(hunk_anchor, &buffer) { cmp::Ordering::Less => { hunks.next(); } @@ -2491,12 +2505,12 @@ async fn test_random_set_ranges(cx: &mut TestAppContext, mut rng: StdRng) { for _ in 0..operations { let snapshot = buf.update(cx, |buf, _| buf.snapshot()); - let num_ranges = rng.gen_range(0..=10); + let num_ranges = rng.random_range(0..=10); let max_row = snapshot.max_point().row; let mut ranges = (0..num_ranges) .map(|_| { - let start = rng.gen_range(0..max_row); - let end = rng.gen_range(start + 1..max_row + 1); + let start = rng.random_range(0..max_row); + let end = rng.random_range(start + 1..max_row + 1); Point::row_range(start..end) }) .collect::>(); @@ -2519,8 +2533,8 @@ async fn test_random_set_ranges(cx: &mut TestAppContext, mut rng: StdRng) { let mut seen_ranges = Vec::default(); for (_, buf, range) in snapshot.excerpts() { - let start = range.context.start.to_point(&buf); - let end = range.context.end.to_point(&buf); + let start = range.context.start.to_point(buf); + let end = range.context.end.to_point(buf); seen_ranges.push(start..end); if let Some(last_end) = last_end.take() { @@ -2562,11 +2576,11 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { let mut needs_diff_calculation = false; for _ in 0..operations { - match rng.gen_range(0..100) { + match rng.random_range(0..100) { 0..=14 if !buffers.is_empty() => { let buffer = buffers.choose(&mut rng).unwrap(); buffer.update(cx, |buf, cx| { - let edit_count = rng.gen_range(1..5); + let edit_count = rng.random_range(1..5); buf.randomly_edit(&mut rng, edit_count, cx); log::info!("buffer text:\n{}", buf.text()); needs_diff_calculation = true; @@ -2577,11 +2591,11 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { multibuffer.update(cx, |multibuffer, cx| { let ids = multibuffer.excerpt_ids(); let mut excerpts = HashSet::default(); - for _ in 0..rng.gen_range(0..ids.len()) { + for _ in 0..rng.random_range(0..ids.len()) { excerpts.extend(ids.choose(&mut rng).copied()); } - let line_count = rng.gen_range(0..5); + let line_count = rng.random_range(0..5); let excerpt_ixs = excerpts .iter() @@ -2600,7 +2614,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { } 20..=29 if !reference.excerpts.is_empty() => { let mut ids_to_remove = vec![]; - for _ in 0..rng.gen_range(1..=3) { + for _ in 0..rng.random_range(1..=3) { let Some(excerpt) = reference.excerpts.choose(&mut rng) else { break; }; @@ -2620,8 +2634,12 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { let multibuffer = multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx)); let offset = - multibuffer.clip_offset(rng.gen_range(0..=multibuffer.len()), Bias::Left); - let bias = if rng.r#gen() { Bias::Left } else { Bias::Right }; + multibuffer.clip_offset(rng.random_range(0..=multibuffer.len()), Bias::Left); + let bias = if rng.random() { + Bias::Left + } else { + Bias::Right + }; log::info!("Creating anchor at {} with bias {:?}", offset, bias); anchors.push(multibuffer.anchor_at(offset, bias)); anchors.sort_by(|a, b| a.cmp(b, &multibuffer)); @@ -2654,7 +2672,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { 45..=55 if !reference.excerpts.is_empty() => { multibuffer.update(cx, |multibuffer, cx| { let snapshot = multibuffer.snapshot(cx); - let excerpt_ix = rng.gen_range(0..reference.excerpts.len()); + let excerpt_ix = rng.random_range(0..reference.excerpts.len()); let excerpt = &reference.excerpts[excerpt_ix]; let start = excerpt.range.start; let end = excerpt.range.end; @@ -2691,7 +2709,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { }); } _ => { - let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) { + let buffer_handle = if buffers.is_empty() || rng.random_bool(0.4) { let mut base_text = util::RandomCharIter::new(&mut rng) .take(256) .collect::(); @@ -2708,7 +2726,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { buffers.choose(&mut rng).unwrap() }; - let prev_excerpt_ix = rng.gen_range(0..=reference.excerpts.len()); + let prev_excerpt_ix = rng.random_range(0..=reference.excerpts.len()); let prev_excerpt_id = reference .excerpts .get(prev_excerpt_ix) @@ -2716,8 +2734,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { let excerpt_ix = (prev_excerpt_ix + 1).min(reference.excerpts.len()); let (range, anchor_range) = buffer_handle.read_with(cx, |buffer, _| { - let end_row = rng.gen_range(0..=buffer.max_point().row); - let start_row = rng.gen_range(0..=end_row); + let end_row = rng.random_range(0..=buffer.max_point().row); + let start_row = rng.random_range(0..=end_row); let end_ix = buffer.point_to_offset(Point::new(end_row, 0)); let start_ix = buffer.point_to_offset(Point::new(start_row, 0)); let anchor_range = buffer.anchor_before(start_ix)..buffer.anchor_after(end_ix); @@ -2739,9 +2757,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { let id = buffer_handle.read(cx).remote_id(); if multibuffer.diff_for(id).is_none() { let base_text = base_texts.get(&id).unwrap(); - let diff = cx.new(|cx| { - BufferDiff::new_with_base_text(base_text, &buffer_handle, cx) - }); + let diff = cx + .new(|cx| BufferDiff::new_with_base_text(base_text, buffer_handle, cx)); reference.add_diff(diff.clone(), cx); multibuffer.add_diff(diff, cx) } @@ -2767,7 +2784,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { } } - if rng.gen_bool(0.3) { + if rng.random_bool(0.3) { multibuffer.update(cx, |multibuffer, cx| { old_versions.push((multibuffer.snapshot(cx), multibuffer.subscribe())); }) @@ -2816,7 +2833,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos); for _ in 0..5 { - let start_row = rng.gen_range(0..=expected_row_infos.len()); + let start_row = rng.random_range(0..=expected_row_infos.len()); assert_eq!( snapshot .row_infos(MultiBufferRow(start_row as u32)) @@ -2873,8 +2890,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { let text_rope = Rope::from(expected_text.as_str()); for _ in 0..10 { - let end_ix = text_rope.clip_offset(rng.gen_range(0..=text_rope.len()), Bias::Right); - let start_ix = text_rope.clip_offset(rng.gen_range(0..=end_ix), Bias::Left); + let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right); + let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left); let text_for_range = snapshot .text_for_range(start_ix..end_ix) @@ -2909,7 +2926,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { } for _ in 0..10 { - let end_ix = text_rope.clip_offset(rng.gen_range(0..=text_rope.len()), Bias::Right); + let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right); assert_eq!( snapshot.reversed_chars_at(end_ix).collect::(), expected_text[..end_ix].chars().rev().collect::(), @@ -2917,8 +2934,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) { } for _ in 0..10 { - let end_ix = rng.gen_range(0..=text_rope.len()); - let start_ix = rng.gen_range(0..=end_ix); + let end_ix = rng.random_range(0..=text_rope.len()); + let start_ix = rng.random_range(0..=end_ix); assert_eq!( snapshot .bytes_in_range(start_ix..end_ix) @@ -2968,7 +2985,7 @@ fn test_history(cx: &mut App) { }); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); multibuffer.update(cx, |this, _| { - this.history.group_interval = group_interval; + this.set_group_interval(group_interval); }); multibuffer.update(cx, |multibuffer, cx| { multibuffer.push_excerpts( @@ -3593,24 +3610,20 @@ fn assert_position_translation(snapshot: &MultiBufferSnapshot) { for (anchors, bias) in [(&left_anchors, Bias::Left), (&right_anchors, Bias::Right)] { for (ix, (offset, anchor)) in offsets.iter().zip(anchors).enumerate() { - if ix > 0 { - if *offset == 252 { - if offset > &offsets[ix - 1] { - let prev_anchor = left_anchors[ix - 1]; - assert!( - anchor.cmp(&prev_anchor, snapshot).is_gt(), - "anchor({}, {bias:?}).cmp(&anchor({}, {bias:?}).is_gt()", - offsets[ix], - offsets[ix - 1], - ); - assert!( - prev_anchor.cmp(&anchor, snapshot).is_lt(), - "anchor({}, {bias:?}).cmp(&anchor({}, {bias:?}).is_lt()", - offsets[ix - 1], - offsets[ix], - ); - } - } + if ix > 0 && *offset == 252 && offset > &offsets[ix - 1] { + let prev_anchor = left_anchors[ix - 1]; + assert!( + anchor.cmp(&prev_anchor, snapshot).is_gt(), + "anchor({}, {bias:?}).cmp(&anchor({}, {bias:?}).is_gt()", + offsets[ix], + offsets[ix - 1], + ); + assert!( + prev_anchor.cmp(anchor, snapshot).is_lt(), + "anchor({}, {bias:?}).cmp(&anchor({}, {bias:?}).is_lt()", + offsets[ix - 1], + offsets[ix], + ); } } } @@ -3626,7 +3639,7 @@ fn assert_position_translation(snapshot: &MultiBufferSnapshot) { fn assert_line_indents(snapshot: &MultiBufferSnapshot) { let max_row = snapshot.max_point().row; let buffer_id = snapshot.excerpts().next().unwrap().1.remote_id(); - let text = text::Buffer::new(0, buffer_id, snapshot.text()); + let text = text::Buffer::new(ReplicaId::LOCAL, buffer_id, snapshot.text()); let mut line_indents = text .line_indents_in_row_range(0..max_row + 1) .collect::>(); @@ -3717,3 +3730,235 @@ fn test_new_empty_buffers_title_can_be_set(cx: &mut App) { }); assert_eq!(multibuffer.read(cx).title(cx), "Hey"); } + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { + let multibuffer = if rng.random() { + let len = rng.random_range(0..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + // Verify chars bitmap + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} + +#[gpui::test(iterations = 100)] +fn test_random_chunk_bitmaps_with_diffs(cx: &mut App, mut rng: StdRng) { + use buffer_diff::BufferDiff; + use util::RandomCharIter; + + let multibuffer = if rng.random() { + let len = rng.random_range(100..10000); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + let buffer = cx.new(|cx| Buffer::local(text, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + } else { + MultiBuffer::build_random(&mut rng, cx) + }; + + let _diff_count = rng.random_range(1..5); + let mut diffs = Vec::new(); + + multibuffer.update(cx, |multibuffer, cx| { + for buffer_id in multibuffer.excerpt_buffer_ids() { + if rng.random_bool(0.7) { + if let Some(buffer_handle) = multibuffer.buffer(buffer_id) { + let buffer_text = buffer_handle.read(cx).text(); + let mut base_text = String::new(); + + for line in buffer_text.lines() { + if rng.random_bool(0.3) { + continue; + } else if rng.random_bool(0.3) { + let line_len = rng.random_range(0..50); + let modified_line = RandomCharIter::new(&mut rng) + .take(line_len) + .collect::(); + base_text.push_str(&modified_line); + base_text.push('\n'); + } else { + base_text.push_str(line); + base_text.push('\n'); + } + } + + if rng.random_bool(0.5) { + let extra_lines = rng.random_range(1..5); + for _ in 0..extra_lines { + let line_len = rng.random_range(0..50); + let extra_line = RandomCharIter::new(&mut rng) + .take(line_len) + .collect::(); + base_text.push_str(&extra_line); + base_text.push('\n'); + } + } + + let diff = + cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer_handle, cx)); + diffs.push(diff.clone()); + multibuffer.add_diff(diff, cx); + } + } + } + }); + + multibuffer.update(cx, |multibuffer, cx| { + if rng.random_bool(0.5) { + multibuffer.set_all_diff_hunks_expanded(cx); + } else { + let snapshot = multibuffer.snapshot(cx); + let text = snapshot.text(); + + let mut ranges = Vec::new(); + for _ in 0..rng.random_range(1..5) { + if snapshot.len() == 0 { + break; + } + + let diff_size = rng.random_range(5..1000); + let mut start = rng.random_range(0..snapshot.len()); + + while !text.is_char_boundary(start) { + start = start.saturating_sub(1); + } + + let mut end = rng.random_range(start..snapshot.len().min(start + diff_size)); + + while !text.is_char_boundary(end) { + end = end.saturating_add(1); + } + let start_anchor = snapshot.anchor_after(start); + let end_anchor = snapshot.anchor_before(end); + ranges.push(start_anchor..end_anchor); + } + multibuffer.expand_diff_hunks(ranges, cx); + } + }); + + let snapshot = multibuffer.read(cx).snapshot(cx); + + let chunks = snapshot.chunks(0..snapshot.len(), false); + + for chunk in chunks { + let chunk_text = chunk.text; + let chars_bitmap = chunk.chars; + let tabs_bitmap = chunk.tabs; + + if chunk_text.is_empty() { + assert_eq!( + chars_bitmap, 0, + "Empty chunk should have empty chars bitmap" + ); + assert_eq!(tabs_bitmap, 0, "Empty chunk should have empty tabs bitmap"); + continue; + } + + assert!( + chunk_text.len() <= 128, + "Chunk text length {} exceeds 128 bytes", + chunk_text.len() + ); + + let char_indices = chunk_text + .char_indices() + .map(|(i, _)| i) + .collect::>(); + + for byte_idx in 0..chunk_text.len() { + let should_have_bit = char_indices.contains(&byte_idx); + let has_bit = chars_bitmap & (1 << byte_idx) != 0; + + if has_bit != should_have_bit { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Char indices: {:?}", char_indices); + eprintln!("Chars bitmap: {:#b}", chars_bitmap); + } + + assert_eq!( + has_bit, should_have_bit, + "Chars bitmap mismatch at byte index {} in chunk {:?}. Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, should_have_bit, has_bit + ); + } + + for (byte_idx, byte) in chunk_text.bytes().enumerate() { + let is_tab = byte == b'\t'; + let has_bit = tabs_bitmap & (1 << byte_idx) != 0; + + if has_bit != is_tab { + eprintln!("Chunk text bytes: {:?}", chunk_text.as_bytes()); + eprintln!("Tabs bitmap: {:#b}", tabs_bitmap); + assert_eq!( + has_bit, is_tab, + "Tabs bitmap mismatch at byte index {} in chunk {:?}. Byte: {:?}, Expected bit: {}, Got bit: {}", + byte_idx, chunk_text, byte as char, is_tab, has_bit + ); + } + } + } +} diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs new file mode 100644 index 0000000000000000000000000000000000000000..568d1ac8671fc3e10fb7656dfdffa7211accd1cd --- /dev/null +++ b/crates/multi_buffer/src/path_key.rs @@ -0,0 +1,419 @@ +use std::{mem, ops::Range, sync::Arc}; + +use collections::HashSet; +use gpui::{App, AppContext, Context, Entity}; +use itertools::Itertools; +use language::{Buffer, BufferSnapshot}; +use rope::Point; +use text::{Bias, OffsetRangeExt, locator::Locator}; +use util::{post_inc, rel_path::RelPath}; + +use crate::{ + Anchor, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, build_excerpt_ranges, +}; + +#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Hash, Debug)] +pub struct PathKey { + // Used by the derived PartialOrd & Ord + pub sort_prefix: Option, + pub path: Arc, +} + +impl PathKey { + pub fn with_sort_prefix(sort_prefix: u64, path: Arc) -> Self { + Self { + sort_prefix: Some(sort_prefix), + path, + } + } + + pub fn for_buffer(buffer: &Entity, cx: &App) -> Self { + if let Some(file) = buffer.read(cx).file() { + Self::with_sort_prefix(file.worktree_id(cx).to_proto(), file.path().clone()) + } else { + Self { + sort_prefix: None, + path: RelPath::unix(&buffer.entity_id().to_string()) + .unwrap() + .into_arc(), + } + } + } +} + +impl MultiBuffer { + pub fn paths(&self) -> impl Iterator + '_ { + self.excerpts_by_path.keys().cloned() + } + + pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { + if let Some(to_remove) = self.excerpts_by_path.remove(&path) { + self.remove_excerpts(to_remove, cx) + } + } + + pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { + let excerpt_id = self.excerpts_by_path.get(path)?.first()?; + let snapshot = self.read(cx); + let excerpt = snapshot.excerpt(*excerpt_id)?; + Some(Anchor::in_buffer( + *excerpt_id, + excerpt.buffer_id, + excerpt.range.context.start, + )) + } + + pub fn excerpt_paths(&self) -> impl Iterator { + self.excerpts_by_path.keys() + } + + /// Sets excerpts, returns `true` if at least one new excerpt was added. + pub fn set_excerpts_for_path( + &mut self, + path: PathKey, + buffer: Entity, + ranges: impl IntoIterator>, + context_line_count: u32, + cx: &mut Context, + ) -> (Vec>, bool) { + let buffer_snapshot = buffer.read(cx).snapshot(); + let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot); + + let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); + self.set_merged_excerpt_ranges_for_path( + path, + buffer, + excerpt_ranges, + &buffer_snapshot, + new, + counts, + cx, + ) + } + + pub fn set_excerpt_ranges_for_path( + &mut self, + path: PathKey, + buffer: Entity, + buffer_snapshot: &BufferSnapshot, + excerpt_ranges: Vec>, + cx: &mut Context, + ) -> (Vec>, bool) { + let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); + self.set_merged_excerpt_ranges_for_path( + path, + buffer, + excerpt_ranges, + buffer_snapshot, + new, + counts, + cx, + ) + } + + pub fn set_anchored_excerpts_for_path( + &self, + path_key: PathKey, + buffer: Entity, + ranges: Vec>, + context_line_count: u32, + cx: &Context, + ) -> impl Future>> + use<> { + let buffer_snapshot = buffer.read(cx).snapshot(); + let multi_buffer = cx.weak_entity(); + let mut app = cx.to_async(); + async move { + let snapshot = buffer_snapshot.clone(); + let (excerpt_ranges, new, counts) = app + .background_spawn(async move { + let ranges = ranges.into_iter().map(|range| range.to_point(&snapshot)); + let excerpt_ranges = + build_excerpt_ranges(ranges, context_line_count, &snapshot); + let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges); + (excerpt_ranges, new, counts) + }) + .await; + + multi_buffer + .update(&mut app, move |multi_buffer, cx| { + let (ranges, _) = multi_buffer.set_merged_excerpt_ranges_for_path( + path_key, + buffer, + excerpt_ranges, + &buffer_snapshot, + new, + counts, + cx, + ); + ranges + }) + .ok() + .unwrap_or_default() + } + } + + pub(super) fn expand_excerpts_with_paths( + &mut self, + ids: impl IntoIterator, + line_count: u32, + direction: ExpandExcerptDirection, + cx: &mut Context, + ) { + let grouped = ids + .into_iter() + .chunk_by(|id| self.paths_by_excerpt.get(id).cloned()) + .into_iter() + .flat_map(|(k, v)| Some((k?, v.into_iter().collect::>()))) + .collect::>(); + let snapshot = self.snapshot(cx); + + for (path, ids) in grouped.into_iter() { + let Some(excerpt_ids) = self.excerpts_by_path.get(&path) else { + continue; + }; + + let ids_to_expand = HashSet::from_iter(ids); + let expanded_ranges = excerpt_ids.iter().filter_map(|excerpt_id| { + let excerpt = snapshot.excerpt(*excerpt_id)?; + + let mut context = excerpt.range.context.to_point(&excerpt.buffer); + if ids_to_expand.contains(excerpt_id) { + match direction { + ExpandExcerptDirection::Up => { + context.start.row = context.start.row.saturating_sub(line_count); + context.start.column = 0; + } + ExpandExcerptDirection::Down => { + context.end.row = + (context.end.row + line_count).min(excerpt.buffer.max_point().row); + context.end.column = excerpt.buffer.line_len(context.end.row); + } + ExpandExcerptDirection::UpAndDown => { + context.start.row = context.start.row.saturating_sub(line_count); + context.start.column = 0; + context.end.row = + (context.end.row + line_count).min(excerpt.buffer.max_point().row); + context.end.column = excerpt.buffer.line_len(context.end.row); + } + } + } + + Some(ExcerptRange { + context, + primary: excerpt.range.primary.to_point(&excerpt.buffer), + }) + }); + let mut merged_ranges: Vec> = Vec::new(); + for range in expanded_ranges { + if let Some(last_range) = merged_ranges.last_mut() + && last_range.context.end >= range.context.start + { + last_range.context.end = range.context.end; + continue; + } + merged_ranges.push(range) + } + let Some(excerpt_id) = excerpt_ids.first() else { + continue; + }; + let Some(buffer_id) = &snapshot.buffer_id_for_excerpt(*excerpt_id) else { + continue; + }; + + let Some(buffer) = self.buffers.get(buffer_id).map(|b| b.buffer.clone()) else { + continue; + }; + + let buffer_snapshot = buffer.read(cx).snapshot(); + self.update_path_excerpts(path.clone(), buffer, &buffer_snapshot, merged_ranges, cx); + } + } + + /// Sets excerpts, returns `true` if at least one new excerpt was added. + fn set_merged_excerpt_ranges_for_path( + &mut self, + path: PathKey, + buffer: Entity, + ranges: Vec>, + buffer_snapshot: &BufferSnapshot, + new: Vec>, + counts: Vec, + cx: &mut Context, + ) -> (Vec>, bool) { + let (excerpt_ids, added_a_new_excerpt) = + self.update_path_excerpts(path, buffer, buffer_snapshot, new, cx); + + let mut result = Vec::new(); + let mut ranges = ranges.into_iter(); + for (excerpt_id, range_count) in excerpt_ids.into_iter().zip(counts.into_iter()) { + for range in ranges.by_ref().take(range_count) { + let range = Anchor::range_in_buffer( + excerpt_id, + buffer_snapshot.remote_id(), + buffer_snapshot.anchor_before(&range.primary.start) + ..buffer_snapshot.anchor_after(&range.primary.end), + ); + result.push(range) + } + } + (result, added_a_new_excerpt) + } + + fn update_path_excerpts( + &mut self, + path: PathKey, + buffer: Entity, + buffer_snapshot: &BufferSnapshot, + new: Vec>, + cx: &mut Context, + ) -> (Vec, bool) { + let mut insert_after = self + .excerpts_by_path + .range(..path.clone()) + .next_back() + .map(|(_, value)| *value.last().unwrap()) + .unwrap_or(ExcerptId::min()); + + let existing = self + .excerpts_by_path + .get(&path) + .cloned() + .unwrap_or_default(); + + let mut new_iter = new.into_iter().peekable(); + let mut existing_iter = existing.into_iter().peekable(); + + let mut excerpt_ids = Vec::new(); + let mut to_remove = Vec::new(); + let mut to_insert: Vec<(ExcerptId, ExcerptRange)> = Vec::new(); + let mut added_a_new_excerpt = false; + let snapshot = self.snapshot(cx); + + let mut next_excerpt_id = + if let Some(last_entry) = self.snapshot.borrow().excerpt_ids.last() { + last_entry.id.0 + 1 + } else { + 1 + }; + + let mut next_excerpt_id = move || ExcerptId(post_inc(&mut next_excerpt_id)); + + let mut excerpts_cursor = snapshot.excerpts.cursor::>(()); + excerpts_cursor.next(); + + loop { + let new = new_iter.peek(); + let existing = if let Some(existing_id) = existing_iter.peek() { + let locator = snapshot.excerpt_locator_for_id(*existing_id); + excerpts_cursor.seek_forward(&Some(locator), Bias::Left); + if let Some(excerpt) = excerpts_cursor.item() { + if excerpt.buffer_id != buffer_snapshot.remote_id() { + to_remove.push(*existing_id); + existing_iter.next(); + continue; + } + Some(( + *existing_id, + excerpt.range.context.to_point(buffer_snapshot), + )) + } else { + None + } + } else { + None + }; + + if let Some((last_id, last)) = to_insert.last_mut() { + if let Some(new) = new + && last.context.end >= new.context.start + { + last.context.end = last.context.end.max(new.context.end); + excerpt_ids.push(*last_id); + new_iter.next(); + continue; + } + if let Some((existing_id, existing_range)) = &existing + && last.context.end >= existing_range.start + { + last.context.end = last.context.end.max(existing_range.end); + to_remove.push(*existing_id); + self.snapshot + .get_mut() + .replaced_excerpts + .insert(*existing_id, *last_id); + existing_iter.next(); + continue; + } + } + + match (new, existing) { + (None, None) => break, + (None, Some((existing_id, _))) => { + existing_iter.next(); + to_remove.push(existing_id); + continue; + } + (Some(_), None) => { + added_a_new_excerpt = true; + let new_id = next_excerpt_id(); + excerpt_ids.push(new_id); + to_insert.push((new_id, new_iter.next().unwrap())); + continue; + } + (Some(new), Some((_, existing_range))) => { + if existing_range.end < new.context.start { + let existing_id = existing_iter.next().unwrap(); + to_remove.push(existing_id); + continue; + } else if existing_range.start > new.context.end { + let new_id = next_excerpt_id(); + excerpt_ids.push(new_id); + to_insert.push((new_id, new_iter.next().unwrap())); + continue; + } + + if existing_range.start == new.context.start + && existing_range.end == new.context.end + { + self.insert_excerpts_with_ids_after( + insert_after, + buffer.clone(), + mem::take(&mut to_insert), + cx, + ); + insert_after = existing_iter.next().unwrap(); + excerpt_ids.push(insert_after); + new_iter.next(); + } else { + let existing_id = existing_iter.next().unwrap(); + let new_id = next_excerpt_id(); + self.snapshot + .get_mut() + .replaced_excerpts + .insert(existing_id, new_id); + to_remove.push(existing_id); + let mut range = new_iter.next().unwrap(); + range.context.start = range.context.start.min(existing_range.start); + range.context.end = range.context.end.max(existing_range.end); + excerpt_ids.push(new_id); + to_insert.push((new_id, range)); + } + } + }; + } + + self.insert_excerpts_with_ids_after(insert_after, buffer, to_insert, cx); + self.remove_excerpts(to_remove, cx); + if excerpt_ids.is_empty() { + self.excerpts_by_path.remove(&path); + } else { + for excerpt_id in &excerpt_ids { + self.paths_by_excerpt.insert(*excerpt_id, path.clone()); + } + self.excerpts_by_path + .insert(path, excerpt_ids.iter().dedup().cloned().collect()); + } + + (excerpt_ids, added_a_new_excerpt) + } +} diff --git a/crates/multi_buffer/src/position.rs b/crates/multi_buffer/src/position.rs index 06508750597b97d7275b964114bcdad0d0e34c79..8a3ce78d0d9f7a6880dbc3202c002507c800b7b0 100644 --- a/crates/multi_buffer/src/position.rs +++ b/crates/multi_buffer/src/position.rs @@ -126,17 +126,17 @@ impl Default for TypedRow { impl PartialOrd for TypedOffset { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(&other)) + Some(self.cmp(other)) } } impl PartialOrd for TypedPoint { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(&other)) + Some(self.cmp(other)) } } impl PartialOrd for TypedRow { fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(&other)) + Some(self.cmp(other)) } } diff --git a/crates/multi_buffer/src/transaction.rs b/crates/multi_buffer/src/transaction.rs new file mode 100644 index 0000000000000000000000000000000000000000..062d25d8233777190113aaa3e6a7f62396cfd08f --- /dev/null +++ b/crates/multi_buffer/src/transaction.rs @@ -0,0 +1,524 @@ +use gpui::{App, Context, Entity}; +use language::{self, Buffer, TextDimension, TransactionId}; +use std::{ + collections::HashMap, + ops::{Range, Sub}, + time::{Duration, Instant}, +}; +use sum_tree::Bias; +use text::BufferId; + +use crate::BufferState; + +use super::{Event, ExcerptSummary, MultiBuffer}; + +#[derive(Clone)] +pub(super) struct History { + next_transaction_id: TransactionId, + undo_stack: Vec, + redo_stack: Vec, + transaction_depth: usize, + group_interval: Duration, +} + +impl Default for History { + fn default() -> Self { + History { + next_transaction_id: clock::Lamport::MIN, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + transaction_depth: 0, + group_interval: Duration::from_millis(300), + } + } +} + +#[derive(Clone)] +struct Transaction { + id: TransactionId, + buffer_transactions: HashMap, + first_edit_at: Instant, + last_edit_at: Instant, + suppress_grouping: bool, +} + +impl History { + fn start_transaction(&mut self, now: Instant) -> Option { + self.transaction_depth += 1; + if self.transaction_depth == 1 { + let id = self.next_transaction_id.tick(); + self.undo_stack.push(Transaction { + id, + buffer_transactions: Default::default(), + first_edit_at: now, + last_edit_at: now, + suppress_grouping: false, + }); + Some(id) + } else { + None + } + } + + fn end_transaction( + &mut self, + now: Instant, + buffer_transactions: HashMap, + ) -> bool { + assert_ne!(self.transaction_depth, 0); + self.transaction_depth -= 1; + if self.transaction_depth == 0 { + if buffer_transactions.is_empty() { + self.undo_stack.pop(); + false + } else { + self.redo_stack.clear(); + let transaction = self.undo_stack.last_mut().unwrap(); + transaction.last_edit_at = now; + for (buffer_id, transaction_id) in buffer_transactions { + transaction + .buffer_transactions + .entry(buffer_id) + .or_insert(transaction_id); + } + true + } + } else { + false + } + } + + fn push_transaction<'a, T>( + &mut self, + buffer_transactions: T, + now: Instant, + cx: &Context, + ) where + T: IntoIterator, &'a language::Transaction)>, + { + assert_eq!(self.transaction_depth, 0); + let transaction = Transaction { + id: self.next_transaction_id.tick(), + buffer_transactions: buffer_transactions + .into_iter() + .map(|(buffer, transaction)| (buffer.read(cx).remote_id(), transaction.id)) + .collect(), + first_edit_at: now, + last_edit_at: now, + suppress_grouping: false, + }; + if !transaction.buffer_transactions.is_empty() { + self.undo_stack.push(transaction); + self.redo_stack.clear(); + } + } + + fn finalize_last_transaction(&mut self) { + if let Some(transaction) = self.undo_stack.last_mut() { + transaction.suppress_grouping = true; + } + } + + fn forget(&mut self, transaction_id: TransactionId) -> Option { + if let Some(ix) = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.undo_stack.remove(ix)) + } else if let Some(ix) = self + .redo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.redo_stack.remove(ix)) + } else { + None + } + } + + fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> { + self.undo_stack + .iter() + .find(|transaction| transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter() + .find(|transaction| transaction.id == transaction_id) + }) + } + + fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { + self.undo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + }) + } + + fn pop_undo(&mut self) -> Option<&mut Transaction> { + assert_eq!(self.transaction_depth, 0); + if let Some(transaction) = self.undo_stack.pop() { + self.redo_stack.push(transaction); + self.redo_stack.last_mut() + } else { + None + } + } + + fn pop_redo(&mut self) -> Option<&mut Transaction> { + assert_eq!(self.transaction_depth, 0); + if let Some(transaction) = self.redo_stack.pop() { + self.undo_stack.push(transaction); + self.undo_stack.last_mut() + } else { + None + } + } + + fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&Transaction> { + let ix = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id)?; + let transaction = self.undo_stack.remove(ix); + self.redo_stack.push(transaction); + self.redo_stack.last() + } + + fn group(&mut self) -> Option { + let mut count = 0; + let mut transactions = self.undo_stack.iter(); + if let Some(mut transaction) = transactions.next_back() { + while let Some(prev_transaction) = transactions.next_back() { + if !prev_transaction.suppress_grouping + && transaction.first_edit_at - prev_transaction.last_edit_at + <= self.group_interval + { + transaction = prev_transaction; + count += 1; + } else { + break; + } + } + } + self.group_trailing(count) + } + + fn group_until(&mut self, transaction_id: TransactionId) { + let mut count = 0; + for transaction in self.undo_stack.iter().rev() { + if transaction.id == transaction_id { + self.group_trailing(count); + break; + } else if transaction.suppress_grouping { + break; + } else { + count += 1; + } + } + } + + fn group_trailing(&mut self, n: usize) -> Option { + let new_len = self.undo_stack.len() - n; + let (transactions_to_keep, transactions_to_merge) = self.undo_stack.split_at_mut(new_len); + if let Some(last_transaction) = transactions_to_keep.last_mut() { + if let Some(transaction) = transactions_to_merge.last() { + last_transaction.last_edit_at = transaction.last_edit_at; + } + for to_merge in transactions_to_merge { + for (buffer_id, transaction_id) in &to_merge.buffer_transactions { + last_transaction + .buffer_transactions + .entry(*buffer_id) + .or_insert(*transaction_id); + } + } + } + + self.undo_stack.truncate(new_len); + self.undo_stack.last().map(|t| t.id) + } + + pub(super) fn transaction_depth(&self) -> usize { + self.transaction_depth + } + + pub fn set_group_interval(&mut self, group_interval: Duration) { + self.group_interval = group_interval; + } +} + +impl MultiBuffer { + pub fn start_transaction(&mut self, cx: &mut Context) -> Option { + self.start_transaction_at(Instant::now(), cx) + } + + pub fn start_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); + } + + for BufferState { buffer, .. } in self.buffers.values() { + buffer.update(cx, |buffer, _| buffer.start_transaction_at(now)); + } + self.history.start_transaction(now) + } + + pub fn last_transaction_id(&self, cx: &App) -> Option { + if let Some(buffer) = self.as_singleton() { + buffer + .read(cx) + .peek_undo_stack() + .map(|history_entry| history_entry.transaction_id()) + } else { + let last_transaction = self.history.undo_stack.last()?; + Some(last_transaction.id) + } + } + + pub fn end_transaction(&mut self, cx: &mut Context) -> Option { + self.end_transaction_at(Instant::now(), cx) + } + + pub fn end_transaction_at( + &mut self, + now: Instant, + cx: &mut Context, + ) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)); + } + + let mut buffer_transactions = HashMap::default(); + for BufferState { buffer, .. } in self.buffers.values() { + if let Some(transaction_id) = + buffer.update(cx, |buffer, cx| buffer.end_transaction_at(now, cx)) + { + buffer_transactions.insert(buffer.read(cx).remote_id(), transaction_id); + } + } + + if self.history.end_transaction(now, buffer_transactions) { + let transaction_id = self.history.group().unwrap(); + Some(transaction_id) + } else { + None + } + } + + pub fn edited_ranges_for_transaction( + &self, + transaction_id: TransactionId, + cx: &App, + ) -> Vec> + where + D: TextDimension + Ord + Sub, + { + let Some(transaction) = self.history.transaction(transaction_id) else { + return Vec::new(); + }; + + let mut ranges = Vec::new(); + let snapshot = self.read(cx); + let mut cursor = snapshot.excerpts.cursor::(()); + + for (buffer_id, buffer_transaction) in &transaction.buffer_transactions { + let Some(buffer_state) = self.buffers.get(buffer_id) else { + continue; + }; + + let buffer = buffer_state.buffer.read(cx); + for range in buffer.edited_ranges_for_transaction_id::(*buffer_transaction) { + for excerpt_id in &buffer_state.excerpts { + cursor.seek(excerpt_id, Bias::Left); + if let Some(excerpt) = cursor.item() + && excerpt.locator == *excerpt_id + { + let excerpt_buffer_start = excerpt.range.context.start.summary::(buffer); + let excerpt_buffer_end = excerpt.range.context.end.summary::(buffer); + let excerpt_range = excerpt_buffer_start..excerpt_buffer_end; + if excerpt_range.contains(&range.start) + && excerpt_range.contains(&range.end) + { + let excerpt_start = D::from_text_summary(&cursor.start().text); + + let mut start = excerpt_start; + start.add_assign(&(range.start - excerpt_buffer_start)); + let mut end = excerpt_start; + end.add_assign(&(range.end - excerpt_buffer_start)); + + ranges.push(start..end); + break; + } + } + } + } + } + + ranges.sort_by_key(|range| range.start); + ranges + } + + pub fn merge_transactions( + &mut self, + transaction: TransactionId, + destination: TransactionId, + cx: &mut Context, + ) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.merge_transactions(transaction, destination) + }); + } else if let Some(transaction) = self.history.forget(transaction) + && let Some(destination) = self.history.transaction_mut(destination) + { + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { + if let Some(destination_buffer_transaction_id) = + destination.buffer_transactions.get(&buffer_id) + { + if let Some(state) = self.buffers.get(&buffer_id) { + state.buffer.update(cx, |buffer, _| { + buffer.merge_transactions( + buffer_transaction_id, + *destination_buffer_transaction_id, + ) + }); + } + } else { + destination + .buffer_transactions + .insert(buffer_id, buffer_transaction_id); + } + } + } + } + + pub fn finalize_last_transaction(&mut self, cx: &mut Context) { + self.history.finalize_last_transaction(); + for BufferState { buffer, .. } in self.buffers.values() { + buffer.update(cx, |buffer, _| { + buffer.finalize_last_transaction(); + }); + } + } + + pub fn push_transaction<'a, T>(&mut self, buffer_transactions: T, cx: &Context) + where + T: IntoIterator, &'a language::Transaction)>, + { + self.history + .push_transaction(buffer_transactions, Instant::now(), cx); + self.history.finalize_last_transaction(); + } + + pub fn group_until_transaction( + &mut self, + transaction_id: TransactionId, + cx: &mut Context, + ) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.group_until_transaction(transaction_id) + }); + } else { + self.history.group_until(transaction_id); + } + } + pub fn undo(&mut self, cx: &mut Context) -> Option { + let mut transaction_id = None; + if let Some(buffer) = self.as_singleton() { + transaction_id = buffer.update(cx, |buffer, cx| buffer.undo(cx)); + } else { + while let Some(transaction) = self.history.pop_undo() { + let mut undone = false; + for (buffer_id, buffer_transaction_id) in &mut transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + undone |= buffer.update(cx, |buffer, cx| { + let undo_to = *buffer_transaction_id; + if let Some(entry) = buffer.peek_undo_stack() { + *buffer_transaction_id = entry.transaction_id(); + } + buffer.undo_to_transaction(undo_to, cx) + }); + } + } + + if undone { + transaction_id = Some(transaction.id); + break; + } + } + } + + if let Some(transaction_id) = transaction_id { + cx.emit(Event::TransactionUndone { transaction_id }); + } + + transaction_id + } + + pub fn redo(&mut self, cx: &mut Context) -> Option { + if let Some(buffer) = self.as_singleton() { + return buffer.update(cx, |buffer, cx| buffer.redo(cx)); + } + + while let Some(transaction) = self.history.pop_redo() { + let mut redone = false; + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions.iter_mut() { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + redone |= buffer.update(cx, |buffer, cx| { + let redo_to = *buffer_transaction_id; + if let Some(entry) = buffer.peek_redo_stack() { + *buffer_transaction_id = entry.transaction_id(); + } + buffer.redo_to_transaction(redo_to, cx) + }); + } + } + + if redone { + return Some(transaction.id); + } + } + + None + } + + pub fn undo_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } else if let Some(transaction) = self.history.remove_from_undo(transaction_id) { + for (buffer_id, transaction_id) in &transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.get(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.undo_transaction(*transaction_id, cx) + }); + } + } + } + } + + pub fn forget_transaction(&mut self, transaction_id: TransactionId, cx: &mut Context) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.forget_transaction(transaction_id); + }); + } else if let Some(transaction) = self.history.forget(transaction_id) { + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { + if let Some(state) = self.buffers.get_mut(&buffer_id) { + state.buffer.update(cx, |buffer, _| { + buffer.forget_transaction(buffer_transaction_id); + }); + } + } + } + } +} diff --git a/crates/nc/Cargo.toml b/crates/nc/Cargo.toml index 46ef2d3c62e233cc8693b3fdb3082749c05d9ed5..534ec2271ca44e8880db973c977948aa7d9a9f53 100644 --- a/crates/nc/Cargo.toml +++ b/crates/nc/Cargo.toml @@ -17,4 +17,3 @@ anyhow.workspace = true futures.workspace = true net.workspace = true smol.workspace = true -workspace-hack.workspace = true diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml index fc08bc89f53550fe926ce4a00ac68ce4b0502409..8ce273e30ce891dc981e433237921b44e8ca3fd7 100644 --- a/crates/net/Cargo.toml +++ b/crates/net/Cargo.toml @@ -14,7 +14,6 @@ doctest = false [dependencies] smol.workspace = true -workspace-hack.workspace = true [target.'cfg(target_os = "windows")'.dependencies] anyhow.workspace = true diff --git a/crates/node_runtime/Cargo.toml b/crates/node_runtime/Cargo.toml index 144fc2ae8545619b2548e9f7f3eb070363a02900..dfa40ad666e982c5f037114135c6cf7388f9a910 100644 --- a/crates/node_runtime/Cargo.toml +++ b/crates/node_runtime/Cargo.toml @@ -31,7 +31,6 @@ smol.workspace = true util.workspace = true watch.workspace = true which.workspace = true -workspace-hack.workspace = true [target.'cfg(windows)'.dependencies] async-std = { version = "1.12.0", features = ["unstable"] } diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index f92c122e71a00f08bcba1a4e16c510b00898cb56..3b2ba83ec339c13c3f00cc58d0fcbdffe2efd915 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -76,9 +76,8 @@ impl NodeRuntime { let mut state = self.0.lock().await; let options = loop { - match state.options.borrow().as_ref() { - Some(options) => break options.clone(), - None => {} + if let Some(options) = state.options.borrow().as_ref() { + break options.clone(); } match state.options.changed().await { Ok(()) => {} @@ -197,7 +196,7 @@ impl NodeRuntime { state.instance = Some(instance.boxed_clone()); state.last_options = Some(options); - return instance; + instance } pub async fn binary_path(&self) -> Result { @@ -614,7 +613,7 @@ pub struct SystemNodeRuntime { } impl SystemNodeRuntime { - const MIN_VERSION: semver::Version = Version::new(20, 0, 0); + const MIN_VERSION: semver::Version = Version::new(22, 0, 0); async fn new(node: PathBuf, npm: PathBuf) -> Result { let output = util::command::new_smol_command(&node) .arg("--version") diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index baf5444ef4903dd1d0efc64e7553abe3ed414720..8304c788fdd1ca840d68dbb4eb24bf5e3e79abdc 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -24,7 +24,6 @@ test-support = [ anyhow.workspace = true channel.workspace = true client.workspace = true -collections.workspace = true component.workspace = true db.workspace = true gpui.workspace = true @@ -34,7 +33,6 @@ time.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true zed_actions.workspace = true [dev-dependencies] diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 29653748e4873a271f58f932ee71c820aa755b9a..7cae74a7293694ebedd603ded656af00201c7366 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -1,7 +1,6 @@ use anyhow::{Context as _, Result}; -use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; +use channel::ChannelStore; use client::{ChannelId, Client, UserStore}; -use collections::HashMap; use db::smol::stream::StreamExt; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Task}; use rpc::{Notification, TypedEnvelope, proto}; @@ -22,7 +21,6 @@ impl Global for GlobalNotificationStore {} pub struct NotificationStore { client: Arc, user_store: Entity, - channel_messages: HashMap, channel_store: Entity, notifications: SumTree, loaded_all_notifications: bool, @@ -100,12 +98,10 @@ impl NotificationStore { channel_store: ChannelStore::global(cx), notifications: Default::default(), loaded_all_notifications: false, - channel_messages: Default::default(), _watch_connection_status: watch_connection_status, _subscriptions: vec![ client.add_message_handler(cx.weak_entity(), Self::handle_new_notification), client.add_message_handler(cx.weak_entity(), Self::handle_delete_notification), - client.add_message_handler(cx.weak_entity(), Self::handle_update_notification), ], user_store, client, @@ -120,10 +116,6 @@ impl NotificationStore { self.notifications.summary().unread_count } - pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> { - self.channel_messages.get(&id) - } - // Get the nth newest notification. pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { let count = self.notifications.summary().count; @@ -131,17 +123,19 @@ impl NotificationStore { return None; } let ix = count - 1 - ix; - let mut cursor = self.notifications.cursor::(&()); - cursor.seek(&Count(ix), Bias::Right); - cursor.item() + let (.., item) = self + .notifications + .find::((), &Count(ix), Bias::Right); + item } pub fn notification_for_id(&self, id: u64) -> Option<&NotificationEntry> { - let mut cursor = self.notifications.cursor::(&()); - cursor.seek(&NotificationId(id), Bias::Left); - if let Some(item) = cursor.item() { - if item.id == id { - return Some(item); - } + let (.., item) = + self.notifications + .find::((), &NotificationId(id), Bias::Left); + if let Some(item) = item + && item.id == id + { + return Some(item); } None } @@ -185,7 +179,6 @@ impl NotificationStore { fn handle_connect(&mut self, cx: &mut Context) -> Option>> { self.notifications = Default::default(); - self.channel_messages = Default::default(); cx.notify(); self.load_more_notifications(true, cx) } @@ -223,36 +216,6 @@ impl NotificationStore { })? } - async fn handle_update_notification( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, cx| { - if let Some(notification) = envelope.payload.notification { - if let Some(rpc::Notification::ChannelMessageMention { message_id, .. }) = - Notification::from_proto(¬ification) - { - let fetch_message_task = this.channel_store.update(cx, |this, cx| { - this.fetch_channel_messages(vec![message_id], cx) - }); - - cx.spawn(async move |this, cx| { - let messages = fetch_message_task.await?; - this.update(cx, move |this, cx| { - for message in messages { - this.channel_messages.insert(message_id, message); - } - cx.notify(); - }) - }) - .detach_and_log_err(cx) - } - } - Ok(()) - })? - } - async fn add_notifications( this: Entity, notifications: Vec, @@ -260,7 +223,6 @@ impl NotificationStore { cx: &mut AsyncApp, ) -> Result<()> { let mut user_ids = Vec::new(); - let mut message_ids = Vec::new(); let notifications = notifications .into_iter() @@ -294,29 +256,14 @@ impl NotificationStore { } => { user_ids.push(contact_id); } - Notification::ChannelMessageMention { - sender_id, - message_id, - .. - } => { - user_ids.push(sender_id); - message_ids.push(message_id); - } } } - let (user_store, channel_store) = this.read_with(cx, |this, _| { - (this.user_store.clone(), this.channel_store.clone()) - })?; + let user_store = this.read_with(cx, |this, _| this.user_store.clone())?; user_store .update(cx, |store, cx| store.get_users(user_ids, cx))? .await?; - let messages = channel_store - .update(cx, |store, cx| { - store.fetch_channel_messages(message_ids, cx) - })? - .await?; this.update(cx, |this, cx| { if options.clear_old { cx.emit(NotificationEvent::NotificationsUpdated { @@ -324,7 +271,6 @@ impl NotificationStore { new_count: 0, }); this.notifications = SumTree::default(); - this.channel_messages.clear(); this.loaded_all_notifications = false; } @@ -332,15 +278,6 @@ impl NotificationStore { this.loaded_all_notifications = true; } - this.channel_messages - .extend(messages.into_iter().filter_map(|message| { - if let ChannelMessageId::Saved(id) = message.id { - Some((id, message)) - } else { - None - } - })); - this.splice_notifications( notifications .into_iter() @@ -362,12 +299,12 @@ impl NotificationStore { ) { let mut cursor = self .notifications - .cursor::>(&()); + .cursor::>(()); let mut new_notifications = SumTree::default(); let mut old_range = 0..0; for (i, (id, new_notification)) in notifications.into_iter().enumerate() { - new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left), &()); + new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left), ()); if i == 0 { old_range.start = cursor.start().1.0; @@ -390,22 +327,22 @@ impl NotificationStore { }); } } - } else if let Some(new_notification) = &new_notification { - if is_new { - cx.emit(NotificationEvent::NewNotification { - entry: new_notification.clone(), - }); - } + } else if let Some(new_notification) = &new_notification + && is_new + { + cx.emit(NotificationEvent::NewNotification { + entry: new_notification.clone(), + }); } if let Some(notification) = new_notification { - new_notifications.push(notification, &()); + new_notifications.push(notification, ()); } } old_range.end = cursor.start().1.0; let new_count = new_notifications.summary().count - old_range.start; - new_notifications.append(cursor.suffix(), &()); + new_notifications.append(cursor.suffix(), ()); drop(cursor); self.notifications = new_notifications; @@ -446,7 +383,7 @@ impl EventEmitter for NotificationStore {} impl sum_tree::Item for NotificationEntry { type Summary = NotificationSummary; - fn summary(&self, _cx: &()) -> Self::Summary { + fn summary(&self, _cx: ()) -> Self::Summary { NotificationSummary { max_id: self.id, count: 1, @@ -455,14 +392,12 @@ impl sum_tree::Item for NotificationEntry { } } -impl sum_tree::Summary for NotificationSummary { - type Context = (); - - fn zero(_cx: &()) -> Self { +impl sum_tree::ContextLessSummary for NotificationSummary { + fn zero() -> Self { Default::default() } - fn add_summary(&mut self, summary: &Self, _: &()) { + fn add_summary(&mut self, summary: &Self) { self.max_id = self.max_id.max(summary.max_id); self.count += summary.count; self.unread_count += summary.unread_count; @@ -470,22 +405,22 @@ impl sum_tree::Summary for NotificationSummary { } impl sum_tree::Dimension<'_, NotificationSummary> for NotificationId { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + fn add_summary(&mut self, summary: &NotificationSummary, _: ()) { debug_assert!(summary.max_id > self.0); self.0 = summary.max_id; } } impl sum_tree::Dimension<'_, NotificationSummary> for Count { - fn zero(_cx: &()) -> Self { + fn zero(_cx: ()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + fn add_summary(&mut self, summary: &NotificationSummary, _: ()) { self.0 += summary.count; } } diff --git a/crates/ollama/Cargo.toml b/crates/ollama/Cargo.toml index 2765f234009c99ba4c9feb6e4a9fac804da7e5a1..fed74993fa4050b5ae690735d9b90f229f33ff5c 100644 --- a/crates/ollama/Cargo.toml +++ b/crates/ollama/Cargo.toml @@ -22,4 +22,4 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true +settings.workspace = true diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 64cd1cc0cbc06607ee9b3b72ee81cbeb9489c344..0ed3b6da17d952cc874485337ec380ef3ca990a8 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -1,35 +1,12 @@ use anyhow::{Context as _, Result}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; -use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::time::Duration; +pub use settings::KeepAlive; pub const OLLAMA_API_URL: &str = "http://localhost:11434"; -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -#[serde(untagged)] -pub enum KeepAlive { - /// Keep model alive for N seconds - Seconds(isize), - /// Keep model alive for a fixed duration. Accepts durations like "5m", "10m", "1h", "1d", etc. - Duration(String), -} - -impl KeepAlive { - /// Keep model alive until a new model is loaded or until Ollama shuts down - fn indefinite() -> Self { - Self::Seconds(-1) - } -} - -impl Default for KeepAlive { - fn default() -> Self { - Self::indefinite() - } -} - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Model { @@ -46,19 +23,20 @@ fn get_max_tokens(name: &str) -> u64 { /// Default context length for unknown models. const DEFAULT_TOKENS: u64 = 4096; /// Magic number. Lets many Ollama models work with ~16GB of ram. + /// Models that support context beyond 16k such as codestral (32k) or devstral (128k) will be clamped down to 16k const MAXIMUM_TOKENS: u64 = 16384; match name.split(':').next().unwrap() { - "phi" | "tinyllama" | "granite-code" => 2048, - "llama2" | "yi" | "vicuna" | "stablelm2" => 4096, - "llama3" | "gemma2" | "gemma" | "codegemma" | "starcoder" | "aya" => 8192, + "granite-code" | "phi" | "tinyllama" => 2048, + "llama2" | "stablelm2" | "vicuna" | "yi" => 4096, + "aya" | "codegemma" | "gemma" | "gemma2" | "llama3" | "starcoder" => 8192, "codellama" | "starcoder2" => 16384, - "mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder" - | "dolphin-mixtral" => 32768, - "magistral" => 40000, - "llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r" - | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder" - | "devstral" | "gpt-oss" => 128000, + "codestral" | "dolphin-mixtral" | "llava" | "magistral" | "mistral" | "mixstral" + | "qwen2" | "qwen2.5-coder" => 32768, + "cogito" | "command-r" | "deepseek-coder-v2" | "deepseek-r1" | "deepseek-v3" + | "devstral" | "gemma3" | "gpt-oss" | "granite3.3" | "llama3.1" | "llama3.2" + | "llama3.3" | "mistral-nemo" | "phi3" | "phi3.5" | "phi4" | "qwen3" | "yi-coder" => 128000, + "qwen3-coder" => 256000, _ => DEFAULT_TOKENS, } .clamp(1, MAXIMUM_TOKENS) @@ -117,6 +95,10 @@ pub enum ChatMessage { System { content: String, }, + Tool { + tool_name: String, + content: String, + }, } #[derive(Serialize, Deserialize, Debug)] @@ -155,14 +137,6 @@ pub struct ChatRequest { pub think: Option, } -impl ChatRequest { - pub fn with_tools(mut self, tools: Vec) -> Self { - self.stream = false; - self.tools = tools; - self - } -} - // https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values #[derive(Serialize, Default, Debug)] pub struct ChatOptions { @@ -175,14 +149,10 @@ pub struct ChatOptions { #[derive(Deserialize, Debug)] pub struct ChatResponseDelta { - #[allow(unused)] pub model: String, - #[allow(unused)] pub created_at: String, pub message: ChatMessage, - #[allow(unused)] pub done_reason: Option, - #[allow(unused)] pub done: bool, pub prompt_eval_count: Option, pub eval_count: Option, @@ -219,10 +189,74 @@ pub struct ModelDetails { pub quantization_level: String, } -#[derive(Deserialize, Debug)] +#[derive(Debug)] pub struct ModelShow { - #[serde(default)] pub capabilities: Vec, + pub context_length: Option, + pub architecture: Option, +} + +impl<'de> Deserialize<'de> for ModelShow { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, MapAccess, Visitor}; + use std::fmt; + + struct ModelShowVisitor; + + impl<'de> Visitor<'de> for ModelShowVisitor { + type Value = ModelShow; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a ModelShow object") + } + + fn visit_map
(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut capabilities: Vec = Vec::new(); + let mut architecture: Option = None; + let mut context_length: Option = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "capabilities" => { + capabilities = map.next_value()?; + } + "model_info" => { + let model_info: Value = map.next_value()?; + if let Value::Object(obj) = model_info { + architecture = obj + .get("general.architecture") + .and_then(|v| v.as_str()) + .map(String::from); + + if let Some(arch) = &architecture { + context_length = obj + .get(&format!("{}.context_length", arch)) + .and_then(|v| v.as_u64()); + } + } + } + _ => { + let _: de::IgnoredAny = map.next_value()?; + } + } + } + + Ok(ModelShow { + capabilities, + context_length, + architecture, + }) + } + } + + deserializer.deserialize_map(ModelShowVisitor) + } } impl ModelShow { @@ -240,50 +274,22 @@ impl ModelShow { } } -pub async fn complete( - client: &dyn HttpClient, - api_url: &str, - request: ChatRequest, -) -> Result { - let uri = format!("{api_url}/api/chat"); - let request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Content-Type", "application/json"); - - let serialized_request = serde_json::to_string(&request)?; - let request = request_builder.body(AsyncBody::from(serialized_request))?; - - let mut response = client.send(request).await?; - - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; - - if response.status().is_success() { - let response_message: ChatResponseDelta = serde_json::from_slice(&body)?; - Ok(response_message) - } else { - let body_str = std::str::from_utf8(&body)?; - anyhow::bail!( - "Failed to connect to API: {} {}", - response.status(), - body_str - ); - } -} - pub async fn stream_chat_completion( client: &dyn HttpClient, api_url: &str, + api_key: Option<&str>, request: ChatRequest, ) -> Result>> { let uri = format!("{api_url}/api/chat"); - let request_builder = http::Request::builder() + let request = HttpRequest::builder() .method(Method::POST) .uri(uri) - .header("Content-Type", "application/json"); + .header("Content-Type", "application/json") + .when_some(api_key, |builder, api_key| { + builder.header("Authorization", format!("Bearer {api_key}")) + }) + .body(AsyncBody::from(serde_json::to_string(&request)?))?; - let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; if response.status().is_success() { let reader = BufReader::new(response.into_body()); @@ -309,15 +315,17 @@ pub async fn stream_chat_completion( pub async fn get_models( client: &dyn HttpClient, api_url: &str, - _: Option, + api_key: Option<&str>, ) -> Result> { let uri = format!("{api_url}/api/tags"); - let request_builder = HttpRequest::builder() + let request = HttpRequest::builder() .method(Method::GET) .uri(uri) - .header("Accept", "application/json"); - - let request = request_builder.body(AsyncBody::default())?; + .header("Accept", "application/json") + .when_some(api_key, |builder, api_key| { + builder.header("Authorization", format!("Bearer {api_key}")) + }) + .body(AsyncBody::default())?; let mut response = client.send(request).await?; @@ -336,12 +344,20 @@ pub async fn get_models( } /// Fetch details of a model, used to determine model capabilities -pub async fn show_model(client: &dyn HttpClient, api_url: &str, model: &str) -> Result { +pub async fn show_model( + client: &dyn HttpClient, + api_url: &str, + api_key: Option<&str>, + model: &str, +) -> Result { let uri = format!("{api_url}/api/show"); let request = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") + .when_some(api_key, |builder, api_key| { + builder.header("Authorization", format!("Bearer {api_key}")) + }) .body(AsyncBody::from( serde_json::json!({ "model": model }).to_string(), ))?; @@ -518,6 +534,9 @@ mod tests { assert!(result.supports_tools()); assert!(result.capabilities.contains(&"tools".to_string())); assert!(result.capabilities.contains(&"completion".to_string())); + + assert_eq!(result.architecture, Some("llama".to_string())); + assert_eq!(result.context_length, Some(131072)); } #[test] diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 4157be31723fe66eb8f12ddf581c292e8320be78..2ff3467c4804f7c0a50488a2c4a1e283ea571292 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -15,20 +15,15 @@ path = "src/onboarding.rs" default = [] [dependencies] -ai_onboarding.workspace = true anyhow.workspace = true client.workspace = true component.workspace = true db.workspace = true documented.workspace = true -editor.workspace = true fs.workspace = true fuzzy.workspace = true git.workspace = true gpui.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true menu.workspace = true notifications.workspace = true picker.workspace = true @@ -41,7 +36,9 @@ theme.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true -workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true zlog.workspace = true + +[dev-dependencies] +db = {workspace = true, features = ["test-support"]} diff --git a/crates/onboarding/src/ai_setup_page.rs b/crates/onboarding/src/ai_setup_page.rs deleted file mode 100644 index bb1932bdf21ee9c927f085c8d5ad0a7cbd4c7fbd..0000000000000000000000000000000000000000 --- a/crates/onboarding/src/ai_setup_page.rs +++ /dev/null @@ -1,431 +0,0 @@ -use std::sync::Arc; - -use ai_onboarding::AiUpsellCard; -use client::{Client, UserStore, zed_urls}; -use fs::Fs; -use gpui::{ - Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, - Window, prelude::*, -}; -use itertools; -use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; -use project::DisableAiSettings; -use settings::{Settings, update_settings_file}; -use ui::{ - Badge, ButtonLike, Divider, KeyBinding, Modal, ModalFooter, ModalHeader, Section, SwitchField, - ToggleState, prelude::*, tooltip_container, -}; -use util::ResultExt; -use workspace::{ModalView, Workspace}; -use zed_actions::agent::OpenSettings; - -const FEATURED_PROVIDERS: [&'static str; 4] = ["anthropic", "google", "openai", "ollama"]; - -fn render_llm_provider_section( - tab_index: &mut isize, - workspace: WeakEntity, - disabled: bool, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - v_flex() - .gap_4() - .child( - v_flex() - .child(Label::new("Or use other LLM providers").size(LabelSize::Large)) - .child( - Label::new("Bring your API keys to use the available providers with Zed's UI for free.") - .color(Color::Muted), - ), - ) - .child(render_llm_provider_card(tab_index, workspace, disabled, window, cx)) -} - -fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> impl IntoElement { - let (title, description) = if disabled { - ( - "AI is disabled across Zed", - "Re-enable it any time in Settings.", - ) - } else { - ( - "Privacy is the default for Zed", - "Any use or storage of your data is with your explicit, single-use, opt-in consent.", - ) - }; - - v_flex() - .relative() - .pt_2() - .pb_2p5() - .pl_3() - .pr_2() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.5)) - .bg(cx.theme().colors().surface_background.opacity(0.3)) - .rounded_lg() - .overflow_hidden() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new(title)) - .child( - h_flex() - .gap_1() - .child( - Badge::new("Privacy") - .icon(IconName::ShieldCheck) - .tooltip(move |_, cx| cx.new(|_| AiPrivacyTooltip::new()).into()), - ) - .child( - Button::new("learn_more", "Learn More") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .on_click(|_, _, cx| { - cx.open_url(&zed_urls::ai_privacy_and_security(cx)) - }) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ), - ), - ) - .child( - Label::new(description) - .size(LabelSize::Small) - .color(Color::Muted), - ) -} - -fn render_llm_provider_card( - tab_index: &mut isize, - workspace: WeakEntity, - disabled: bool, - _: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let registry = LanguageModelRegistry::read_global(cx); - - v_flex() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().surface_background.opacity(0.5)) - .rounded_lg() - .overflow_hidden() - .children(itertools::intersperse_with( - FEATURED_PROVIDERS - .into_iter() - .flat_map(|provider_name| { - registry.provider(&LanguageModelProviderId::new(provider_name)) - }) - .enumerate() - .map(|(index, provider)| { - let group_name = SharedString::new(format!("onboarding-hover-group-{}", index)); - let is_authenticated = provider.is_authenticated(cx); - - ButtonLike::new(("onboarding-ai-setup-buttons", index)) - .size(ButtonSize::Large) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }) - .child( - h_flex() - .group(&group_name) - .px_0p5() - .w_full() - .gap_2() - .justify_between() - .child( - h_flex() - .gap_1() - .child( - Icon::new(provider.icon()) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(Label::new(provider.name().0)), - ) - .child( - h_flex() - .gap_1() - .when(!is_authenticated, |el| { - el.visible_on_hover(group_name.clone()) - .child( - Icon::new(IconName::Settings) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child( - Label::new("Configure") - .color(Color::Muted) - .size(LabelSize::Small), - ) - }) - .when(is_authenticated && !disabled, |el| { - el.child( - Icon::new(IconName::Check) - .color(Color::Success) - .size(IconSize::XSmall), - ) - .child( - Label::new("Configured") - .color(Color::Muted) - .size(LabelSize::Small), - ) - }), - ), - ) - .on_click({ - let workspace = workspace.clone(); - move |_, window, cx| { - workspace - .update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - telemetry::event!( - "Welcome AI Modal Opened", - provider = provider.name().0, - ); - - let modal = AiConfigurationModal::new( - provider.clone(), - window, - cx, - ); - window.focus(&modal.focus_handle(cx)); - modal - }); - }) - .log_err(); - } - }) - .into_any_element() - }), - || Divider::horizontal().into_any_element(), - )) - .child(Divider::horizontal()) - .child( - Button::new("agent_settings", "Add Many Others") - .size(ButtonSize::Large) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) - .on_click(|_event, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx) - }) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ) -} - -pub(crate) fn render_ai_setup_page( - workspace: WeakEntity, - user_store: Entity, - client: Arc, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let mut tab_index = 0; - let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; - - v_flex() - .gap_2() - .child( - SwitchField::new( - "enable_ai", - "Enable AI features", - None, - if is_ai_disabled { - ToggleState::Unselected - } else { - ToggleState::Selected - }, - |&toggle_state, _, cx| { - let enabled = match toggle_state { - ToggleState::Indeterminate => { - return; - } - ToggleState::Unselected => true, - ToggleState::Selected => false, - }; - - telemetry::event!( - "Welcome AI Enabled", - toggle = if enabled { "on" } else { "off" }, - ); - - let fs = ::global(cx); - update_settings_file::( - fs, - cx, - move |ai_settings: &mut Option, _| { - *ai_settings = Some(enabled); - }, - ); - }, - ) - .tab_index({ - tab_index += 1; - tab_index - 1 - }), - ) - .child(render_privacy_card(&mut tab_index, is_ai_disabled, cx)) - .child( - v_flex() - .mt_2() - .gap_6() - .child({ - let mut ai_upsell_card = - AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx); - - ai_upsell_card.tab_index = Some({ - tab_index += 1; - tab_index - 1 - }); - - ai_upsell_card - }) - .child(render_llm_provider_section( - &mut tab_index, - workspace, - is_ai_disabled, - window, - cx, - )) - .when(is_ai_disabled, |this| { - this.child( - div() - .id("backdrop") - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().editor_background) - .opacity(0.8) - .block_mouse_except_scroll(), - ) - }), - ) -} - -struct AiConfigurationModal { - focus_handle: FocusHandle, - selected_provider: Arc, - configuration_view: AnyView, -} - -impl AiConfigurationModal { - fn new( - selected_provider: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - let configuration_view = selected_provider.configuration_view(window, cx); - - Self { - focus_handle, - configuration_view, - selected_provider, - } - } - - fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context) { - cx.emit(DismissEvent); - } -} - -impl ModalView for AiConfigurationModal {} - -impl EventEmitter for AiConfigurationModal {} - -impl Focusable for AiConfigurationModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for AiConfigurationModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("OnboardingAiConfigurationModal") - .w(rems(34.)) - .elevation_3(cx) - .track_focus(&self.focus_handle) - .on_action( - cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)), - ) - .child( - Modal::new("onboarding-ai-setup-modal", None) - .header( - ModalHeader::new() - .icon( - Icon::new(self.selected_provider.icon()) - .color(Color::Muted) - .size(IconSize::Small), - ) - .headline(self.selected_provider.name().0), - ) - .section(Section::new().child(self.configuration_view.clone())) - .footer( - ModalFooter::new().end_slot( - Button::new("ai-onb-modal-Done", "Done") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &self.focus_handle.clone(), - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, _window, cx| { - this.cancel(&menu::Cancel, cx) - })), - ), - ), - ) - } -} - -pub struct AiPrivacyTooltip {} - -impl AiPrivacyTooltip { - pub fn new() -> Self { - Self {} - } -} - -impl Render for AiPrivacyTooltip { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - const DESCRIPTION: &'static str = "We believe in opt-in data sharing as the default for building AI products, rather than opt-out. We'll only use or store your data if you affirmatively send it to us. "; - - tooltip_container(window, cx, move |this, _, _| { - this.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::ShieldCheck) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(Label::new("Privacy First")), - ) - .child( - div().max_w_64().child( - Label::new(DESCRIPTION) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 0ac07d9a9d3b17921112d6accf6f4c9c9dd65ef6..63a2894a93504bd658dbf8199534efddf4746f1d 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -186,8 +186,8 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { value = base_keymap.to_string() ); - update_settings_file::(self.fs.clone(), cx, move |setting, _| { - *setting = Some(base_keymap) + update_settings_file(self.fs.clone(), cx, move |setting, _| { + setting.base_keymap = Some(base_keymap.into()) }); } @@ -213,7 +213,7 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { _window: &mut Window, _cx: &mut Context>, ) -> Option { - let keymap_match = &self.matches[ix]; + let keymap_match = &self.matches.get(ix)?; Some( ListItem::new(ix) diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 86ddc22a8687b5f591afb810ead541a0294dc7d9..eaf9c41a53dc6c4b0d8ef9a93a9ed8423ddf2db6 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -2,31 +2,44 @@ use std::sync::Arc; use client::TelemetrySettings; use fs::Fs; -use gpui::{App, IntoElement}; +use gpui::{Action, App, IntoElement}; use settings::{BaseKeymap, Settings, update_settings_file}; use theme::{ Appearance, SystemAppearance, ThemeMode, ThemeName, ThemeRegistry, ThemeSelection, ThemeSettings, }; use ui::{ - ParentElement as _, StatefulInteractiveElement, SwitchField, ToggleButtonGroup, - ToggleButtonSimple, ToggleButtonWithIcon, prelude::*, rems_from_px, + Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor, + ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*, + rems_from_px, }; use vim_mode_setting::VimModeSetting; -use crate::theme_preview::{ThemePreviewStyle, ThemePreviewTile}; +use crate::{ + ImportCursorSettings, ImportVsCodeSettings, SettingsImportState, + theme_preview::{ThemePreviewStyle, ThemePreviewTile}, +}; + +const LIGHT_THEMES: [&str; 3] = ["One Light", "Ayu Light", "Gruvbox Light"]; +const DARK_THEMES: [&str; 3] = ["One Dark", "Ayu Dark", "Gruvbox Dark"]; +const FAMILY_NAMES: [SharedString; 3] = [ + SharedString::new_static("One"), + SharedString::new_static("Ayu"), + SharedString::new_static("Gruvbox"), +]; + +fn get_theme_family_themes(theme_name: &str) -> Option<(&'static str, &'static str)> { + for i in 0..LIGHT_THEMES.len() { + if LIGHT_THEMES[i] == theme_name || DARK_THEMES[i] == theme_name { + return Some((LIGHT_THEMES[i], DARK_THEMES[i])); + } + } + None +} fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { - let theme_selection = ThemeSettings::get_global(cx).theme_selection.clone(); + let theme_selection = ThemeSettings::get_global(cx).theme.clone(); let system_appearance = theme::SystemAppearance::global(cx); - let theme_selection = theme_selection.unwrap_or_else(|| ThemeSelection::Dynamic { - mode: match *system_appearance { - Appearance::Light => ThemeMode::Light, - Appearance::Dark => ThemeMode::Dark, - }, - light: ThemeName("One Light".into()), - dark: ThemeName("One Dark".into()), - }); let theme_mode = theme_selection .mode() @@ -51,10 +64,17 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement MODE_NAMES[mode as usize].clone(), move |_, _, cx| { write_mode_change(mode, cx); + + telemetry::event!( + "Welcome Theme mode Changed", + from = theme_mode, + to = mode + ); }, ) }), ) + .size(ToggleButtonGroupSize::Medium) .tab_index(tab_index) .selected_index(theme_mode as usize) .style(ui::ToggleButtonGroupStyle::Outlined) @@ -88,15 +108,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement ThemeMode::Dark => Appearance::Dark, ThemeMode::System => *system_appearance, }; - let current_theme_name = theme_selection.theme(appearance); - - const LIGHT_THEMES: [&'static str; 3] = ["One Light", "Ayu Light", "Gruvbox Light"]; - const DARK_THEMES: [&'static str; 3] = ["One Dark", "Ayu Dark", "Gruvbox Dark"]; - const FAMILY_NAMES: [SharedString; 3] = [ - SharedString::new_static("One"), - SharedString::new_static("Ayu"), - SharedString::new_static("Gruvbox"), - ]; + let current_theme_name: SharedString = theme_selection.name(appearance).0.into(); let theme_names = match appearance { Appearance::Light => LIGHT_THEMES, @@ -105,7 +117,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement let themes = theme_names.map(|theme| theme_registry.get(theme).unwrap()); - let theme_previews = [0, 1, 2].map(|index| { + [0, 1, 2].map(|index| { let theme = &themes[index]; let is_selected = theme.name == current_theme_name; let name = theme.name.clone(); @@ -117,7 +129,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement .gap_1() .child( h_flex() - .id(name.clone()) + .id(name) .relative() .w_full() .border_2() @@ -140,8 +152,15 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement }) .on_click({ let theme_name = theme.name.clone(); + let current_theme_name = current_theme_name.clone(); + move |_, _, cx| { write_theme_change(theme_name.clone(), theme_mode, cx); + telemetry::event!( + "Welcome Theme Changed", + from = current_theme_name, + to = theme_name + ); } }) .map(|this| { @@ -167,31 +186,32 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement .color(Color::Muted) .size(LabelSize::Small), ) - }); - - theme_previews + }) } fn write_mode_change(mode: ThemeMode, cx: &mut App) { let fs = ::global(cx); - update_settings_file::(fs, cx, move |settings, _cx| { - settings.set_mode(mode); + update_settings_file(fs, cx, move |settings, _cx| { + theme::set_mode(settings, mode); }); } fn write_theme_change(theme: impl Into>, theme_mode: ThemeMode, cx: &mut App) { let fs = ::global(cx); let theme = theme.into(); - update_settings_file::(fs, cx, move |settings, cx| { + update_settings_file(fs, cx, move |settings, cx| { if theme_mode == ThemeMode::System { - settings.theme = Some(ThemeSelection::Dynamic { + let (light_theme, dark_theme) = + get_theme_family_themes(&theme).unwrap_or((theme.as_ref(), theme.as_ref())); + + settings.theme.theme = Some(settings::ThemeSelection::Dynamic { mode: ThemeMode::System, - light: ThemeName(theme.clone()), - dark: ThemeName(theme.clone()), + light: ThemeName(light_theme.into()), + dark: ThemeName(dark_theme.into()), }); } else { let appearance = *SystemAppearance::global(cx); - settings.set_theme(theme.clone(), appearance); + theme::set_theme(settings, theme, appearance); } }); } @@ -201,68 +221,88 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement let fs = ::global(cx); v_flex() - .pt_6() .gap_4() - .border_t_1() - .border_color(cx.theme().colors().border_variant.opacity(0.5)) - .child(Label::new("Telemetry").size(LabelSize::Large)) - .child(SwitchField::new( - "onboarding-telemetry-metrics", - "Help Improve Zed", - Some("Anonymous usage data helps us build the right features and improve your experience.".into()), - if TelemetrySettings::get_global(cx).metrics { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - { - let fs = fs.clone(); - move |selection, _, cx| { - let enabled = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - ToggleState::Indeterminate => { return; }, - }; + .child( + SwitchField::new( + "onboarding-telemetry-metrics", + None::<&str>, + Some("Help improve Zed by sending anonymous usage data".into()), + if TelemetrySettings::get_global(cx).metrics { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + { + let fs = fs.clone(); + move |selection, _, cx| { + let enabled = match selection { + ToggleState::Selected => true, + ToggleState::Unselected => false, + ToggleState::Indeterminate => { + return; + } + }; - update_settings_file::( - fs.clone(), - cx, - move |setting, _| setting.metrics = Some(enabled), - ); - }}, - ).tab_index({ - *tab_index += 1; - *tab_index - })) - .child(SwitchField::new( - "onboarding-telemetry-crash-reports", - "Help Fix Zed", - Some("Send crash reports so we can fix critical issues fast.".into()), - if TelemetrySettings::get_global(cx).diagnostics { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - { - let fs = fs.clone(); - move |selection, _, cx| { - let enabled = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - ToggleState::Indeterminate => { return; }, - }; - - update_settings_file::( - fs.clone(), - cx, - move |setting, _| setting.diagnostics = Some(enabled), - ); - } - } - ).tab_index({ - *tab_index += 1; - *tab_index - })) + update_settings_file(fs.clone(), cx, move |setting, _| { + setting.telemetry.get_or_insert_default().metrics = Some(enabled); + }); + + // This telemetry event shouldn't fire when it's off. If it does we'll be alerted + // and can fix it in a timely manner to respect a user's choice. + telemetry::event!( + "Welcome Page Telemetry Metrics Toggled", + options = if enabled { "on" } else { "off" } + ); + } + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index + }), + ) + .child( + SwitchField::new( + "onboarding-telemetry-crash-reports", + None::<&str>, + Some( + "Help fix Zed by sending crash reports so we can fix critical issues fast" + .into(), + ), + if TelemetrySettings::get_global(cx).diagnostics { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + { + let fs = fs.clone(); + move |selection, _, cx| { + let enabled = match selection { + ToggleState::Selected => true, + ToggleState::Unselected => false, + ToggleState::Indeterminate => { + return; + } + }; + + update_settings_file(fs.clone(), cx, move |setting, _| { + setting.telemetry.get_or_insert_default().diagnostics = Some(enabled); + }); + + // This telemetry event shouldn't fire when it's off. If it does we'll be alerted + // and can fix it in a timely manner to respect a user's choice. + telemetry::event!( + "Welcome Page Telemetry Diagnostics Toggled", + options = if enabled { "on" } else { "off" } + ); + } + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index + }), + ) } fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { @@ -283,7 +323,7 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE ToggleButtonWithIcon::new("VS Code", IconName::EditorVsCode, |_, _, cx| { write_keymap_base(BaseKeymap::VSCode, cx); }), - ToggleButtonWithIcon::new("Jetbrains", IconName::EditorJetBrains, |_, _, cx| { + ToggleButtonWithIcon::new("JetBrains", IconName::EditorJetBrains, |_, _, cx| { write_keymap_base(BaseKeymap::JetBrains, cx); }), ToggleButtonWithIcon::new("Sublime Text", IconName::EditorSublime, |_, _, cx| { @@ -314,9 +354,11 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) { let fs = ::global(cx); - update_settings_file::(fs, cx, move |setting, _| { - *setting = Some(keymap_base); + update_settings_file(fs, cx, move |setting, _| { + setting.base_keymap = Some(keymap_base.into()); }); + + telemetry::event!("Welcome Keymap Changed", keymap = keymap_base); } } @@ -328,19 +370,27 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme }; SwitchField::new( "onboarding-vim-mode", - "Vim Mode", - Some("Coming from Neovim? Use our first-class implementation of Vim Mode.".into()), + Some("Vim Mode"), + Some("Coming from Neovim? Use our first-class implementation of Vim Mode".into()), toggle_state, { let fs = ::global(cx); move |&selection, _, cx| { - update_settings_file::(fs.clone(), cx, move |setting, _| { - *setting = match selection { - ToggleState::Selected => Some(true), - ToggleState::Unselected => Some(false), - ToggleState::Indeterminate => None, + let vim_mode = match selection { + ToggleState::Selected => true, + ToggleState::Unselected => false, + ToggleState::Indeterminate => { + return; } + }; + update_settings_file(fs.clone(), cx, move |setting, _| { + setting.vim_mode = Some(vim_mode); }); + + telemetry::event!( + "Welcome Vim Mode Toggled", + options = if vim_mode { "on" } else { "off" }, + ); } }, ) @@ -350,12 +400,78 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme }) } +fn render_setting_import_button( + tab_index: isize, + label: SharedString, + action: &dyn Action, + imported: bool, +) -> impl IntoElement + 'static { + let action = action.boxed_clone(); + + Button::new(label.clone(), label.clone()) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .label_size(LabelSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(imported) + .tab_index(tab_index) + .when(imported, |this| { + this.icon(IconName::Check) + .icon_size(IconSize::Small) + .color(Color::Success) + }) + .on_click(move |_, window, cx| { + telemetry::event!("Welcome Import Settings", import_source = label,); + window.dispatch_action(action.boxed_clone(), cx); + }) +} + +fn render_import_settings_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { + let import_state = SettingsImportState::global(cx); + let imports: [(SharedString, &dyn Action, bool); 2] = [ + ( + "VS Code".into(), + &ImportVsCodeSettings { skip_prompt: false }, + import_state.vscode, + ), + ( + "Cursor".into(), + &ImportCursorSettings { skip_prompt: false }, + import_state.cursor, + ), + ]; + + let [vscode, cursor] = imports.map(|(label, action, imported)| { + *tab_index += 1; + render_setting_import_button(*tab_index - 1, label, action, imported) + }); + + h_flex() + .gap_2() + .flex_wrap() + .justify_between() + .child( + v_flex() + .gap_0p5() + .max_w_5_6() + .child(Label::new("Import Settings")) + .child( + Label::new("Automatically pull your settings from other editors") + .color(Color::Muted), + ), + ) + .child(h_flex().gap_1().child(vscode).child(cursor)) +} + pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement { let mut tab_index = 0; v_flex() + .id("basics-page") .gap_6() .child(render_theme_section(&mut tab_index, cx)) .child(render_base_keymap_section(&mut tab_index, cx)) + .child(render_import_settings_section(&mut tab_index, cx)) .child(render_vim_mode_switch(&mut tab_index, cx)) + .child(Divider::horizontal().color(ui::DividerColor::BorderVariant)) .child(render_telemetry_section(&mut tab_index, cx)) } diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs deleted file mode 100644 index d941a0315afd726d493ecb121d2862d0067e6dd3..0000000000000000000000000000000000000000 --- a/crates/onboarding/src/editing_page.rs +++ /dev/null @@ -1,765 +0,0 @@ -use std::sync::Arc; - -use editor::{EditorSettings, ShowMinimap}; -use fs::Fs; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{ - Action, AnyElement, App, Context, FontFeatures, IntoElement, Pixels, SharedString, Task, Window, -}; -use language::language_settings::{AllLanguageSettings, FormatOnSave}; -use picker::{Picker, PickerDelegate}; -use project::project_settings::ProjectSettings; -use settings::{Settings as _, update_settings_file}; -use theme::{FontFamilyCache, FontFamilyName, ThemeSettings}; -use ui::{ - ButtonLike, ListItem, ListItemSpacing, NumericStepper, PopoverMenu, SwitchField, - ToggleButtonGroup, ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, Tooltip, - prelude::*, -}; - -use crate::{ImportCursorSettings, ImportVsCodeSettings, SettingsImportState}; - -fn read_show_mini_map(cx: &App) -> ShowMinimap { - editor::EditorSettings::get_global(cx).minimap.show -} - -fn write_show_mini_map(show: ShowMinimap, cx: &mut App) { - let fs = ::global(cx); - - // This is used to speed up the UI - // the UI reads the current values to get what toggle state to show on buttons - // there's a slight delay if we just call update_settings_file so we manually set - // the value here then call update_settings file to get around the delay - let mut curr_settings = EditorSettings::get_global(cx).clone(); - curr_settings.minimap.show = show; - EditorSettings::override_global(curr_settings, cx); - - update_settings_file::(fs, cx, move |editor_settings, _| { - telemetry::event!( - "Welcome Minimap Clicked", - from = editor_settings.minimap.unwrap_or_default(), - to = show - ); - editor_settings.minimap.get_or_insert_default().show = Some(show); - }); -} - -fn read_inlay_hints(cx: &App) -> bool { - AllLanguageSettings::get_global(cx) - .defaults - .inlay_hints - .enabled -} - -fn write_inlay_hints(enabled: bool, cx: &mut App) { - let fs = ::global(cx); - - let mut curr_settings = AllLanguageSettings::get_global(cx).clone(); - curr_settings.defaults.inlay_hints.enabled = enabled; - AllLanguageSettings::override_global(curr_settings, cx); - - update_settings_file::(fs, cx, move |all_language_settings, cx| { - all_language_settings - .defaults - .inlay_hints - .get_or_insert_with(|| { - AllLanguageSettings::get_global(cx) - .clone() - .defaults - .inlay_hints - }) - .enabled = enabled; - }); -} - -fn read_git_blame(cx: &App) -> bool { - ProjectSettings::get_global(cx).git.inline_blame_enabled() -} - -fn write_git_blame(enabled: bool, cx: &mut App) { - let fs = ::global(cx); - - let mut curr_settings = ProjectSettings::get_global(cx).clone(); - curr_settings - .git - .inline_blame - .get_or_insert_default() - .enabled = enabled; - ProjectSettings::override_global(curr_settings, cx); - - update_settings_file::(fs, cx, move |project_settings, _| { - project_settings - .git - .inline_blame - .get_or_insert_default() - .enabled = enabled; - }); -} - -fn write_ui_font_family(font: SharedString, cx: &mut App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |theme_settings, _| { - telemetry::event!( - "Welcome Font Changed", - type = "ui font", - old = theme_settings.ui_font_family, - new = font.clone() - ); - theme_settings.ui_font_family = Some(FontFamilyName(font.into())); - }); -} - -fn write_ui_font_size(size: Pixels, cx: &mut App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |theme_settings, _| { - theme_settings.ui_font_size = Some(size.into()); - }); -} - -fn write_buffer_font_size(size: Pixels, cx: &mut App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |theme_settings, _| { - theme_settings.buffer_font_size = Some(size.into()); - }); -} - -fn write_buffer_font_family(font_family: SharedString, cx: &mut App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |theme_settings, _| { - telemetry::event!( - "Welcome Font Changed", - type = "editor font", - old = theme_settings.buffer_font_family, - new = font_family.clone() - ); - - theme_settings.buffer_font_family = Some(FontFamilyName(font_family.into())); - }); -} - -fn read_font_ligatures(cx: &App) -> bool { - ThemeSettings::get_global(cx) - .buffer_font - .features - .is_calt_enabled() - .unwrap_or(true) -} - -fn write_font_ligatures(enabled: bool, cx: &mut App) { - let fs = ::global(cx); - let bit = if enabled { 1 } else { 0 }; - - update_settings_file::(fs, cx, move |theme_settings, _| { - let mut features = theme_settings - .buffer_font_features - .as_mut() - .map(|features| features.tag_value_list().to_vec()) - .unwrap_or_default(); - - if let Some(calt_index) = features.iter().position(|(tag, _)| tag == "calt") { - features[calt_index].1 = bit; - } else { - features.push(("calt".into(), bit)); - } - - theme_settings.buffer_font_features = Some(FontFeatures(Arc::new(features))); - }); -} - -fn read_format_on_save(cx: &App) -> bool { - match AllLanguageSettings::get_global(cx).defaults.format_on_save { - FormatOnSave::On | FormatOnSave::List(_) => true, - FormatOnSave::Off => false, - } -} - -fn write_format_on_save(format_on_save: bool, cx: &mut App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |language_settings, _| { - language_settings.defaults.format_on_save = Some(match format_on_save { - true => FormatOnSave::On, - false => FormatOnSave::Off, - }); - }); -} - -fn render_setting_import_button( - tab_index: isize, - label: SharedString, - icon_name: IconName, - action: &dyn Action, - imported: bool, -) -> impl IntoElement { - let action = action.boxed_clone(); - h_flex().w_full().child( - ButtonLike::new(label.clone()) - .full_width() - .style(ButtonStyle::Outlined) - .size(ButtonSize::Large) - .tab_index(tab_index) - .child( - h_flex() - .w_full() - .justify_between() - .child( - h_flex() - .gap_1p5() - .px_1() - .child( - Icon::new(icon_name) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(Label::new(label.clone())), - ) - .when(imported, |this| { - this.child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::Check) - .color(Color::Success) - .size(IconSize::XSmall), - ) - .child(Label::new("Imported").size(LabelSize::Small)), - ) - }), - ) - .on_click(move |_, window, cx| { - telemetry::event!("Welcome Import Settings", import_source = label,); - window.dispatch_action(action.boxed_clone(), cx); - }), - ) -} - -fn render_import_settings_section(tab_index: &mut isize, cx: &App) -> impl IntoElement { - let import_state = SettingsImportState::global(cx); - let imports: [(SharedString, IconName, &dyn Action, bool); 2] = [ - ( - "VS Code".into(), - IconName::EditorVsCode, - &ImportVsCodeSettings { skip_prompt: false }, - import_state.vscode, - ), - ( - "Cursor".into(), - IconName::EditorCursor, - &ImportCursorSettings { skip_prompt: false }, - import_state.cursor, - ), - ]; - - let [vscode, cursor] = imports.map(|(label, icon_name, action, imported)| { - *tab_index += 1; - render_setting_import_button(*tab_index - 1, label, icon_name, action, imported) - }); - - v_flex() - .gap_4() - .child( - v_flex() - .child(Label::new("Import Settings").size(LabelSize::Large)) - .child( - Label::new("Automatically pull your settings from other editors.") - .color(Color::Muted), - ), - ) - .child(h_flex().w_full().gap_4().child(vscode).child(cursor)) -} - -fn render_font_customization_section( - tab_index: &mut isize, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = theme_settings.ui_font_size(cx); - let ui_font_family = theme_settings.ui_font.family.clone(); - let buffer_font_family = theme_settings.buffer_font.family.clone(); - let buffer_font_size = theme_settings.buffer_font_size(cx); - - let ui_font_picker = - cx.new(|cx| font_picker(ui_font_family.clone(), write_ui_font_family, window, cx)); - - let buffer_font_picker = cx.new(|cx| { - font_picker( - buffer_font_family.clone(), - write_buffer_font_family, - window, - cx, - ) - }); - - let ui_font_handle = ui::PopoverMenuHandle::default(); - let buffer_font_handle = ui::PopoverMenuHandle::default(); - - h_flex() - .w_full() - .gap_4() - .child( - v_flex() - .w_full() - .gap_1() - .child(Label::new("UI Font")) - .child( - h_flex() - .w_full() - .justify_between() - .gap_2() - .child( - PopoverMenu::new("ui-font-picker") - .menu({ - let ui_font_picker = ui_font_picker.clone(); - move |_window, _cx| Some(ui_font_picker.clone()) - }) - .trigger( - ButtonLike::new("ui-font-family-button") - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .full_width() - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }) - .child( - h_flex() - .w_full() - .justify_between() - .child(Label::new(ui_font_family)) - .child( - Icon::new(IconName::ChevronUpDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - ), - ) - .full_width(true) - .anchor(gpui::Corner::TopLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(4.0), - }) - .with_handle(ui_font_handle), - ) - .child( - NumericStepper::new( - "ui-font-size", - ui_font_size.to_string(), - move |_, _, cx| { - write_ui_font_size(ui_font_size - px(1.), cx); - }, - move |_, _, cx| { - write_ui_font_size(ui_font_size + px(1.), cx); - }, - ) - .style(ui::NumericStepperStyle::Outlined) - .tab_index({ - *tab_index += 2; - *tab_index - 2 - }), - ), - ), - ) - .child( - v_flex() - .w_full() - .gap_1() - .child(Label::new("Editor Font")) - .child( - h_flex() - .w_full() - .justify_between() - .gap_2() - .child( - PopoverMenu::new("buffer-font-picker") - .menu({ - let buffer_font_picker = buffer_font_picker.clone(); - move |_window, _cx| Some(buffer_font_picker.clone()) - }) - .trigger( - ButtonLike::new("buffer-font-family-button") - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .full_width() - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }) - .child( - h_flex() - .w_full() - .justify_between() - .child(Label::new(buffer_font_family)) - .child( - Icon::new(IconName::ChevronUpDown) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - ), - ) - .full_width(true) - .anchor(gpui::Corner::TopLeft) - .offset(gpui::Point { - x: px(0.0), - y: px(4.0), - }) - .with_handle(buffer_font_handle), - ) - .child( - NumericStepper::new( - "buffer-font-size", - buffer_font_size.to_string(), - move |_, _, cx| { - write_buffer_font_size(buffer_font_size - px(1.), cx); - }, - move |_, _, cx| { - write_buffer_font_size(buffer_font_size + px(1.), cx); - }, - ) - .style(ui::NumericStepperStyle::Outlined) - .tab_index({ - *tab_index += 2; - *tab_index - 2 - }), - ), - ), - ) -} - -type FontPicker = Picker; - -pub struct FontPickerDelegate { - fonts: Vec, - filtered_fonts: Vec, - selected_index: usize, - current_font: SharedString, - on_font_changed: Arc, -} - -impl FontPickerDelegate { - fn new( - current_font: SharedString, - on_font_changed: impl Fn(SharedString, &mut App) + 'static, - cx: &mut Context, - ) -> Self { - let font_family_cache = FontFamilyCache::global(cx); - - let fonts: Vec = font_family_cache - .list_font_families(cx) - .into_iter() - .collect(); - - let selected_index = fonts - .iter() - .position(|font| *font == current_font) - .unwrap_or(0); - - Self { - fonts: fonts.clone(), - filtered_fonts: fonts - .iter() - .enumerate() - .map(|(index, font)| StringMatch { - candidate_id: index, - string: font.to_string(), - positions: Vec::new(), - score: 0.0, - }) - .collect(), - selected_index, - current_font, - on_font_changed: Arc::new(on_font_changed), - } - } -} - -impl PickerDelegate for FontPickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_fonts.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.selected_index = ix.min(self.filtered_fonts.len().saturating_sub(1)); - cx.notify(); - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search fonts…".into() - } - - fn update_matches( - &mut self, - query: String, - _window: &mut Window, - cx: &mut Context, - ) -> Task<()> { - let fonts = self.fonts.clone(); - let current_font = self.current_font.clone(); - - let matches: Vec = if query.is_empty() { - fonts - .iter() - .enumerate() - .map(|(index, font)| StringMatch { - candidate_id: index, - string: font.to_string(), - positions: Vec::new(), - score: 0.0, - }) - .collect() - } else { - let _candidates: Vec = fonts - .iter() - .enumerate() - .map(|(id, font)| StringMatchCandidate::new(id, font.as_ref())) - .collect(); - - fonts - .iter() - .enumerate() - .filter(|(_, font)| font.to_lowercase().contains(&query.to_lowercase())) - .map(|(index, font)| StringMatch { - candidate_id: index, - string: font.to_string(), - positions: Vec::new(), - score: 0.0, - }) - .collect() - }; - - let selected_index = if query.is_empty() { - fonts - .iter() - .position(|font| *font == current_font) - .unwrap_or(0) - } else { - matches - .iter() - .position(|m| fonts[m.candidate_id] == current_font) - .unwrap_or(0) - }; - - self.filtered_fonts = matches; - self.selected_index = selected_index; - cx.notify(); - - Task::ready(()) - } - - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context) { - if let Some(font_match) = self.filtered_fonts.get(self.selected_index) { - let font = font_match.string.clone(); - (self.on_font_changed)(font.into(), cx); - } - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let font_match = self.filtered_fonts.get(ix)?; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child(Label::new(font_match.string.clone())) - .into_any_element(), - ) - } -} - -fn font_picker( - current_font: SharedString, - on_font_changed: impl Fn(SharedString, &mut App) + 'static, - window: &mut Window, - cx: &mut Context, -) -> FontPicker { - let delegate = FontPickerDelegate::new(current_font, on_font_changed, cx); - - Picker::uniform_list(delegate, window, cx) - .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(20.).into())) -} - -fn render_popular_settings_section( - tab_index: &mut isize, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - const LIGATURE_TOOLTIP: &'static str = - "Font ligatures combine two characters into one. For example, turning =/= into ≠."; - - v_flex() - .pt_6() - .gap_4() - .border_t_1() - .border_color(cx.theme().colors().border_variant.opacity(0.5)) - .child(Label::new("Popular Settings").size(LabelSize::Large)) - .child(render_font_customization_section(tab_index, window, cx)) - .child( - SwitchField::new( - "onboarding-font-ligatures", - "Font Ligatures", - Some("Combine text characters into their associated symbols.".into()), - if read_font_ligatures(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - let enabled = toggle_state == &ToggleState::Selected; - telemetry::event!( - "Welcome Font Ligature", - options = if enabled { "on" } else { "off" }, - ); - - write_font_ligatures(enabled, cx); - }, - ) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }) - .tooltip(Tooltip::text(LIGATURE_TOOLTIP)), - ) - .child( - SwitchField::new( - "onboarding-format-on-save", - "Format on Save", - Some("Format code automatically when saving.".into()), - if read_format_on_save(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - let enabled = toggle_state == &ToggleState::Selected; - telemetry::event!( - "Welcome Format On Save Changed", - options = if enabled { "on" } else { "off" }, - ); - - write_format_on_save(enabled, cx); - }, - ) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ) - .child( - SwitchField::new( - "onboarding-enable-inlay-hints", - "Inlay Hints", - Some("See parameter names for function and method calls inline.".into()), - if read_inlay_hints(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - let enabled = toggle_state == &ToggleState::Selected; - telemetry::event!( - "Welcome Inlay Hints Changed", - options = if enabled { "on" } else { "off" }, - ); - - write_inlay_hints(enabled, cx); - }, - ) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ) - .child( - SwitchField::new( - "onboarding-git-blame-switch", - "Inline Git Blame", - Some("See who committed each line on a given file.".into()), - if read_git_blame(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - let enabled = toggle_state == &ToggleState::Selected; - telemetry::event!( - "Welcome Git Blame Changed", - options = if enabled { "on" } else { "off" }, - ); - - write_git_blame(enabled, cx); - }, - ) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }), - ) - .child( - h_flex() - .items_start() - .justify_between() - .child( - v_flex().child(Label::new("Minimap")).child( - Label::new("See a high-level overview of your source code.") - .color(Color::Muted), - ), - ) - .child( - ToggleButtonGroup::single_row( - "onboarding-show-mini-map", - [ - ToggleButtonSimple::new("Auto", |_, _, cx| { - write_show_mini_map(ShowMinimap::Auto, cx); - }) - .tooltip(Tooltip::text( - "Show the minimap if the editor's scrollbar is visible.", - )), - ToggleButtonSimple::new("Always", |_, _, cx| { - write_show_mini_map(ShowMinimap::Always, cx); - }), - ToggleButtonSimple::new("Never", |_, _, cx| { - write_show_mini_map(ShowMinimap::Never, cx); - }), - ], - ) - .selected_index(match read_show_mini_map(cx) { - ShowMinimap::Auto => 0, - ShowMinimap::Always => 1, - ShowMinimap::Never => 2, - }) - .tab_index(tab_index) - .style(ToggleButtonGroupStyle::Outlined) - .width(ui::rems_from_px(3. * 64.)), - ), - ) -} - -pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement { - let mut tab_index = 0; - v_flex() - .gap_6() - .child(render_import_settings_section(&mut tab_index, cx)) - .child(render_popular_settings_section(&mut tab_index, window, cx)) -} diff --git a/crates/onboarding/src/multibuffer_hint.rs b/crates/onboarding/src/multibuffer_hint.rs index 3a20cbb6bd4443356db3a9fc1402b1102558ea02..9d290306d83308125f13985cc3e950b7d88d4866 100644 --- a/crates/onboarding/src/multibuffer_hint.rs +++ b/crates/onboarding/src/multibuffer_hint.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use db::kvp::KEY_VALUE_STORE; use gpui::{App, EntityId, EventEmitter, Subscription}; use ui::{IconButtonShape, Tooltip, prelude::*}; -use workspace::item::{ItemEvent, ItemHandle}; +use workspace::item::{ItemBufferKind, ItemEvent, ItemHandle}; use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub struct MultibufferHint { @@ -79,7 +79,7 @@ impl MultibufferHint { return ToolbarItemLocation::Hidden; }; - if active_pane_item.is_singleton(cx) + if active_pane_item.buffer_kind(cx) == ItemBufferKind::Singleton || active_pane_item.breadcrumbs(cx.theme(), cx).is_none() || !active_pane_item.can_save(cx) { diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index e07a8dc9fb6c6c20b311863da1414dfd6e83eecd..562dea8748eaddad415d7098f6c34f0bea7b5169 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -5,8 +5,8 @@ use db::kvp::KEY_VALUE_STORE; use fs::Fs; use gpui::{ Action, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, SharedString, Subscription, - Task, WeakEntity, Window, actions, + FocusHandle, Focusable, Global, IntoElement, KeyContext, Render, ScrollHandle, SharedString, + Subscription, Task, WeakEntity, Window, actions, }; use notifications::status_toast::{StatusToast, ToastIcon}; use schemars::JsonSchema; @@ -14,8 +14,8 @@ use serde::Deserialize; use settings::{SettingsStore, VsCodeSettingsSource}; use std::sync::Arc; use ui::{ - Avatar, ButtonLike, FluentBuilder, Headline, KeyBinding, ParentElement as _, - StatefulInteractiveElement, Vector, VectorName, prelude::*, rems_from_px, + Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName, + WithScrollbar as _, prelude::*, rems_from_px, }; use workspace::{ AppState, Workspace, WorkspaceId, @@ -25,10 +25,8 @@ use workspace::{ open_new, register_serializable_item, with_active_or_new_workspace, }; -mod ai_setup_page; mod base_keymap_picker; mod basics_page; -mod editing_page; pub mod multibuffer_hint; mod theme_preview; mod welcome; @@ -65,12 +63,6 @@ actions!( actions!( onboarding, [ - /// Activates the Basics page. - ActivateBasicsPage, - /// Activates the Editing page. - ActivateEditingPage, - /// Activates the AI Setup page. - ActivateAISetupPage, /// Finish the onboarding process. Finish, /// Sign in while in the onboarding flow. @@ -215,276 +207,40 @@ pub fn show_onboarding_view(app_state: Arc, cx: &mut App) -> Task &'static str { - match self { - SelectedPage::Basics => "Basics", - SelectedPage::Editing => "Editing", - SelectedPage::AiSetup => "AI Setup", - } - } -} - struct Onboarding { workspace: WeakEntity, focus_handle: FocusHandle, - selected_page: SelectedPage, user_store: Entity, + scroll_handle: ScrollHandle, _settings_subscription: Subscription, } impl Onboarding { fn new(workspace: &Workspace, cx: &mut App) -> Entity { - cx.new(|cx| Self { - workspace: workspace.weak_handle(), - focus_handle: cx.focus_handle(), - selected_page: SelectedPage::Basics, - user_store: workspace.user_store().clone(), - _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), - }) - } - - fn set_page( - &mut self, - page: SelectedPage, - clicked: Option<&'static str>, - cx: &mut Context, - ) { - if let Some(click) = clicked { - telemetry::event!( - "Welcome Tab Clicked", - from = self.selected_page.name(), - to = page.name(), - clicked = click, - ); - } - - self.selected_page = page; - cx.notify(); - cx.emit(ItemEvent::UpdateTab); - } - - fn render_nav_buttons( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> [impl IntoElement; 3] { - let pages = [ - SelectedPage::Basics, - SelectedPage::Editing, - SelectedPage::AiSetup, - ]; - - let text = ["Basics", "Editing", "AI Setup"]; - - let actions: [&dyn Action; 3] = [ - &ActivateBasicsPage, - &ActivateEditingPage, - &ActivateAISetupPage, - ]; - - let mut binding = actions.map(|action| { - KeyBinding::for_action_in(action, &self.focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(12.))) - }); + let font_family_cache = theme::FontFamilyCache::global(cx); - pages.map(|page| { - let i = page as usize; - let selected = self.selected_page == page; - h_flex() - .id(text[i]) - .relative() - .w_full() - .gap_2() - .px_2() - .py_0p5() - .justify_between() - .rounded_sm() - .when(selected, |this| { - this.child( - div() - .h_4() - .w_px() - .bg(cx.theme().colors().text_accent) - .absolute() - .left_0(), - ) + cx.new(|cx| { + cx.spawn(async move |this, cx| { + font_family_cache.prefetch(cx).await; + this.update(cx, |_, cx| { + cx.notify(); }) - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .child(Label::new(text[i]).map(|this| { - if selected { - this.color(Color::Default) - } else { - this.color(Color::Muted) - } - })) - .child(binding[i].take().map_or( - gpui::Empty.into_any_element(), - IntoElement::into_any_element, - )) - .on_click(cx.listener(move |this, click_event, _, cx| { - let click = match click_event { - gpui::ClickEvent::Mouse(_) => "mouse", - gpui::ClickEvent::Keyboard(_) => "keyboard", - }; - - this.set_page(page, Some(click), cx); - })) - }) - } - - fn render_nav(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ai_setup_page = matches!(self.selected_page, SelectedPage::AiSetup); + }) + .detach(); - v_flex() - .h_full() - .w(rems_from_px(220.)) - .flex_shrink_0() - .gap_4() - .justify_between() - .child( - v_flex() - .gap_6() - .child( - h_flex() - .px_2() - .gap_4() - .child(Vector::square(VectorName::ZedLogo, rems(2.5))) - .child( - v_flex() - .child( - Headline::new("Welcome to Zed").size(HeadlineSize::Small), - ) - .child( - Label::new("The editor for what's next") - .color(Color::Muted) - .size(LabelSize::Small) - .italic(), - ), - ), - ) - .child( - v_flex() - .gap_4() - .child( - v_flex() - .py_4() - .border_y_1() - .border_color(cx.theme().colors().border_variant.opacity(0.5)) - .gap_1() - .children(self.render_nav_buttons(window, cx)), - ) - .map(|this| { - let keybinding = KeyBinding::for_action_in( - &Finish, - &self.focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))); - - if ai_setup_page { - this.child( - ButtonLike::new("start_building") - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .child( - h_flex() - .ml_1() - .w_full() - .justify_between() - .child(Label::new("Start Building")) - .children(keybinding), - ) - .on_click(|_, window, cx| { - window.dispatch_action(Finish.boxed_clone(), cx); - }), - ) - } else { - this.child( - ButtonLike::new("skip_all") - .size(ButtonSize::Medium) - .child( - h_flex() - .ml_1() - .w_full() - .justify_between() - .child( - Label::new("Skip All").color(Color::Muted), - ) - .children(keybinding), - ) - .on_click(|_, window, cx| { - window.dispatch_action(Finish.boxed_clone(), cx); - }), - ) - } - }), - ), - ) - .child( - if let Some(user) = self.user_store.read(cx).current_user() { - v_flex() - .gap_1() - .child( - h_flex() - .ml_2() - .gap_2() - .max_w_full() - .w_full() - .child(Avatar::new(user.avatar_uri.clone())) - .child(Label::new(user.github_login.clone()).truncate()), - ) - .child( - ButtonLike::new("open_account") - .size(ButtonSize::Medium) - .child( - h_flex() - .ml_1() - .w_full() - .justify_between() - .child(Label::new("Open Account")) - .children( - KeyBinding::for_action_in( - &OpenAccount, - &self.focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenAccount.boxed_clone(), cx); - }), - ) - .into_any_element() - } else { - Button::new("sign_in", "Sign In") - .full_width() - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .key_binding( - KeyBinding::for_action_in(&SignIn, &self.focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(SignIn.boxed_clone(), cx); - }) - .into_any_element() - }, - ) + Self { + workspace: workspace.weak_handle(), + focus_handle: cx.focus_handle(), + scroll_handle: ScrollHandle::new(), + user_store: workspace.user_store().clone(), + _settings_subscription: cx + .observe_global::(move |_, cx| cx.notify()), + } + }) } fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) { - telemetry::event!("Welcome Skip Clicked"); + telemetry::event!("Finish Setup"); go_to_welcome_page(cx); } @@ -494,7 +250,7 @@ impl Onboarding { window .spawn(cx, async move |cx| { client - .sign_in_with_optional_connect(true, &cx) + .sign_in_with_optional_connect(true, cx) .await .notify_async_err(cx); }) @@ -505,29 +261,14 @@ impl Onboarding { cx.open_url(&zed_urls::account_url(cx)) } - fn render_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let client = Client::global(cx); - - match self.selected_page { - SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(), - SelectedPage::Editing => { - crate::editing_page::render_editing_page(window, cx).into_any_element() - } - SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page( - self.workspace.clone(), - self.user_store.clone(), - client, - window, - cx, - ) - .into_any_element(), - } + fn render_page(&mut self, cx: &mut Context) -> AnyElement { + crate::basics_page::render_basics_page(cx).into_any_element() } } impl Render for Onboarding { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() + div() .image_cache(gpui::retain_all("onboarding-page")) .key_context({ let mut ctx = KeyContext::new_with_defaults(); @@ -541,15 +282,6 @@ impl Render for Onboarding { .on_action(Self::on_finish) .on_action(Self::handle_sign_in) .on_action(Self::handle_open_account) - .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| { - this.set_page(SelectedPage::Basics, Some("action"), cx); - })) - .on_action(cx.listener(|this, _: &ActivateEditingPage, _, cx| { - this.set_page(SelectedPage::Editing, Some("action"), cx); - })) - .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| { - this.set_page(SelectedPage::AiSetup, Some("action"), cx); - })) .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| { window.focus_next(); cx.notify(); @@ -559,28 +291,66 @@ impl Render for Onboarding { cx.notify(); })) .child( - h_flex() - .max_w(rems_from_px(1100.)) - .max_h(rems_from_px(850.)) + div() + .max_w(Rems(48.0)) .size_full() - .m_auto() - .py_20() - .px_12() - .items_start() - .gap_12() - .child(self.render_nav(window, cx)) + .mx_auto() .child( v_flex() .id("page-content") + .m_auto() + .p_12() .size_full() .max_w_full() .min_w_0() - .pl_12() - .border_l_1() - .border_color(cx.theme().colors().border_variant.opacity(0.5)) + .gap_6() .overflow_y_scroll() - .child(self.render_page(window, cx)), - ), + .child( + h_flex() + .w_full() + .gap_4() + .justify_between() + .child( + h_flex() + .gap_4() + .child(Vector::square(VectorName::ZedLogo, rems(2.5))) + .child( + v_flex() + .child( + Headline::new("Welcome to Zed") + .size(HeadlineSize::Small), + ) + .child( + Label::new("The editor for what's next") + .color(Color::Muted) + .size(LabelSize::Small) + .italic(), + ), + ), + ) + .child({ + Button::new("finish_setup", "Finish Setup") + .style(ButtonStyle::Filled) + .size(ButtonSize::Medium) + .width(Rems(12.0)) + .key_binding( + KeyBinding::for_action_in( + &Finish, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(|_, window, cx| { + window.dispatch_action(Finish.boxed_clone(), cx); + }) + }), + ) + .child(Divider::horizontal().color(ui::DividerColor::BorderVariant)) + .child(self.render_page(cx)) + .track_scroll(&self.scroll_handle), + ) + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx), ) } } @@ -608,19 +378,23 @@ impl Item for Onboarding { false } + fn can_split(&self) -> bool { + true + } + fn clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, - ) -> Option> { - Some(cx.new(|cx| Onboarding { + ) -> Task>> { + Task::ready(Some(cx.new(|cx| Onboarding { workspace: self.workspace.clone(), user_store: self.user_store.clone(), - selected_page: self.selected_page, + scroll_handle: ScrollHandle::new(), focus_handle: cx.focus_handle(), _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), - })) + }))) } fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { @@ -802,25 +576,10 @@ impl workspace::SerializableItem for Onboarding { cx: &mut App, ) -> gpui::Task>> { window.spawn(cx, async move |cx| { - if let Some(page_number) = + if let Some(_) = persistence::ONBOARDING_PAGES.get_onboarding_page(item_id, workspace_id)? { - let page = match page_number { - 0 => Some(SelectedPage::Basics), - 1 => Some(SelectedPage::Editing), - 2 => Some(SelectedPage::AiSetup), - _ => None, - }; - workspace.update(cx, |workspace, cx| { - let onboarding_page = Onboarding::new(workspace, cx); - if let Some(page) = page { - zlog::info!("Onboarding page {page:?} loaded"); - onboarding_page.update(cx, |onboarding_page, cx| { - onboarding_page.set_page(page, None, cx); - }) - } - onboarding_page - }) + workspace.update(cx, |workspace, cx| Onboarding::new(workspace, cx)) } else { Err(anyhow::anyhow!("No onboarding page to deserialize")) } @@ -836,10 +595,10 @@ impl workspace::SerializableItem for Onboarding { cx: &mut ui::Context, ) -> Option>> { let workspace_id = workspace.database_id()?; - let page_number = self.selected_page as u16; + Some(cx.background_spawn(async move { persistence::ONBOARDING_PAGES - .save_onboarding_page(item_id, workspace_id, page_number) + .save_onboarding_page(item_id, workspace_id) .await })) } @@ -850,35 +609,56 @@ impl workspace::SerializableItem for Onboarding { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref ONBOARDING_PAGES: OnboardingPagesDb = - &[ - sql!( - CREATE TABLE onboarding_pages ( - workspace_id INTEGER, - item_id INTEGER UNIQUE, - page_number INTEGER, - - PRIMARY KEY(workspace_id, item_id), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ) STRICT; - ), - ]; + pub struct OnboardingPagesDb(ThreadSafeConnection); + + impl Domain for OnboardingPagesDb { + const NAME: &str = stringify!(OnboardingPagesDb); + + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE onboarding_pages ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + page_number INTEGER, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + ), + sql!( + CREATE TABLE onboarding_pages_2 ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + INSERT INTO onboarding_pages_2 SELECT workspace_id, item_id FROM onboarding_pages; + DROP TABLE onboarding_pages; + ALTER TABLE onboarding_pages_2 RENAME TO onboarding_pages; + ), + ]; } + db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]); + impl OnboardingPagesDb { query! { pub async fn save_onboarding_page( item_id: workspace::ItemId, - workspace_id: workspace::WorkspaceId, - page_number: u16 + workspace_id: workspace::WorkspaceId ) -> Result<()> { - INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id, page_number) - VALUES (?, ?, ?) + INSERT OR REPLACE INTO onboarding_pages(item_id, workspace_id) + VALUES (?, ?) } } @@ -886,8 +666,8 @@ mod persistence { pub fn get_onboarding_page( item_id: workspace::ItemId, workspace_id: workspace::WorkspaceId - ) -> Result> { - SELECT page_number + ) -> Result> { + SELECT item_id FROM onboarding_pages WHERE item_id = ? AND workspace_id = ? } diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 9f299eb6ea0a994097bac282b60f08decb7ed838..8bd65d8a2707acdc53333071486f41741398a82a 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -206,16 +206,16 @@ impl ThemePreviewTile { sidebar_width, skeleton_height.clone(), )) - .child(Self::render_pane(seed, theme, skeleton_height.clone())) + .child(Self::render_pane(seed, theme, skeleton_height)) } fn render_borderless(seed: f32, theme: Arc) -> impl IntoElement { - return Self::render_editor( + Self::render_editor( seed, theme, Self::SIDEBAR_WIDTH_DEFAULT, Self::SKELETON_HEIGHT_DEFAULT, - ); + ) } fn render_border(seed: f32, theme: Arc) -> impl IntoElement { @@ -246,7 +246,7 @@ impl ThemePreviewTile { ) -> impl IntoElement { let sidebar_width = relative(0.20); - return div() + div() .size_full() .p(Self::ROOT_PADDING) .rounded(Self::ROOT_RADIUS) @@ -260,7 +260,7 @@ impl ThemePreviewTile { .overflow_hidden() .child(div().size_full().child(Self::render_editor( seed, - theme.clone(), + theme, sidebar_width, Self::SKELETON_HEIGHT_DEFAULT, ))) @@ -278,7 +278,7 @@ impl ThemePreviewTile { )), ), ) - .into_any_element(); + .into_any_element() } } @@ -329,9 +329,9 @@ impl Component for ThemePreviewTile { let themes_to_preview = vec![ one_dark.clone().ok(), - one_light.clone().ok(), - gruvbox_dark.clone().ok(), - gruvbox_light.clone().ok(), + one_light.ok(), + gruvbox_dark.ok(), + gruvbox_light.ok(), ] .into_iter() .flatten() @@ -348,7 +348,7 @@ impl Component for ThemePreviewTile { div() .w(px(240.)) .h(px(180.)) - .child(ThemePreviewTile::new(one_dark.clone(), 0.42)) + .child(ThemePreviewTile::new(one_dark, 0.42)) .into_any_element(), )])] } else { @@ -362,13 +362,12 @@ impl Component for ThemePreviewTile { .gap_4() .children( themes_to_preview - .iter() - .enumerate() - .map(|(_, theme)| { + .into_iter() + .map(|theme| { div() .w(px(200.)) .h(px(140.)) - .child(ThemePreviewTile::new(theme.clone(), 0.42)) + .child(ThemePreviewTile::new(theme, 0.42)) }) .collect::>(), ) diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs index 610f6a98e322b24207777aa7f307b848e3a49f3c..b2711cd52d61a51711bd8ec90581b981d7bcf784 100644 --- a/crates/onboarding/src/welcome.rs +++ b/crates/onboarding/src/welcome.rs @@ -5,7 +5,7 @@ use gpui::{ use menu::{SelectNext, SelectPrevious}; use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; use workspace::{ - NewFile, Open, WorkspaceId, + NewFile, Open, item::{Item, ItemEvent}, with_active_or_new_workspace, }; @@ -78,13 +78,7 @@ struct Section { } impl Section { - fn render( - self, - index_offset: usize, - focus: &FocusHandle, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { + fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement { v_flex() .min_w_full() .child( @@ -104,7 +98,7 @@ impl Section { self.entries .iter() .enumerate() - .map(|(index, entry)| entry.render(index_offset + index, &focus, window, cx)), + .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), ) } } @@ -116,13 +110,7 @@ struct SectionEntry { } impl SectionEntry { - fn render( - &self, - button_index: usize, - focus: &FocusHandle, - window: &Window, - cx: &App, - ) -> impl IntoElement { + fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { ButtonLike::new(("onboarding-button-id", button_index)) .tab_index(button_index as isize) .full_width() @@ -141,9 +129,8 @@ impl SectionEntry { ) .child(Label::new(self.title)), ) - .children( - KeyBinding::for_action_in(self.action, focus, window, cx) - .map(|s| s.size(rems_from_px(12.))), + .child( + KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)), ), ) .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) @@ -167,7 +154,7 @@ impl WelcomePage { } impl Render for WelcomePage { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let (first_section, second_section) = CONTENT; let first_section_entries = first_section.entries.len(); let last_index = first_section_entries + second_section.entries.len(); @@ -215,13 +202,11 @@ impl Render for WelcomePage { .child(first_section.render( Default::default(), &self.focus_handle, - window, cx, )) .child(second_section.render( first_section_entries, &self.focus_handle, - window, cx, )) .child( @@ -339,15 +324,6 @@ impl Item for WelcomePage { false } - fn clone_on_split( - &self, - _workspace_id: Option, - _: &mut Window, - _: &mut Context, - ) -> Option> { - None - } - fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { f(*event) } @@ -414,13 +390,19 @@ impl workspace::SerializableItem for WelcomePage { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref WELCOME_PAGES: WelcomePagesDb = - &[ - sql!( + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( CREATE TABLE welcome_pages ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -430,10 +412,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - ), - ]; + )]); } + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + impl WelcomePagesDb { query! { pub async fn save_welcome_page( diff --git a/crates/open_ai/Cargo.toml b/crates/open_ai/Cargo.toml index bae00f0a8e888390cc8be4909e752567b94d1a27..49284eff79c11414c0811abd107f7c16ca701179 100644 --- a/crates/open_ai/Cargo.toml +++ b/crates/open_ai/Cargo.toml @@ -23,5 +23,5 @@ schemars = { workspace = true, optional = true } log.workspace = true serde.workspace = true serde_json.workspace = true +settings.workspace = true strum.workspace = true -workspace-hack.workspace = true diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 604e8fe6221e80661d515e6e865914dabcc2d170..1cada03a60c54668d2675c2e076345a9507fcb43 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -3,13 +3,14 @@ use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::B use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub use settings::OpenAiReasoningEffort as ReasoningEffort; use std::{convert::TryFrom, future::Future}; use strum::EnumIter; pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1"; fn is_none_or_empty, U>(opt: &Option) -> bool { - opt.as_ref().map_or(true, |v| v.as_ref().is_empty()) + opt.as_ref().is_none_or(|v| v.as_ref().is_empty()) } #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -241,7 +242,7 @@ impl Model { /// /// If the model does not support the parameter, do not pass it up. pub fn supports_prompt_cache_key(&self) -> bool { - return true; + true } } @@ -269,24 +270,15 @@ pub struct Request { } #[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] +#[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, Required, None, + #[serde(untagged)] Other(ToolDefinition), } -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -#[serde(rename_all = "lowercase")] -pub enum ReasoningEffort { - Minimal, - Low, - Medium, - High, -} - #[derive(Clone, Deserialize, Serialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ToolDefinition { @@ -432,16 +424,20 @@ pub struct ChoiceDelta { pub finish_reason: Option, } +#[derive(Serialize, Deserialize, Debug)] +pub struct OpenAiError { + message: String, +} + #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub enum ResponseStreamResult { Ok(ResponseStreamEvent), - Err { error: String }, + Err { error: OpenAiError }, } #[derive(Serialize, Deserialize, Debug)] pub struct ResponseStreamEvent { - pub model: String, pub choices: Vec, pub usage: Option, } @@ -457,7 +453,7 @@ pub async fn stream_completion( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)); + .header("Authorization", format!("Bearer {}", api_key.trim())); let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; let mut response = client.send(request).await?; @@ -468,14 +464,14 @@ pub async fn stream_completion( .filter_map(|line| async move { match line { Ok(line) => { - let line = line.strip_prefix("data: ")?; + let line = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:"))?; if line == "[DONE]" { None } else { match serde_json::from_str(line) { Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), Ok(ResponseStreamResult::Err { error }) => { - Some(Err(anyhow!(error))) + Some(Err(anyhow!(error.message))) } Err(error) => { log::error!( @@ -502,11 +498,6 @@ pub async fn stream_completion( error: OpenAiError, } - #[derive(Deserialize)] - struct OpenAiError { - message: String, - } - match serde_json::from_str::(&body) { Ok(response) if !response.error.message.is_empty() => Err(anyhow!( "API request to {} failed: {}", @@ -566,7 +557,7 @@ pub fn embed<'a>( .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)) + .header("Authorization", format!("Bearer {}", api_key.trim())) .body(body) .map(|request| client.send(request)); diff --git a/crates/open_router/Cargo.toml b/crates/open_router/Cargo.toml index bbc4fe190fa3985ef82505078d76dd06adf2abd9..cccb92c33b05b8fff0e5e78277c9f7fa29844ace 100644 --- a/crates/open_router/Cargo.toml +++ b/crates/open_router/Cargo.toml @@ -22,4 +22,6 @@ http_client.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -workspace-hack.workspace = true +settings.workspace = true +strum.workspace = true +thiserror.workspace = true diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 3e6e406d9842d5996f2e866d534094ded23fd61c..0081c877756dab46433481ac58f2180877e7667f 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -1,14 +1,37 @@ -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; -use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::convert::TryFrom; +pub use settings::DataCollection; +pub use settings::ModelMode; +pub use settings::OpenRouterAvailableModel as AvailableModel; +pub use settings::OpenRouterProvider as Provider; +use std::{convert::TryFrom, io, time::Duration}; +use strum::EnumString; +use thiserror::Error; pub const OPEN_ROUTER_API_URL: &str = "https://openrouter.ai/api/v1"; +fn extract_retry_after(headers: &http::HeaderMap) -> Option { + if let Some(reset) = headers.get("X-RateLimit-Reset") { + if let Ok(s) = reset.to_str() { + if let Ok(epoch_ms) = s.parse::() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if epoch_ms > now { + return Some(std::time::Duration::from_millis(epoch_ms - now)); + } + } + } + } + None +} + fn is_none_or_empty, U>(opt: &Option) -> bool { - opt.as_ref().map_or(true, |v| v.as_ref().is_empty()) + opt.as_ref().is_none_or(|v| v.as_ref().is_empty()) } #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -55,16 +78,7 @@ pub struct Model { pub supports_images: Option, #[serde(default)] pub mode: ModelMode, -} - -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub enum ModelMode { - #[default] - Default, - Thinking { - budget_tokens: Option, - }, + pub provider: Option, } impl Model { @@ -76,6 +90,7 @@ impl Model { Some(true), Some(false), Some(ModelMode::Default), + None, ) } @@ -90,6 +105,7 @@ impl Model { supports_tools: Option, supports_images: Option, mode: Option, + provider: Option, ) -> Self { Self { name: name.to_owned(), @@ -98,6 +114,7 @@ impl Model { supports_tools, supports_images, mode: mode.unwrap_or(ModelMode::Default), + provider, } } @@ -145,6 +162,7 @@ pub struct Request { #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, pub usage: RequestUsage, + pub provider: Option, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -240,10 +258,10 @@ impl MessageContent { impl From> for MessageContent { fn from(parts: Vec) -> Self { - if parts.len() == 1 { - if let MessagePart::Text { text } = &parts[0] { - return Self::Plain(text.clone()); - } + if parts.len() == 1 + && let MessagePart::Text { text } = &parts[0] + { + return Self::Plain(text.clone()); } Self::Multipart(parts) } @@ -413,76 +431,12 @@ pub struct ModelArchitecture { pub input_modalities: Vec, } -pub async fn complete( - client: &dyn HttpClient, - api_url: &str, - api_key: &str, - request: Request, -) -> Result { - let uri = format!("{api_url}/chat/completions"); - let request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)) - .header("HTTP-Referer", "https://zed.dev") - .header("X-Title", "Zed Editor"); - - let mut request_body = request; - request_body.stream = false; - - let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request_body)?))?; - let mut response = client.send(request).await?; - - if response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - let response: Response = serde_json::from_str(&body)?; - Ok(response) - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - #[derive(Deserialize)] - struct OpenRouterResponse { - error: OpenRouterError, - } - - #[derive(Deserialize)] - struct OpenRouterError { - message: String, - #[serde(default)] - code: String, - } - - match serde_json::from_str::(&body) { - Ok(response) if !response.error.message.is_empty() => { - let error_message = if !response.error.code.is_empty() { - format!("{}: {}", response.error.code, response.error.message) - } else { - response.error.message - }; - - Err(anyhow!( - "Failed to connect to OpenRouter API: {}", - error_message - )) - } - _ => Err(anyhow!( - "Failed to connect to OpenRouter API: {} {}", - response.status(), - body, - )), - } - } -} - pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, api_key: &str, request: Request, -) -> Result>> { +) -> Result>, OpenRouterError> { let uri = format!("{api_url}/chat/completions"); let request_builder = HttpRequest::builder() .method(Method::POST) @@ -492,8 +446,15 @@ pub async fn stream_completion( .header("HTTP-Referer", "https://zed.dev") .header("X-Title", "Zed Editor"); - let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; - let mut response = client.send(request).await?; + let request = request_builder + .body(AsyncBody::from( + serde_json::to_string(&request).map_err(OpenRouterError::SerializeRequest)?, + )) + .map_err(OpenRouterError::BuildRequestBody)?; + let mut response = client + .send(request) + .await + .map_err(OpenRouterError::HttpSend)?; if response.status().is_success() { let reader = BufReader::new(response.into_body()); @@ -513,86 +474,89 @@ pub async fn stream_completion( match serde_json::from_str::(line) { Ok(response) => Some(Ok(response)), Err(error) => { - #[derive(Deserialize)] - struct ErrorResponse { - error: String, - } - - match serde_json::from_str::(line) { - Ok(err_response) => Some(Err(anyhow!(err_response.error))), - Err(_) => { - if line.trim().is_empty() { - None - } else { - Some(Err(anyhow!( - "Failed to parse response: {}. Original content: '{}'", - error, line - ))) - } - } + if line.trim().is_empty() { + None + } else { + Some(Err(OpenRouterError::DeserializeResponse(error))) } } } } } - Err(error) => Some(Err(anyhow!(error))), + Err(error) => Some(Err(OpenRouterError::ReadResponse(error))), } }) .boxed()) } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - #[derive(Deserialize)] - struct OpenRouterResponse { - error: OpenRouterError, - } - - #[derive(Deserialize)] - struct OpenRouterError { - message: String, - #[serde(default)] - code: String, - } + let code = ApiErrorCode::from_status(response.status().as_u16()); - match serde_json::from_str::(&body) { - Ok(response) if !response.error.message.is_empty() => { - let error_message = if !response.error.code.is_empty() { - format!("{}: {}", response.error.code, response.error.message) - } else { - response.error.message - }; - - Err(anyhow!( - "Failed to connect to OpenRouter API: {}", - error_message - )) + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(OpenRouterError::ReadResponse)?; + + let error_response = match serde_json::from_str::(&body) { + Ok(OpenRouterErrorResponse { error }) => error, + Err(_) => OpenRouterErrorBody { + code: response.status().as_u16(), + message: body, + metadata: None, + }, + }; + + match code { + ApiErrorCode::RateLimitError => { + let retry_after = extract_retry_after(response.headers()); + Err(OpenRouterError::RateLimit { + retry_after: retry_after.unwrap_or_else(|| std::time::Duration::from_secs(60)), + }) + } + ApiErrorCode::OverloadedError => { + let retry_after = extract_retry_after(response.headers()); + Err(OpenRouterError::ServerOverloaded { retry_after }) } - _ => Err(anyhow!( - "Failed to connect to OpenRouter API: {} {}", - response.status(), - body, - )), + _ => Err(OpenRouterError::ApiError(ApiError { + code: code, + message: error_response.message, + })), } } } -pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result> { - let uri = format!("{api_url}/models"); +pub async fn list_models( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, +) -> Result, OpenRouterError> { + let uri = format!("{api_url}/models/user"); let request_builder = HttpRequest::builder() .method(Method::GET) .uri(uri) - .header("Accept", "application/json"); + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .header("HTTP-Referer", "https://zed.dev") + .header("X-Title", "Zed Editor"); - let request = request_builder.body(AsyncBody::default())?; - let mut response = client.send(request).await?; + let request = request_builder + .body(AsyncBody::default()) + .map_err(OpenRouterError::BuildRequestBody)?; + let mut response = client + .send(request) + .await + .map_err(OpenRouterError::HttpSend)?; let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(OpenRouterError::ReadResponse)?; if response.status().is_success() { let response: ListModelsResponse = - serde_json::from_str(&body).context("Unable to parse OpenRouter models response")?; + serde_json::from_str(&body).map_err(OpenRouterError::DeserializeResponse)?; let models = response .data @@ -632,15 +596,147 @@ pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result(&body) { + Ok(OpenRouterErrorResponse { error }) => error, + Err(_) => OpenRouterErrorBody { + code: response.status().as_u16(), + message: body, + metadata: None, + }, + }; + + match code { + ApiErrorCode::RateLimitError => { + let retry_after = extract_retry_after(response.headers()); + Err(OpenRouterError::RateLimit { + retry_after: retry_after.unwrap_or_else(|| std::time::Duration::from_secs(60)), + }) + } + ApiErrorCode::OverloadedError => { + let retry_after = extract_retry_after(response.headers()); + Err(OpenRouterError::ServerOverloaded { retry_after }) + } + _ => Err(OpenRouterError::ApiError(ApiError { + code: code, + message: error_response.message, + })), + } + } +} + +#[derive(Debug)] +pub enum OpenRouterError { + /// Failed to serialize the HTTP request body to JSON + SerializeRequest(serde_json::Error), + + /// Failed to construct the HTTP request body + BuildRequestBody(http::Error), + + /// Failed to send the HTTP request + HttpSend(anyhow::Error), + + /// Failed to deserialize the response from JSON + DeserializeResponse(serde_json::Error), + + /// Failed to read from response stream + ReadResponse(io::Error), + + /// Rate limit exceeded + RateLimit { retry_after: Duration }, + + /// Server overloaded + ServerOverloaded { retry_after: Option }, + + /// API returned an error response + ApiError(ApiError), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenRouterErrorBody { + pub code: u16, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenRouterErrorResponse { + pub error: OpenRouterErrorBody, +} + +#[derive(Debug, Serialize, Deserialize, Error)] +#[error("OpenRouter API Error: {code}: {message}")] +pub struct ApiError { + pub code: ApiErrorCode, + pub message: String, +} + +/// An OpenROuter API error code. +/// +#[derive(Debug, PartialEq, Eq, Clone, Copy, EnumString, Serialize, Deserialize)] +#[strum(serialize_all = "snake_case")] +pub enum ApiErrorCode { + /// 400: Bad Request (invalid or missing params, CORS) + InvalidRequestError, + /// 401: Invalid credentials (OAuth session expired, disabled/invalid API key) + AuthenticationError, + /// 402: Your account or API key has insufficient credits. Add more credits and retry the request. + PaymentRequiredError, + /// 403: Your chosen model requires moderation and your input was flagged + PermissionError, + /// 408: Your request timed out + RequestTimedOut, + /// 429: You are being rate limited + RateLimitError, + /// 502: Your chosen model is down or we received an invalid response from it + ApiError, + /// 503: There is no available model provider that meets your routing requirements + OverloadedError, +} + +impl std::fmt::Display for ApiErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ApiErrorCode::InvalidRequestError => "invalid_request_error", + ApiErrorCode::AuthenticationError => "authentication_error", + ApiErrorCode::PaymentRequiredError => "payment_required_error", + ApiErrorCode::PermissionError => "permission_error", + ApiErrorCode::RequestTimedOut => "request_timed_out", + ApiErrorCode::RateLimitError => "rate_limit_error", + ApiErrorCode::ApiError => "api_error", + ApiErrorCode::OverloadedError => "overloaded_error", + }; + write!(f, "{s}") + } +} + +impl ApiErrorCode { + pub fn from_status(status: u16) -> Self { + match status { + 400 => ApiErrorCode::InvalidRequestError, + 401 => ApiErrorCode::AuthenticationError, + 402 => ApiErrorCode::PaymentRequiredError, + 403 => ApiErrorCode::PermissionError, + 408 => ApiErrorCode::RequestTimedOut, + 429 => ApiErrorCode::RateLimitError, + 502 => ApiErrorCode::ApiError, + 503 => ApiErrorCode::OverloadedError, + _ => ApiErrorCode::ApiError, + } } } diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index d4c69acbf9f72f498623e2f253dabe3e960209f0..5069fa2373d16e7afb69f8f9899d86edb09d55a9 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -26,7 +26,6 @@ ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 8c5e78d77bce76e62ef94d2501dbef588cd76f00..9e49fabb474d765aa79703ef55c1c98842bee209 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use editor::scroll::ScrollOffset; use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll}; use editor::{RowHighlightOptions, SelectionEffects}; use fuzzy::StringMatch; @@ -19,7 +20,7 @@ use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; -use workspace::{DismissDecision, ModalView}; +use workspace::{DismissDecision, ModalView, Workspace}; pub fn init(cx: &mut App) { cx.observe_new(OutlineView::register).detach(); @@ -47,7 +48,8 @@ pub fn toggle( .snapshot(cx) .outline(Some(cx.theme().syntax())); - if let Some((workspace, outline)) = editor.read(cx).workspace().zip(outline) { + let workspace = window.root::().flatten(); + if let Some((workspace, outline)) = workspace.zip(outline) { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { OutlineView::new(outline, editor, window, cx) @@ -81,8 +83,19 @@ impl ModalView for OutlineView { } impl Render for OutlineView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w(rems(34.)) + .on_action(cx.listener( + |_this: &mut OutlineView, + _: &zed_actions::outline::ToggleOutline, + _window: &mut Window, + cx: &mut Context| { + // When outline::Toggle is triggered while the outline is open, dismiss it + cx.emit(DismissEvent); + }, + )) + .child(self.picker.clone()) } } @@ -119,7 +132,7 @@ struct OutlineViewDelegate { active_editor: Entity, outline: Outline, selected_match_index: usize, - prev_scroll_position: Option>, + prev_scroll_position: Option>, matches: Vec, last_query: String, } @@ -232,7 +245,10 @@ impl PickerDelegate for OutlineViewDelegate { let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); - let cursor_offset = editor.selections.newest::(cx).head(); + let cursor_offset = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); (buffer, cursor_offset) }); selected_index = self @@ -378,7 +394,7 @@ mod tests { use language::{Language, LanguageConfig, LanguageMatcher}; use project::{FakeFs, Project}; use serde_json::json; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{AppState, Workspace}; #[gpui::test] @@ -419,7 +435,7 @@ mod tests { .unwrap(); let editor = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_path((worktree_id, "a.rs"), None, true, window, cx) + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) }) .await .unwrap() @@ -660,7 +676,7 @@ mod tests { let selections = editor.update(cx, |editor, cx| { editor .selections - .all::(cx) + .all::(&editor.display_snapshot(cx)) .into_iter() .map(|s| s.start..s.end) .collect::>() diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 6950929304fb37e1f0d140adc2b461188cbeaaf1..72e2d1eb63b1253e66bf2b7ef46dfb714fb24db6 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -26,7 +26,6 @@ log.workspace = true menu.workspace = true outline.workspace = true project.workspace = true -schemars.workspace = true search.workspace = true serde.workspace = true serde_json.workspace = true @@ -39,7 +38,6 @@ util.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true -workspace-hack.workspace = true [dev-dependencies] search = { workspace = true, features = ["test-support"] } diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 004a27b0cf06a2be90969767ddd95b8eb4de47e6..112aa3d21ebda9ef57d3bedda20e3f90735a0173 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -4,11 +4,11 @@ use anyhow::Context as _; use collections::{BTreeSet, HashMap, HashSet, hash_map}; use db::kvp::KEY_VALUE_STORE; use editor::{ - AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorSettings, ExcerptId, - ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects, ShowScrollbar, + AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptId, ExcerptRange, + MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects, display_map::ToDisplayPoint, items::{entry_git_aware_label_color, entry_label_color}, - scroll::{Autoscroll, ScrollAnchor, ScrollbarAutoHide}, + scroll::{Autoscroll, ScrollAnchor}, }; use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; @@ -29,7 +29,7 @@ use std::{ collections::BTreeMap, hash::Hash, ops::Range, - path::{MAIN_SEPARATOR_STR, Path, PathBuf}, + path::{Path, PathBuf}, sync::{ Arc, OnceLock, atomic::{self, AtomicBool}, @@ -38,26 +38,25 @@ use std::{ u32, }; -use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; +use outline_panel_settings::{DockSide, OutlinePanelSettings, ShowIndentGuides}; use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use smol::channel; use theme::{SyntaxTheme, ThemeSettings}; -use ui::{DynamicSpacing, IndentGuideColors, IndentGuideLayout}; -use util::{RangeExt, ResultExt, TryFutureExt, debug_panic}; +use ui::{ + ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, DynamicSpacing, FluentBuilder, + HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, IndentGuideColors, + IndentGuideLayout, Label, LabelCommon, ListItem, ScrollAxes, Scrollbars, StyledExt, + StyledTypography, Toggleable, Tooltip, WithScrollbar, h_flex, v_flex, +}; +use util::{RangeExt, ResultExt, TryFutureExt, debug_panic, rel_path::RelPath}; use workspace::{ OpenInTerminal, WeakItemHandle, Workspace, dock::{DockPosition, Panel, PanelEvent}, item::ItemHandle, searchable::{SearchEvent, SearchableItem}, - ui::{ - ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder, HighlightedLabel, - Icon, IconButton, IconButtonShape, IconName, IconSize, Label, LabelCommon, ListItem, - Scrollbar, ScrollbarState, StyledExt, StyledTypography, Toggleable, Tooltip, h_flex, - v_flex, - }, }; use worktree::{Entry, ProjectEntryId, WorktreeId}; @@ -108,7 +107,7 @@ pub struct OutlinePanel { pending_serialization: Task>, fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>, fs_entries: Vec, - fs_children_count: HashMap, FsChildren>>, + fs_children_count: HashMap, FsChildren>>, collapsed_entries: HashSet, unfolded_dirs: HashMap>, selected_entry: SelectedEntry, @@ -125,10 +124,6 @@ pub struct OutlinePanel { cached_entries: Vec, filter_editor: Entity, mode: ItemsDisplayMode, - show_scrollbar: bool, - vertical_scrollbar_state: ScrollbarState, - horizontal_scrollbar_state: ScrollbarState, - hide_scrollbar_task: Option>, max_width_item_index: Option, preserve_selection_on_buffer_fold_toggles: HashSet, pending_default_expansion_depth: Option, @@ -503,16 +498,16 @@ impl SearchData { && multi_buffer_snapshot .chars_at(extended_context_left_border) .last() - .map_or(false, |c| !c.is_whitespace()); + .is_some_and(|c| !c.is_whitespace()); let truncated_right = entire_context_text .chars() .last() - .map_or(true, |c| !c.is_whitespace()) + .is_none_or(|c| !c.is_whitespace()) && extended_context_right_border > context_right_border && multi_buffer_snapshot .chars_at(extended_context_right_border) .next() - .map_or(false, |c| !c.is_whitespace()); + .is_some_and(|c| !c.is_whitespace()); search_match_indices.iter_mut().for_each(|range| { range.start = multi_buffer_snapshot.clip_offset( range.start.saturating_sub(left_whitespaces_offset), @@ -733,10 +728,11 @@ impl OutlinePanel { ) -> Entity { let project = workspace.project().clone(); let workspace_handle = cx.entity().downgrade(); - let outline_panel = cx.new(|cx| { + + cx.new(|cx| { let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Filter...", cx); + editor.set_placeholder_text("Filter...", window, cx); editor }); let filter_update_subscription = cx.subscribe_in( @@ -751,10 +747,6 @@ impl OutlinePanel { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, Self::focus_in); - let focus_out_subscription = - cx.on_focus_out(&focus_handle, window, |outline_panel, _, window, cx| { - outline_panel.hide_scrollbar(window, cx); - }); let workspace_subscription = cx.subscribe_in( &workspace .weak_handle() @@ -867,12 +859,6 @@ impl OutlinePanel { workspace: workspace_handle, project, fs: workspace.app_state().fs.clone(), - show_scrollbar: !Self::should_autohide_scrollbar(cx), - hide_scrollbar_task: None, - vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) - .parent_entity(&cx.entity()), - horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone()) - .parent_entity(&cx.entity()), max_width_item_index: None, scroll_handle, focus_handle, @@ -902,7 +888,6 @@ impl OutlinePanel { settings_subscription, icons_subscription, focus_subscription, - focus_out_subscription, workspace_subscription, filter_update_subscription, ], @@ -912,9 +897,7 @@ impl OutlinePanel { outline_panel.replace_active_editor(item, editor, window, cx); } outline_panel - }); - - outline_panel + }) } fn serialization_key(workspace: &Workspace) -> Option { @@ -1170,12 +1153,11 @@ impl OutlinePanel { }); } else { let mut offset = Point::default(); - if let Some(buffer_id) = scroll_to_buffer { - if multi_buffer_snapshot.as_singleton().is_none() - && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) - { - offset.y = -(active_editor.read(cx).file_header_size() as f32); - } + if let Some(buffer_id) = scroll_to_buffer + && multi_buffer_snapshot.as_singleton().is_none() + && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) + { + offset.y = -(active_editor.read(cx).file_header_size() as f64); } active_editor.update(cx, |editor, cx| { @@ -1260,7 +1242,7 @@ impl OutlinePanel { dirs_worktree_id == worktree_id && dirs .last() - .map_or(false, |dir| dir.path.as_ref() == parent_path) + .is_some_and(|dir| dir.path.as_ref() == parent_path) } _ => false, }) @@ -1454,9 +1436,7 @@ impl OutlinePanel { if self .unfolded_dirs .get(&directory_worktree) - .map_or(true, |unfolded_dirs| { - !unfolded_dirs.contains(&directory_entry.id) - }) + .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.id)) { return false; } @@ -1606,16 +1586,14 @@ impl OutlinePanel { } PanelEntry::FoldedDirs(folded_dirs) => { let mut folded = false; - if let Some(dir_entry) = folded_dirs.entries.last() { - if self + if let Some(dir_entry) = folded_dirs.entries.last() + && self .collapsed_entries .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id)) - { - folded = true; - buffers_to_fold.extend( - self.buffers_inside_directory(folded_dirs.worktree_id, dir_entry), - ); - } + { + folded = true; + buffers_to_fold + .extend(self.buffers_inside_directory(folded_dirs.worktree_id, dir_entry)); } folded } @@ -1915,7 +1893,7 @@ impl OutlinePanel { if let Some(clipboard_text) = self .selected_entry() .and_then(|entry| self.abs_path(entry, cx)) - .map(|p| p.to_string_lossy().to_string()) + .map(|p| p.to_string_lossy().into_owned()) { cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text)); } @@ -1927,6 +1905,7 @@ impl OutlinePanel { _: &mut Window, cx: &mut Context, ) { + let path_style = self.project.read(cx).path_style(cx); if let Some(clipboard_text) = self .selected_entry() .and_then(|entry| match entry { @@ -1936,7 +1915,7 @@ impl OutlinePanel { } PanelEntry::Search(_) | PanelEntry::Outline(..) => None, }) - .map(|p| p.to_string_lossy().to_string()) + .map(|p| p.display(path_style).to_string()) { cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text)); } @@ -2108,11 +2087,11 @@ impl OutlinePanel { dirs_to_expand.push(current_entry.id); } - if traversal.back_to_parent() { - if let Some(parent_entry) = traversal.entry() { - current_entry = parent_entry.clone(); - continue; - } + if traversal.back_to_parent() + && let Some(parent_entry) = traversal.entry() + { + current_entry = parent_entry.clone(); + continue; } break; } @@ -2159,7 +2138,7 @@ impl OutlinePanel { ExcerptOutlines::Invalidated(outlines) => Some(outlines), ExcerptOutlines::NotFetched => None, }) - .map_or(false, |outlines| !outlines.is_empty()); + .is_some_and(|outlines| !outlines.is_empty()); let is_expanded = !self .collapsed_entries .contains(&CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)); @@ -2294,7 +2273,7 @@ impl OutlinePanel { let color = entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active); let icon = if settings.file_icons { - FileIcons::get_icon(&entry.path, cx) + FileIcons::get_icon(entry.path.as_std_path(), cx) .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element()) } else { None @@ -2325,7 +2304,7 @@ impl OutlinePanel { is_active, ); let icon = if settings.folder_icons { - FileIcons::get_folder_icon(is_expanded, cx) + FileIcons::get_folder_icon(is_expanded, directory.entry.path.as_std_path(), cx) } else { FileIcons::get_chevron_icon(is_expanded, cx) } @@ -2351,13 +2330,13 @@ impl OutlinePanel { Some(file) => { let path = file.path(); let icon = if settings.file_icons { - FileIcons::get_icon(path.as_ref(), cx) + FileIcons::get_icon(path.as_std_path(), cx) } else { None } .map(Icon::from_path) .map(|icon| icon.color(color).into_any_element()); - (icon, file_name(path.as_ref())) + (icon, file_name(path.as_std_path())) } None => (None, "Untitled".to_string()), }, @@ -2422,7 +2401,7 @@ impl OutlinePanel { .unwrap_or_default(); let color = entry_git_aware_label_color(git_status, is_ignored, is_active); let icon = if settings.folder_icons { - FileIcons::get_folder_icon(is_expanded, cx) + FileIcons::get_folder_icon(is_expanded, &Path::new(&name), cx) } else { FileIcons::get_chevron_icon(is_expanded, cx) } @@ -2475,17 +2454,17 @@ impl OutlinePanel { let search_data = match render_data.get() { Some(search_data) => search_data, None => { - if let ItemsDisplayMode::Search(search_state) = &mut self.mode { - if let Some(multi_buffer_snapshot) = multi_buffer_snapshot { - search_state - .highlight_search_match_tx - .try_send(HighlightArguments { - multi_buffer_snapshot: multi_buffer_snapshot.clone(), - match_range: match_range.clone(), - search_data: Arc::clone(render_data), - }) - .ok(); - } + if let ItemsDisplayMode::Search(search_state) = &mut self.mode + && let Some(multi_buffer_snapshot) = multi_buffer_snapshot + { + search_state + .highlight_search_match_tx + .try_send(HighlightArguments { + multi_buffer_snapshot: multi_buffer_snapshot.clone(), + match_range: match_range.clone(), + search_data: Arc::clone(render_data), + }) + .ok(); } return None; } @@ -2505,6 +2484,7 @@ impl OutlinePanel { annotation_range: None, range: search_data.context_range.clone(), text: search_data.context_text.clone(), + source_range_for_text: search_data.context_range.clone(), highlight_ranges: search_data .highlights_data .get() @@ -2629,7 +2609,7 @@ impl OutlinePanel { } fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String { - let name = match self.project.read(cx).worktree_for_id(*worktree_id, cx) { + match self.project.read(cx).worktree_for_id(*worktree_id, cx) { Some(worktree) => { let worktree = worktree.read(cx); match worktree.snapshot().root_entry() { @@ -2637,21 +2617,18 @@ impl OutlinePanel { if root_entry.id == entry.id { file_name(worktree.abs_path().as_ref()) } else { - let path = worktree.absolutize(entry.path.as_ref()).ok(); - let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); - file_name(path) + let path = worktree.absolutize(entry.path.as_ref()); + file_name(&path) } } None => { - let path = worktree.absolutize(entry.path.as_ref()).ok(); - let path = path.as_deref().unwrap_or_else(|| entry.path.as_ref()); - file_name(path) + let path = worktree.absolutize(entry.path.as_ref()); + file_name(&path) } } } - None => file_name(entry.path.as_ref()), - }; - name + None => file_name(entry.path.as_std_path()), + } } fn update_fs_entries( @@ -2686,12 +2663,13 @@ impl OutlinePanel { new_collapsed_entries = outline_panel.collapsed_entries.clone(); new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); - let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( + + multi_buffer_snapshot.excerpts().fold( HashMap::default(), |mut buffer_excerpts, (excerpt_id, buffer_snapshot, excerpt_range)| { let buffer_id = buffer_snapshot.remote_id(); let file = File::from_dyn(buffer_snapshot.file()); - let entry_id = file.and_then(|file| file.project_entry_id(cx)); + let entry_id = file.and_then(|file| file.project_entry_id()); let worktree = file.map(|file| file.worktree.read(cx).snapshot()); let is_new = new_entries.contains(&excerpt_id) || !outline_panel.excerpts.contains_key(&buffer_id); @@ -2733,8 +2711,7 @@ impl OutlinePanel { ); buffer_excerpts }, - ); - buffer_excerpts + ) }) else { return; }; @@ -2833,11 +2810,12 @@ impl OutlinePanel { let new_entry_added = entries_to_add .insert(current_entry.id, current_entry) .is_none(); - if new_entry_added && traversal.back_to_parent() { - if let Some(parent_entry) = traversal.entry() { - current_entry = parent_entry.to_owned(); - continue; - } + if new_entry_added + && traversal.back_to_parent() + && let Some(parent_entry) = traversal.entry() + { + current_entry = parent_entry.to_owned(); + continue; } break; } @@ -2864,7 +2842,7 @@ impl OutlinePanel { } let mut new_children_count = - HashMap::, FsChildren>>::default(); + HashMap::, FsChildren>>::default(); let worktree_entries = new_worktree_entries .into_iter() @@ -2878,18 +2856,17 @@ impl OutlinePanel { entries .into_iter() .filter_map(|entry| { - if auto_fold_dirs { - if let Some(parent) = entry.path.parent() { - let children = new_children_count - .entry(worktree_id) - .or_default() - .entry(Arc::from(parent)) - .or_default(); - if entry.is_dir() { - children.dirs += 1; - } else { - children.files += 1; - } + if auto_fold_dirs && let Some(parent) = entry.path.parent() + { + let children = new_children_count + .entry(worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default(); + if entry.is_dir() { + children.dirs += 1; + } else { + children.files += 1; } } @@ -2956,7 +2933,7 @@ impl OutlinePanel { .map(|(parent_dir_id, _)| { new_unfolded_dirs .get(&directory.worktree_id) - .map_or(true, |unfolded_dirs| { + .is_none_or(|unfolded_dirs| { unfolded_dirs .contains(parent_dir_id) }) @@ -3123,7 +3100,10 @@ impl OutlinePanel { cx: &mut Context, ) -> Option { let selection = editor.update(cx, |editor, cx| { - editor.selections.newest::(cx).head() + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head() }); let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let multi_buffer = editor.read(cx).buffer(); @@ -3216,13 +3196,13 @@ impl OutlinePanel { .into_iter() .flat_map(|excerpt| excerpt.iter_outlines()) .flat_map(|outline| { - let start = multi_buffer_snapshot - .anchor_in_excerpt(excerpt_id, outline.range.start)? - .to_display_point(&editor_snapshot); - let end = multi_buffer_snapshot - .anchor_in_excerpt(excerpt_id, outline.range.end)? - .to_display_point(&editor_snapshot); - Some((start..end, outline)) + let range = multi_buffer_snapshot + .anchor_range_in_excerpt(excerpt_id, outline.range.clone())?; + Some(( + range.start.to_display_point(&editor_snapshot) + ..range.end.to_display_point(&editor_snapshot), + outline, + )) }) .collect::>(); @@ -3357,13 +3337,11 @@ impl OutlinePanel { let buffer_language = buffer_snapshot.language().cloned(); let fetched_outlines = cx .background_spawn(async move { - let mut outlines = buffer_snapshot - .outline_items_containing( - excerpt_range.context, - false, - Some(&syntax_theme), - ) - .unwrap_or_default(); + let mut outlines = buffer_snapshot.outline_items_containing( + excerpt_range.context, + false, + Some(&syntax_theme), + ); outlines.retain(|outline| { buffer_language.is_none() || buffer_language.as_ref() @@ -3409,30 +3387,29 @@ impl OutlinePanel { { excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); - if let Some(default_depth) = pending_default_depth { - if let ExcerptOutlines::Outlines(outlines) = + if let Some(default_depth) = pending_default_depth + && let ExcerptOutlines::Outlines(outlines) = &excerpt.outlines - { - outlines - .iter() - .filter(|outline| { - (default_depth == 0 - || outline.depth >= default_depth) - && outlines_with_children.contains(&( - outline.range.clone(), - outline.depth, - )) - }) - .for_each(|outline| { - outline_panel.collapsed_entries.insert( - CollapsedEntry::Outline( - buffer_id, - excerpt_id, - outline.range.clone(), - ), - ); - }); - } + { + outlines + .iter() + .filter(|outline| { + (default_depth == 0 + || outline.depth >= default_depth) + && outlines_with_children.contains(&( + outline.range.clone(), + outline.depth, + )) + }) + .for_each(|outline| { + outline_panel.collapsed_entries.insert( + CollapsedEntry::Outline( + buffer_id, + excerpt_id, + outline.range.clone(), + ), + ); + }); } // Even if no outlines to check, we still need to update cached entries @@ -3448,9 +3425,8 @@ impl OutlinePanel { } fn is_singleton_active(&self, cx: &App) -> bool { - self.active_editor().map_or(false, |active_editor| { - active_editor.read(cx).buffer().read(cx).is_singleton() - }) + self.active_editor() + .is_some_and(|active_editor| active_editor.read(cx).buffer().read(cx).is_singleton()) } fn invalidate_outlines(&mut self, ids: &[ExcerptId]) { @@ -3545,17 +3521,17 @@ impl OutlinePanel { .buffer_snapshot_for_id(*buffer_id, cx) .and_then(|buffer_snapshot| { let file = File::from_dyn(buffer_snapshot.file())?; - file.worktree.read(cx).absolutize(&file.path).ok() + Some(file.worktree.read(cx).absolutize(&file.path)) }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. - })) => self - .project - .read(cx) - .worktree_for_id(*worktree_id, cx)? - .read(cx) - .absolutize(&entry.path) - .ok(), + })) => Some( + self.project + .read(cx) + .worktree_for_id(*worktree_id, cx)? + .read(cx) + .absolutize(&entry.path), + ), PanelEntry::FoldedDirs(FoldedDirsEntry { worktree_id, entries: dirs, @@ -3564,13 +3540,13 @@ impl OutlinePanel { self.project .read(cx) .worktree_for_id(*worktree_id, cx) - .and_then(|worktree| worktree.read(cx).absolutize(&entry.path).ok()) + .map(|worktree| worktree.read(cx).absolutize(&entry.path)) }), PanelEntry::Search(_) | PanelEntry::Outline(..) => None, } } - fn relative_path(&self, entry: &FsEntry, cx: &App) -> Option> { + fn relative_path(&self, entry: &FsEntry, cx: &App) -> Option> { match entry { FsEntry::ExternalFile(FsEntryExternalFile { buffer_id, .. }) => { let buffer_snapshot = self.buffer_snapshot_for_id(*buffer_id, cx)?; @@ -3611,10 +3587,9 @@ impl OutlinePanel { .update_in(cx, |outline_panel, window, cx| { outline_panel.cached_entries = new_cached_entries; outline_panel.max_width_item_index = max_width_item_index; - if outline_panel.selected_entry.is_invalidated() - || matches!(outline_panel.selected_entry, SelectedEntry::None) - { - if let Some(new_selected_entry) = + if (outline_panel.selected_entry.is_invalidated() + || matches!(outline_panel.selected_entry, SelectedEntry::None)) + && let Some(new_selected_entry) = outline_panel.active_editor().and_then(|active_editor| { outline_panel.location_for_editor_selection( &active_editor, @@ -3622,9 +3597,8 @@ impl OutlinePanel { cx, ) }) - { - outline_panel.select_entry(new_selected_entry, false, window, cx); - } + { + outline_panel.select_entry(new_selected_entry, false, window, cx); } outline_panel.autoscroll(cx); @@ -3656,7 +3630,7 @@ impl OutlinePanel { #[derive(Debug)] struct ParentStats { - path: Arc, + path: Arc, folded: bool, expanded: bool, depth: usize, @@ -3670,7 +3644,7 @@ impl OutlinePanel { let is_root = project .read(cx) .worktree_for_id(directory_entry.worktree_id, cx) - .map_or(false, |worktree| { + .is_some_and(|worktree| { worktree.read(cx).root_entry() == Some(&directory_entry.entry) }); let folded = auto_fold_dirs @@ -3678,7 +3652,7 @@ impl OutlinePanel { && outline_panel .unfolded_dirs .get(&directory_entry.worktree_id) - .map_or(true, |unfolded_dirs| { + .is_none_or(|unfolded_dirs| { !unfolded_dirs.contains(&directory_entry.entry.id) }); let fs_depth = outline_panel @@ -3758,7 +3732,7 @@ impl OutlinePanel { .iter() .rev() .nth(folded_dirs.entries.len() + 1) - .map_or(true, |parent| parent.expanded); + .is_none_or(|parent| parent.expanded); if start_of_collapsed_dir_sequence || parent_expanded || query.is_some() @@ -3818,7 +3792,7 @@ impl OutlinePanel { .iter() .all(|entry| entry.path != parent.path) }) - .map_or(true, |parent| parent.expanded); + .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( &mut generation_state, @@ -3843,7 +3817,7 @@ impl OutlinePanel { .iter() .all(|entry| entry.path != parent.path) }) - .map_or(true, |parent| parent.expanded); + .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( &mut generation_state, @@ -3921,19 +3895,19 @@ impl OutlinePanel { } else { None }; - if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider { - if !active_editor.read(cx).is_buffer_folded(buffer_id, cx) { - outline_panel.add_excerpt_entries( - &mut generation_state, - buffer_id, - entry_excerpts, - depth, - track_matches, - is_singleton, - query.as_deref(), - cx, - ); - } + if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider + && !active_editor.read(cx).is_buffer_folded(buffer_id, cx) + { + outline_panel.add_excerpt_entries( + &mut generation_state, + buffer_id, + entry_excerpts, + depth, + track_matches, + is_singleton, + query.as_deref(), + cx, + ); } } } @@ -3964,7 +3938,7 @@ impl OutlinePanel { .iter() .all(|entry| entry.path != parent.path) }) - .map_or(true, |parent| parent.expanded); + .is_none_or(|parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( &mut generation_state, @@ -4052,8 +4026,9 @@ impl OutlinePanel { let id = state.entries.len(); match &entry { PanelEntry::Fs(fs_entry) => { - if let Some(file_name) = - self.relative_path(fs_entry, cx).as_deref().map(file_name) + if let Some(file_name) = self + .relative_path(fs_entry, cx) + .and_then(|path| Some(path.file_name()?.to_string())) { state .match_candidates @@ -4106,7 +4081,7 @@ impl OutlinePanel { .iter() .map(|entry| self.entry_name(&worktree_id, entry, cx)) .collect::(); - dir_names_segment.to_string_lossy().to_string() + dir_names_segment.to_string_lossy().into_owned() } fn query(&self, cx: &App) -> Option { @@ -4404,15 +4379,16 @@ impl OutlinePanel { }) .filter(|(match_range, _)| { let editor = active_editor.read(cx); - if let Some(buffer_id) = match_range.start.buffer_id { - if editor.is_buffer_folded(buffer_id, cx) { - return false; - } + let snapshot = editor.buffer().read(cx).snapshot(cx); + if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.start) + && editor.is_buffer_folded(buffer_id, cx) + { + return false; } - if let Some(buffer_id) = match_range.start.buffer_id { - if editor.is_buffer_folded(buffer_id, cx) { - return false; - } + if let Some(buffer_id) = snapshot.buffer_id_for_anchor(match_range.end) + && editor.is_buffer_folded(buffer_id, cx) + { + return false; } true }); @@ -4444,7 +4420,7 @@ impl OutlinePanel { } fn should_replace_active_item(&self, new_active_item: &dyn ItemHandle) -> bool { - self.active_item().map_or(true, |active_item| { + self.active_item().is_none_or(|active_item| { !self.pinned && active_item.item_id() != new_active_item.item_id() }) } @@ -4456,16 +4432,14 @@ impl OutlinePanel { cx: &mut Context, ) { self.pinned = !self.pinned; - if !self.pinned { - if let Some((active_item, active_editor)) = self + if !self.pinned + && let Some((active_item, active_editor)) = self .workspace .upgrade() .and_then(|workspace| workspace_active_editor(workspace.read(cx), cx)) - { - if self.should_replace_active_item(active_item.as_ref()) { - self.replace_active_editor(active_item, active_editor, window, cx); - } - } + && self.should_replace_active_item(active_item.as_ref()) + { + self.replace_active_editor(active_item, active_editor, window, cx); } cx.notify(); @@ -4503,169 +4477,23 @@ impl OutlinePanel { cx.notify(); } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { - if !Self::should_show_scrollbar(cx) - || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) - { - return None; - } - Some( - div() - .occlude() - .id("project-panel-vertical-scroll") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|outline_panel, _, window, cx| { - if !outline_panel.vertical_scrollbar_state.is_dragging() - && !outline_panel.focus_handle.contains_focused(window, cx) - { - outline_panel.hide_scrollbar(window, cx); - cx.notify(); - } - - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())), - ) - } - - fn render_horizontal_scrollbar( - &self, - _: &mut Window, - cx: &mut Context, - ) -> Option> { - if !Self::should_show_scrollbar(cx) - || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging()) - { - return None; - } - Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| { - div() - .occlude() - .id("project-panel-horizontal-scroll") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|outline_panel, _, window, cx| { - if !outline_panel.horizontal_scrollbar_state.is_dragging() - && !outline_panel.focus_handle.contains_focused(window, cx) - { - outline_panel.hide_scrollbar(window, cx); - cx.notify(); - } - - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .w_full() - .absolute() - .right_1() - .left_1() - .bottom_0() - .h(px(12.)) - .cursor_default() - .child(scrollbar) - }) - } - - fn should_show_scrollbar(cx: &App) -> bool { - let show = OutlinePanelSettings::get_global(cx) - .scrollbar - .show - .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); - match show { - ShowScrollbar::Auto => true, - ShowScrollbar::System => true, - ShowScrollbar::Always => true, - ShowScrollbar::Never => false, - } - } - - fn should_autohide_scrollbar(cx: &App) -> bool { - let show = OutlinePanelSettings::get_global(cx) - .scrollbar - .show - .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); - match show { - ShowScrollbar::Auto => true, - ShowScrollbar::System => cx - .try_global::() - .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), - ShowScrollbar::Always => false, - ShowScrollbar::Never => true, - } - } - - fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - if !Self::should_autohide_scrollbar(cx) { - return; - } - self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - panel - .update(cx, |panel, cx| { - panel.show_scrollbar = false; - cx.notify(); - }) - .log_err(); - })) - } - fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 { let item_text_chars = match entry { PanelEntry::Fs(FsEntry::ExternalFile(external)) => self .buffer_snapshot_for_id(external.buffer_id, cx) - .and_then(|snapshot| { - Some(snapshot.file()?.path().file_name()?.to_string_lossy().len()) - }) + .and_then(|snapshot| Some(snapshot.file()?.path().file_name()?.len())) .unwrap_or_default(), PanelEntry::Fs(FsEntry::Directory(directory)) => directory .entry .path .file_name() - .map(|name| name.to_string_lossy().len()) + .map(|name| name.len()) .unwrap_or_default(), PanelEntry::Fs(FsEntry::File(file)) => file .entry .path .file_name() - .map(|name| name.to_string_lossy().len()) + .map(|name| name.len()) .unwrap_or_default(), PanelEntry::FoldedDirs(folded_dirs) => { folded_dirs @@ -4674,11 +4502,11 @@ impl OutlinePanel { .map(|dir| { dir.path .file_name() - .map(|name| name.to_string_lossy().len()) + .map(|name| name.len()) .unwrap_or_default() }) .sum::() - + folded_dirs.entries.len().saturating_sub(1) * MAIN_SEPARATOR_STR.len() + + folded_dirs.entries.len().saturating_sub(1) * "/".len() } PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self .excerpt_label(excerpt.buffer_id, &excerpt.range, cx) @@ -4702,7 +4530,7 @@ impl OutlinePanel { indent_size: f32, window: &mut Window, cx: &mut Context, - ) -> Div { + ) -> impl IntoElement { let contents = if self.cached_entries.is_empty() { let header = if self.updating_fs_entries || self.updating_cached_entries { None @@ -4713,6 +4541,7 @@ impl OutlinePanel { }; v_flex() + .id("empty-outline-state") .flex_1() .justify_center() .size_full() @@ -4818,7 +4647,7 @@ impl OutlinePanel { .with_compute_indents_fn(cx.entity(), |outline_panel, range, _, _| { let entries = outline_panel.cached_entries.get(range); if let Some(entries) = entries { - entries.into_iter().map(|item| item.depth).collect() + entries.iter().map(|item| item.depth).collect() } else { smallvec::SmallVec::new() } @@ -4862,10 +4691,16 @@ impl OutlinePanel { .flex_shrink() .size_full() .child(list_contents.size_full().flex_shrink()) - .children(self.render_vertical_scrollbar(cx)) - .when_some( - self.render_horizontal_scrollbar(window, cx), - |this, scrollbar| this.pb_4().child(scrollbar), + .custom_scrollbars( + Scrollbars::for_settings::() + .tracked_scroll_handle(self.scroll_handle.clone()) + .with_track_along( + ScrollAxes::Horizontal, + cx.theme().colors().panel_background, + ) + .tracked_entity(cx.entity_id()), + window, + cx, ) } .children(self.context_menu.as_ref().map(|(menu, position, _)| { @@ -4966,7 +4801,7 @@ fn workspace_active_editor( } fn back_to_common_visited_parent( - visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, + visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, worktree_id: &WorktreeId, new_entry: &Entry, ) -> Option<(WorktreeId, ProjectEntryId)> { @@ -5004,10 +4839,14 @@ impl Panel for OutlinePanel { "Outline Panel" } + fn panel_key() -> &'static str { + OUTLINE_PANEL_KEY + } + fn position(&self, _: &Window, cx: &App) -> DockPosition { match OutlinePanelSettings::get_global(cx).dock { - OutlinePanelDockPosition::Left => DockPosition::Left, - OutlinePanelDockPosition::Right => DockPosition::Right, + DockSide::Left => DockPosition::Left, + DockSide::Right => DockPosition::Right, } } @@ -5016,17 +4855,13 @@ impl Panel for OutlinePanel { } fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings, _| { - let dock = match position { - DockPosition::Left | DockPosition::Bottom => OutlinePanelDockPosition::Left, - DockPosition::Right => OutlinePanelDockPosition::Right, - }; - settings.dock = Some(dock); - }, - ); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + let dock = match position { + DockPosition::Left | DockPosition::Bottom => DockSide::Left, + DockPosition::Right => DockSide::Right, + }; + settings.outline_panel.get_or_insert_default().dock = Some(dock); + }); } fn size(&self, _: &Window, cx: &App) -> Pixels { @@ -5067,24 +4902,23 @@ impl Panel for OutlinePanel { let old_active = outline_panel.active; outline_panel.active = active; if old_active != active { - if active { - if let Some((active_item, active_editor)) = + if active + && let Some((active_item, active_editor)) = outline_panel.workspace.upgrade().and_then(|workspace| { workspace_active_editor(workspace.read(cx), cx) }) - { - if outline_panel.should_replace_active_item(active_item.as_ref()) { - outline_panel.replace_active_editor( - active_item, - active_editor, - window, - cx, - ); - } else { - outline_panel.update_fs_entries(active_editor, None, window, cx) - } - return; + { + if outline_panel.should_replace_active_item(active_item.as_ref()) { + outline_panel.replace_active_editor( + active_item, + active_editor, + window, + cx, + ); + } else { + outline_panel.update_fs_entries(active_editor, None, window, cx) } + return; } if !outline_panel.pinned { @@ -5105,7 +4939,7 @@ impl Panel for OutlinePanel { impl Focusable for OutlinePanel { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.filter_editor.focus_handle(cx).clone() + self.filter_editor.focus_handle(cx) } } @@ -5115,9 +4949,9 @@ impl EventEmitter for OutlinePanel {} impl Render for OutlinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (is_local, is_via_ssh) = self - .project - .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh())); + let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| { + (project.is_local(), project.is_via_remote_server()) + }); let query = self.query(cx); let pinned = self.pinned; let settings = OutlinePanelSettings::get_global(cx); @@ -5134,15 +4968,6 @@ impl Render for OutlinePanel { .size_full() .overflow_hidden() .relative() - .on_hover(cx.listener(|this, hovered, window, cx| { - if *hovered { - this.show_scrollbar = true; - this.hide_scrollbar_task.take(); - cx.notify(); - } else if !this.focus_handle.contains_focused(window, cx) { - this.hide_scrollbar(window, cx); - } - })) .key_context(self.dispatch_context(window, cx)) .on_action(cx.listener(Self::open_selected_entry)) .on_action(cx.listener(Self::cancel)) @@ -5319,8 +5144,8 @@ fn subscribe_for_editor_events( }) .copied(), ); - if !ignore_selections_change { - if let Some(entry_to_select) = latest_unfolded_buffer_id + if !ignore_selections_change + && let Some(entry_to_select) = latest_unfolded_buffer_id .or(latest_folded_buffer_id) .and_then(|toggled_buffer_id| { outline_panel.fs_entries.iter().find_map( @@ -5344,16 +5169,15 @@ fn subscribe_for_editor_events( ) }) .map(PanelEntry::Fs) - { - outline_panel.select_entry(entry_to_select, true, window, cx); - } + { + outline_panel.select_entry(entry_to_select, true, window, cx); } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::Reparsed(buffer_id) => { if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) { - for (_, excerpt) in excerpts { + for excerpt in excerpts.values_mut() { excerpt.invalidate_outlines(); } } @@ -5416,8 +5240,9 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); - populate_with_test_ra_project(&fs, "/rust-analyzer").await; - let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await; + let root = path!("/rust-analyzer"); + populate_with_test_ra_project(&fs, root).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); @@ -5462,7 +5287,7 @@ mod tests { }); }); - let all_matches = r#"/rust-analyzer/ + let all_matches = r#"rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5481,7 +5306,9 @@ mod tests { analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs - search: param_names_for_lifetime_elision_hints: self"#; + search: param_names_for_lifetime_elision_hints: self"# + .to_string(); + let select_first_in_all_matches = |line_to_select: &str| { assert!(all_matches.contains(line_to_select)); all_matches.replacen( @@ -5498,7 +5325,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5514,7 +5341,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5532,13 +5359,13 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), format!( - r#"/rust-analyzer/ + r#"rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5569,7 +5396,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5583,7 +5410,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5602,13 +5429,13 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), format!( - r#"/rust-analyzer/ + r#"rust-analyzer/ crates/ ide/src/{SELECTED_MARKER} rust-analyzer/src/ @@ -5630,7 +5457,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5645,8 +5472,9 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); - populate_with_test_ra_project(&fs, "/rust-analyzer").await; - let project = Project::test(fs.clone(), ["/rust-analyzer".as_ref()], cx).await; + let root = path!("/rust-analyzer"); + populate_with_test_ra_project(&fs, root).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); @@ -5690,7 +5518,7 @@ mod tests { ); }); }); - let all_matches = r#"/rust-analyzer/ + let all_matches = r#"rust-analyzer/ crates/ ide/src/ inlay_hints/ @@ -5709,7 +5537,8 @@ mod tests { analysis_stats.rs search: param_names_for_lifetime_elision_hints: true, config.rs - search: param_names_for_lifetime_elision_hints: self"#; + search: param_names_for_lifetime_elision_hints: self"# + .to_string(); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); @@ -5718,7 +5547,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, None, cx, @@ -5741,7 +5570,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, None, cx, @@ -5767,7 +5596,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, None, cx, @@ -5782,8 +5611,9 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); - populate_with_test_ra_project(&fs, path!("/rust-analyzer")).await; - let project = Project::test(fs.clone(), [path!("/rust-analyzer").as_ref()], cx).await; + let root = path!("/rust-analyzer"); + populate_with_test_ra_project(&fs, root).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new(rust_lang())) }); @@ -5827,17 +5657,15 @@ mod tests { ); }); }); - let root_path = format!("{}/", path!("/rust-analyzer")); - let all_matches = format!( - r#"{root_path} + let all_matches = r#"rust-analyzer/ crates/ ide/src/ inlay_hints/ fn_lifetime_fn.rs - search: match config.param_names_for_lifetime_elision_hints {{ - search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {{ - search: Some(it) if config.param_names_for_lifetime_elision_hints => {{ - search: InlayHintsConfig {{ param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }}, + search: match config.param_names_for_lifetime_elision_hints { + search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints { + search: Some(it) if config.param_names_for_lifetime_elision_hints => { + search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG }, inlay_hints.rs search: pub param_names_for_lifetime_elision_hints: bool, search: param_names_for_lifetime_elision_hints: self @@ -5849,7 +5677,7 @@ mod tests { search: param_names_for_lifetime_elision_hints: true, config.rs search: param_names_for_lifetime_elision_hints: self"# - ); + .to_string(); let select_first_in_all_matches = |line_to_select: &str| { assert!(all_matches.contains(line_to_select)); all_matches.replacen( @@ -5873,7 +5701,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5896,7 +5724,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5933,7 +5761,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5970,7 +5798,7 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -5991,7 +5819,7 @@ mod tests { let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( - "/root", + path!("/root"), json!({ "one": { "a.txt": "aaa aaa" @@ -6003,7 +5831,7 @@ mod tests { }), ) .await; - let project = Project::test(fs.clone(), [Path::new("/root/one")], cx).await; + let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await; let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); @@ -6014,7 +5842,7 @@ mod tests { let items = workspace .update(cx, |workspace, window, cx| { workspace.open_paths( - vec![PathBuf::from("/root/two")], + vec![PathBuf::from(path!("/root/two"))], OpenOptions { visible: Some(OpenVisible::OnlyDirectories), ..Default::default() @@ -6073,18 +5901,20 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/root/one/ + format!( + r#"one/ a.txt search: aaa aaa <==== selected search: aaa aaa -/root/two/ +two/ b.txt - search: a aaa"# + search: a aaa"#, + ), ); }); @@ -6099,16 +5929,18 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/root/one/ + format!( + r#"one/ a.txt <==== selected -/root/two/ +two/ b.txt - search: a aaa"# + search: a aaa"#, + ), ); }); @@ -6123,14 +5955,16 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/root/one/ + format!( + r#"one/ a.txt -/root/two/ <==== selected"# +two/ <==== selected"#, + ), ); }); @@ -6144,16 +5978,18 @@ mod tests { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/root/one/ + format!( + r#"one/ a.txt -/root/two/ <==== selected +two/ <==== selected b.txt - search: a aaa"# + search: a aaa"#, + ) ); }); } @@ -6179,7 +6015,7 @@ struct OutlineEntryExcerpt { }), ) .await; - let project = Project::test(fs.clone(), [root.as_ref()], cx).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| { project.languages().add(Arc::new( rust_lang() @@ -6232,7 +6068,7 @@ struct OutlineEntryExcerpt { assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6259,7 +6095,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6286,7 +6122,7 @@ outline: struct OutlineEntryExcerpt <==== selected assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6313,7 +6149,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6340,7 +6176,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6367,7 +6203,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6394,7 +6230,7 @@ outline: struct OutlineEntryExcerpt <==== selected assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6421,7 +6257,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6448,7 +6284,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6475,7 +6311,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6502,7 +6338,7 @@ outline: struct OutlineEntryExcerpt <==== selected assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -6522,7 +6358,7 @@ outline: struct OutlineEntryExcerpt async fn test_frontend_repo_structure(cx: &mut TestAppContext) { init_test(cx); - let root = "/frontend-project"; + let root = path!("/frontend-project"); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( root, @@ -6559,7 +6395,7 @@ outline: struct OutlineEntryExcerpt }), ) .await; - let project = Project::test(fs.clone(), [root.as_ref()], cx).await; + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; let workspace = add_outline_panel(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); let outline_panel = outline_panel(&workspace, cx); @@ -6608,15 +6444,16 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/frontend-project/ + format!( + r#"frontend-project/ public/lottie/ syntax-tree.json - search: { "something": "static" } <==== selected + search: {{ "something": "static" }} <==== selected src/ app/(site)/ (about)/jobs/[slug]/ @@ -6628,6 +6465,7 @@ outline: struct OutlineEntryExcerpt components/ ErrorBoundary.tsx search: static"# + ) ); }); @@ -6645,20 +6483,22 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/frontend-project/ + format!( + r#"frontend-project/ public/lottie/ syntax-tree.json - search: { "something": "static" } + search: {{ "something": "static" }} src/ app/(site)/ <==== selected components/ ErrorBoundary.tsx search: static"# + ) ); }); @@ -6673,20 +6513,22 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/frontend-project/ + format!( + r#"frontend-project/ public/lottie/ syntax-tree.json - search: { "something": "static" } + search: {{ "something": "static" }} src/ app/(site)/ components/ ErrorBoundary.tsx search: static <==== selected"# + ) ); }); @@ -6705,19 +6547,21 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/frontend-project/ + format!( + r#"frontend-project/ public/lottie/ syntax-tree.json - search: { "something": "static" } + search: {{ "something": "static" }} src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected"# + ) ); }); @@ -6736,20 +6580,22 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, ), - r#"/frontend-project/ + format!( + r#"frontend-project/ public/lottie/ syntax-tree.json - search: { "something": "static" } + search: {{ "something": "static" }} src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected search: static"# + ) ); }); } @@ -6798,6 +6644,7 @@ outline: struct OutlineEntryExcerpt selected_entry: Option<&PanelEntry>, cx: &mut App, ) -> String { + let project = project.read(cx); let mut display_string = String::new(); for entry in cached_entries { if !display_string.is_empty() { @@ -6812,44 +6659,39 @@ outline: struct OutlineEntryExcerpt panic!("Did not cover external files with tests") } FsEntry::Directory(directory) => { - match project - .read(cx) + let path = if let Some(worktree) = project .worktree_for_id(directory.worktree_id, cx) - .and_then(|worktree| { - if worktree.read(cx).root_entry() == Some(&directory.entry.entry) { - Some(worktree.read(cx).abs_path()) - } else { - None - } + .filter(|worktree| { + worktree.read(cx).root_entry() == Some(&directory.entry.entry) }) { - Some(root_path) => format!( - "{}/{}", - root_path.display(), - directory.entry.path.display(), - ), - None => format!( - "{}/", - directory - .entry - .path - .file_name() - .unwrap_or_default() - .to_string_lossy() - ), - } + worktree + .read(cx) + .root_name() + .join(&directory.entry.path) + .as_unix_str() + .to_string() + } else { + directory + .entry + .path + .file_name() + .unwrap_or_default() + .to_string() + }; + format!("{path}/") } FsEntry::File(file) => file .entry .path .file_name() - .map(|name| name.to_string_lossy().to_string()) + .map(|name| name.to_string()) .unwrap_or_default(), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries .iter() .filter_map(|dir| dir.path.file_name()) - .map(|name| name.to_string_lossy().to_string() + "/") + .map(|name| name.to_string() + "/") .collect(), PanelEntry::Outline(outline_entry) => match outline_entry { OutlineEntry::Excerpt(_) => continue, @@ -6864,7 +6706,7 @@ outline: struct OutlineEntryExcerpt .render_data .get_or_init(|| SearchData::new( &search_entry.match_range, - &multi_buffer_snapshot + multi_buffer_snapshot )) .context_text ) @@ -7123,13 +6965,13 @@ outline: struct OutlineEntryExcerpt fn selected_row_text(editor: &Entity, cx: &mut App) -> String { editor.update(cx, |editor, cx| { - let selections = editor.selections.all::(cx); - assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions"); - let selection = selections.first().unwrap(); - let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - let line_start = language::Point::new(selection.start.row, 0); - let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right); - multi_buffer_snapshot.text_for_range(line_start..line_end).collect::().trim().to_owned() + let selections = editor.selections.all::(&editor.display_snapshot(cx)); + assert_eq!(selections.len(), 1, "Active editor should have exactly one selection after any outline panel interactions"); + let selection = selections.first().unwrap(); + let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let line_start = language::Point::new(selection.start.row, 0); + let line_end = multi_buffer_snapshot.clip_point(language::Point::new(selection.end.row, u32::MAX), language::Bias::Right); + multi_buffer_snapshot.text_for_range(line_start..line_end).collect::().trim().to_owned() }) } @@ -7255,7 +7097,7 @@ outline: struct OutlineEntryExcerpt assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7314,7 +7156,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7338,7 +7180,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7403,7 +7245,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7544,7 +7386,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7582,7 +7424,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7616,7 +7458,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, @@ -7648,7 +7490,7 @@ outline: fn main()" assert_eq!( display_entries( &project, - &snapshot(&outline_panel, cx), + &snapshot(outline_panel, cx), &outline_panel.cached_entries, outline_panel.selected_entry(), cx, diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index 133d28b748d2978e07a540b3c8c7517b03dc4767..77fb15ddeb273b6fbe928e5f364f4a135321e7be 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -1,28 +1,13 @@ -use editor::ShowScrollbar; -use gpui::Pixels; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; +use editor::EditorSettings; +use gpui::{App, Pixels}; +pub use settings::{DockSide, Settings, ShowIndentGuides}; +use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum OutlinePanelDockPosition { - Left, - Right, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ShowIndentGuides { - Always, - Never, -} - -#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct OutlinePanelSettings { pub button: bool, pub default_width: Pixels, - pub dock: OutlinePanelDockPosition, + pub dock: DockSide, pub file_icons: bool, pub folder_icons: bool, pub git_status: bool, @@ -34,7 +19,7 @@ pub struct OutlinePanelSettings { pub expand_outlines_with_depth: usize, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct ScrollbarSettings { /// When to show the scrollbar in the project panel. /// @@ -42,97 +27,39 @@ pub struct ScrollbarSettings { pub show: Option, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct ScrollbarSettingsContent { - /// When to show the scrollbar in the project panel. - /// - /// Default: inherits editor scrollbar settings - pub show: Option>, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct IndentGuidesSettings { pub show: ShowIndentGuides, } -#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct IndentGuidesSettingsContent { - /// When to show the scrollbar in the outline panel. - pub show: Option, -} - -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct OutlinePanelSettingsContent { - /// Whether to show the outline panel button in the status bar. - /// - /// Default: true - pub button: Option, - /// Customize default width (in pixels) taken by outline panel - /// - /// Default: 240 - pub default_width: Option, - /// The position of outline panel - /// - /// Default: left - pub dock: Option, - /// Whether to show file icons in the outline panel. - /// - /// Default: true - pub file_icons: Option, - /// Whether to show folder icons or chevrons for directories in the outline panel. - /// - /// Default: true - pub folder_icons: Option, - /// Whether to show the git status in the outline panel. - /// - /// Default: true - pub git_status: Option, - /// Amount of indentation (in pixels) for nested items. - /// - /// Default: 20 - pub indent_size: Option, - /// Whether to reveal it in the outline panel automatically, - /// when a corresponding project entry becomes active. - /// Gitignored entries are never auto revealed. - /// - /// Default: true - pub auto_reveal_entries: Option, - /// Whether to fold directories automatically - /// when directory has only one directory inside. - /// - /// Default: true - pub auto_fold_dirs: Option, - /// Settings related to indent guides in the outline panel. - pub indent_guides: Option, - /// Scrollbar-related settings - pub scrollbar: Option, - /// Default depth to expand outline items in the current file. - /// The default depth to which outline entries are expanded on reveal. - /// - Set to 0 to collapse all items that have children - /// - Set to 1 or higher to collapse items at that depth or deeper - /// - /// Default: 100 - pub expand_outlines_with_depth: Option, +impl ScrollbarVisibility for OutlinePanelSettings { + fn visibility(&self, cx: &App) -> ShowScrollbar { + self.scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) + } } impl Settings for OutlinePanelSettings { - const KEY: Option<&'static str> = Some("outline_panel"); - - type FileContent = OutlinePanelSettingsContent; - - fn load( - sources: SettingsSources, - _: &mut gpui::App, - ) -> anyhow::Result { - sources.json_merge() - } - - fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) { - if let Some(b) = vscode.read_bool("outline.icons") { - current.file_icons = Some(b); - current.folder_icons = Some(b); + fn from_settings(content: &settings::SettingsContent) -> Self { + let panel = content.outline_panel.as_ref().unwrap(); + Self { + button: panel.button.unwrap(), + default_width: panel.default_width.map(gpui::px).unwrap(), + dock: panel.dock.unwrap(), + file_icons: panel.file_icons.unwrap(), + folder_icons: panel.folder_icons.unwrap(), + git_status: panel.git_status.unwrap(), + indent_size: panel.indent_size.unwrap(), + indent_guides: IndentGuidesSettings { + show: panel.indent_guides.unwrap().show.unwrap(), + }, + auto_reveal_entries: panel.auto_reveal_entries.unwrap(), + auto_fold_dirs: panel.auto_fold_dirs.unwrap(), + scrollbar: ScrollbarSettings { + show: panel.scrollbar.unwrap().show.map(Into::into), + }, + expand_outlines_with_depth: panel.expand_outlines_with_depth.unwrap(), } - - vscode.bool_setting("git.decorations.enabled", &mut current.git_status); } } diff --git a/crates/panel/Cargo.toml b/crates/panel/Cargo.toml index 530a92356c2403edfa4ddcb7c6afd35b99630823..3c51e6d6dcdb31922c07bd1d16923fdd10eeceb7 100644 --- a/crates/panel/Cargo.toml +++ b/crates/panel/Cargo.toml @@ -18,4 +18,3 @@ settings.workspace = true theme.workspace = true ui.workspace = true workspace.workspace = true -workspace-hack.workspace = true diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs index 658a51167ba7da3f02c49ab77b50e72dabbbae57..1930f654e9b632e52719103e5b0a399cfe94f70a 100644 --- a/crates/panel/src/panel.rs +++ b/crates/panel/src/panel.rs @@ -52,7 +52,7 @@ impl RenderOnce for PanelTab { pub fn panel_button(label: impl Into) -> ui::Button { let label = label.into(); - let id = ElementId::Name(label.clone().to_lowercase().replace(' ', "_").into()); + let id = ElementId::Name(label.to_lowercase().replace(' ', "_").into()); ui::Button::new(id, label) .label_size(ui::LabelSize::Small) .icon_size(ui::IconSize::Small) diff --git a/crates/paths/Cargo.toml b/crates/paths/Cargo.toml index cf6dabf0e1f50f24622a144a166caad9a9d11f80..24da7d46e9e7d14c8577550b34c592d12a19af74 100644 --- a/crates/paths/Cargo.toml +++ b/crates/paths/Cargo.toml @@ -8,10 +8,13 @@ license = "GPL-3.0-or-later" [lints] workspace = true +[features] +test-support = [] + [lib] path = "src/paths.rs" [dependencies] dirs.workspace = true +ignore.workspace = true util.workspace = true -workspace-hack.workspace = true diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 47a0f12c0634dbde48d015e4f577519babc67b34..1197e9c546075dbe9342efe49ace1766fd281925 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -2,9 +2,10 @@ use std::env; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; pub use util::paths::home_dir; +use util::rel_path::RelPath; /// A default editorconfig file name to use when resolving project settings. pub const EDITORCONFIG_NAME: &str = ".editorconfig"; @@ -29,8 +30,17 @@ static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); static CONFIG_DIR: OnceLock = OnceLock::new(); /// Returns the relative path to the zed_server directory on the ssh host. -pub fn remote_server_dir_relative() -> &'static Path { - Path::new(".zed_server") +pub fn remote_server_dir_relative() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".zed_server").unwrap()); + *CACHED +} + +/// Returns the relative path to the zed_wsl_server directory on the wsl host. +pub fn remote_wsl_server_dir_relative() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".zed_wsl_server").unwrap()); + *CACHED } /// Sets a custom directory for all user data, overriding the default data directory. @@ -41,7 +51,7 @@ pub fn remote_server_dir_relative() -> &'static Path { /// # Arguments /// /// * `dir` - The path to use as the custom data directory. This will be used as the base -/// directory for all user data, including databases, extensions, and logs. +/// directory for all user data, including databases, extensions, and logs. /// /// # Returns /// @@ -59,11 +69,11 @@ pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { } CUSTOM_DATA_DIR.get_or_init(|| { let mut path = PathBuf::from(dir); - if path.is_relative() { + if path.is_relative() && path.exists() { let abs_path = path .canonicalize() .expect("failed to canonicalize custom data directory's path to an absolute path"); - path = PathBuf::from(util::paths::SanitizedPath::from(abs_path)) + path = util::paths::SanitizedPath::new(&abs_path).into() } std::fs::create_dir_all(&path).expect("failed to create custom data directory"); path @@ -278,7 +288,7 @@ pub fn snippets_dir() -> &'static PathBuf { /// Returns the path to the contexts directory. /// /// This is where the saved contexts from the Assistant are stored. -pub fn contexts_dir() -> &'static PathBuf { +pub fn text_threads_dir() -> &'static PathBuf { static CONTEXTS_DIR: OnceLock = OnceLock::new(); CONTEXTS_DIR.get_or_init(|| { if cfg!(target_os = "macos") { @@ -360,12 +370,12 @@ pub fn debug_adapters_dir() -> &'static PathBuf { DEBUG_ADAPTERS_DIR.get_or_init(|| data_dir().join("debug_adapters")) } -/// Returns the path to the agent servers directory +/// Returns the path to the external agents directory /// /// This is where agent servers are downloaded to -pub fn agent_servers_dir() -> &'static PathBuf { - static AGENT_SERVERS_DIR: OnceLock = OnceLock::new(); - AGENT_SERVERS_DIR.get_or_init(|| data_dir().join("agent_servers")) +pub fn external_agents_dir() -> &'static PathBuf { + static EXTERNAL_AGENTS_DIR: OnceLock = OnceLock::new(); + EXTERNAL_AGENTS_DIR.get_or_init(|| data_dir().join("external_agents")) } /// Returns the path to the Copilot directory. @@ -393,28 +403,34 @@ pub fn remote_servers_dir() -> &'static PathBuf { } /// Returns the relative path to a `.zed` folder within a project. -pub fn local_settings_folder_relative_path() -> &'static Path { - Path::new(".zed") +pub fn local_settings_folder_name() -> &'static str { + ".zed" } /// Returns the relative path to a `.vscode` folder within a project. -pub fn local_vscode_folder_relative_path() -> &'static Path { - Path::new(".vscode") +pub fn local_vscode_folder_name() -> &'static str { + ".vscode" } /// Returns the relative path to a `settings.json` file within a project. -pub fn local_settings_file_relative_path() -> &'static Path { - Path::new(".zed/settings.json") +pub fn local_settings_file_relative_path() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".zed/settings.json").unwrap()); + *CACHED } /// Returns the relative path to a `tasks.json` file within a project. -pub fn local_tasks_file_relative_path() -> &'static Path { - Path::new(".zed/tasks.json") +pub fn local_tasks_file_relative_path() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".zed/tasks.json").unwrap()); + *CACHED } /// Returns the relative path to a `.vscode/tasks.json` file within a project. -pub fn local_vscode_tasks_file_relative_path() -> &'static Path { - Path::new(".vscode/tasks.json") +pub fn local_vscode_tasks_file_relative_path() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".vscode/tasks.json").unwrap()); + *CACHED } pub fn debug_task_file_name() -> &'static str { @@ -427,13 +443,17 @@ pub fn task_file_name() -> &'static str { /// Returns the relative path to a `debug.json` file within a project. /// .zed/debug.json -pub fn local_debug_file_relative_path() -> &'static Path { - Path::new(".zed/debug.json") +pub fn local_debug_file_relative_path() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".zed/debug.json").unwrap()); + *CACHED } /// Returns the relative path to a `.vscode/launch.json` file within a project. -pub fn local_vscode_launch_file_relative_path() -> &'static Path { - Path::new(".vscode/launch.json") +pub fn local_vscode_launch_file_relative_path() -> &'static RelPath { + static CACHED: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".vscode/launch.json").unwrap()); + *CACHED } pub fn user_ssh_config_file() -> PathBuf { @@ -515,3 +535,16 @@ fn add_vscode_user_data_paths(paths: &mut Vec, product_name: &str) { ); } } + +#[cfg(any(test, feature = "test-support"))] +pub fn global_gitignore_path() -> Option { + Some(home_dir().join(".config").join("git").join("ignore")) +} + +#[cfg(not(any(test, feature = "test-support")))] +pub fn global_gitignore_path() -> Option { + static GLOBAL_GITIGNORE_PATH: OnceLock> = OnceLock::new(); + GLOBAL_GITIGNORE_PATH + .get_or_init(::ignore::gitignore::gitconfig_excludes_path) + .clone() +} diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 5f89793d28cb7f6ff896879713e9aa66661d5e7a..1344d177f42f9ab6a15d8f5f1353b98eadfd175f 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -22,10 +22,9 @@ gpui.workspace = true menu.workspace = true schemars.workspace = true serde.workspace = true +theme.workspace = true ui.workspace = true -util.workspace = true workspace.workspace = true -workspace-hack.workspace = true [dev-dependencies] ctor.workspace = true diff --git a/crates/picker/src/head.rs b/crates/picker/src/head.rs index aba7b8a1d05afdc1f485574178914f50f55bc12c..700896e3412bf96ceff25891c106d5a4dbc51460 100644 --- a/crates/picker/src/head.rs +++ b/crates/picker/src/head.rs @@ -23,7 +23,7 @@ impl Head { ) -> Self { let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text(placeholder_text, cx); + editor.set_placeholder_text(placeholder_text.as_ref(), window, cx); editor }); cx.subscribe_in(&editor, window, edit_handler).detach(); diff --git a/crates/picker/src/highlighted_match_with_paths.rs b/crates/picker/src/highlighted_match_with_paths.rs index 255e0150e8d6d9684b4f5b1315d4975f037ace48..74271047621b26be573dc2eebfffe9e9e0f1a138 100644 --- a/crates/picker/src/highlighted_match_with_paths.rs +++ b/crates/picker/src/highlighted_match_with_paths.rs @@ -2,6 +2,7 @@ use ui::{HighlightedLabel, prelude::*}; #[derive(Clone)] pub struct HighlightedMatchWithPaths { + pub prefix: Option, pub match_label: HighlightedMatch, pub paths: Vec, } @@ -10,36 +11,36 @@ pub struct HighlightedMatchWithPaths { pub struct HighlightedMatch { pub text: String, pub highlight_positions: Vec, - pub char_count: usize, pub color: Color, } impl HighlightedMatch { pub fn join(components: impl Iterator, separator: &str) -> Self { - let mut char_count = 0; - let separator_char_count = separator.chars().count(); + // Track a running byte offset and insert separators between parts. + let mut first = true; + let mut byte_offset = 0; let mut text = String::new(); let mut highlight_positions = Vec::new(); for component in components { - if char_count != 0 { + if !first { text.push_str(separator); - char_count += separator_char_count; + byte_offset += separator.len(); } + first = false; highlight_positions.extend( component .highlight_positions .iter() - .map(|position| position + char_count), + .map(|position| position + byte_offset), ); text.push_str(&component.text); - char_count += component.text.chars().count(); + byte_offset += component.text.len(); } Self { text, highlight_positions, - char_count, color: Color::Default, } } @@ -67,9 +68,49 @@ impl HighlightedMatchWithPaths { impl RenderOnce for HighlightedMatchWithPaths { fn render(mut self, _window: &mut Window, _: &mut App) -> impl IntoElement { v_flex() - .child(self.match_label.clone()) + .child( + h_flex().gap_1().child(self.match_label.clone()).when_some( + self.prefix.as_ref(), + |this, prefix| { + this.child(Label::new(format!("({})", prefix)).color(Color::Muted)) + }, + ), + ) .when(!self.paths.is_empty(), |this| { self.render_paths_children(this) }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_offsets_positions_by_bytes_not_chars() { + // "αβγ" is 3 Unicode scalar values, 6 bytes in UTF-8. + let left_text = "αβγ".to_string(); + let right_text = "label".to_string(); + let left = HighlightedMatch { + text: left_text, + highlight_positions: vec![], + color: Color::Default, + }; + let right = HighlightedMatch { + text: right_text, + highlight_positions: vec![0, 1], + color: Color::Default, + }; + let joined = HighlightedMatch::join([left, right].into_iter(), ""); + + assert!( + joined + .highlight_positions + .iter() + .all(|&p| joined.text.is_char_boundary(p)), + "join produced non-boundary positions {:?} for text {:?}", + joined.highlight_positions, + joined.text + ); + } +} diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 34af5fed02e66fe242c398ebcf910bc89d81a256..d9a23ec93b80287dd1b7b483c8b6315b2119bfd5 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -11,18 +11,19 @@ use editor::{ use gpui::{ Action, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render, - ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, list, - prelude::*, uniform_list, + ScrollStrategy, Task, UniformListScrollHandle, Window, actions, div, list, prelude::*, + uniform_list, }; use head::Head; use schemars::JsonSchema; use serde::Deserialize; use std::{ops::Range, sync::Arc, time::Duration}; +use theme::ThemeSettings; use ui::{ - Color, Divider, Label, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, prelude::*, v_flex, + Color, Divider, DocumentationAside, DocumentationEdge, DocumentationSide, Label, ListItem, + ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, }; -use util::ResultExt; -use workspace::ModalView; +use workspace::{ModalView, item::Settings}; enum ElementContainer { List(ListState), @@ -65,13 +66,8 @@ pub struct Picker { width: Option, widest_item: Option, max_height: Option, - focus_handle: FocusHandle, /// An external control to display a scrollbar in the `Picker`. show_scrollbar: bool, - /// An internal state that controls whether to show the scrollbar based on the user's focus. - scrollbar_visibility: bool, - scrollbar_state: ScrollbarState, - hide_scrollbar_task: Option>, /// Whether the `Picker` is rendered as a self-contained modal. /// /// Set this to `false` when rendering the `Picker` as part of a larger modal. @@ -227,6 +223,14 @@ pub trait PickerDelegate: Sized + 'static { ) -> Option { None } + + fn documentation_aside( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } } impl Focusable for Picker { @@ -271,6 +275,15 @@ impl Picker { Self::new(delegate, ContainerKind::UniformList, head, window, cx) } + /// A picker, which displays its matches using `gpui::list`, matches can have different heights. + /// The picker allows the user to perform search items by text. + /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that. + pub fn nonsearchable_list(delegate: D, window: &mut Window, cx: &mut Context) -> Self { + let head = Head::empty(Self::on_empty_head_blur, window, cx); + + Self::new(delegate, ContainerKind::List, head, window, cx) + } + /// A picker, which displays its matches using `gpui::list`, matches can have different heights. /// The picker allows the user to perform search items by text. /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that. @@ -293,13 +306,6 @@ impl Picker { cx: &mut Context, ) -> Self { let element_container = Self::create_element_container(container); - let scrollbar_state = match &element_container { - ElementContainer::UniformList(scroll_handle) => { - ScrollbarState::new(scroll_handle.clone()) - } - ElementContainer::List(state) => ScrollbarState::new(state.clone()), - }; - let focus_handle = cx.focus_handle(); let mut this = Self { delegate, head, @@ -309,12 +315,8 @@ impl Picker { width: None, widest_item: None, max_height: Some(rems(18.).into()), - focus_handle, show_scrollbar: false, - scrollbar_visibility: true, - scrollbar_state, is_modal: true, - hide_scrollbar_task: None, }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. @@ -359,6 +361,16 @@ impl Picker { self } + pub fn list_measure_all(mut self) -> Self { + match self.element_container { + ElementContainer::List(state) => { + self.element_container = ElementContainer::List(state.measure_all()); + } + _ => {} + } + self + } + pub fn focus(&self, window: &mut Window, cx: &mut App) { self.focus_handle(cx).focus(window); } @@ -615,7 +627,7 @@ impl Picker { Head::Editor(editor) => { let placeholder = self.delegate.placeholder_text(window, cx); editor.update(cx, |editor, cx| { - editor.set_placeholder_text(placeholder, cx); + editor.set_placeholder_text(placeholder.as_ref(), window, cx); cx.notify(); }); } @@ -790,67 +802,6 @@ impl Picker { } } } - - fn hide_scrollbar(&mut self, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - self.hide_scrollbar_task = Some(cx.spawn(async move |panel, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - panel - .update(cx, |panel, cx| { - panel.scrollbar_visibility = false; - cx.notify(); - }) - .log_err(); - })) - } - - fn render_scrollbar(&self, cx: &mut Context) -> Option> { - if !self.show_scrollbar - || !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) - { - return None; - } - Some( - div() - .occlude() - .id("picker-scroll") - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .on_mouse_move(cx.listener(|_, _, _window, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _window, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|picker, _, window, cx| { - if !picker.scrollbar_state.is_dragging() - && !picker.focus_handle.contains_focused(window, cx) - { - picker.hide_scrollbar(cx); - cx.notify(); - } - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _window, cx| { - cx.notify(); - })) - .children(Scrollbar::vertical(self.scrollbar_state.clone())), - ) - } } impl EventEmitter for Picker {} @@ -858,8 +809,15 @@ impl ModalView for Picker {} impl Render for Picker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let window_size = window.viewport_size(); + let rem_size = window.rem_size(); + let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; + + let aside = self.delegate.documentation_aside(window, cx); + let editor_position = self.delegate.editor_position(); - v_flex() + let menu = v_flex() .key_context("Picker") .size_full() .when_some(self.width, |el, width| el.w(width)) @@ -900,17 +858,22 @@ impl Render for Picker { .overflow_hidden() .children(self.delegate.render_header(window, cx)) .child(self.render_element_container(cx)) - .on_hover(cx.listener(|this, hovered, window, cx| { - if *hovered { - this.scrollbar_visibility = true; - this.hide_scrollbar_task.take(); - cx.notify(); - } else if !this.focus_handle.contains_focused(window, cx) { - this.hide_scrollbar(cx); - } - })) - .when_some(self.render_scrollbar(cx), |div, scrollbar| { - div.child(scrollbar) + .when(self.show_scrollbar, |this| { + let base_scrollbar_config = + Scrollbars::new(ScrollAxes::Vertical).width_sm(); + + this.map(|this| match &self.element_container { + ElementContainer::List(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state.clone()), + window, + cx, + ), + ElementContainer::UniformList(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state.clone()), + window, + cx, + ), + }) }), ) }) @@ -937,6 +900,47 @@ impl Render for Picker { } } Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }) + }); + + let Some(aside) = aside else { + return menu; + }; + + let render_aside = |aside: DocumentationAside, cx: &mut Context| { + WithRemSize::new(ui_font_size) + .occlude() + .elevation_2(cx) + .w_full() + .p_2() + .overflow_hidden() + .when(is_wide_window, |this| this.max_w_96()) + .when(!is_wide_window, |this| this.max_w_48()) + .child((aside.render)(cx)) + }; + + if is_wide_window { + div().relative().child(menu).child( + h_flex() + .absolute() + .when(aside.side == DocumentationSide::Left, |this| { + this.right_full().mr_1() + }) + .when(aside.side == DocumentationSide::Right, |this| { + this.left_full().ml_1() + }) + .when(aside.edge == DocumentationEdge::Top, |this| this.top_0()) + .when(aside.edge == DocumentationEdge::Bottom, |this| { + this.bottom_0() + }) + .child(render_aside(aside, cx)), + ) + } else { + v_flex() + .w_full() + .gap_1() + .justify_end() + .child(render_aside(aside, cx)) + .child(menu) + } } } diff --git a/crates/picker/src/popover_menu.rs b/crates/picker/src/popover_menu.rs index d05308ee71e87a472ffcb33e9727ef74fae70602..42eedb2492149aa56de527e38fcf4f2b0e4da608 100644 --- a/crates/picker/src/popover_menu.rs +++ b/crates/picker/src/popover_menu.rs @@ -1,9 +1,9 @@ use gpui::{ - AnyView, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, + AnyView, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Point, + Subscription, }; use ui::{ - App, ButtonCommon, FluentBuilder as _, IntoElement, PopoverMenu, PopoverMenuHandle, - PopoverTrigger, RenderOnce, Window, px, + FluentBuilder as _, IntoElement, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*, }; use crate::{Picker, PickerDelegate}; @@ -19,6 +19,7 @@ where tooltip: TT, handle: Option>>, anchor: Corner, + offset: Option>, _subscriptions: Vec, } @@ -43,6 +44,10 @@ where trigger, tooltip, handle: None, + offset: Some(Point { + x: px(0.0), + y: px(-2.0), + }), anchor, } } @@ -51,6 +56,11 @@ where self.handle = Some(handle); self } + + pub fn offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } } impl EventEmitter for PickerPopoverMenu @@ -85,10 +95,7 @@ where .menu(move |_window, _cx| Some(picker.clone())) .trigger_with_tooltip(self.trigger, self.tooltip) .anchor(self.anchor) - .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle)) - .offset(gpui::Point { - x: px(0.0), - y: px(-2.0), - }) + .when_some(self.handle, |menu, handle| menu.with_handle(handle)) + .when_some(self.offset, |menu, offset| menu.offset(offset)) } } diff --git a/crates/prettier/Cargo.toml b/crates/prettier/Cargo.toml index fb31f9ea1fe52fd7445fce708cdfe3db22dd06bb..9da1e4c8d67fe60e8f0ead9448b73440f6053172 100644 --- a/crates/prettier/Cargo.toml +++ b/crates/prettier/Cargo.toml @@ -29,7 +29,6 @@ paths.workspace = true serde.workspace = true serde_json.workspace = true util.workspace = true -workspace-hack.workspace = true [dev-dependencies] fs = { workspace = true, features = ["test-support"] } diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index 33320e6845964932aa7bfe051f3ffe4fba1a6168..b9c40e814c4caf760123cf460e2eed7298f9e951 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -12,7 +12,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::paths::PathMatcher; +use util::paths::{PathMatcher, PathStyle}; #[derive(Debug, Clone)] pub enum Prettier { @@ -119,7 +119,7 @@ impl Prettier { None } }).any(|workspace_definition| { - workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition]).ok().map_or(false, |path_matcher| path_matcher.is_match(subproject_path)) + workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition], PathStyle::local()).ok().is_some_and(|path_matcher| path_matcher.is_match(subproject_path)) }) { anyhow::ensure!(has_prettier_in_node_modules(fs, &path_to_check).await?, "Path {path_to_check:?} is the workspace root for project in {closest_package_json_path:?}, but it has no prettier installed"); log::info!("Found prettier path {path_to_check:?} in the workspace root for project in {closest_package_json_path:?}"); @@ -185,11 +185,11 @@ impl Prettier { .metadata(&ignore_path) .await .with_context(|| format!("fetching metadata for {ignore_path:?}"))? + && !metadata.is_dir + && !metadata.is_symlink { - if !metadata.is_dir && !metadata.is_symlink { - log::info!("Found prettier ignore at {ignore_path:?}"); - return Ok(ControlFlow::Continue(Some(path_to_check))); - } + log::info!("Found prettier ignore at {ignore_path:?}"); + return Ok(ControlFlow::Continue(Some(path_to_check))); } match &closest_package_json_path { None => closest_package_json_path = Some(path_to_check.clone()), @@ -215,21 +215,24 @@ impl Prettier { }) .any(|workspace_definition| { workspace_definition == subproject_path.to_string_lossy() - || PathMatcher::new(&[workspace_definition]) - .ok() - .map_or(false, |path_matcher| { - path_matcher.is_match(subproject_path) - }) + || PathMatcher::new( + &[workspace_definition], + PathStyle::local(), + ) + .ok() + .is_some_and( + |path_matcher| path_matcher.is_match(subproject_path), + ) }) { let workspace_ignore = path_to_check.join(".prettierignore"); - if let Some(metadata) = fs.metadata(&workspace_ignore).await? { - if !metadata.is_dir { - log::info!( - "Found prettier ignore at workspace root {workspace_ignore:?}" - ); - return Ok(ControlFlow::Continue(Some(path_to_check))); - } + if let Some(metadata) = fs.metadata(&workspace_ignore).await? + && !metadata.is_dir + { + log::info!( + "Found prettier ignore at workspace root {workspace_ignore:?}" + ); + return Ok(ControlFlow::Continue(Some(path_to_check))); } } } @@ -549,18 +552,16 @@ async fn read_package_json( .metadata(&possible_package_json) .await .with_context(|| format!("fetching metadata for package json {possible_package_json:?}"))? + && !package_json_metadata.is_dir + && !package_json_metadata.is_symlink { - if !package_json_metadata.is_dir && !package_json_metadata.is_symlink { - let package_json_contents = fs - .load(&possible_package_json) - .await - .with_context(|| format!("reading {possible_package_json:?} file contents"))?; - return serde_json::from_str::>( - &package_json_contents, - ) + let package_json_contents = fs + .load(&possible_package_json) + .await + .with_context(|| format!("reading {possible_package_json:?} file contents"))?; + return serde_json::from_str::>(&package_json_contents) .map(Some) .with_context(|| format!("parsing {possible_package_json:?} file contents")); - } } Ok(None) } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 57d6d6ca283af0fd51ed10622f55edc9fb086e7e..d9285a8c24ec5130dd8ce8abf5bbd77c830e0f3f 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -58,7 +58,6 @@ lsp.workspace = true markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true -pathdiff.workspace = true paths.workspace = true postage.workspace = true prettier.workspace = true @@ -67,12 +66,12 @@ regex.workspace = true remote.workspace = true rpc.workspace = true schemars.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true sha2.workspace = true shellexpand.workspace = true -shlex.workspace = true smallvec.workspace = true smol.workspace = true snippet.workspace = true @@ -85,10 +84,11 @@ text.workspace = true toml.workspace = true url.workspace = true util.workspace = true +watch.workspace = true which.workspace = true worktree.workspace = true +zeroize.workspace = true zlog.workspace = true -workspace-hack.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..a1897a89d1f0fe52fedf8902e8c631a367627b20 --- /dev/null +++ b/crates/project/src/agent_server_store.rs @@ -0,0 +1,1896 @@ +use std::{ + any::Any, + borrow::Borrow, + collections::HashSet, + path::{Path, PathBuf}, + str::FromStr as _, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context as _, Result, bail}; +use collections::HashMap; +use fs::{Fs, RemoveOptions, RenameOptions}; +use futures::StreamExt as _; +use gpui::{ + AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, +}; +use http_client::{HttpClient, github::AssetKind}; +use node_runtime::NodeRuntime; +use remote::RemoteClient; +use rpc::{AnyProtoClient, TypedEnvelope, proto}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::SettingsStore; +use task::Shell; +use util::{ResultExt as _, debug_panic}; + +use crate::ProjectEnvironment; + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct AgentServerCommand { + #[serde(rename = "command")] + pub path: PathBuf, + #[serde(default)] + pub args: Vec, + pub env: Option>, +} + +impl std::fmt::Debug for AgentServerCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let filtered_env = self.env.as_ref().map(|env| { + env.iter() + .map(|(k, v)| { + ( + k, + if util::redact::should_redact(k) { + "[REDACTED]" + } else { + v + }, + ) + }) + .collect::>() + }); + + f.debug_struct("AgentServerCommand") + .field("path", &self.path) + .field("args", &self.args) + .field("env", &filtered_env) + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ExternalAgentServerName(pub SharedString); + +impl std::fmt::Display for ExternalAgentServerName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&'static str> for ExternalAgentServerName { + fn from(value: &'static str) -> Self { + ExternalAgentServerName(value.into()) + } +} + +impl From for SharedString { + fn from(value: ExternalAgentServerName) -> Self { + value.0 + } +} + +impl Borrow for ExternalAgentServerName { + fn borrow(&self) -> &str { + &self.0 + } +} + +pub trait ExternalAgentServer { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>>; + + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl dyn ExternalAgentServer { + fn downcast_mut(&mut self) -> Option<&mut T> { + self.as_any_mut().downcast_mut() + } +} + +enum AgentServerStoreState { + Local { + node_runtime: NodeRuntime, + fs: Arc, + project_environment: Entity, + downstream_client: Option<(u64, AnyProtoClient)>, + settings: Option, + http_client: Arc, + _subscriptions: [Subscription; 1], + }, + Remote { + project_id: u64, + upstream_client: Entity, + }, + Collab, +} + +pub struct AgentServerStore { + state: AgentServerStoreState, + external_agents: HashMap>, + agent_icons: HashMap, +} + +pub struct AgentServersUpdated; + +impl EventEmitter for AgentServerStore {} + +#[cfg(test)] +mod ext_agent_tests { + use super::*; + use std::fmt::Write as _; + + // Helper to build a store in Collab mode so we can mutate internal maps without + // needing to spin up a full project environment. + fn collab_store() -> AgentServerStore { + AgentServerStore { + state: AgentServerStoreState::Collab, + external_agents: HashMap::default(), + agent_icons: HashMap::default(), + } + } + + // A simple fake that implements ExternalAgentServer without needing async plumbing. + struct NoopExternalAgent; + + impl ExternalAgentServer for NoopExternalAgent { + fn get_command( + &mut self, + _root_dir: Option<&str>, + _extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + _cx: &mut AsyncApp, + ) -> Task)>> { + Task::ready(Ok(( + AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + }, + "".to_string(), + None, + ))) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + } + + #[test] + fn external_agent_server_name_display() { + let name = ExternalAgentServerName(SharedString::from("Ext: Tool")); + let mut s = String::new(); + write!(&mut s, "{name}").unwrap(); + assert_eq!(s, "Ext: Tool"); + } + + #[test] + fn sync_extension_agents_removes_previous_extension_entries() { + let mut store = collab_store(); + + // Seed with a couple of agents that will be replaced by extensions + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("foo-agent")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("bar-agent")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("custom")), + Box::new(NoopExternalAgent) as Box, + ); + + // Simulate the removal phase: if we're syncing extensions that provide + // "foo-agent" and "bar-agent", those should be removed first + let extension_agent_names: HashSet = + ["foo-agent".to_string(), "bar-agent".to_string()] + .into_iter() + .collect(); + + let keys_to_remove: Vec<_> = store + .external_agents + .keys() + .filter(|name| extension_agent_names.contains(name.0.as_ref())) + .cloned() + .collect(); + + for key in keys_to_remove { + store.external_agents.remove(&key); + } + + // Only the custom entry should remain. + let remaining: Vec<_> = store + .external_agents + .keys() + .map(|k| k.0.to_string()) + .collect(); + assert_eq!(remaining, vec!["custom".to_string()]); + } +} + +impl AgentServerStore { + /// Synchronizes extension-provided agent servers with the store. + pub fn sync_extension_agents<'a, I>( + &mut self, + manifests: I, + extensions_dir: PathBuf, + cx: &mut Context, + ) where + I: IntoIterator, + { + // Collect manifests first so we can iterate twice + let manifests: Vec<_> = manifests.into_iter().collect(); + + // Remove existing extension-provided agents by tracking which ones we're about to add + let extension_agent_names: HashSet<_> = manifests + .iter() + .flat_map(|(_, manifest)| manifest.agent_servers.keys().map(|k| k.to_string())) + .collect(); + + let keys_to_remove: Vec<_> = self + .external_agents + .keys() + .filter(|name| { + // Remove if it matches an extension agent name from any extension + extension_agent_names.contains(name.0.as_ref()) + }) + .cloned() + .collect(); + for key in &keys_to_remove { + self.external_agents.remove(key); + self.agent_icons.remove(key); + } + + // Insert agent servers from extension manifests + match &self.state { + AgentServerStoreState::Local { + project_environment, + fs, + http_client, + .. + } => { + for (ext_id, manifest) in manifests { + for (agent_name, agent_entry) in &manifest.agent_servers { + let display = SharedString::from(agent_entry.name.clone()); + + // Store absolute icon path if provided, resolving symlinks for dev extensions + if let Some(icon) = &agent_entry.icon { + let icon_path = extensions_dir.join(ext_id).join(icon); + // Canonicalize to resolve symlinks (dev extensions are symlinked) + let absolute_icon_path = icon_path + .canonicalize() + .unwrap_or(icon_path) + .to_string_lossy() + .to_string(); + self.agent_icons.insert( + ExternalAgentServerName(display.clone()), + SharedString::from(absolute_icon_path), + ); + } + + // Archive-based launcher (download from URL) + self.external_agents.insert( + ExternalAgentServerName(display), + Box::new(LocalExtensionArchiveAgent { + fs: fs.clone(), + http_client: http_client.clone(), + project_environment: project_environment.clone(), + extension_id: Arc::from(ext_id), + agent_id: agent_name.clone(), + targets: agent_entry.targets.clone(), + env: agent_entry.env.clone(), + }) as Box, + ); + } + } + } + _ => { + // Only local projects support local extension agents + } + } + + cx.emit(AgentServersUpdated); + } + + pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { + self.agent_icons.get(name).cloned() + } + + pub fn init_remote(session: &AnyProtoClient) { + session.add_entity_message_handler(Self::handle_external_agents_updated); + session.add_entity_message_handler(Self::handle_loading_status_updated); + session.add_entity_message_handler(Self::handle_new_version_available); + } + + pub fn init_headless(session: &AnyProtoClient) { + session.add_entity_request_handler(Self::handle_get_agent_server_command); + } + + fn agent_servers_settings_changed(&mut self, cx: &mut Context) { + let AgentServerStoreState::Local { + settings: old_settings, + .. + } = &mut self.state + else { + debug_panic!( + "should not be subscribed to agent server settings changes in non-local project" + ); + return; + }; + + let new_settings = cx + .global::() + .get::(None) + .clone(); + if Some(&new_settings) == old_settings.as_ref() { + return; + } + + self.reregister_agents(cx); + } + + fn reregister_agents(&mut self, cx: &mut Context) { + let AgentServerStoreState::Local { + node_runtime, + fs, + project_environment, + downstream_client, + settings: old_settings, + http_client, + .. + } = &mut self.state + else { + debug_panic!("Non-local projects should never attempt to reregister. This is a bug!"); + + return; + }; + + let new_settings = cx + .global::() + .get::(None) + .clone(); + + self.external_agents.clear(); + self.external_agents.insert( + GEMINI_NAME.into(), + Box::new(LocalGemini { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .gemini + .clone() + .and_then(|settings| settings.custom_command()), + ignore_system_version: new_settings + .gemini + .as_ref() + .and_then(|settings| settings.ignore_system_version) + .unwrap_or(false), + }), + ); + self.external_agents.insert( + CODEX_NAME.into(), + Box::new(LocalCodex { + fs: fs.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .codex + .clone() + .and_then(|settings| settings.custom_command()), + http_client: http_client.clone(), + is_remote: downstream_client.is_some(), + }), + ); + self.external_agents.insert( + CLAUDE_CODE_NAME.into(), + Box::new(LocalClaudeCode { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .claude + .clone() + .and_then(|settings| settings.custom_command()), + }), + ); + self.external_agents + .extend(new_settings.custom.iter().map(|(name, settings)| { + ( + ExternalAgentServerName(name.clone()), + Box::new(LocalCustomAgent { + command: settings.command.clone(), + project_environment: project_environment.clone(), + }) as Box, + ) + })); + + *old_settings = Some(new_settings.clone()); + + if let Some((project_id, downstream_client)) = downstream_client { + downstream_client + .send(proto::ExternalAgentsUpdated { + project_id: *project_id, + names: self + .external_agents + .keys() + .map(|name| name.to_string()) + .collect(), + }) + .log_err(); + } + cx.emit(AgentServersUpdated); + } + + pub fn local( + node_runtime: NodeRuntime, + fs: Arc, + project_environment: Entity, + http_client: Arc, + cx: &mut Context, + ) -> Self { + let subscription = cx.observe_global::(|this, cx| { + this.agent_servers_settings_changed(cx); + }); + let mut this = Self { + state: AgentServerStoreState::Local { + node_runtime, + fs, + project_environment, + http_client, + downstream_client: None, + settings: None, + _subscriptions: [subscription], + }, + external_agents: Default::default(), + agent_icons: Default::default(), + }; + if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} + this.agent_servers_settings_changed(cx); + this + } + + pub(crate) fn remote(project_id: u64, upstream_client: Entity) -> Self { + // Set up the builtin agents here so they're immediately available in + // remote projects--we know that the HeadlessProject on the other end + // will have them. + let external_agents: [(ExternalAgentServerName, Box); 3] = [ + ( + CLAUDE_CODE_NAME.into(), + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: CLAUDE_CODE_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ), + ( + CODEX_NAME.into(), + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: CODEX_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ), + ( + GEMINI_NAME.into(), + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: GEMINI_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ), + ]; + + Self { + state: AgentServerStoreState::Remote { + project_id, + upstream_client, + }, + external_agents: external_agents.into_iter().collect(), + agent_icons: HashMap::default(), + } + } + + pub(crate) fn collab(_cx: &mut Context) -> Self { + Self { + state: AgentServerStoreState::Collab, + external_agents: Default::default(), + agent_icons: Default::default(), + } + } + + pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context) { + match &mut self.state { + AgentServerStoreState::Local { + downstream_client, .. + } => { + *downstream_client = Some((project_id, client.clone())); + // Send the current list of external agents downstream, but only after a delay, + // to avoid having the message arrive before the downstream project's agent server store + // sets up its handlers. + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(1)).await; + let names = this.update(cx, |this, _| { + this.external_agents + .keys() + .map(|name| name.to_string()) + .collect() + })?; + client + .send(proto::ExternalAgentsUpdated { project_id, names }) + .log_err(); + anyhow::Ok(()) + }) + .detach(); + } + AgentServerStoreState::Remote { .. } => { + debug_panic!( + "external agents over collab not implemented, remote project should not be shared" + ); + } + AgentServerStoreState::Collab => { + debug_panic!("external agents over collab not implemented, should not be shared"); + } + } + } + + pub fn get_external_agent( + &mut self, + name: &ExternalAgentServerName, + ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { + self.external_agents + .get_mut(name) + .map(|agent| agent.as_mut()) + } + + pub fn external_agents(&self) -> impl Iterator { + self.external_agents.keys() + } + + async fn handle_get_agent_server_command( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let (command, root_dir, login_command) = this + .update(&mut cx, |this, cx| { + let AgentServerStoreState::Local { + downstream_client, .. + } = &this.state + else { + debug_panic!("should not receive GetAgentServerCommand in a non-local project"); + bail!("unexpected GetAgentServerCommand request in a non-local project"); + }; + let agent = this + .external_agents + .get_mut(&*envelope.payload.name) + .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; + let (status_tx, new_version_available_tx) = downstream_client + .clone() + .map(|(project_id, downstream_client)| { + let (status_tx, mut status_rx) = watch::channel(SharedString::from("")); + let (new_version_available_tx, mut new_version_available_rx) = + watch::channel(None); + cx.spawn({ + let downstream_client = downstream_client.clone(); + let name = envelope.payload.name.clone(); + async move |_, _| { + while let Some(status) = status_rx.recv().await.ok() { + downstream_client.send( + proto::ExternalAgentLoadingStatusUpdated { + project_id, + name: name.clone(), + status: status.to_string(), + }, + )?; + } + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + cx.spawn({ + let name = envelope.payload.name.clone(); + async move |_, _| { + if let Some(version) = + new_version_available_rx.recv().await.ok().flatten() + { + downstream_client.send( + proto::NewExternalAgentVersionAvailable { + project_id, + name: name.clone(), + version, + }, + )?; + } + anyhow::Ok(()) + } + }) + .detach_and_log_err(cx); + (status_tx, new_version_available_tx) + }) + .unzip(); + anyhow::Ok(agent.get_command( + envelope.payload.root_dir.as_deref(), + HashMap::default(), + status_tx, + new_version_available_tx, + &mut cx.to_async(), + )) + })?? + .await?; + Ok(proto::AgentServerCommand { + path: command.path.to_string_lossy().into_owned(), + args: command.args, + env: command + .env + .map(|env| env.into_iter().collect()) + .unwrap_or_default(), + root_dir: root_dir, + login: login_command.map(|cmd| cmd.to_proto()), + }) + } + + async fn handle_external_agents_updated( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + let AgentServerStoreState::Remote { + project_id, + upstream_client, + } = &this.state + else { + debug_panic!( + "handle_external_agents_updated should not be called for a non-remote project" + ); + bail!("unexpected ExternalAgentsUpdated message") + }; + + let mut status_txs = this + .external_agents + .iter_mut() + .filter_map(|(name, agent)| { + Some(( + name.clone(), + agent + .downcast_mut::()? + .status_tx + .take(), + )) + }) + .collect::>(); + let mut new_version_available_txs = this + .external_agents + .iter_mut() + .filter_map(|(name, agent)| { + Some(( + name.clone(), + agent + .downcast_mut::()? + .new_version_available_tx + .take(), + )) + }) + .collect::>(); + + this.external_agents = envelope + .payload + .names + .into_iter() + .map(|name| { + let agent = RemoteExternalAgentServer { + project_id: *project_id, + upstream_client: upstream_client.clone(), + name: ExternalAgentServerName(name.clone().into()), + status_tx: status_txs.remove(&*name).flatten(), + new_version_available_tx: new_version_available_txs + .remove(&*name) + .flatten(), + }; + ( + ExternalAgentServerName(name.into()), + Box::new(agent) as Box, + ) + }) + .collect(); + cx.emit(AgentServersUpdated); + Ok(()) + })? + } + + async fn handle_loading_status_updated( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) + && let Some(agent) = agent.downcast_mut::() + && let Some(status_tx) = &mut agent.status_tx + { + status_tx.send(envelope.payload.status.into()).ok(); + } + }) + } + + async fn handle_new_version_available( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) + && let Some(agent) = agent.downcast_mut::() + && let Some(new_version_available_tx) = &mut agent.new_version_available_tx + { + new_version_available_tx + .send(Some(envelope.payload.version)) + .ok(); + } + }) + } +} + +fn get_or_npm_install_builtin_agent( + binary_name: SharedString, + package_name: SharedString, + entrypoint_path: PathBuf, + minimum_version: Option, + status_tx: Option>, + new_version_available: Option>>, + fs: Arc, + node_runtime: NodeRuntime, + cx: &mut AsyncApp, +) -> Task> { + cx.spawn(async move |cx| { + let node_path = node_runtime.binary_path().await?; + let dir = paths::external_agents_dir().join(binary_name.as_str()); + fs.create_dir(&dir).await?; + + let mut stream = fs.read_dir(&dir).await?; + let mut versions = Vec::new(); + let mut to_delete = Vec::new(); + while let Some(entry) = stream.next().await { + let Ok(entry) = entry else { continue }; + let Some(file_name) = entry.file_name() else { + continue; + }; + + if let Some(name) = file_name.to_str() + && let Some(version) = semver::Version::from_str(name).ok() + && fs + .is_file(&dir.join(file_name).join(&entrypoint_path)) + .await + { + versions.push((version, file_name.to_owned())); + } else { + to_delete.push(file_name.to_owned()) + } + } + + versions.sort(); + let newest_version = if let Some((version, file_name)) = versions.last().cloned() + && minimum_version.is_none_or(|minimum_version| version >= minimum_version) + { + versions.pop(); + Some(file_name) + } else { + None + }; + log::debug!("existing version of {package_name}: {newest_version:?}"); + to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name)); + + cx.background_spawn({ + let fs = fs.clone(); + let dir = dir.clone(); + async move { + for file_name in to_delete { + fs.remove_dir( + &dir.join(file_name), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .ok(); + } + } + }) + .detach(); + + let version = if let Some(file_name) = newest_version { + cx.background_spawn({ + let file_name = file_name.clone(); + let dir = dir.clone(); + let fs = fs.clone(); + async move { + let latest_version = node_runtime + .npm_package_latest_version(&package_name) + .await + .ok(); + if let Some(latest_version) = latest_version + && &latest_version != &file_name.to_string_lossy() + { + let download_result = download_latest_version( + fs, + dir.clone(), + node_runtime, + package_name.clone(), + ) + .await + .log_err(); + if let Some(mut new_version_available) = new_version_available + && download_result.is_some() + { + new_version_available.send(Some(latest_version)).ok(); + } + } + } + }) + .detach(); + file_name + } else { + if let Some(mut status_tx) = status_tx { + status_tx.send("Installing…".into()).ok(); + } + let dir = dir.clone(); + cx.background_spawn(download_latest_version( + fs.clone(), + dir.clone(), + node_runtime, + package_name.clone(), + )) + .await? + .into() + }; + + let agent_server_path = dir.join(version).join(entrypoint_path); + let agent_server_path_exists = fs.is_file(&agent_server_path).await; + anyhow::ensure!( + agent_server_path_exists, + "Missing entrypoint path {} after installation", + agent_server_path.to_string_lossy() + ); + + anyhow::Ok(AgentServerCommand { + path: node_path, + args: vec![agent_server_path.to_string_lossy().into_owned()], + env: None, + }) + }) +} + +fn find_bin_in_path( + bin_name: SharedString, + root_dir: PathBuf, + env: HashMap, + cx: &mut AsyncApp, +) -> Task> { + cx.background_executor().spawn(async move { + let which_result = if cfg!(windows) { + which::which(bin_name.as_str()) + } else { + let shell_path = env.get("PATH").cloned(); + which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir) + }; + + if let Err(which::Error::CannotFindBinaryPath) = which_result { + return None; + } + + which_result.log_err() + }) +} + +async fn download_latest_version( + fs: Arc, + dir: PathBuf, + node_runtime: NodeRuntime, + package_name: SharedString, +) -> Result { + log::debug!("downloading latest version of {package_name}"); + + let tmp_dir = tempfile::tempdir_in(&dir)?; + + node_runtime + .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")]) + .await?; + + let version = node_runtime + .npm_package_installed_version(tmp_dir.path(), &package_name) + .await? + .context("expected package to be installed")?; + + fs.rename( + &tmp_dir.keep(), + &dir.join(&version), + RenameOptions { + ignore_if_exists: true, + overwrite: true, + }, + ) + .await?; + + anyhow::Ok(version) +} + +struct RemoteExternalAgentServer { + project_id: u64, + upstream_client: Entity, + name: ExternalAgentServerName, + status_tx: Option>, + new_version_available_tx: Option>>, +} + +impl ExternalAgentServer for RemoteExternalAgentServer { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let project_id = self.project_id; + let name = self.name.to_string(); + let upstream_client = self.upstream_client.downgrade(); + let root_dir = root_dir.map(|root_dir| root_dir.to_owned()); + self.status_tx = status_tx; + self.new_version_available_tx = new_version_available_tx; + cx.spawn(async move |cx| { + let mut response = upstream_client + .update(cx, |upstream_client, _| { + upstream_client + .proto_client() + .request(proto::GetAgentServerCommand { + project_id, + name, + root_dir: root_dir.clone(), + }) + })? + .await?; + let root_dir = response.root_dir; + response.env.extend(extra_env); + let command = upstream_client.update(cx, |client, _| { + client.build_command( + Some(response.path), + &response.args, + &response.env.into_iter().collect(), + Some(root_dir.clone()), + None, + ) + })??; + Ok(( + AgentServerCommand { + path: command.program.into(), + args: command.args, + env: Some(command.env), + }, + root_dir, + None, + )) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalGemini { + fs: Arc, + node_runtime: NodeRuntime, + project_environment: Entity, + custom_command: Option, + ignore_system_version: bool, +} + +impl ExternalAgentServer for LocalGemini { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let node_runtime = self.node_runtime.clone(); + let project_environment = self.project_environment.downgrade(); + let custom_command = self.custom_command.clone(); + let ignore_system_version = self.ignore_system_version; + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + + let mut command = if let Some(mut custom_command) = custom_command { + env.extend(custom_command.env.unwrap_or_default()); + custom_command.env = Some(env); + custom_command + } else if !ignore_system_version + && let Some(bin) = + find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await + { + AgentServerCommand { + path: bin, + args: Vec::new(), + env: Some(env), + } + } else { + let mut command = get_or_npm_install_builtin_agent( + GEMINI_NAME.into(), + "@google/gemini-cli".into(), + "node_modules/@google/gemini-cli/dist/index.js".into(), + if cfg!(windows) { + // v0.8.x on Windows has a bug that causes the initialize request to hang forever + Some("0.9.0".parse().unwrap()) + } else { + Some("0.2.1".parse().unwrap()) + }, + status_tx, + new_version_available_tx, + fs, + node_runtime, + cx, + ) + .await?; + command.env = Some(env); + command + }; + + // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments. + let login = task::SpawnInTerminal { + command: Some(command.path.to_string_lossy().into_owned()), + args: command.args.clone(), + env: command.env.clone().unwrap_or_default(), + label: "gemini /auth".into(), + ..Default::default() + }; + + command.env.get_or_insert_default().extend(extra_env); + command.args.push("--experimental-acp".into()); + Ok(( + command, + root_dir.to_string_lossy().into_owned(), + Some(login), + )) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalClaudeCode { + fs: Arc, + node_runtime: NodeRuntime, + project_environment: Entity, + custom_command: Option, +} + +impl ExternalAgentServer for LocalClaudeCode { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let node_runtime = self.node_runtime.clone(); + let project_environment = self.project_environment.downgrade(); + let custom_command = self.custom_command.clone(); + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + env.insert("ANTHROPIC_API_KEY".into(), "".into()); + + let (mut command, login_command) = if let Some(mut custom_command) = custom_command { + env.extend(custom_command.env.unwrap_or_default()); + custom_command.env = Some(env); + (custom_command, None) + } else { + let mut command = get_or_npm_install_builtin_agent( + "claude-code-acp".into(), + "@zed-industries/claude-code-acp".into(), + "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(), + Some("0.5.2".parse().unwrap()), + status_tx, + new_version_available_tx, + fs, + node_runtime, + cx, + ) + .await?; + command.env = Some(env); + let login = command + .args + .first() + .and_then(|path| { + path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js") + }) + .map(|path_prefix| task::SpawnInTerminal { + command: Some(command.path.to_string_lossy().into_owned()), + args: vec![ + Path::new(path_prefix) + .join("@anthropic-ai/claude-agent-sdk/cli.js") + .to_string_lossy() + .to_string(), + "/login".into(), + ], + env: command.env.clone().unwrap_or_default(), + label: "claude /login".into(), + ..Default::default() + }); + (command, login) + }; + + command.env.get_or_insert_default().extend(extra_env); + Ok(( + command, + root_dir.to_string_lossy().into_owned(), + login_command, + )) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalCodex { + fs: Arc, + project_environment: Entity, + http_client: Arc, + custom_command: Option, + is_remote: bool, +} + +impl ExternalAgentServer for LocalCodex { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + status_tx: Option>, + _new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let project_environment = self.project_environment.downgrade(); + let http = self.http_client.clone(); + let custom_command = self.custom_command.clone(); + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + let is_remote = self.is_remote; + + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + if is_remote { + env.insert("NO_BROWSER".to_owned(), "1".to_owned()); + } + + let mut command = if let Some(mut custom_command) = custom_command { + env.extend(custom_command.env.unwrap_or_default()); + custom_command.env = Some(env); + custom_command + } else { + let dir = paths::external_agents_dir().join(CODEX_NAME); + fs.create_dir(&dir).await?; + + // Find or install the latest Codex release (no update checks for now). + let release = ::http_client::github::latest_github_release( + CODEX_ACP_REPO, + true, + false, + http.clone(), + ) + .await + .context("fetching Codex latest release")?; + + let version_dir = dir.join(&release.tag_name); + if !fs.is_dir(&version_dir).await { + if let Some(mut status_tx) = status_tx { + status_tx.send("Installing…".into()).ok(); + } + + let tag = release.tag_name.clone(); + let version_number = tag.trim_start_matches('v'); + let asset_name = asset_name(version_number) + .context("codex acp is not supported for this architecture")?; + let asset = release + .assets + .into_iter() + .find(|asset| asset.name == asset_name) + .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; + // Strip "sha256:" prefix from digest if present (GitHub API format) + let digest = asset + .digest + .as_deref() + .and_then(|d| d.strip_prefix("sha256:").or(Some(d))); + ::http_client::github_download::download_server_binary( + &*http, + &asset.browser_download_url, + digest, + &version_dir, + if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") { + AssetKind::Zip + } else { + AssetKind::TarGz + }, + ) + .await?; + + // remove older versions + util::fs::remove_matching(&dir, |entry| entry != version_dir).await; + } + + let bin_name = if cfg!(windows) { + "codex-acp.exe" + } else { + "codex-acp" + }; + let bin_path = version_dir.join(bin_name); + anyhow::ensure!( + fs.is_file(&bin_path).await, + "Missing Codex binary at {} after installation", + bin_path.to_string_lossy() + ); + + let mut cmd = AgentServerCommand { + path: bin_path, + args: Vec::new(), + env: None, + }; + cmd.env = Some(env); + cmd + }; + + command.env.get_or_insert_default().extend(extra_env); + Ok((command, root_dir.to_string_lossy().into_owned(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp"; + +fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> { + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + return None; + }; + + let platform = if cfg!(target_os = "macos") { + "apple-darwin" + } else if cfg!(target_os = "windows") { + "pc-windows-msvc" + } else if cfg!(target_os = "linux") { + "unknown-linux-gnu" + } else { + return None; + }; + + // Only Windows x86_64 uses .zip in release assets + let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") { + "zip" + } else { + "tar.gz" + }; + + Some((arch, platform, ext)) +} + +fn asset_name(version: &str) -> Option { + let (arch, platform, ext) = get_platform_info()?; + Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}")) +} + +struct LocalExtensionArchiveAgent { + fs: Arc, + http_client: Arc, + project_environment: Entity, + extension_id: Arc, + agent_id: Arc, + targets: HashMap, + env: HashMap, +} + +struct LocalCustomAgent { + project_environment: Entity, + command: AgentServerCommand, +} + +impl ExternalAgentServer for LocalExtensionArchiveAgent { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let http_client = self.http_client.clone(); + let project_environment = self.project_environment.downgrade(); + let extension_id = self.extension_id.clone(); + let agent_id = self.agent_id.clone(); + let targets = self.targets.clone(); + let base_env = self.env.clone(); + + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + // Get project environment + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + + // Merge manifest env and extra env + env.extend(base_env); + env.extend(extra_env); + + let cache_key = format!("{}/{}", extension_id, agent_id); + let dir = paths::external_agents_dir().join(&cache_key); + fs.create_dir(&dir).await?; + + // Determine platform key + let os = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + anyhow::bail!("unsupported OS"); + }; + + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + anyhow::bail!("unsupported architecture"); + }; + + let platform_key = format!("{}-{}", os, arch); + let target_config = targets.get(&platform_key).with_context(|| { + format!( + "no target specified for platform '{}'. Available platforms: {}", + platform_key, + targets + .keys() + .map(|k| k.as_str()) + .collect::>() + .join(", ") + ) + })?; + + let archive_url = &target_config.archive; + + // Use URL as version identifier for caching + // Hash the URL to get a stable directory name + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + archive_url.hash(&mut hasher); + let url_hash = hasher.finish(); + let version_dir = dir.join(format!("v_{:x}", url_hash)); + + if !fs.is_dir(&version_dir).await { + // Determine SHA256 for verification + let sha256 = if let Some(provided_sha) = &target_config.sha256 { + // Use provided SHA256 + Some(provided_sha.clone()) + } else if archive_url.starts_with("https://github.com/") { + // Try to fetch SHA256 from GitHub API + // Parse URL to extract repo and tag/file info + // Format: https://github.com/owner/repo/releases/download/tag/file.zip + if let Some(caps) = archive_url.strip_prefix("https://github.com/") { + let parts: Vec<&str> = caps.split('/').collect(); + if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" { + let repo = format!("{}/{}", parts[0], parts[1]); + let tag = parts[4]; + let filename = parts[5..].join("/"); + + // Try to get release info from GitHub + if let Ok(release) = ::http_client::github::get_release_by_tag_name( + &repo, + tag, + http_client.clone(), + ) + .await + { + // Find matching asset + if let Some(asset) = + release.assets.iter().find(|a| a.name == filename) + { + // Strip "sha256:" prefix if present + asset.digest.as_ref().and_then(|d| { + d.strip_prefix("sha256:") + .map(|s| s.to_string()) + .or_else(|| Some(d.clone())) + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // Determine archive type from URL + let asset_kind = if archive_url.ends_with(".zip") { + AssetKind::Zip + } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") { + AssetKind::TarGz + } else { + anyhow::bail!("unsupported archive type in URL: {}", archive_url); + }; + + // Download and extract + ::http_client::github_download::download_server_binary( + &*http_client, + archive_url, + sha256.as_deref(), + &version_dir, + asset_kind, + ) + .await?; + } + + // Validate and resolve cmd path + let cmd = &target_config.cmd; + if cmd.contains("..") { + anyhow::bail!("command path cannot contain '..': {}", cmd); + } + + let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") { + // Relative to extraction directory + version_dir.join(&cmd[2..]) + } else { + // On PATH + anyhow::bail!("command must be relative (start with './'): {}", cmd); + }; + + anyhow::ensure!( + fs.is_file(&cmd_path).await, + "Missing command {} after extraction", + cmd_path.to_string_lossy() + ); + + let command = AgentServerCommand { + path: cmd_path, + args: target_config.args.clone(), + env: Some(env), + }; + + Ok((command, root_dir.to_string_lossy().into_owned(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl ExternalAgentServer for LocalCustomAgent { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let mut command = self.command.clone(); + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + let project_environment = self.project_environment.downgrade(); + cx.spawn(async move |cx| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + env.extend(command.env.unwrap_or_default()); + env.extend(extra_env); + command.env = Some(env); + Ok((command, root_dir.to_string_lossy().into_owned(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +pub const GEMINI_NAME: &'static str = "gemini"; +pub const CLAUDE_CODE_NAME: &'static str = "claude"; +pub const CODEX_NAME: &'static str = "codex"; + +#[derive(Default, Clone, JsonSchema, Debug, PartialEq)] +pub struct AllAgentServersSettings { + pub gemini: Option, + pub claude: Option, + pub codex: Option, + pub custom: HashMap, +} +#[derive(Default, Clone, JsonSchema, Debug, PartialEq)] +pub struct BuiltinAgentServerSettings { + pub path: Option, + pub args: Option>, + pub env: Option>, + pub ignore_system_version: Option, + pub default_mode: Option, +} + +impl BuiltinAgentServerSettings { + pub(crate) fn custom_command(self) -> Option { + self.path.map(|path| AgentServerCommand { + path, + args: self.args.unwrap_or_default(), + env: self.env, + }) + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: settings::BuiltinAgentServerSettings) -> Self { + BuiltinAgentServerSettings { + path: value.path, + args: value.args, + env: value.env, + ignore_system_version: value.ignore_system_version, + default_mode: value.default_mode, + } + } +} + +impl From for BuiltinAgentServerSettings { + fn from(value: AgentServerCommand) -> Self { + BuiltinAgentServerSettings { + path: Some(value.path), + args: Some(value.args), + env: value.env, + ..Default::default() + } + } +} + +#[derive(Clone, JsonSchema, Debug, PartialEq)] +pub struct CustomAgentServerSettings { + pub command: AgentServerCommand, + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + pub default_mode: Option, +} + +impl From for CustomAgentServerSettings { + fn from(value: settings::CustomAgentServerSettings) -> Self { + CustomAgentServerSettings { + command: AgentServerCommand { + path: value.path, + args: value.args, + env: value.env, + }, + default_mode: value.default_mode, + } + } +} + +impl settings::Settings for AllAgentServersSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let agent_settings = content.agent_servers.clone().unwrap(); + Self { + gemini: agent_settings.gemini.map(Into::into), + claude: agent_settings.claude.map(Into::into), + codex: agent_settings.codex.map(Into::into), + custom: agent_settings + .custom + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + } + } +} + +#[cfg(test)] +mod extension_agent_tests { + use super::*; + use gpui::TestAppContext; + use std::sync::Arc; + + #[test] + fn extension_agent_constructs_proper_display_names() { + // Verify the display name format for extension-provided agents + let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent")); + assert!(name1.0.contains(": ")); + + let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent")); + assert_eq!(name2.0, "MyExt: MyAgent"); + + // Non-extension agents shouldn't have the separator + let custom = ExternalAgentServerName(SharedString::from("custom")); + assert!(!custom.0.contains(": ")); + } + + struct NoopExternalAgent; + + impl ExternalAgentServer for NoopExternalAgent { + fn get_command( + &mut self, + _root_dir: Option<&str>, + _extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + _cx: &mut AsyncApp, + ) -> Task)>> { + Task::ready(Ok(( + AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + }, + "".to_string(), + None, + ))) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + } + + #[test] + fn sync_removes_only_extension_provided_agents() { + let mut store = AgentServerStore { + state: AgentServerStoreState::Collab, + external_agents: HashMap::default(), + agent_icons: HashMap::default(), + }; + + // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("Ext1: Agent1")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("Ext2: Agent2")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("custom-agent")), + Box::new(NoopExternalAgent) as Box, + ); + + // Simulate removal phase + let keys_to_remove: Vec<_> = store + .external_agents + .keys() + .filter(|name| name.0.contains(": ")) + .cloned() + .collect(); + + for key in keys_to_remove { + store.external_agents.remove(&key); + } + + // Only custom-agent should remain + assert_eq!(store.external_agents.len(), 1); + assert!( + store + .external_agents + .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent"))) + ); + } + + #[test] + fn archive_launcher_constructs_with_all_fields() { + use extension::AgentServerManifestEntry; + + let mut env = HashMap::default(); + env.insert("GITHUB_TOKEN".into(), "secret".into()); + + let mut targets = HashMap::default(); + targets.insert( + "darwin-aarch64".to_string(), + extension::TargetConfig { + archive: + "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip" + .into(), + cmd: "./agent".into(), + args: vec![], + sha256: None, + }, + ); + + let _entry = AgentServerManifestEntry { + name: "GitHub Agent".into(), + targets, + env, + icon: None, + }; + + // Verify display name construction + let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent")); + assert_eq!(expected_name.0, "GitHub Agent"); + } + + #[gpui::test] + async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + let http_client = http_client::FakeHttpClient::with_404_response(); + let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx)); + + let agent = LocalExtensionArchiveAgent { + fs, + http_client, + project_environment, + extension_id: Arc::from("my-extension"), + agent_id: Arc::from("my-agent"), + targets: { + let mut map = HashMap::default(); + map.insert( + "darwin-aarch64".to_string(), + extension::TargetConfig { + archive: "https://example.com/my-agent-darwin-arm64.zip".into(), + cmd: "./my-agent".into(), + args: vec!["--serve".into()], + sha256: None, + }, + ); + map + }, + env: { + let mut map = HashMap::default(); + map.insert("PORT".into(), "8080".into()); + map + }, + }; + + // Verify agent is properly constructed + assert_eq!(agent.extension_id.as_ref(), "my-extension"); + assert_eq!(agent.agent_id.as_ref(), "my-agent"); + assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string())); + assert!(agent.targets.contains_key("darwin-aarch64")); + } + + #[test] + fn sync_extension_agents_registers_archive_launcher() { + use extension::AgentServerManifestEntry; + + let expected_name = ExternalAgentServerName(SharedString::from("Release Agent")); + assert_eq!(expected_name.0, "Release Agent"); + + // Verify the manifest entry structure for archive-based installation + let mut env = HashMap::default(); + env.insert("API_KEY".into(), "secret".into()); + + let mut targets = HashMap::default(); + targets.insert( + "linux-x86_64".to_string(), + extension::TargetConfig { + archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(), + cmd: "./release-agent".into(), + args: vec!["serve".into()], + sha256: None, + }, + ); + + let manifest_entry = AgentServerManifestEntry { + name: "Release Agent".into(), + targets: targets.clone(), + env, + icon: None, + }; + + // Verify target config is present + assert!(manifest_entry.targets.contains_key("linux-x86_64")); + let target = manifest_entry.targets.get("linux-x86_64").unwrap(); + assert_eq!(target.cmd, "./release-agent"); + } +} diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b8101e14f39b4faf54b76eaab955864e4ef82ae5..39e302a2d9b1ae92cce9691c957cb9fcfbf26d7d 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -20,13 +20,13 @@ use language::{ }, }; use rpc::{ - AnyProtoClient, ErrorExt as _, TypedEnvelope, - proto::{self, ToProto}, + AnyProtoClient, ErrorCode, ErrorExt as _, TypedEnvelope, + proto::{self}, }; use smol::channel::Receiver; -use std::{io, path::Path, pin::pin, sync::Arc, time::Instant}; -use text::BufferId; -use util::{ResultExt as _, TryFutureExt, debug_panic, maybe}; +use std::{io, pin::pin, sync::Arc, time::Instant}; +use text::{BufferId, ReplicaId}; +use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, paths::PathStyle, rel_path::RelPath}; use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; /// A set of open buffers. @@ -88,9 +88,18 @@ pub enum BufferStoreEvent { }, } -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub struct ProjectTransaction(pub HashMap, language::Transaction>); +impl PartialEq for ProjectTransaction { + fn eq(&self, other: &Self) -> bool { + self.0.len() == other.0.len() + && self.0.iter().all(|(buffer, transaction)| { + other.0.get(buffer).is_some_and(|t| t.id == transaction.id) + }) + } +} + impl EventEmitter for BufferStore {} impl RemoteBufferStore { @@ -149,7 +158,7 @@ impl RemoteBufferStore { pub fn handle_create_buffer_for_peer( &mut self, envelope: TypedEnvelope, - replica_id: u16, + replica_id: ReplicaId, capability: Capability, cx: &mut Context, ) -> Result>> { @@ -168,7 +177,7 @@ impl RemoteBufferStore { .with_context(|| { format!("no worktree found for id {}", file.worktree_id) })?; - buffer_file = Some(Arc::new(File::from_proto(file, worktree.clone(), cx)?) + buffer_file = Some(Arc::new(File::from_proto(file, worktree, cx)?) as Arc); } Buffer::from_proto(replica_id, capability, state, buffer_file) @@ -234,7 +243,7 @@ impl RemoteBufferStore { } } } - return Ok(None); + Ok(None) } pub fn incomplete_buffer_ids(&self) -> Vec { @@ -283,7 +292,7 @@ impl RemoteBufferStore { fn open_buffer( &self, - path: Arc, + path: Arc, worktree: Entity, cx: &mut Context, ) -> Task>> { @@ -310,7 +319,11 @@ impl RemoteBufferStore { }) } - fn create_buffer(&self, cx: &mut Context) -> Task>> { + fn create_buffer( + &self, + project_searchable: bool, + cx: &mut Context, + ) -> Task>> { let create = self.upstream_client.request(proto::OpenNewBuffer { project_id: self.project_id, }); @@ -318,8 +331,13 @@ impl RemoteBufferStore { let response = create.await?; let buffer_id = BufferId::new(response.buffer_id)?; - this.update(cx, |this, cx| this.wait_for_remote_buffer(buffer_id, cx))? - .await + this.update(cx, |this, cx| { + if !project_searchable { + this.non_searchable_buffers.insert(buffer_id); + } + this.wait_for_remote_buffer(buffer_id, cx) + })? + .await }) } @@ -352,7 +370,7 @@ impl LocalBufferStore { &self, buffer_handle: Entity, worktree: Entity, - path: Arc, + path: Arc, mut has_changed_file: bool, cx: &mut Context, ) -> Task> { @@ -371,7 +389,7 @@ impl LocalBufferStore { } let save = worktree.update(cx, |worktree, cx| { - worktree.write_file(path.as_ref(), text, line_ending, cx) + worktree.write_file(path, text, line_ending, cx) }); cx.spawn(async move |this, cx| { @@ -413,13 +431,10 @@ impl LocalBufferStore { cx: &mut Context, ) { cx.subscribe(worktree, |this, worktree, event, cx| { - if worktree.read(cx).is_local() { - match event { - worktree::Event::UpdatedEntries(changes) => { - Self::local_worktree_entries_changed(this, &worktree, changes, cx); - } - _ => {} - } + if worktree.read(cx).is_local() + && let worktree::Event::UpdatedEntries(changes) = event + { + Self::local_worktree_entries_changed(this, &worktree, changes, cx); } }) .detach(); @@ -428,7 +443,7 @@ impl LocalBufferStore { fn local_worktree_entries_changed( this: &mut BufferStore, worktree_handle: &Entity, - changes: &[(Arc, ProjectEntryId, PathChange)], + changes: &[(Arc, ProjectEntryId, PathChange)], cx: &mut Context, ) { let snapshot = worktree_handle.read(cx).snapshot(); @@ -447,7 +462,7 @@ impl LocalBufferStore { fn local_worktree_entry_changed( this: &mut BufferStore, entry_id: ProjectEntryId, - path: &Arc, + path: &Arc, worktree: &Entity, snapshot: &worktree::Snapshot, cx: &mut Context, @@ -467,6 +482,7 @@ impl LocalBufferStore { Some(buffer) } else { this.opened_buffers.remove(&buffer_id); + this.non_searchable_buffers.remove(&buffer_id); None }; @@ -594,36 +610,36 @@ impl LocalBufferStore { else { return Task::ready(Err(anyhow!("no such worktree"))); }; - self.save_local_buffer(buffer, worktree, path.path.clone(), true, cx) + self.save_local_buffer(buffer, worktree, path.path, true, cx) } fn open_buffer( &self, - path: Arc, + path: Arc, worktree: Entity, cx: &mut Context, ) -> Task>> { - let load_buffer = worktree.update(cx, |worktree, cx| { - let load_file = worktree.load_file(path.as_ref(), cx); - let reservation = cx.reserve_entity(); - let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); - cx.spawn(async move |_, cx| { - let loaded = load_file.await?; - let text_buffer = cx - .background_spawn(async move { text::Buffer::new(0, buffer_id, loaded.text) }) - .await; - cx.insert_entity(reservation, |_| { - Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite) - }) - }) - }); - + let load_file = worktree.update(cx, |worktree, cx| worktree.load_file(path.as_ref(), cx)); cx.spawn(async move |this, cx| { - let buffer = match load_buffer.await { - Ok(buffer) => Ok(buffer), + let path = path.clone(); + let buffer = match load_file.await.with_context(|| { + format!("Could not open path: {}", path.display(PathStyle::local())) + }) { + Ok(loaded) => { + let reservation = cx.reserve_entity::()?; + let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); + let text_buffer = cx + .background_spawn(async move { + text::Buffer::new(ReplicaId::LOCAL, buffer_id, loaded.text) + }) + .await; + cx.insert_entity(reservation, |_| { + Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite) + })? + } Err(error) if is_not_found_error(&error) => cx.new(|cx| { let buffer_id = BufferId::from(cx.entity_id().as_non_zero_u64()); - let text_buffer = text::Buffer::new(0, buffer_id, ""); + let text_buffer = text::Buffer::new(ReplicaId::LOCAL, buffer_id, ""); Buffer::build( text_buffer, Some(Arc::new(File { @@ -636,9 +652,9 @@ impl LocalBufferStore { })), Capability::ReadWrite, ) - }), - Err(e) => Err(e), - }?; + })?, + Err(e) => return Err(e), + }; this.update(cx, |this, cx| { this.add_buffer(buffer.clone(), cx)?; let buffer_id = buffer.read(cx).remote_id(); @@ -664,12 +680,21 @@ impl LocalBufferStore { }) } - fn create_buffer(&self, cx: &mut Context) -> Task>> { + fn create_buffer( + &self, + project_searchable: bool, + cx: &mut Context, + ) -> Task>> { cx.spawn(async move |buffer_store, cx| { let buffer = cx.new(|cx| Buffer::local("", cx).with_language(language::PLAIN_TEXT.clone(), cx))?; buffer_store.update(cx, |buffer_store, cx| { buffer_store.add_buffer(buffer.clone(), cx).log_err(); + if !project_searchable { + buffer_store + .non_searchable_buffers + .insert(buffer.read(cx).remote_id()); + } })?; Ok(buffer) }) @@ -810,6 +835,7 @@ impl BufferStore { entry .insert( + // todo(lw): hot foreground spawn cx.spawn(async move |this, cx| { let load_result = load_buffer.await; this.update(cx, |this, cx| { @@ -831,13 +857,25 @@ impl BufferStore { } }; - cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + cx.background_spawn(async move { + task.await.map_err(|e| { + if e.error_code() != ErrorCode::Internal { + anyhow!(e.error_code()) + } else { + anyhow!("{e}") + } + }) + }) } - pub fn create_buffer(&mut self, cx: &mut Context) -> Task>> { + pub fn create_buffer( + &mut self, + project_searchable: bool, + cx: &mut Context, + ) -> Task>> { match &self.state { - BufferStoreState::Local(this) => this.create_buffer(cx), - BufferStoreState::Remote(this) => this.create_buffer(cx), + BufferStoreState::Local(this) => this.create_buffer(project_searchable, cx), + BufferStoreState::Remote(this) => this.create_buffer(project_searchable, cx), } } @@ -848,7 +886,7 @@ impl BufferStore { ) -> Task> { match &mut self.state { BufferStoreState::Local(this) => this.save_buffer(buffer, cx), - BufferStoreState::Remote(this) => this.save_remote_buffer(buffer.clone(), None, cx), + BufferStoreState::Remote(this) => this.save_remote_buffer(buffer, None, cx), } } @@ -867,7 +905,14 @@ impl BufferStore { }; cx.spawn(async move |this, cx| { task.await?; - this.update(cx, |_, cx| { + this.update(cx, |this, cx| { + old_file.clone().and_then(|file| { + this.path_to_buffer_id.remove(&ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + }); + cx.emit(BufferStoreEvent::BufferChangedFilePath { buffer, old_file }); }) }) @@ -880,7 +925,7 @@ impl BufferStore { path: file.path.clone(), worktree_id: file.worktree_id(cx), }); - let is_remote = buffer.replica_id() != 0; + let is_remote = buffer.replica_id().is_remote(); let open_buffer = OpenBuffer::Complete { buffer: buffer_entity.downgrade(), }; @@ -938,7 +983,15 @@ impl BufferStore { ) -> impl Iterator>>)> { self.loading_buffers.iter().map(|(path, task)| { let task = task.clone(); - (path, async move { task.await.map_err(|e| anyhow!("{e}")) }) + (path, async move { + task.await.map_err(|e| { + if e.error_code() != ErrorCode::Internal { + anyhow!(e.error_code()) + } else { + anyhow!("{e}") + } + }) + }) }) } @@ -947,10 +1000,9 @@ impl BufferStore { } pub fn get_by_path(&self, path: &ProjectPath) -> Option> { - self.path_to_buffer_id.get(path).and_then(|buffer_id| { - let buffer = self.get(*buffer_id); - buffer - }) + self.path_to_buffer_id + .get(path) + .and_then(|buffer_id| self.get(*buffer_id)) } pub fn get(&self, buffer_id: BufferId) -> Option> { @@ -1094,10 +1146,10 @@ impl BufferStore { .collect::>() })?; for buffer_task in buffers { - if let Some(buffer) = buffer_task.await.log_err() { - if tx.send(buffer).await.is_err() { - return anyhow::Ok(()); - } + if let Some(buffer) = buffer_task.await.log_err() + && tx.send(buffer).await.is_err() + { + return anyhow::Ok(()); } } } @@ -1142,7 +1194,7 @@ impl BufferStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let payload = envelope.payload.clone(); + let payload = envelope.payload; let buffer_id = BufferId::new(payload.buffer_id)?; let ops = payload .operations @@ -1173,11 +1225,11 @@ impl BufferStore { buffer_id: BufferId, handle: OpenLspBufferHandle, ) { - if let Some(shared_buffers) = self.shared_buffers.get_mut(&peer_id) { - if let Some(buffer) = shared_buffers.get_mut(&buffer_id) { - buffer.lsp_handle = Some(handle); - return; - } + if let Some(shared_buffers) = self.shared_buffers.get_mut(&peer_id) + && let Some(buffer) = shared_buffers.get_mut(&buffer_id) + { + buffer.lsp_handle = Some(handle); + return; } debug_panic!("tried to register shared lsp handle, but buffer was not shared") } @@ -1273,7 +1325,7 @@ impl BufferStore { pub fn handle_create_buffer_for_peer( &mut self, envelope: TypedEnvelope, - replica_id: u16, + replica_id: ReplicaId, capability: Capability, cx: &mut Context, ) -> Result<()> { @@ -1313,10 +1365,7 @@ impl BufferStore { let new_path = file.path.clone(); buffer.file_updated(Arc::new(file), cx); - if old_file - .as_ref() - .map_or(true, |old| *old.path() != new_path) - { + if old_file.as_ref().is_none_or(|old| *old.path() != new_path) { Some(old_file) } else { None @@ -1345,7 +1394,7 @@ impl BufferStore { mut cx: AsyncApp, ) -> Result { let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - let (buffer, project_id) = this.read_with(&mut cx, |this, _| { + let (buffer, project_id) = this.read_with(&cx, |this, _| { anyhow::Ok(( this.get_existing(buffer_id)?, this.downstream_client @@ -1359,10 +1408,11 @@ impl BufferStore { buffer.wait_for_version(deserialize_version(&envelope.payload.version)) })? .await?; - let buffer_id = buffer.read_with(&mut cx, |buffer, _| buffer.remote_id())?; + let buffer_id = buffer.read_with(&cx, |buffer, _| buffer.remote_id())?; - if let Some(new_path) = envelope.payload.new_path { - let new_path = ProjectPath::from_proto(new_path); + if let Some(new_path) = envelope.payload.new_path + && let Some(new_path) = ProjectPath::from_proto(new_path) + { this.update(&mut cx, |this, cx| { this.save_buffer_as(buffer.clone(), new_path, cx) })? @@ -1372,7 +1422,7 @@ impl BufferStore { .await?; } - buffer.read_with(&mut cx, |buffer, _| proto::BufferSaved { + buffer.read_with(&cx, |buffer, _| proto::BufferSaved { project_id, buffer_id: buffer_id.into(), version: serialize_version(buffer.saved_version()), @@ -1388,14 +1438,14 @@ impl BufferStore { let peer_id = envelope.sender_id; let buffer_id = BufferId::new(envelope.payload.buffer_id)?; this.update(&mut cx, |this, cx| { - if let Some(shared) = this.shared_buffers.get_mut(&peer_id) { - if shared.remove(&buffer_id).is_some() { - cx.emit(BufferStoreEvent::SharedBufferClosed(peer_id, buffer_id)); - if shared.is_empty() { - this.shared_buffers.remove(&peer_id); - } - return; + if let Some(shared) = this.shared_buffers.get_mut(&peer_id) + && shared.remove(&buffer_id).is_some() + { + cx.emit(BufferStoreEvent::SharedBufferClosed(peer_id, buffer_id)); + if shared.is_empty() { + this.shared_buffers.remove(&peer_id); } + return; } debug_panic!( "peer_id {} closed buffer_id {} which was either not open or already closed", @@ -1592,6 +1642,7 @@ impl BufferStore { &mut self, text: &str, language: Option>, + project_searchable: bool, cx: &mut Context, ) -> Entity { let buffer = cx.new(|cx| { @@ -1601,6 +1652,9 @@ impl BufferStore { self.add_buffer(buffer.clone(), cx).log_err(); let buffer_id = buffer.read(cx).remote_id(); + if !project_searchable { + self.non_searchable_buffers.insert(buffer_id); + } if let Some(file) = File::from_dyn(buffer.read(cx).file()) { self.path_to_buffer_id.insert( @@ -1670,10 +1724,6 @@ impl BufferStore { } serialized_transaction } - - pub(crate) fn mark_buffer_as_non_searchable(&mut self, buffer_id: BufferId) { - self.non_searchable_buffers.insert(buffer_id); - } } impl OpenBuffer { diff --git a/crates/project/src/color_extractor.rs b/crates/project/src/color_extractor.rs index 5473da88af5bee6e66b005956366a289478f7ee4..6e9907e30b7393a3074f4af579536d74140418f9 100644 --- a/crates/project/src/color_extractor.rs +++ b/crates/project/src/color_extractor.rs @@ -4,8 +4,8 @@ use gpui::{Hsla, Rgba}; use lsp::{CompletionItem, Documentation}; use regex::{Regex, RegexBuilder}; -const HEX: &'static str = r#"(#(?:[\da-fA-F]{3}){1,2})"#; -const RGB_OR_HSL: &'static str = r#"(rgba?|hsla?)\(\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*(?:,\s*(1|0?\.\d+))?\s*\)"#; +const HEX: &str = r#"(#(?:[\da-fA-F]{3}){1,2})"#; +const RGB_OR_HSL: &str = r#"(rgba?|hsla?)\(\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*,\s*(\d{1,3}%?)\s*(?:,\s*(1|0?\.\d+))?\s*\)"#; static RELAXED_HEX_REGEX: LazyLock = LazyLock::new(|| { RegexBuilder::new(HEX) @@ -102,7 +102,7 @@ fn parse(str: &str, mode: ParseMode) -> Option { }; } - return None; + None } fn parse_component(value: &str, max: f32) -> Option { @@ -141,7 +141,7 @@ mod tests { use gpui::rgba; use lsp::{CompletionItem, CompletionItemKind}; - pub const COLOR_TABLE: &[(&'static str, Option)] = &[ + pub const COLOR_TABLE: &[(&str, Option)] = &[ // -- Invalid -- // Invalid hex ("f0f", None), diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index c96ab4e8f3ba87133d9b64e9701130f5d32adfb9..e358ddfbf51a362d95b8b75a9b4831ca4089875d 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -1,7 +1,7 @@ pub mod extension; pub mod registry; -use std::{path::Path, sync::Arc}; +use std::sync::Arc; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; @@ -10,7 +10,7 @@ use futures::{FutureExt as _, future::join_all}; use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions}; use registry::ContextServerDescriptorRegistry; use settings::{Settings as _, SettingsStore}; -use util::ResultExt as _; +use util::{ResultExt as _, rel_path::RelPath}; use crate::{ Project, @@ -282,8 +282,18 @@ impl ContextServerStore { self.servers.get(id).map(|state| state.configuration()) } - pub fn all_server_ids(&self) -> Vec { - self.servers.keys().cloned().collect() + pub fn server_ids(&self, cx: &App) -> HashSet { + self.servers + .keys() + .cloned() + .chain( + self.registry + .read(cx) + .context_server_descriptors() + .into_iter() + .map(|(id, _)| ContextServerId(id)), + ) + .collect() } pub fn running_servers(&self) -> Vec> { @@ -368,7 +378,7 @@ impl ContextServerStore { } pub fn restart_server(&mut self, id: &ContextServerId, cx: &mut Context) -> Result<()> { - if let Some(state) = self.servers.get(&id) { + if let Some(state) = self.servers.get(id) { let configuration = state.configuration(); self.stop_server(&state.server().id(), cx)?; @@ -397,9 +407,8 @@ impl ContextServerStore { let server = server.clone(); let configuration = configuration.clone(); async move |this, cx| { - match server.clone().start(&cx).await { + match server.clone().start(cx).await { Ok(_) => { - log::info!("Started {} context server", id); debug_assert!(server.client().is_some()); this.update(cx, |this, cx| { @@ -463,22 +472,23 @@ impl ContextServerStore { configuration: Arc, cx: &mut Context, ) -> Arc { - let root_path = self - .project - .read_with(cx, |project, cx| project.active_project_directory(cx)) - .ok() - .flatten() - .or_else(|| { - self.worktree_store.read_with(cx, |store, cx| { - store.visible_worktrees(cx).fold(None, |acc, item| { - if acc.is_none() { - item.read(cx).root_dir() - } else { - acc + let project = self.project.upgrade(); + let mut root_path = None; + if let Some(project) = project { + let project = project.read(cx); + if project.is_local() { + if let Some(path) = project.active_project_directory(cx) { + root_path = Some(path); + } else { + for worktree in self.worktree_store.read(cx).visible_worktrees(cx) { + if let Some(path) = worktree.read(cx).root_dir() { + root_path = Some(path); + break; } - }) - }) - }); + } + } + } + }; if let Some(factory) = self.context_server_factory.as_ref() { factory(id, configuration) @@ -501,7 +511,7 @@ impl ContextServerStore { .next() .map(|worktree| settings::SettingsLocation { worktree_id: worktree.read(cx).id(), - path: Path::new(""), + path: RelPath::empty(), }); &ProjectSettings::get(location, cx).context_servers } @@ -588,7 +598,7 @@ impl ContextServerStore { for server_id in this.servers.keys() { // All servers that are not in desired_servers should be removed from the store. // This can happen if the user removed a server from the context server settings. - if !configured_servers.contains_key(&server_id) { + if !configured_servers.contains_key(server_id) { if disabled_servers.contains_key(&server_id.0) { servers_to_stop.insert(server_id.clone()); } else { @@ -642,8 +652,8 @@ mod tests { #[gpui::test] async fn test_context_server_status(cx: &mut TestAppContext) { - const SERVER_1_ID: &'static str = "mcp-1"; - const SERVER_2_ID: &'static str = "mcp-2"; + const SERVER_1_ID: &str = "mcp-1"; + const SERVER_2_ID: &str = "mcp-2"; let (_fs, project) = setup_context_server_test( cx, @@ -722,8 +732,8 @@ mod tests { #[gpui::test] async fn test_context_server_status_events(cx: &mut TestAppContext) { - const SERVER_1_ID: &'static str = "mcp-1"; - const SERVER_2_ID: &'static str = "mcp-2"; + const SERVER_1_ID: &str = "mcp-1"; + const SERVER_2_ID: &str = "mcp-2"; let (_fs, project) = setup_context_server_test( cx, @@ -761,7 +771,7 @@ mod tests { &store, vec![ (server_1_id.clone(), ContextServerStatus::Starting), - (server_1_id.clone(), ContextServerStatus::Running), + (server_1_id, ContextServerStatus::Running), (server_2_id.clone(), ContextServerStatus::Starting), (server_2_id.clone(), ContextServerStatus::Running), (server_2_id.clone(), ContextServerStatus::Stopped), @@ -784,7 +794,7 @@ mod tests { #[gpui::test(iterations = 25)] async fn test_context_server_concurrent_starts(cx: &mut TestAppContext) { - const SERVER_1_ID: &'static str = "mcp-1"; + const SERVER_1_ID: &str = "mcp-1"; let (_fs, project) = setup_context_server_test( cx, @@ -845,8 +855,8 @@ mod tests { #[gpui::test] async fn test_context_server_maintain_servers_loop(cx: &mut TestAppContext) { - const SERVER_1_ID: &'static str = "mcp-1"; - const SERVER_2_ID: &'static str = "mcp-2"; + const SERVER_1_ID: &str = "mcp-1"; + const SERVER_2_ID: &str = "mcp-2"; let server_1_id = ContextServerId(SERVER_1_ID.into()); let server_2_id = ContextServerId(SERVER_2_ID.into()); @@ -916,7 +926,7 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -935,7 +945,7 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -962,7 +972,7 @@ mod tests { vec![ ( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -971,12 +981,13 @@ mod tests { ), ( server_2_id.0.clone(), - ContextServerSettings::Custom { + settings::ContextServerSettingsContent::Custom { enabled: true, command: ContextServerCommand { path: "somebinary".into(), args: vec!["arg".to_string()], env: None, + timeout: None, }, }, ), @@ -1002,7 +1013,7 @@ mod tests { vec![ ( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -1011,12 +1022,13 @@ mod tests { ), ( server_2_id.0.clone(), - ContextServerSettings::Custom { + settings::ContextServerSettingsContent::Custom { enabled: true, command: ContextServerCommand { path: "somebinary".into(), args: vec!["anotherArg".to_string()], env: None, + timeout: None, }, }, ), @@ -1037,7 +1049,7 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -1060,7 +1072,7 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Extension { + settings::ContextServerSettingsContent::Extension { enabled: true, settings: json!({ "somevalue": false @@ -1084,7 +1096,7 @@ mod tests { #[gpui::test] async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) { - const SERVER_1_ID: &'static str = "mcp-1"; + const SERVER_1_ID: &str = "mcp-1"; let server_1_id = ContextServerId(SERVER_1_ID.into()); @@ -1099,6 +1111,7 @@ mod tests { path: "somebinary".into(), args: vec!["arg".to_string()], env: None, + timeout: None, }, }, )], @@ -1145,12 +1158,13 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Custom { + settings::ContextServerSettingsContent::Custom { enabled: false, command: ContextServerCommand { path: "somebinary".into(), args: vec!["arg".to_string()], env: None, + timeout: None, }, }, )], @@ -1173,11 +1187,12 @@ mod tests { set_context_server_configuration( vec![( server_1_id.0.clone(), - ContextServerSettings::Custom { + settings::ContextServerSettingsContent::Custom { enabled: true, command: ContextServerCommand { path: "somebinary".into(), args: vec!["arg".to_string()], + timeout: None, env: None, }, }, @@ -1190,18 +1205,17 @@ mod tests { } fn set_context_server_configuration( - context_servers: Vec<(Arc, ContextServerSettings)>, + context_servers: Vec<(Arc, settings::ContextServerSettingsContent)>, cx: &mut TestAppContext, ) { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - let mut settings = ProjectSettings::default(); - for (id, config) in context_servers { - settings.context_servers.insert(id, config); - } - store - .set_user_settings(&serde_json::to_string(&settings).unwrap(), cx) - .unwrap(); + store.update_user_settings(cx, |content| { + content.project.context_servers.clear(); + for (id, config) in context_servers { + content.project.context_servers.insert(id, config); + } + }); }) }); } @@ -1231,6 +1245,7 @@ mod tests { path: "somebinary".into(), args: vec!["arg".to_string()], env: None, + timeout: None, }, } } @@ -1319,6 +1334,7 @@ mod tests { path: self.path.clone(), args: vec!["arg1".to_string(), "arg2".to_string()], env: None, + timeout: None, })) } diff --git a/crates/project/src/context_server_store/extension.rs b/crates/project/src/context_server_store/extension.rs index 1eb0fe7da129ba9dbd3ee640cb6e02474a3990b6..ca5cacf3b549523dee8b85242bea86653eecbf7a 100644 --- a/crates/project/src/context_server_store/extension.rs +++ b/crates/project/src/context_server_store/extension.rs @@ -63,12 +63,13 @@ impl registry::ContextServerDescriptor for ContextServerDescriptor { .await?; command.command = extension.path_from_extension(&command.command); - log::info!("loaded command for context server {id}: {command:?}"); + log::debug!("loaded command for context server {id}: {command:?}"); Ok(ContextServerCommand { path: command.command, args: command.args, env: Some(command.env.into_iter().collect()), + timeout: None, }) }) } diff --git a/crates/project/src/debugger.rs b/crates/project/src/debugger.rs index 6c22468040097768688d93cde0720320a9e45be9..0bf6a0d61b792bd747992a821adc82150d93c8bf 100644 --- a/crates/project/src/debugger.rs +++ b/crates/project/src/debugger.rs @@ -6,9 +6,9 @@ //! //! There are few reasons for this divide: //! - Breakpoints persist across debug sessions and they're not really specific to any particular session. Sure, we have to send protocol messages for them -//! (so they're a "thing" in the protocol), but we also want to set them before any session starts up. +//! (so they're a "thing" in the protocol), but we also want to set them before any session starts up. //! - Debug clients are doing the heavy lifting, and this is where UI grabs all of it's data from. They also rely on breakpoint store during initialization to obtain -//! current set of breakpoints. +//! current set of breakpoints. //! - Since DAP store knows about all of the available debug sessions, it is responsible for routing RPC requests to sessions. It also knows how to find adapters for particular kind of session. pub mod breakpoint_store; diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs index 025dca410069db0350d8d32509244a4889c62415..42663ab9852a5dc2e9850d20dd20940c6723d03c 100644 --- a/crates/project/src/debugger/breakpoint_store.rs +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -192,7 +192,7 @@ impl BreakpointStore { } pub(crate) fn shared(&mut self, project_id: u64, downstream_client: AnyProtoClient) { - self.downstream_client = Some((downstream_client.clone(), project_id)); + self.downstream_client = Some((downstream_client, project_id)); } pub(crate) fn unshared(&mut self, cx: &mut Context) { @@ -267,7 +267,7 @@ impl BreakpointStore { message: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let breakpoints = this.read_with(&mut cx, |this, _| this.breakpoint_store())?; + let breakpoints = this.read_with(&cx, |this, _| this.breakpoint_store())?; let path = this .update(&mut cx, |this, cx| { this.project_path_for_absolute_path(message.payload.path.as_ref(), cx) @@ -317,8 +317,8 @@ impl BreakpointStore { .iter() .filter_map(|breakpoint| { breakpoint.bp.bp.to_proto( - &path, - &breakpoint.position(), + path, + breakpoint.position(), &breakpoint.session_state, ) }) @@ -387,7 +387,7 @@ impl BreakpointStore { pub fn abs_path_from_buffer(buffer: &Entity, cx: &App) -> Option> { worktree::File::from_dyn(buffer.read(cx).file()) - .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()) + .map(|file| file.worktree.read(cx).absolutize(&file.path)) .map(Arc::::from) } @@ -450,9 +450,9 @@ impl BreakpointStore { }); if let Some(found_bp) = found_bp { - found_bp.message = Some(log_message.clone()); + found_bp.message = Some(log_message); } else { - breakpoint.bp.message = Some(log_message.clone()); + breakpoint.bp.message = Some(log_message); // We did not remove any breakpoint, hence let's toggle one. breakpoint_set .breakpoints @@ -482,9 +482,9 @@ impl BreakpointStore { }); if let Some(found_bp) = found_bp { - found_bp.hit_condition = Some(hit_condition.clone()); + found_bp.hit_condition = Some(hit_condition); } else { - breakpoint.bp.hit_condition = Some(hit_condition.clone()); + breakpoint.bp.hit_condition = Some(hit_condition); // We did not remove any breakpoint, hence let's toggle one. breakpoint_set .breakpoints @@ -514,9 +514,9 @@ impl BreakpointStore { }); if let Some(found_bp) = found_bp { - found_bp.condition = Some(condition.clone()); + found_bp.condition = Some(condition); } else { - breakpoint.bp.condition = Some(condition.clone()); + breakpoint.bp.condition = Some(condition); // We did not remove any breakpoint, hence let's toggle one. breakpoint_set .breakpoints @@ -591,7 +591,7 @@ impl BreakpointStore { cx: &mut Context, ) { if let Some(breakpoints) = self.breakpoints.remove(&old_path) { - self.breakpoints.insert(new_path.clone(), breakpoints); + self.breakpoints.insert(new_path, breakpoints); cx.notify(); } @@ -623,12 +623,11 @@ impl BreakpointStore { file_breakpoints.breakpoints.iter().filter_map({ let range = range.clone(); move |bp| { - if let Some(range) = &range { - if bp.position().cmp(&range.start, buffer_snapshot).is_lt() - || bp.position().cmp(&range.end, buffer_snapshot).is_gt() - { - return None; - } + if let Some(range) = &range + && (bp.position().cmp(&range.start, buffer_snapshot).is_lt() + || bp.position().cmp(&range.end, buffer_snapshot).is_gt()) + { + return None; } let session_state = active_session_id .and_then(|id| bp.session_state.get(&id)) @@ -753,7 +752,7 @@ impl BreakpointStore { .iter() .map(|breakpoint| { let position = snapshot - .summary_for_anchor::(&breakpoint.position()) + .summary_for_anchor::(breakpoint.position()) .row; let breakpoint = &breakpoint.bp; SourceBreakpoint { @@ -795,7 +794,7 @@ impl BreakpointStore { .update(cx, |this, cx| { let path = ProjectPath { worktree_id: worktree.read(cx).id(), - path: relative_path.into(), + path: relative_path, }; this.open_buffer(path, cx) })? @@ -832,7 +831,6 @@ impl BreakpointStore { new_breakpoints.insert(path, breakpoints_for_file); } this.update(cx, |this, cx| { - log::info!("Finish deserializing breakpoints & initializing breakpoint store"); for (path, count) in new_breakpoints.iter().map(|(path, bp_in_file)| { (path.to_string_lossy(), bp_in_file.breakpoints.len()) }) { @@ -906,7 +904,7 @@ impl BreakpointState { } #[inline] - pub fn to_int(&self) -> i32 { + pub fn to_int(self) -> i32 { match self { BreakpointState::Enabled => 0, BreakpointState::Disabled => 1, diff --git a/crates/project/src/debugger/dap_command.rs b/crates/project/src/debugger/dap_command.rs index 3be3192369452b58fd2382471ca2f41f4aeac75f..772ff2dcfeb98fcda794092f8071fad5c6fcdcd4 100644 --- a/crates/project/src/debugger/dap_command.rs +++ b/crates/project/src/debugger/dap_command.rs @@ -1454,7 +1454,7 @@ impl DapCommand for EvaluateCommand { variables_reference: message.variable_reference, named_variables: message.named_variables, indexed_variables: message.indexed_variables, - memory_reference: message.memory_reference.clone(), + memory_reference: message.memory_reference, value_location_reference: None, //TODO }) } diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 6f834b5dc0cfd3fc6357d92403bdb7cbfefdd4b0..7d80c563e9678ec097dab030bdca047a967e2cf0 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -7,8 +7,7 @@ use super::{ use crate::{ InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, debugger::session::SessionQuirks, - project_settings::ProjectSettings, - terminals::{SshCommand, wrap_for_ssh}, + project_settings::{DapBinary, ProjectSettings}, worktree_store::WorktreeStore, }; use anyhow::{Context as _, Result, anyhow}; @@ -23,18 +22,19 @@ use dap::{ inline_value::VariableLookupKind, messages::Message, }; -use fs::Fs; +use fs::{Fs, RemoveOptions}; use futures::{ - StreamExt, + StreamExt, TryStreamExt as _, channel::mpsc::{self, UnboundedSender}, future::{Shared, join_all}, }; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; use http_client::HttpClient; -use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind}; +use language::{Buffer, LanguageToolchainStore}; use node_runtime::NodeRuntime; +use settings::InlayHintKind; -use remote::{SshRemoteClient, ssh_session::SshArgs}; +use remote::RemoteClient; use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, @@ -49,8 +49,8 @@ use std::{ path::{Path, PathBuf}, sync::{Arc, Once}, }; -use task::{DebugScenario, SpawnInTerminal, TaskContext, TaskTemplate}; -use util::ResultExt as _; +use task::{DebugScenario, Shell, SpawnInTerminal, TaskContext, TaskTemplate}; +use util::{ResultExt as _, rel_path::RelPath}; use worktree::Worktree; #[derive(Debug)] @@ -68,7 +68,7 @@ pub enum DapStoreEvent { enum DapStoreMode { Local(LocalDapStore), - Ssh(SshDapStore), + Remote(RemoteDapStore), Collab, } @@ -78,12 +78,15 @@ pub struct LocalDapStore { http_client: Arc, environment: Entity, toolchain_store: Arc, + is_headless: bool, } -pub struct SshDapStore { - ssh_client: Entity, +pub struct RemoteDapStore { + remote_client: Entity, upstream_client: AnyProtoClient, upstream_project_id: u64, + node_runtime: NodeRuntime, + http_client: Arc, } pub struct DapStore { @@ -134,33 +137,40 @@ impl DapStore { toolchain_store: Arc, worktree_store: Entity, breakpoint_store: Entity, + is_headless: bool, cx: &mut Context, ) -> Self { let mode = DapStoreMode::Local(LocalDapStore { - fs, + fs: fs.clone(), environment, http_client, node_runtime, toolchain_store, + is_headless, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } - pub fn new_ssh( + pub fn new_remote( project_id: u64, - ssh_client: Entity, + remote_client: Entity, breakpoint_store: Entity, worktree_store: Entity, + node_runtime: NodeRuntime, + http_client: Arc, + fs: Arc, cx: &mut Context, ) -> Self { - let mode = DapStoreMode::Ssh(SshDapStore { - upstream_client: ssh_client.read(cx).proto_client(), - ssh_client, + let mode = DapStoreMode::Remote(RemoteDapStore { + upstream_client: remote_client.read(cx).proto_client(), + remote_client, upstream_project_id: project_id, + node_runtime, + http_client, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } pub fn new_collab( @@ -168,17 +178,55 @@ impl DapStore { _upstream_client: AnyProtoClient, breakpoint_store: Entity, worktree_store: Entity, + fs: Arc, cx: &mut Context, ) -> Self { - Self::new(DapStoreMode::Collab, breakpoint_store, worktree_store, cx) + Self::new( + DapStoreMode::Collab, + breakpoint_store, + worktree_store, + fs, + cx, + ) } fn new( mode: DapStoreMode, breakpoint_store: Entity, worktree_store: Entity, - _cx: &mut Context, + fs: Arc, + cx: &mut Context, ) -> Self { + cx.background_spawn(async move { + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + + let mut children = fs.read_dir(&dir).await?.try_collect::>().await?; + children.sort_by_key(|child| semver::Version::parse(child.file_name()?.to_str()?).ok()); + + if let Some(child) = children.last() + && let Some(name) = child.file_name() + && let Some(name) = name.to_str() + && semver::Version::parse(name).is_ok() + { + children.pop(); + } + + for child in children { + fs.remove_dir( + &child, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .ok(); + } + + anyhow::Ok(()) + }) + .detach(); + Self { mode, next_session_id: 0, @@ -206,21 +254,31 @@ impl DapStore { let settings_location = SettingsLocation { worktree_id: worktree.read(cx).id(), - path: Path::new(""), + path: RelPath::empty(), }; let dap_settings = ProjectSettings::get(Some(settings_location), cx) .dap .get(&adapter.name()); - let user_installed_path = - dap_settings.and_then(|s| s.binary.as_ref().map(PathBuf::from)); + let user_installed_path = dap_settings.and_then(|s| match &s.binary { + DapBinary::Default => None, + DapBinary::Custom(binary) => Some(PathBuf::from(binary)), + }); let user_args = dap_settings.map(|s| s.args.clone()); + let user_env = dap_settings.map(|s| s.env.clone()); - let delegate = self.delegate(&worktree, console, cx); + let delegate = self.delegate(worktree, console, cx); let cwd: Arc = worktree.read(cx).abs_path().as_ref().into(); cx.spawn(async move |this, cx| { let mut binary = adapter - .get_binary(&delegate, &definition, user_installed_path, user_args, cx) + .get_binary( + &delegate, + &definition, + user_installed_path, + user_args, + user_env, + cx, + ) .await?; let env = this @@ -229,7 +287,11 @@ impl DapStore { .unwrap() .environment .update(cx, |environment, cx| { - environment.get_directory_environment(cwd, cx) + environment.get_local_directory_environment( + &Shell::System, + cwd, + cx, + ) }) })? .await; @@ -242,59 +304,57 @@ impl DapStore { Ok(binary) }) } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary { - session_id: session_id.to_proto(), - project_id: ssh.upstream_project_id, - worktree_id: worktree.read(cx).id().to_proto(), - definition: Some(definition.to_proto()), - }); - let ssh_client = ssh.ssh_client.clone(); + DapStoreMode::Remote(remote) => { + let request = remote + .upstream_client + .request(proto::GetDebugAdapterBinary { + session_id: session_id.to_proto(), + project_id: remote.upstream_project_id, + worktree_id: worktree.read(cx).id().to_proto(), + definition: Some(definition.to_proto()), + }); + let remote = remote.remote_client.clone(); cx.spawn(async move |_, cx| { let response = request.await?; let binary = DebugAdapterBinary::from_proto(response)?; - let (mut ssh_command, envs, path_style) = - ssh_client.read_with(cx, |ssh, _| { - let (SshArgs { arguments, envs }, path_style) = - ssh.ssh_info().context("SSH arguments not found")?; - anyhow::Ok(( - SshCommand { arguments }, - envs.unwrap_or_default(), - path_style, - )) - })??; - - let mut connection = None; - if let Some(c) = binary.connection { - let local_bind_addr = Ipv4Addr::LOCALHOST; - let port = - dap::transport::TcpTransport::unused_port(local_bind_addr).await?; - ssh_command.add_port_forwarding(port, c.host.to_string(), c.port); + let port_forwarding; + let connection; + if let Some(c) = binary.connection { + let host = Ipv4Addr::LOCALHOST; + let port; + if remote.read_with(cx, |remote, _cx| remote.shares_network_interface())? { + port = c.port; + port_forwarding = None; + } else { + port = dap::transport::TcpTransport::unused_port(host).await?; + port_forwarding = Some((port, c.host.to_string(), c.port)); + } connection = Some(TcpArguments { port, - host: local_bind_addr, + host, timeout: c.timeout, }) + } else { + port_forwarding = None; + connection = None; } - let (program, args) = wrap_for_ssh( - &ssh_command, - binary - .command - .as_ref() - .map(|command| (command, &binary.arguments)), - binary.cwd.as_deref(), - binary.envs, - None, - path_style, - ); + let command = remote.read_with(cx, |remote, _cx| { + remote.build_command( + binary.command, + &binary.arguments, + &binary.envs, + binary.cwd.map(|path| path.display().to_string()), + port_forwarding, + ) + })??; Ok(DebugAdapterBinary { - command: Some(program), - arguments: args, - envs, + command: Some(command.program), + arguments: command.args, + envs: command.env, cwd: None, connection, request_args: binary.request_args, @@ -360,9 +420,9 @@ impl DapStore { ))) } } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::RunDebugLocators { - project_id: ssh.upstream_project_id, + DapStoreMode::Remote(remote) => { + let request = remote.upstream_client.request(proto::RunDebugLocators { + project_id: remote.upstream_project_id, build_command: Some(build_command.to_proto()), locator: locator_name.to_owned(), }); @@ -401,6 +461,15 @@ impl DapStore { }); } + let (remote_client, node_runtime, http_client) = match &self.mode { + DapStoreMode::Local(_) => (None, None, None), + DapStoreMode::Remote(remote_dap_store) => ( + Some(remote_dap_store.remote_client.clone()), + Some(remote_dap_store.node_runtime.clone()), + Some(remote_dap_store.http_client.clone()), + ), + DapStoreMode::Collab => (None, None, None), + }; let session = Session::new( self.breakpoint_store.clone(), session_id, @@ -409,6 +478,9 @@ impl DapStore { adapter, task_context, quirks, + remote_client, + node_runtime, + http_client, cx, ); @@ -470,9 +542,8 @@ impl DapStore { session_id: impl Borrow, ) -> Option> { let session_id = session_id.borrow(); - let client = self.sessions.get(session_id).cloned(); - client + self.sessions.get(session_id).cloned() } pub fn sessions(&self) -> impl Iterator> { self.sessions.values() @@ -539,6 +610,7 @@ impl DapStore { local_store.environment.update(cx, |env, cx| { env.get_worktree_environment(worktree.clone(), cx) }), + local_store.is_headless, )) } @@ -685,7 +757,7 @@ impl DapStore { let shutdown_id = parent_session.update(cx, |parent_session, _| { parent_session.remove_child_session_id(session_id); - if parent_session.child_session_ids().len() == 0 { + if parent_session.child_session_ids().is_empty() { Some(parent_session.session_id()) } else { None @@ -702,7 +774,7 @@ impl DapStore { cx.emit(DapStoreEvent::DebugClientShutdown(session_id)); cx.background_spawn(async move { - if shutdown_children.len() > 0 { + if !shutdown_children.is_empty() { let _ = join_all(shutdown_children).await; } @@ -722,7 +794,7 @@ impl DapStore { downstream_client: AnyProtoClient, _: &mut Context, ) { - self.downstream_client = Some((downstream_client.clone(), project_id)); + self.downstream_client = Some((downstream_client, project_id)); } pub fn unshared(&mut self, cx: &mut Context) { @@ -871,6 +943,7 @@ pub struct DapAdapterDelegate { http_client: Arc, toolchain_store: Arc, load_shell_env_task: Shared>>>, + is_headless: bool, } impl DapAdapterDelegate { @@ -882,6 +955,7 @@ impl DapAdapterDelegate { http_client: Arc, toolchain_store: Arc, load_shell_env_task: Shared>>>, + is_headless: bool, ) -> Self { Self { fs, @@ -891,6 +965,7 @@ impl DapAdapterDelegate { node_runtime, toolchain_store, load_shell_env_task, + is_headless, } } } @@ -902,7 +977,7 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate { } fn worktree_root_path(&self) -> &Path { - &self.worktree.abs_path() + self.worktree.abs_path() } fn http_client(&self) -> Arc { self.http_client.clone() @@ -944,16 +1019,18 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate { fn toolchain_store(&self) -> Arc { self.toolchain_store.clone() } - async fn read_text_file(&self, path: PathBuf) -> Result { + + async fn read_text_file(&self, path: &RelPath) -> Result { let entry = self .worktree - .entry_for_path(&path) + .entry_for_path(path) .with_context(|| format!("no worktree entry for path {path:?}"))?; - let abs_path = self - .worktree - .absolutize(&entry.path) - .with_context(|| format!("cannot absolutize path {path:?}"))?; + let abs_path = self.worktree.absolutize(&entry.path); self.fs.load(&abs_path).await } + + fn is_headless(&self) -> bool { + self.is_headless + } } diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index fa265dae586148f9c8efe14187ee26c805c65e42..662b9ca7efcd53b8792127e531a9baba24967ea1 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -117,7 +117,7 @@ impl DapLocator for CargoLocator { .cwd .clone() .context("Couldn't get cwd from debug config which is needed for locators")?; - let builder = ShellBuilder::new(true, &build_config.shell).non_interactive(); + let builder = ShellBuilder::new(&build_config.shell, cfg!(windows)).non_interactive(); let (program, args) = builder.build( Some("cargo".into()), &build_config @@ -126,7 +126,7 @@ impl DapLocator for CargoLocator { .cloned() .take_while(|arg| arg != "--") .chain(Some("--message-format=json".to_owned())) - .collect(), + .collect::>(), ); let mut child = util::command::new_smol_command(program) .args(args) @@ -146,7 +146,7 @@ impl DapLocator for CargoLocator { let is_test = build_config .args .first() - .map_or(false, |arg| arg == "test" || arg == "t"); + .is_some_and(|arg| arg == "test" || arg == "t"); let executables = output .lines() @@ -187,12 +187,12 @@ impl DapLocator for CargoLocator { .cloned(); } let executable = { - if let Some(ref name) = test_name.as_ref().and_then(|name| { + if let Some(name) = test_name.as_ref().and_then(|name| { name.strip_prefix('$') .map(|name| build_config.env.get(name)) .unwrap_or(Some(name)) }) { - find_best_executable(&executables, &name).await + find_best_executable(&executables, name).await } else { None } diff --git a/crates/project/src/debugger/locators/go.rs b/crates/project/src/debugger/locators/go.rs index 61436fce8f3659d4b12c3010b82e0d845654c4e9..eec06084ec78548e1a627080663d2afccc8a0aca 100644 --- a/crates/project/src/debugger/locators/go.rs +++ b/crates/project/src/debugger/locators/go.rs @@ -174,7 +174,7 @@ impl DapLocator for GoLocator { request: "launch".to_string(), mode: "test".to_string(), program, - args: args, + args, build_flags, cwd: build_config.cwd.clone(), env: build_config.env.clone(), @@ -185,7 +185,7 @@ impl DapLocator for GoLocator { label: resolved_label.to_string().into(), adapter: adapter.0.clone(), build: None, - config: config, + config, tcp_connection: None, }) } @@ -220,7 +220,7 @@ impl DapLocator for GoLocator { request: "launch".to_string(), mode: "debug".to_string(), program, - args: args, + args, build_flags, }) .unwrap(); diff --git a/crates/project/src/debugger/locators/python.rs b/crates/project/src/debugger/locators/python.rs index 3de1281aed36c6a96970d08e0e4f5cb0ef3bd67f..c3754548d0676e76f08368828d554600ab700fc0 100644 --- a/crates/project/src/debugger/locators/python.rs +++ b/crates/project/src/debugger/locators/python.rs @@ -28,20 +28,12 @@ impl DapLocator for PythonLocator { let valid_program = build_config.command.starts_with("$ZED_") || Path::new(&build_config.command) .file_name() - .map_or(false, |name| { - name.to_str().is_some_and(|path| path.starts_with("python")) - }); + .is_some_and(|name| name.to_str().is_some_and(|path| path.starts_with("python"))); if !valid_program || build_config.args.iter().any(|arg| arg == "-c") { // We cannot debug selections. return None; } - let command = if build_config.command - == VariableName::Custom("PYTHON_ACTIVE_ZED_TOOLCHAIN".into()).template_value() - { - VariableName::Custom("PYTHON_ACTIVE_ZED_TOOLCHAIN_RAW".into()).template_value() - } else { - build_config.command.clone() - }; + let command = build_config.command.clone(); let module_specifier_position = build_config .args .iter() @@ -59,10 +51,8 @@ impl DapLocator for PythonLocator { let program_position = mod_name .is_none() .then(|| { - build_config - .args - .iter() - .position(|arg| *arg == "\"$ZED_FILE\"") + let zed_file = VariableName::File.template_value_with_whitespace(); + build_config.args.iter().position(|arg| *arg == zed_file) }) .flatten(); let args = if let Some(position) = program_position { @@ -104,3 +94,53 @@ impl DapLocator for PythonLocator { bail!("Python locator should not require DapLocator::run to be ran"); } } + +#[cfg(test)] +mod test { + use serde_json::json; + + use super::*; + + #[gpui::test] + async fn test_python_locator() { + let adapter = DebugAdapterName("Debugpy".into()); + let build_task = TaskTemplate { + label: "run module '$ZED_FILE'".into(), + command: "$ZED_CUSTOM_PYTHON_ACTIVE_ZED_TOOLCHAIN".into(), + args: vec!["-m".into(), "$ZED_CUSTOM_PYTHON_MODULE_NAME".into()], + env: Default::default(), + cwd: Some("$ZED_WORKTREE_ROOT".into()), + use_new_terminal: false, + allow_concurrent_runs: false, + reveal: task::RevealStrategy::Always, + reveal_target: task::RevealTarget::Dock, + hide: task::HideStrategy::Never, + tags: vec!["python-module-main-method".into()], + shell: task::Shell::System, + show_summary: false, + show_command: false, + }; + + let expected_scenario = DebugScenario { + adapter: "Debugpy".into(), + label: "run module 'main.py'".into(), + build: None, + config: json!({ + "request": "launch", + "python": "$ZED_CUSTOM_PYTHON_ACTIVE_ZED_TOOLCHAIN", + "args": [], + "cwd": "$ZED_WORKTREE_ROOT", + "module": "$ZED_CUSTOM_PYTHON_MODULE_NAME", + }), + tcp_connection: None, + }; + + assert_eq!( + PythonLocator + .create_scenario(&build_task, "run module 'main.py'", &adapter) + .await + .expect("Failed to create a scenario"), + expected_scenario + ); + } +} diff --git a/crates/project/src/debugger/memory.rs b/crates/project/src/debugger/memory.rs index fec3c344c5a433eebb3a1f314a8fd911bd603022..42ad64e6880ba653c6c95cb13f0e6bcc23c9bdae 100644 --- a/crates/project/src/debugger/memory.rs +++ b/crates/project/src/debugger/memory.rs @@ -3,6 +3,7 @@ //! Each byte in memory can either be mapped or unmapped. We try to mimic that twofold: //! - We assume that the memory is divided into pages of a fixed size. //! - We assume that each page can be either mapped or unmapped. +//! //! These two assumptions drive the shape of the memory representation. //! In particular, we want the unmapped pages to be represented without allocating any memory, as *most* //! of the memory in a program space is usually unmapped. @@ -165,8 +166,8 @@ impl Memory { /// - If it succeeds/fails wholesale, cool; we have no unknown memory regions in this page. /// - If it succeeds partially, we know # of mapped bytes. /// We might also know the # of unmapped bytes. -/// However, we're still unsure about what's *after* the unreadable region. /// +/// However, we're still unsure about what's *after* the unreadable region. /// This is where this builder comes in. It lets us track the state of figuring out contents of a single page. pub(super) struct MemoryPageBuilder { chunks: MappedPageContents, @@ -318,19 +319,18 @@ impl Iterator for MemoryIterator { return None; } if let Some((current_page_address, current_memory_chunk)) = self.current_known_page.as_mut() + && current_page_address.0 <= self.start { - if current_page_address.0 <= self.start { - if let Some(next_cell) = current_memory_chunk.next() { - self.start += 1; - return Some(next_cell); - } else { - self.current_known_page.take(); - } + if let Some(next_cell) = current_memory_chunk.next() { + self.start += 1; + return Some(next_cell); + } else { + self.current_known_page.take(); } } if !self.fetch_next_page() { self.start += 1; - return Some(MemoryCell(None)); + Some(MemoryCell(None)) } else { self.next() } diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index d9c28df497b3baa4543e6271106ddb1cd11b4419..b5fbfd80d6152faf9d04715138859dc565e8cba8 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -14,12 +14,13 @@ use super::dap_command::{ TerminateCommand, TerminateThreadsCommand, ThreadsCommand, VariablesCommand, }; use super::dap_store::DapStore; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, bail}; use base64::Engine; use collections::{HashMap, HashSet, IndexMap}; use dap::adapters::{DebugAdapterBinary, DebugAdapterName}; use dap::messages::Response; use dap::requests::{Request, RunInTerminal, StartDebugging}; +use dap::transport::TcpTransport; use dap::{ Capabilities, ContinueArguments, EvaluateArgumentsContext, Module, Source, StackFrameId, SteppingGranularity, StoppedEvent, VariableReference, @@ -31,21 +32,30 @@ use dap::{ RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, VariablePresentationHint, WriteMemoryArguments, }; -use futures::SinkExt; use futures::channel::mpsc::UnboundedSender; use futures::channel::{mpsc, oneshot}; +use futures::io::BufReader; +use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt}; use futures::{FutureExt, future::Shared}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; +use http_client::HttpClient; +use node_runtime::NodeRuntime; +use remote::RemoteClient; use rpc::ErrorExt; +use serde::{Deserialize, Serialize}; use serde_json::Value; -use smol::stream::StreamExt; +use smol::net::{TcpListener, TcpStream}; use std::any::TypeId; use std::collections::BTreeMap; +use std::net::Ipv4Addr; use std::ops::RangeInclusive; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; use std::u64; use std::{ any::Any, @@ -56,6 +66,8 @@ use std::{ }; use task::TaskContext; use text::{PointUtf16, ToPointUtf16}; +use url::Url; +use util::command::new_smol_command; use util::{ResultExt, debug_panic, maybe}; use worktree::Worktree; @@ -170,8 +182,8 @@ fn client_source(abs_path: &Path) -> dap::Source { dap::Source { name: abs_path .file_name() - .map(|filename| filename.to_string_lossy().to_string()), - path: Some(abs_path.to_string_lossy().to_string()), + .map(|filename| filename.to_string_lossy().into_owned()), + path: Some(abs_path.to_string_lossy().into_owned()), source_reference: None, presentation_hint: None, origin: None, @@ -226,7 +238,7 @@ impl RunningMode { fn unset_breakpoints_from_paths(&self, paths: &Vec>, cx: &mut App) -> Task<()> { let tasks: Vec<_> = paths - .into_iter() + .iter() .map(|path| { self.request(dap_command::SetBreakpoints { source: client_source(path), @@ -431,7 +443,7 @@ impl RunningMode { let should_send_exception_breakpoints = capabilities .exception_breakpoint_filters .as_ref() - .map_or(false, |filters| !filters.is_empty()) + .is_some_and(|filters| !filters.is_empty()) || !configuration_done_supported; let supports_exception_filters = capabilities .supports_exception_filter_options @@ -508,13 +520,12 @@ impl RunningMode { .ok(); } - let ret = if configuration_done_supported { + if configuration_done_supported { this.request(ConfigurationDone {}) } else { Task::ready(Ok(())) } - .await; - ret + .await } }); @@ -697,6 +708,10 @@ pub struct Session { task_context: TaskContext, memory: memory::Memory, quirks: SessionQuirks, + remote_client: Option>, + node_runtime: Option, + http_client: Option>, + companion_port: Option, } trait CacheableCommand: Any + Send + Sync { @@ -710,9 +725,7 @@ where T: LocalDapCommand + PartialEq + Eq + Hash, { fn dyn_eq(&self, rhs: &dyn CacheableCommand) -> bool { - (rhs as &dyn Any) - .downcast_ref::() - .map_or(false, |rhs| self == rhs) + (rhs as &dyn Any).downcast_ref::() == Some(self) } fn dyn_hash(&self, mut hasher: &mut dyn Hasher) { @@ -815,6 +828,9 @@ impl Session { adapter: DebugAdapterName, task_context: TaskContext, quirks: SessionQuirks, + remote_client: Option>, + node_runtime: Option, + http_client: Option>, cx: &mut App, ) -> Entity { cx.new::(|cx| { @@ -841,7 +857,7 @@ impl Session { }) .detach(); - let this = Self { + Self { mode: SessionState::Booting(None), id: session_id, child_session_ids: HashSet::default(), @@ -870,9 +886,11 @@ impl Session { task_context, memory: memory::Memory::new(), quirks, - }; - - this + remote_client, + node_runtime, + http_client, + companion_port: None, + } }) } @@ -1085,7 +1103,7 @@ impl Session { }) .detach(); - return tx; + tx } pub fn is_started(&self) -> bool { @@ -1399,7 +1417,7 @@ impl Session { let breakpoint_store = self.breakpoint_store.clone(); if let Some((local, path)) = self.as_running_mut().and_then(|local| { let breakpoint = local.tmp_breakpoint.take()?; - let path = breakpoint.path.clone(); + let path = breakpoint.path; Some((local, path)) }) { local @@ -1562,7 +1580,21 @@ impl Session { Events::ProgressStart(_) => {} Events::ProgressUpdate(_) => {} Events::Invalidated(_) => {} - Events::Other(_) => {} + Events::Other(event) => { + if event.event == "launchBrowserInCompanion" { + let Some(request) = serde_json::from_value(event.body).ok() else { + log::error!("failed to deserialize launchBrowserInCompanion event"); + return; + }; + self.launch_browser_for_remote_server(request, cx); + } else if event.event == "killCompanionBrowser" { + let Some(request) = serde_json::from_value(event.body).ok() else { + log::error!("failed to deserialize killCompanionBrowser event"); + return; + }; + self.kill_browser(request, cx); + } + } } } @@ -1630,7 +1662,7 @@ impl Session { + 'static, cx: &mut Context, ) -> Task> { - if !T::is_supported(&capabilities) { + if !T::is_supported(capabilities) { log::warn!( "Attempted to send a DAP request that isn't supported: {:?}", request @@ -1688,7 +1720,7 @@ impl Session { self.requests .entry((&*key.0 as &dyn Any).type_id()) .and_modify(|request_map| { - request_map.remove(&key); + request_map.remove(key); }); } @@ -1715,7 +1747,7 @@ impl Session { this.threads = result .into_iter() - .map(|thread| (ThreadId(thread.id), Thread::from(thread.clone()))) + .map(|thread| (ThreadId(thread.id), Thread::from(thread))) .collect(); this.invalidate_command_type::(); @@ -2558,10 +2590,7 @@ impl Session { mode: Option, cx: &mut Context, ) -> Task> { - let command = DataBreakpointInfoCommand { - context: context.clone(), - mode, - }; + let command = DataBreakpointInfoCommand { context, mode }; self.request(command, |_, response, _| response.ok(), cx) } @@ -2724,4 +2753,340 @@ impl Session { pub fn quirks(&self) -> SessionQuirks { self.quirks } + + fn launch_browser_for_remote_server( + &mut self, + mut request: LaunchBrowserInCompanionParams, + cx: &mut Context, + ) { + let Some(remote_client) = self.remote_client.clone() else { + log::error!("can't launch browser in companion for non-remote project"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; + let Some(node_runtime) = self.node_runtime.clone() else { + return; + }; + + let mut console_output = self.console_output(cx); + let task = cx.spawn(async move |this, cx| { + let forward_ports_process = if remote_client + .read_with(cx, |client, _| client.shares_network_interface())? + { + request.other.insert( + "proxyUri".into(), + format!("127.0.0.1:{}", request.server_port).into(), + ); + None + } else { + let port = TcpTransport::unused_port(Ipv4Addr::LOCALHOST) + .await + .context("getting port for DAP")?; + request + .other + .insert("proxyUri".into(), format!("127.0.0.1:{port}").into()); + let mut port_forwards = vec![(port, "localhost".to_owned(), request.server_port)]; + + if let Some(value) = request.params.get("url") + && let Some(url) = value.as_str() + && let Some(url) = Url::parse(url).ok() + && let Some(frontend_port) = url.port() + { + port_forwards.push((frontend_port, "localhost".to_owned(), frontend_port)); + } + + let child = remote_client.update(cx, |client, _| { + let command = client.build_forward_ports_command(port_forwards)?; + let child = new_smol_command(command.program) + .args(command.args) + .envs(command.env) + .spawn() + .context("spawning port forwarding process")?; + anyhow::Ok(child) + })??; + Some(child) + }; + + let mut companion_process = None; + let companion_port = + if let Some(companion_port) = this.read_with(cx, |this, _| this.companion_port)? { + companion_port + } else { + let task = cx.spawn(async move |cx| spawn_companion(node_runtime, cx).await); + match task.await { + Ok((port, child)) => { + companion_process = Some(child); + port + } + Err(e) => { + console_output + .send(format!("Failed to launch browser companion process: {e}")) + .await + .ok(); + return Err(e); + } + } + }; + + let mut background_tasks = Vec::new(); + if let Some(mut forward_ports_process) = forward_ports_process { + background_tasks.push(cx.spawn(async move |_| { + forward_ports_process.status().await.log_err(); + })); + }; + if let Some(mut companion_process) = companion_process { + if let Some(stderr) = companion_process.stderr.take() { + let mut console_output = console_output.clone(); + background_tasks.push(cx.spawn(async move |_| { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + console_output + .send(format!("companion stderr: {line}")) + .await + .ok(); + line.clear(); + } + })); + } + background_tasks.push(cx.spawn({ + let mut console_output = console_output.clone(); + async move |_| match companion_process.status().await { + Ok(status) => { + if status.success() { + console_output + .send("Companion process exited normally".into()) + .await + .ok(); + } else { + console_output + .send(format!( + "Companion process exited abnormally with {status:?}" + )) + .await + .ok(); + } + } + Err(e) => { + console_output + .send(format!("Failed to join companion process: {e}")) + .await + .ok(); + } + } + })); + } + + // TODO pass wslInfo as needed + + let companion_address = format!("127.0.0.1:{companion_port}"); + let mut companion_started = false; + for _ in 0..10 { + if TcpStream::connect(&companion_address).await.is_ok() { + companion_started = true; + break; + } + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + } + if !companion_started { + console_output + .send("Browser companion failed to start".into()) + .await + .ok(); + bail!("Browser companion failed to start"); + } + + let response = http_client + .post_json( + &format!("http://{companion_address}/launch-and-attach"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + ) + .await; + match response { + Ok(response) => { + if !response.status().is_success() { + console_output + .send("Launch request to companion failed".into()) + .await + .ok(); + return Err(anyhow!("launch request failed")); + } + } + Err(e) => { + console_output + .send("Failed to read response from companion".into()) + .await + .ok(); + return Err(e); + } + } + + this.update(cx, |this, _| { + this.background_tasks.extend(background_tasks); + this.companion_port = Some(companion_port); + })?; + + anyhow::Ok(()) + }); + self.background_tasks.push(cx.spawn(async move |_, _| { + task.await.log_err(); + })); + } + + fn kill_browser(&self, request: KillCompanionBrowserParams, cx: &mut App) { + let Some(companion_port) = self.companion_port else { + log::error!("received killCompanionBrowser but js-debug-companion is not running"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; + + cx.spawn(async move |_| { + http_client + .post_json( + &format!("http://127.0.0.1:{companion_port}/kill"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + ) + .await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct LaunchBrowserInCompanionParams { + server_port: u16, + params: HashMap, + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct KillCompanionBrowserParams { + launch_id: u64, +} + +async fn spawn_companion( + node_runtime: NodeRuntime, + cx: &mut AsyncApp, +) -> Result<(u16, smol::process::Child)> { + let binary_path = node_runtime + .binary_path() + .await + .context("getting node path")?; + let path = cx + .spawn(async move |cx| get_or_install_companion(node_runtime, cx).await) + .await?; + log::info!("will launch js-debug-companion version {path:?}"); + + let port = { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("getting port for companion")?; + listener.local_addr()?.port() + }; + + let dir = paths::data_dir() + .join("js_debug_companion_state") + .to_string_lossy() + .to_string(); + + let child = new_smol_command(binary_path) + .arg(path) + .args([ + format!("--listen=127.0.0.1:{port}"), + format!("--state={dir}"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning companion child process")?; + + Ok((port, child)) +} + +async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Result { + const PACKAGE_NAME: &str = "@zed-industries/js-debug-companion-cli"; + + async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { + let temp_dir = tempfile::tempdir().context("creating temporary directory")?; + node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + .await + .context("installing latest companion package")?; + let version = node + .npm_package_installed_version(temp_dir.path(), PACKAGE_NAME) + .await + .context("getting installed companion version")? + .context("companion was not installed")?; + smol::fs::rename(temp_dir.path(), dir.join(&version)) + .await + .context("moving companion package into place")?; + Ok(dir.join(version)) + } + + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + let (latest_installed_version, latest_version) = cx + .background_spawn({ + let dir = dir.clone(); + let node = node.clone(); + async move { + smol::fs::create_dir_all(&dir) + .await + .context("creating companion installation directory")?; + + let mut children = smol::fs::read_dir(&dir) + .await + .context("reading companion installation directory")? + .try_collect::>() + .await + .context("reading companion installation directory entries")?; + children + .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok()); + + let latest_installed_version = children.last().and_then(|child| { + let version = child.file_name().into_string().ok()?; + Some((child.path(), version)) + }); + let latest_version = node + .npm_package_latest_version(PACKAGE_NAME) + .await + .log_err(); + anyhow::Ok((latest_installed_version, latest_version)) + } + }) + .await?; + + let path = if let Some((installed_path, installed_version)) = latest_installed_version { + if let Some(latest_version) = latest_version + && latest_version != installed_version + { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .detach(); + } + Ok(installed_path) + } else { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .await + }; + + Ok(path? + .join("node_modules") + .join(PACKAGE_NAME) + .join("out") + .join("cli.js")) } diff --git a/crates/project/src/direnv.rs b/crates/project/src/direnv.rs deleted file mode 100644 index 9ba0ad10e3173a1930186db28f7efb5d9a8267f7..0000000000000000000000000000000000000000 --- a/crates/project/src/direnv.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::environment::EnvironmentErrorMessage; -use std::process::ExitStatus; - -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] -use {collections::HashMap, std::path::Path, util::ResultExt}; - -#[derive(Clone)] -pub enum DirenvError { - NotFound, - FailedRun, - NonZeroExit(ExitStatus, Vec), - InvalidJson, -} - -impl From for Option { - fn from(value: DirenvError) -> Self { - match value { - DirenvError::NotFound => None, - DirenvError::FailedRun | DirenvError::NonZeroExit(_, _) => { - Some(EnvironmentErrorMessage(String::from( - "Failed to run direnv. See logs for more info", - ))) - } - DirenvError::InvalidJson => Some(EnvironmentErrorMessage(String::from( - "Direnv returned invalid json. See logs for more info", - ))), - } - } -} - -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] -pub async fn load_direnv_environment( - env: &HashMap, - dir: &Path, -) -> Result>, DirenvError> { - let Ok(direnv_path) = which::which("direnv") else { - return Err(DirenvError::NotFound); - }; - - let args = &["export", "json"]; - let Some(direnv_output) = smol::process::Command::new(&direnv_path) - .args(args) - .envs(env) - .env("TERM", "dumb") - .current_dir(dir) - .output() - .await - .log_err() - else { - return Err(DirenvError::FailedRun); - }; - - if !direnv_output.status.success() { - log::error!( - "Loading direnv environment failed ({}), stderr: {}", - direnv_output.status, - String::from_utf8_lossy(&direnv_output.stderr) - ); - return Err(DirenvError::NonZeroExit( - direnv_output.status, - direnv_output.stderr, - )); - } - - let output = String::from_utf8_lossy(&direnv_output.stdout); - if output.is_empty() { - // direnv outputs nothing when it has no changes to apply to environment variables - return Ok(HashMap::new()); - } - - match serde_json::from_str(&output) { - Ok(env) => Ok(env), - Err(err) => { - log::error!( - "json parse error {}, while parsing output of `{} {}`:\n{}", - err, - direnv_path.display(), - args.join(" "), - output - ); - Err(DirenvError::InvalidJson) - } - } -} diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs index 7379a7ef726c6004fc2b29a5b61a47cb9603fbb3..0f713b7deb3aca07ea7f867fc768ab2af9716c15 100644 --- a/crates/project/src/environment.rs +++ b/crates/project/src/environment.rs @@ -1,6 +1,10 @@ -use futures::{FutureExt, future::Shared}; +use anyhow::{Context as _, bail}; +use futures::{FutureExt, StreamExt as _, channel::mpsc, future::Shared}; use language::Buffer; -use std::{path::Path, sync::Arc}; +use remote::RemoteClient; +use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID}; +use std::{collections::VecDeque, path::Path, sync::Arc}; +use task::{Shell, shell_to_proto}; use util::ResultExt; use worktree::Worktree; @@ -15,8 +19,11 @@ use crate::{ pub struct ProjectEnvironment { cli_environment: Option>, - environments: HashMap, Shared>>>>, - environment_error_messages: HashMap, EnvironmentErrorMessage>, + local_environments: HashMap<(Shell, Arc), Shared>>>>, + remote_environments: HashMap<(Shell, Arc), Shared>>>>, + environment_error_messages: VecDeque, + environment_error_messages_tx: mpsc::UnboundedSender, + _tasks: Vec>, } pub enum ProjectEnvironmentEvent { @@ -26,11 +33,24 @@ pub enum ProjectEnvironmentEvent { impl EventEmitter for ProjectEnvironment {} impl ProjectEnvironment { - pub fn new(cli_environment: Option>) -> Self { + pub fn new(cli_environment: Option>, cx: &mut Context) -> Self { + let (tx, mut rx) = mpsc::unbounded(); + let task = cx.spawn(async move |this, cx| { + while let Some(message) = rx.next().await { + this.update(cx, |this, cx| { + this.environment_error_messages.push_back(message); + cx.emit(ProjectEnvironmentEvent::ErrorsUpdated); + }) + .ok(); + } + }); Self { cli_environment, - environments: Default::default(), + local_environments: Default::default(), + remote_environments: Default::default(), environment_error_messages: Default::default(), + environment_error_messages_tx: tx, + _tasks: vec![task], } } @@ -44,19 +64,6 @@ impl ProjectEnvironment { } } - /// Returns an iterator over all pairs `(abs_path, error_message)` of - /// environment errors associated with this project environment. - pub(crate) fn environment_errors( - &self, - ) -> impl Iterator, &EnvironmentErrorMessage)> { - self.environment_error_messages.iter() - } - - pub(crate) fn remove_environment_error(&mut self, abs_path: &Path, cx: &mut Context) { - self.environment_error_messages.remove(abs_path); - cx.emit(ProjectEnvironmentEvent::ErrorsUpdated); - } - pub(crate) fn get_buffer_environment( &mut self, buffer: &Entity, @@ -111,15 +118,16 @@ impl ProjectEnvironment { abs_path = parent.into(); } - self.get_directory_environment(abs_path, cx) + self.get_local_directory_environment(&Shell::System, abs_path, cx) } /// Returns the project environment, if possible. /// If the project was opened from the CLI, then the inherited CLI environment is returned. /// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in /// that directory, to get environment variables as if the user has `cd`'d there. - pub fn get_directory_environment( + pub fn get_local_directory_environment( &mut self, + shell: &Shell, abs_path: Arc, cx: &mut Context, ) -> Shared>>> { @@ -132,11 +140,83 @@ impl ProjectEnvironment { return Task::ready(Some(cli_environment)).shared(); } - self.environments - .entry(abs_path.clone()) - .or_insert_with(|| get_directory_env_impl(abs_path.clone(), cx).shared()) + self.local_environments + .entry((shell.clone(), abs_path.clone())) + .or_insert_with(|| { + let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone(); + let shell = shell.clone(); + let tx = self.environment_error_messages_tx.clone(); + cx.spawn(async move |_, cx| { + let mut shell_env = cx + .background_spawn(load_directory_shell_environment( + shell, + abs_path.clone(), + load_direnv, + tx, + )) + .await + .log_err(); + + if let Some(shell_env) = shell_env.as_mut() { + let path = shell_env + .get("PATH") + .map(|path| path.as_str()) + .unwrap_or_default(); + log::debug!( + "using project environment variables shell launched in {:?}. PATH={:?}", + abs_path, + path + ); + + set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell); + } + + shell_env + }) + .shared() + }) + .clone() + } + + pub fn get_remote_directory_environment( + &mut self, + shell: &Shell, + abs_path: Arc, + remote_client: Entity, + cx: &mut Context, + ) -> Shared>>> { + if cfg!(any(test, feature = "test-support")) { + return Task::ready(Some(HashMap::default())).shared(); + } + + self.remote_environments + .entry((shell.clone(), abs_path.clone())) + .or_insert_with(|| { + let response = + remote_client + .read(cx) + .proto_client() + .request(proto::GetDirectoryEnvironment { + project_id: REMOTE_SERVER_PROJECT_ID, + shell: Some(shell_to_proto(shell.clone())), + directory: abs_path.to_string_lossy().to_string(), + }); + cx.spawn(async move |_, _| { + let environment = response.await.log_err()?; + Some(environment.environment.into_iter().collect()) + }) + .shared() + }) .clone() } + + pub fn peek_environment_error(&self) -> Option<&String> { + self.environment_error_messages.front() + } + + pub fn pop_environment_error(&mut self) -> Option { + self.environment_error_messages.pop_front() + } } fn set_origin_marker(env: &mut HashMap, origin: EnvironmentOrigin) { @@ -159,170 +239,118 @@ impl From for String { } } -#[derive(Debug)] -pub struct EnvironmentErrorMessage(pub String); - -impl std::fmt::Display for EnvironmentErrorMessage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl EnvironmentErrorMessage { - #[allow(dead_code)] - fn from_str(s: &str) -> Self { - Self(String::from(s)) - } -} - async fn load_directory_shell_environment( - abs_path: &Path, - load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - match smol::fs::metadata(abs_path).await { - Ok(meta) => { - let dir = if meta.is_dir() { - abs_path - } else if let Some(parent) = abs_path.parent() { - parent - } else { - return ( - None, - Some(EnvironmentErrorMessage(format!( - "Failed to load shell environment in {}: not a directory", - abs_path.display() - ))), - ); - }; + shell: Shell, + abs_path: Arc, + load_direnv: DirenvSettings, + tx: mpsc::UnboundedSender, +) -> anyhow::Result> { + let meta = smol::fs::metadata(&abs_path).await.with_context(|| { + tx.unbounded_send(format!("Failed to open {}", abs_path.display())) + .ok(); + format!("stat {abs_path:?}") + })?; + + let dir = if meta.is_dir() { + abs_path.clone() + } else { + abs_path + .parent() + .with_context(|| { + tx.unbounded_send(format!("Failed to open {}", abs_path.display())) + .ok(); + format!("getting parent of {abs_path:?}") + })? + .into() + }; - load_shell_environment(&dir, load_direnv).await + if cfg!(target_os = "windows") { + // Note: direnv is not available on Windows, so we skip direnv processing + // and just return the shell environment + let (shell, args) = shell.program_and_args(); + let mut envs = util::shell_env::capture(shell.clone(), args, abs_path) + .await + .with_context(|| { + tx.unbounded_send("Failed to load environment variables".into()) + .ok(); + format!("capturing shell environment with {shell:?}") + })?; + if let Some(path) = envs.remove("Path") { + // windows env vars are case-insensitive, so normalize the path var + // so we can just assume `PATH` in other places + envs.insert("PATH".into(), path); + } + Ok(envs) + } else { + let (shell, args) = shell.program_and_args(); + let mut envs = util::shell_env::capture(shell.clone(), args, abs_path) + .await + .with_context(|| { + tx.unbounded_send("Failed to load environment variables".into()) + .ok(); + format!("capturing shell environment with {shell:?}") + })?; + + // If the user selects `Direct` for direnv, it would set an environment + // variable that later uses to know that it should not run the hook. + // We would include in `.envs` call so it is okay to run the hook + // even if direnv direct mode is enabled. + let direnv_environment = match load_direnv { + DirenvSettings::ShellHook => None, + DirenvSettings::Direct => load_direnv_environment(&envs, &dir) + .await + .with_context(|| { + tx.unbounded_send("Failed to load direnv environment".into()) + .ok(); + "load direnv environment" + }) + .log_err(), + }; + if let Some(direnv_environment) = direnv_environment { + for (key, value) in direnv_environment { + if let Some(value) = value { + envs.insert(key, value); + } else { + envs.remove(&key); + } + } } - Err(err) => ( - None, - Some(EnvironmentErrorMessage(format!( - "Failed to load shell environment in {}: {}", - abs_path.display(), - err - ))), - ), - } -} - -#[cfg(any(test, feature = "test-support"))] -async fn load_shell_environment( - _dir: &Path, - _load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - let fake_env = [("ZED_FAKE_TEST_ENV".into(), "true".into())] - .into_iter() - .collect(); - (Some(fake_env), None) -} -#[cfg(all(target_os = "windows", not(any(test, feature = "test-support"))))] -async fn load_shell_environment( - _dir: &Path, - _load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - // TODO the current code works with Unix $SHELL only, implement environment loading on windows - (None, None) + Ok(envs) + } } -#[cfg(not(any(target_os = "windows", test, feature = "test-support")))] -async fn load_shell_environment( +async fn load_direnv_environment( + env: &HashMap, dir: &Path, - load_direnv: &DirenvSettings, -) -> ( - Option>, - Option, -) { - use crate::direnv::load_direnv_environment; - use util::shell_env; - - let dir_ = dir.to_owned(); - let mut envs = match smol::unblock(move || shell_env::capture(&dir_)).await { - Ok(envs) => envs, - Err(err) => { - util::log_err(&err); - return ( - None, - Some(EnvironmentErrorMessage::from_str( - "Failed to load environment variables. See log for details", - )), - ); - } +) -> anyhow::Result>> { + let Some(direnv_path) = which::which("direnv").ok() else { + return Ok(HashMap::default()); }; - // If the user selects `Direct` for direnv, it would set an environment - // variable that later uses to know that it should not run the hook. - // We would include in `.envs` call so it is okay to run the hook - // even if direnv direct mode is enabled. - let (direnv_environment, direnv_error) = match load_direnv { - DirenvSettings::ShellHook => (None, None), - DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await { - Ok(env) => (Some(env), None), - Err(err) => (None, err.into()), - }, - }; - if let Some(direnv_environment) = direnv_environment { - for (key, value) in direnv_environment { - if let Some(value) = value { - envs.insert(key, value); - } else { - envs.remove(&key); - } - } + let args = &["export", "json"]; + let direnv_output = smol::process::Command::new(&direnv_path) + .args(args) + .envs(env) + .env("TERM", "dumb") + .current_dir(dir) + .output() + .await + .context("running direnv")?; + + if !direnv_output.status.success() { + bail!( + "Loading direnv environment failed ({}), stderr: {}", + direnv_output.status, + String::from_utf8_lossy(&direnv_output.stderr) + ); } - (Some(envs), direnv_error) -} - -fn get_directory_env_impl( - abs_path: Arc, - cx: &Context, -) -> Task>> { - let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone(); - - cx.spawn(async move |this, cx| { - let (mut shell_env, error_message) = cx - .background_spawn({ - let abs_path = abs_path.clone(); - async move { load_directory_shell_environment(&abs_path, &load_direnv).await } - }) - .await; - - if let Some(shell_env) = shell_env.as_mut() { - let path = shell_env - .get("PATH") - .map(|path| path.as_str()) - .unwrap_or_default(); - log::info!( - "using project environment variables shell launched in {:?}. PATH={:?}", - abs_path, - path - ); - - set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell); - } - - if let Some(error) = error_message { - this.update(cx, |this, cx| { - log::error!("{error}",); - this.environment_error_messages.insert(abs_path, error); - cx.emit(ProjectEnvironmentEvent::ErrorsUpdated) - }) - .log_err(); - } + let output = String::from_utf8_lossy(&direnv_output.stdout); + if output.is_empty() { + // direnv outputs nothing when it has no changes to apply to environment variables + return Ok(HashMap::default()); + } - shell_env - }) + serde_json::from_str(&output).context("parsing direnv json") } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 3163a10239f6ccdba7452697b9d9cac18a721ec3..03642df3b4f395e190d03feb04203f7595aaf3cf 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,3 +1,4 @@ +pub mod branch_diff; mod conflict_set; pub mod git_traversal; @@ -7,7 +8,7 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; use anyhow::{Context as _, Result, anyhow, bail}; -use askpass::AskPassDelegate; +use askpass::{AskPassDelegate, EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadTheDocs}; use buffer_diff::{BufferDiff, BufferDiffEvent}; use client::ProjectId; use collections::HashMap; @@ -20,7 +21,7 @@ use futures::{ stream::FuturesOrdered, }; use git::{ - BuildPermalinkParams, GitHostingProviderRegistry, WORK_DIRECTORY_REPO_PATH, + BuildPermalinkParams, GitHostingProviderRegistry, Oid, blame::Blame, parse_git_remote_url, repository::{ @@ -28,8 +29,10 @@ use git::{ GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, UpstreamTrackingStatus, }, + stash::{GitStash, StashEntry}, status::{ - FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, + DiffTreeType, FileStatus, GitSummary, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, + UnmergedStatus, UnmergedStatusCode, }, }; use gpui::{ @@ -44,7 +47,7 @@ use parking_lot::Mutex; use postage::stream::Stream as _; use rpc::{ AnyProtoClient, TypedEnvelope, - proto::{self, FromProto, SSH_PROJECT_ID, ToProto, git_reset, split_repository_update}, + proto::{self, git_reset, split_repository_update}, }; use serde::Deserialize; use std::{ @@ -54,6 +57,7 @@ use std::{ mem, ops::Range, path::{Path, PathBuf}, + str::FromStr, sync::{ Arc, atomic::{self, AtomicU64}, @@ -61,12 +65,19 @@ use std::{ time::Instant, }; use sum_tree::{Edit, SumTree, TreeSet}; +use task::Shell; use text::{Bias, BufferId}; -use util::{ResultExt, debug_panic, post_inc}; +use util::{ + ResultExt, debug_panic, + paths::{PathStyle, SanitizedPath}, + post_inc, + rel_path::RelPath, +}; use worktree::{ File, PathChange, PathKey, PathProgress, PathSummary, PathTarget, ProjectEntryId, UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree, }; +use zeroize::Zeroize; pub struct GitStore { state: GitStoreState, @@ -141,14 +152,10 @@ enum GitStoreState { project_environment: Entity, fs: Arc, }, - Ssh { - upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, - downstream: Option<(AnyProtoClient, ProjectId)>, - }, Remote { upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, + upstream_project_id: u64, + downstream: Option<(AnyProtoClient, ProjectId)>, }, } @@ -191,7 +198,7 @@ impl StatusEntry { }; proto::StatusEntry { - repo_path: self.repo_path.as_ref().to_proto(), + repo_path: self.repo_path.to_proto(), simple_status, status: Some(status_to_proto(self.status)), } @@ -202,7 +209,7 @@ impl TryFrom for StatusEntry { type Error = anyhow::Error; fn try_from(value: proto::StatusEntry) -> Result { - let repo_path = RepoPath(Arc::::from_proto(value.repo_path)); + let repo_path = RepoPath::from_proto(&value.repo_path).context("invalid repo path")?; let status = status_from_proto(value.simple_status, value.status)?; Ok(Self { repo_path, status }) } @@ -211,7 +218,7 @@ impl TryFrom for StatusEntry { impl sum_tree::Item for StatusEntry { type Summary = PathSummary; - fn summary(&self, _: &::Context) -> Self::Summary { + fn summary(&self, _: ::Context<'_>) -> Self::Summary { PathSummary { max_path: self.repo_path.0.clone(), item_summary: self.status.summary(), @@ -242,12 +249,14 @@ pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, pub work_directory_abs_path: Arc, + pub path_style: PathStyle, pub branch: Option, pub head_commit: Option, pub scan_id: u64, pub merge: MergeDetails, pub remote_origin_url: Option, pub remote_upstream_url: Option, + pub stash_entries: GitStash, } type JobId = u64; @@ -293,10 +302,15 @@ pub enum RepositoryState { }, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum RepositoryEvent { - Updated { full_scan: bool, new_instance: bool }, + StatusesChanged { + // TODO could report which statuses changed here + full_scan: bool, + }, MergeHeadsChanged, + BranchChanged, + StashEntriesChanged, } #[derive(Clone, Debug)] @@ -306,7 +320,7 @@ pub struct JobsUpdated; pub enum GitStoreEvent { ActiveRepositoryChanged(Option), RepositoryUpdated(RepositoryId, RepositoryEvent, bool), - RepositoryAdded(RepositoryId), + RepositoryAdded, RepositoryRemoved(RepositoryId), IndexWriteError(anyhow::Error), JobsUpdated, @@ -355,7 +369,7 @@ impl GitStore { worktree_store: &Entity, buffer_store: Entity, upstream_client: AnyProtoClient, - project_id: ProjectId, + project_id: u64, cx: &mut Context, ) -> Self { Self::new( @@ -364,23 +378,6 @@ impl GitStore { GitStoreState::Remote { upstream_client, upstream_project_id: project_id, - }, - cx, - ) - } - - pub fn ssh( - worktree_store: &Entity, - buffer_store: Entity, - upstream_client: AnyProtoClient, - cx: &mut Context, - ) -> Self { - Self::new( - worktree_store.clone(), - buffer_store, - GitStoreState::Ssh { - upstream_client, - upstream_project_id: ProjectId(SSH_PROJECT_ID), downstream: None, }, cx, @@ -417,6 +414,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_get_default_branch); client.add_entity_request_handler(Self::handle_change_branch); client.add_entity_request_handler(Self::handle_create_branch); + client.add_entity_request_handler(Self::handle_rename_branch); client.add_entity_request_handler(Self::handle_git_init); client.add_entity_request_handler(Self::handle_push); client.add_entity_request_handler(Self::handle_pull); @@ -425,6 +423,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_unstage); client.add_entity_request_handler(Self::handle_stash); client.add_entity_request_handler(Self::handle_stash_pop); + client.add_entity_request_handler(Self::handle_stash_apply); + client.add_entity_request_handler(Self::handle_stash_drop); client.add_entity_request_handler(Self::handle_commit); client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); @@ -435,6 +435,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_askpass); client.add_entity_request_handler(Self::handle_check_for_pushed_commits); client.add_entity_request_handler(Self::handle_git_diff); + client.add_entity_request_handler(Self::handle_tree_diff); + client.add_entity_request_handler(Self::handle_get_blob_content); client.add_entity_request_handler(Self::handle_open_unstaged_diff); client.add_entity_request_handler(Self::handle_open_uncommitted_diff); client.add_entity_message_handler(Self::handle_update_diff_bases); @@ -448,10 +450,19 @@ impl GitStore { pub fn is_local(&self) -> bool { matches!(self.state, GitStoreState::Local { .. }) } + pub fn set_active_repo_for_path(&mut self, project_path: &ProjectPath, cx: &mut Context) { + if let Some((repo, _)) = self.repository_and_path_for_project_path(project_path, cx) { + let id = repo.read(cx).id; + if self.active_repo_id != Some(id) { + self.active_repo_id = Some(id); + cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id))); + } + } + } pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context) { match &mut self.state { - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { @@ -527,9 +538,6 @@ impl GitStore { }), }); } - GitStoreState::Remote { .. } => { - debug_panic!("shared called on remote store"); - } } } @@ -541,15 +549,12 @@ impl GitStore { } => { downstream_client.take(); } - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { downstream_client.take(); } - GitStoreState::Remote { .. } => { - debug_panic!("unshared called on remote store"); - } } self.shared_diffs.clear(); } @@ -561,7 +566,7 @@ impl GitStore { pub fn active_repository(&self) -> Option> { self.active_repo_id .as_ref() - .map(|id| self.repositories[&id].clone()) + .map(|id| self.repositories[id].clone()) } pub fn open_unstaged_diff( @@ -570,23 +575,22 @@ impl GitStore { cx: &mut Context, ) -> Task>> { let buffer_id = buffer.read(cx).remote_id(); - if let Some(diff_state) = self.diffs.get(&buffer_id) { - if let Some(unstaged_diff) = diff_state + if let Some(diff_state) = self.diffs.get(&buffer_id) + && let Some(unstaged_diff) = diff_state .read(cx) .unstaged_diff .as_ref() .and_then(|weak| weak.upgrade()) + { + if let Some(task) = + diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) { - if let Some(task) = - diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) - { - return cx.background_executor().spawn(async move { - task.await; - Ok(unstaged_diff) - }); - } - return Task::ready(Ok(unstaged_diff)); + return cx.background_executor().spawn(async move { + task.await; + Ok(unstaged_diff) + }); } + return Task::ready(Ok(unstaged_diff)); } let Some((repo, repo_path)) = @@ -620,6 +624,52 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + pub fn open_diff_since( + &mut self, + oid: Option, + buffer: Entity, + repo: Entity, + languages: Arc, + cx: &mut Context, + ) -> Task>> { + cx.spawn(async move |this, cx| { + let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?; + let content = match oid { + None => None, + Some(oid) => Some( + repo.update(cx, |repo, cx| repo.load_blob_content(oid, cx))? + .await?, + ), + }; + let buffer_diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx))?; + + buffer_diff + .update(cx, |buffer_diff, cx| { + buffer_diff.set_base_text( + content.map(Arc::new), + buffer_snapshot.language().cloned(), + Some(languages.clone()), + buffer_snapshot.text, + cx, + ) + })? + .await?; + let unstaged_diff = this + .update(cx, |this, cx| this.open_unstaged_diff(buffer.clone(), cx))? + .await?; + buffer_diff.update(cx, |buffer_diff, _| { + buffer_diff.set_secondary_diff(unstaged_diff); + })?; + + this.update(cx, |_, cx| { + cx.subscribe(&buffer_diff, Self::on_buffer_diff_event) + .detach(); + })?; + + Ok(buffer_diff) + }) + } + pub fn open_uncommitted_diff( &mut self, buffer: Entity, @@ -627,23 +677,22 @@ impl GitStore { ) -> Task>> { let buffer_id = buffer.read(cx).remote_id(); - if let Some(diff_state) = self.diffs.get(&buffer_id) { - if let Some(uncommitted_diff) = diff_state + if let Some(diff_state) = self.diffs.get(&buffer_id) + && let Some(uncommitted_diff) = diff_state .read(cx) .uncommitted_diff .as_ref() .and_then(|weak| weak.upgrade()) + { + if let Some(task) = + diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) { - if let Some(task) = - diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) - { - return cx.background_executor().spawn(async move { - task.await; - Ok(uncommitted_diff) - }); - } - return Task::ready(Ok(uncommitted_diff)); + return cx.background_executor().spawn(async move { + task.await; + Ok(uncommitted_diff) + }); } + return Task::ready(Ok(uncommitted_diff)); } let Some((repo, repo_path)) = @@ -660,6 +709,7 @@ impl GitStore { repo.load_committed_text(buffer_id, repo_path, cx) }); + // todo(lw): hot foreground spawn cx.spawn(async move |this, cx| { Self::open_diff_internal(this, DiffKind::Uncommitted, changes.await, buffer, cx) .await @@ -764,29 +814,26 @@ impl GitStore { log::debug!("open conflict set"); let buffer_id = buffer.read(cx).remote_id(); - if let Some(git_state) = self.diffs.get(&buffer_id) { - if let Some(conflict_set) = git_state + if let Some(git_state) = self.diffs.get(&buffer_id) + && let Some(conflict_set) = git_state .read(cx) .conflict_set .as_ref() .and_then(|weak| weak.upgrade()) - { - let conflict_set = conflict_set.clone(); - let buffer_snapshot = buffer.read(cx).text_snapshot(); + { + let conflict_set = conflict_set; + let buffer_snapshot = buffer.read(cx).text_snapshot(); - git_state.update(cx, |state, cx| { - let _ = state.reparse_conflict_markers(buffer_snapshot, cx); - }); + git_state.update(cx, |state, cx| { + let _ = state.reparse_conflict_markers(buffer_snapshot, cx); + }); - return conflict_set; - } + return conflict_set; } let is_unmerged = self .repository_and_path_for_buffer_id(buffer_id, cx) - .map_or(false, |(repo, path)| { - repo.read(cx).snapshot.has_conflict(&path) - }); + .is_some_and(|(repo, path)| repo.read(cx).snapshot.has_conflict(&path)); let git_store = cx.weak_entity(); let buffer_git_state = self .diffs @@ -917,7 +964,7 @@ impl GitStore { return Task::ready(Err(anyhow!("failed to find a git repository for buffer"))); }; let content = match &version { - Some(version) => buffer.rope_for_version(version).clone(), + Some(version) => buffer.rope_for_version(version), None => buffer.as_rope().clone(), }; let version = version.unwrap_or(buffer.version()); @@ -973,16 +1020,12 @@ impl GitStore { { return Task::ready(Err(anyhow!("no permalink available"))); } - let Some(file_path) = file.worktree.read(cx).absolutize(&file.path).ok() else { - return Task::ready(Err(anyhow!("no permalink available"))); - }; + let file_path = file.worktree.read(cx).absolutize(&file.path); return cx.spawn(async move |cx| { let provider_registry = cx.update(GitHostingProviderRegistry::default_global)?; get_permalink_in_rust_registry_src(provider_registry, file_path, selection) .context("no permalink available") }); - - // TODO remote case }; let buffer_id = buffer.read(cx).remote_id(); @@ -1011,17 +1054,9 @@ impl GitStore { parse_git_remote_url(provider_registry, &origin_url) .context("parsing Git remote URL")?; - let path = repo_path.to_str().with_context(|| { - format!("converting repo path {repo_path:?} to string") - })?; - Ok(provider.build_permalink( remote, - BuildPermalinkParams { - sha: &sha, - path, - selection: Some(selection), - }, + BuildPermalinkParams::new(&sha, &repo_path, Some(selection)), )) } RepositoryState::Remote { project_id, client } => { @@ -1052,21 +1087,17 @@ impl GitStore { } => downstream_client .as_ref() .map(|state| (state.client.clone(), state.project_id)), - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => downstream_client.clone(), - GitStoreState::Remote { .. } => None, } } fn upstream_client(&self) -> Option { match &self.state { GitStoreState::Local { .. } => None, - GitStoreState::Ssh { - upstream_client, .. - } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, .. } => Some(upstream_client.clone()), } @@ -1139,7 +1170,6 @@ impl GitStore { _ => {} } } - fn on_repository_event( &mut self, repo: Entity, @@ -1151,29 +1181,26 @@ impl GitStore { for (buffer_id, diff) in self.diffs.iter() { if let Some((buffer_repo, repo_path)) = self.repository_and_path_for_buffer_id(*buffer_id, cx) + && buffer_repo == repo { - if buffer_repo == repo { - diff.update(cx, |diff, cx| { - if let Some(conflict_set) = &diff.conflict_set { - let conflict_status_changed = - conflict_set.update(cx, |conflict_set, cx| { - let has_conflict = repo_snapshot.has_conflict(&repo_path); - conflict_set.set_has_conflict(has_conflict, cx) - })?; - if conflict_status_changed { - let buffer_store = self.buffer_store.read(cx); - if let Some(buffer) = buffer_store.get(*buffer_id) { - let _ = diff.reparse_conflict_markers( - buffer.read(cx).text_snapshot(), - cx, - ); - } + diff.update(cx, |diff, cx| { + if let Some(conflict_set) = &diff.conflict_set { + let conflict_status_changed = + conflict_set.update(cx, |conflict_set, cx| { + let has_conflict = repo_snapshot.has_conflict(&repo_path); + conflict_set.set_has_conflict(has_conflict, cx) + })?; + if conflict_status_changed { + let buffer_store = self.buffer_store.read(cx); + if let Some(buffer) = buffer_store.get(*buffer_id) { + let _ = diff + .reparse_conflict_markers(buffer.read(cx).text_snapshot(), cx); } } - anyhow::Ok(()) - }) - .ok(); - } + } + anyhow::Ok(()) + }) + .ok(); } } cx.emit(GitStoreEvent::RepositoryUpdated( @@ -1247,7 +1274,7 @@ impl GitStore { self._subscriptions .push(cx.subscribe(&repo, Self::on_jobs_updated)); self.repositories.insert(id, repo); - cx.emit(GitStoreEvent::RepositoryAdded(id)); + cx.emit(GitStoreEvent::RepositoryAdded); self.active_repo_id.get_or_insert_with(|| { cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id))); id @@ -1277,7 +1304,7 @@ impl GitStore { ) { match event { BufferStoreEvent::BufferAdded(buffer) => { - cx.subscribe(&buffer, |this, buffer, event, cx| { + cx.subscribe(buffer, |this, buffer, event, cx| { if let BufferEvent::LanguageChanged = event { let buffer_id = buffer.read(cx).remote_id(); if let Some(diff_state) = this.diffs.get(&buffer_id) { @@ -1295,7 +1322,7 @@ impl GitStore { } } BufferStoreEvent::BufferDropped(buffer_id) => { - self.diffs.remove(&buffer_id); + self.diffs.remove(buffer_id); for diffs in self.shared_diffs.values_mut() { diffs.remove(buffer_id); } @@ -1346,7 +1373,7 @@ impl GitStore { }); if let Some((repo, path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) { let recv = repo.update(cx, |repo, cx| { - log::debug!("hunks changed for {}", path.display()); + log::debug!("hunks changed for {}", path.as_unix_str()); repo.spawn_set_index_text_job( path, new_index_text.as_ref().map(|rope| rope.to_string()), @@ -1384,8 +1411,8 @@ impl GitStore { repository.update(cx, |repository, cx| { let repo_abs_path = &repository.work_directory_abs_path; if changed_repos.iter().any(|update| { - update.old_work_directory_abs_path.as_ref() == Some(&repo_abs_path) - || update.new_work_directory_abs_path.as_ref() == Some(&repo_abs_path) + update.old_work_directory_abs_path.as_ref() == Some(repo_abs_path) + || update.new_work_directory_abs_path.as_ref() == Some(repo_abs_path) }) { repository.reload_buffer_diff_bases(cx); } @@ -1438,14 +1465,9 @@ impl GitStore { GitStoreState::Local { fs, .. } => { let fs = fs.clone(); cx.background_executor() - .spawn(async move { fs.git_init(&path, fallback_branch_name) }) - } - GitStoreState::Ssh { - upstream_client, - upstream_project_id: project_id, - .. + .spawn(async move { fs.git_init(&path, fallback_branch_name).await }) } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, upstream_project_id: project_id, .. @@ -1455,8 +1477,8 @@ impl GitStore { cx.background_executor().spawn(async move { client .request(proto::GitInit { - project_id: project_id.0, - abs_path: path.to_string_lossy().to_string(), + project_id: project_id, + abs_path: path.to_string_lossy().into_owned(), fallback_branch_name, }) .await?; @@ -1479,14 +1501,19 @@ impl GitStore { cx.background_executor() .spawn(async move { fs.git_clone(&repo, &path).await }) } - GitStoreState::Ssh { + GitStoreState::Remote { upstream_client, upstream_project_id, .. } => { + if upstream_client.is_via_collab() { + return Task::ready(Err(anyhow!( + "Git Clone isn't supported for project guests" + ))); + } let request = upstream_client.request(proto::GitClone { - project_id: upstream_project_id.0, - abs_path: path.to_string_lossy().to_string(), + project_id: *upstream_project_id, + abs_path: path.to_string_lossy().into_owned(), remote_repo: repo, }); @@ -1499,9 +1526,6 @@ impl GitStore { } }) } - GitStoreState::Remote { .. } => { - Task::ready(Err(anyhow!("Git Clone isn't supported for remote users"))) - } } } @@ -1511,37 +1535,35 @@ impl GitStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { + let path_style = this.worktree_store.read(cx).path_style(); let mut update = envelope.payload; let id = RepositoryId::from_proto(update.id); - let client = this - .upstream_client() - .context("no upstream client")? - .clone(); + let client = this.upstream_client().context("no upstream client")?; - let mut is_new = false; + let mut repo_subscription = None; let repo = this.repositories.entry(id).or_insert_with(|| { - is_new = true; let git_store = cx.weak_entity(); - cx.new(|cx| { + let repo = cx.new(|cx| { Repository::remote( id, Path::new(&update.abs_path).into(), + path_style, ProjectId(update.project_id), client, git_store, cx, ) - }) + }); + repo_subscription = Some(cx.subscribe(&repo, Self::on_repository_event)); + cx.emit(GitStoreEvent::RepositoryAdded); + repo }); - if is_new { - this._subscriptions - .push(cx.subscribe(&repo, Self::on_repository_event)) - } + this._subscriptions.extend(repo_subscription); repo.update(cx, { let update = update.clone(); - |repo, cx| repo.apply_remote_update(update, is_new, cx) + |repo, cx| repo.apply_remote_update(update, cx) })?; this.active_repo_id.get_or_insert_with(|| { @@ -1720,9 +1742,8 @@ impl GitStore { .payload .paths .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); + .map(|path| RepoPath::new(&path)) + .collect::>>()?; repository_handle .update(&mut cx, |repository_handle, cx| { @@ -1744,9 +1765,8 @@ impl GitStore { .payload .paths .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); + .map(|path| RepoPath::new(&path)) + .collect::>>()?; repository_handle .update(&mut cx, |repository_handle, cx| { @@ -1769,9 +1789,8 @@ impl GitStore { .payload .paths .into_iter() - .map(PathBuf::from) - .map(RepoPath::new) - .collect(); + .map(|path| RepoPath::new(&path)) + .collect::>>()?; repository_handle .update(&mut cx, |repository_handle, cx| { @@ -1789,16 +1808,53 @@ impl GitStore { ) -> Result { let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let stash_index = envelope.payload.stash_index.map(|i| i as usize); + + repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.stash_pop(stash_index, cx) + })? + .await?; + + Ok(proto::Ack {}) + } + + async fn handle_stash_apply( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let stash_index = envelope.payload.stash_index.map(|i| i as usize); repository_handle .update(&mut cx, |repository_handle, cx| { - repository_handle.stash_pop(cx) + repository_handle.stash_apply(stash_index, cx) })? .await?; Ok(proto::Ack {}) } + async fn handle_stash_drop( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let stash_index = envelope.payload.stash_index.map(|i| i as usize); + + repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.stash_drop(stash_index, cx) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_set_index_text( this: Entity, envelope: TypedEnvelope, @@ -1806,7 +1862,7 @@ impl GitStore { ) -> Result { let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; - let repo_path = RepoPath::from_str(&envelope.payload.path); + let repo_path = RepoPath::from_proto(&envelope.payload.path)?; repository_handle .update(&mut cx, |repository_handle, cx| { @@ -1948,6 +2004,25 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_rename_branch( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let branch = envelope.payload.branch; + let new_name = envelope.payload.new_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.rename_branch(branch, new_name) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_show( this: Entity, envelope: TypedEnvelope, @@ -1988,7 +2063,7 @@ impl GitStore { .files .into_iter() .map(|file| proto::CommitFile { - path: file.path.to_string(), + path: file.path.to_proto(), old_text: file.old_text, new_text: file.new_text, }) @@ -2028,8 +2103,8 @@ impl GitStore { .payload .paths .iter() - .map(|s| RepoPath::from_str(s)) - .collect(); + .map(|s| RepoPath::from_proto(s)) + .collect::>>()?; repository_handle .update(&mut cx, |repository_handle, cx| { @@ -2084,13 +2159,19 @@ impl GitStore { anyhow::bail!("no askpass found"); }; - let response = askpass.ask_password(envelope.payload.prompt).await?; + let response = askpass + .ask_password(envelope.payload.prompt) + .await + .ok_or_else(|| anyhow::anyhow!("askpass cancelled"))?; delegates .lock() .insert(envelope.payload.askpass_id, askpass); - Ok(proto::AskPassResponse { response }) + // In fact, we don't quite know what we're doing here, as we're sending askpass password unencrypted, but.. + Ok(proto::AskPassResponse { + response: response.decrypt(IKnowWhatIAmDoingAndIHaveReadTheDocs)?, + }) } async fn handle_check_for_pushed_commits( @@ -2139,6 +2220,75 @@ impl GitStore { Ok(proto::GitDiffResponse { diff }) } + async fn handle_tree_diff( + this: Entity, + request: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId(request.payload.repository_id); + let diff_type = if request.payload.is_merge { + DiffTreeType::MergeBase { + base: request.payload.base.into(), + head: request.payload.head.into(), + } + } else { + DiffTreeType::Since { + base: request.payload.base.into(), + head: request.payload.head.into(), + } + }; + + let diff = this + .update(&mut cx, |this, cx| { + let repository = this.repositories().get(&repository_id)?; + Some(repository.update(cx, |repo, cx| repo.diff_tree(diff_type, cx))) + })? + .context("missing repository")? + .await??; + + Ok(proto::GetTreeDiffResponse { + entries: diff + .entries + .into_iter() + .map(|(path, status)| proto::TreeDiffStatus { + path: path.0.to_proto(), + status: match status { + TreeDiffStatus::Added {} => proto::tree_diff_status::Status::Added.into(), + TreeDiffStatus::Modified { .. } => { + proto::tree_diff_status::Status::Modified.into() + } + TreeDiffStatus::Deleted { .. } => { + proto::tree_diff_status::Status::Deleted.into() + } + }, + oid: match status { + TreeDiffStatus::Deleted { old } | TreeDiffStatus::Modified { old } => { + Some(old.to_string()) + } + TreeDiffStatus::Added => None, + }, + }) + .collect(), + }) + } + + async fn handle_get_blob_content( + this: Entity, + request: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let oid = git::Oid::from_str(&request.payload.oid)?; + let repository_id = RepositoryId(request.payload.repository_id); + let content = this + .update(&mut cx, |this, cx| { + let repository = this.repositories().get(&repository_id)?; + Some(repository.update(cx, |repo, cx| repo.load_blob_content(oid, cx))) + })? + .context("missing repository")? + .await?; + Ok(proto::GetBlobContentResponse { content }) + } + async fn handle_open_unstaged_diff( this: Entity, request: TypedEnvelope, @@ -2231,13 +2381,13 @@ impl GitStore { ) -> Result<()> { let buffer_id = BufferId::new(request.payload.buffer_id)?; this.update(&mut cx, |this, cx| { - if let Some(diff_state) = this.diffs.get_mut(&buffer_id) { - if let Some(buffer) = this.buffer_store.read(cx).get(buffer_id) { - let buffer = buffer.read(cx).text_snapshot(); - diff_state.update(cx, |diff_state, cx| { - diff_state.handle_base_texts_updated(buffer, request.payload, cx); - }) - } + if let Some(diff_state) = this.diffs.get_mut(&buffer_id) + && let Some(buffer) = this.buffer_store.read(cx).get(buffer_id) + { + let buffer = buffer.read(cx).text_snapshot(); + diff_state.update(cx, |diff_state, cx| { + diff_state.handle_base_texts_updated(buffer, request.payload, cx); + }) } }) } @@ -2315,9 +2465,10 @@ impl GitStore { fn process_updated_entries( &self, worktree: &Entity, - updated_entries: &[(Arc, ProjectEntryId, PathChange)], + updated_entries: &[(Arc, ProjectEntryId, PathChange)], cx: &mut App, ) -> Task, Vec>> { + let path_style = worktree.read(cx).path_style(); let mut repo_paths = self .repositories .values() @@ -2332,7 +2483,7 @@ impl GitStore { let entries = entries .into_iter() - .filter_map(|path| worktree.absolutize(&path).ok()) + .map(|path| worktree.absolutize(&path)) .collect::>(); let executor = cx.background_executor().clone(); @@ -2352,8 +2503,9 @@ impl GitStore { let mut paths = Vec::new(); // All paths prefixed by a given repo will constitute a continuous range. while let Some(path) = entries.get(ix) - && let Some(repo_path) = - RepositorySnapshot::abs_path_to_repo_path_inner(&repo_path, &path) + && let Some(repo_path) = RepositorySnapshot::abs_path_to_repo_path_inner( + &repo_path, path, path_style, + ) { paths.push((repo_path, ix)); ix += 1; @@ -2507,14 +2659,14 @@ impl BufferGitState { pub fn wait_for_recalculation(&mut self) -> Option + use<>> { if *self.recalculating_tx.borrow() { let mut rx = self.recalculating_tx.subscribe(); - return Some(async move { + Some(async move { loop { let is_recalculating = rx.recv().await; if is_recalculating != Some(true) { break; } } - }); + }) } else { None } @@ -2724,7 +2876,10 @@ fn make_remote_delegate( prompt, }); cx.spawn(async move |_, _| { - tx.send(response.await?.response).ok(); + let mut response = response.await?.response; + tx.send(EncryptedPassword::try_from(response.as_ref())?) + .ok(); + response.zeroize(); anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -2744,7 +2899,7 @@ impl RepositoryId { } impl RepositorySnapshot { - fn empty(id: RepositoryId, work_directory_abs_path: Arc) -> Self { + fn empty(id: RepositoryId, work_directory_abs_path: Arc, path_style: PathStyle) -> Self { Self { id, statuses_by_path: Default::default(), @@ -2755,6 +2910,8 @@ impl RepositorySnapshot { merge: Default::default(), remote_origin_url: None, remote_upstream_url: None, + stash_entries: Default::default(), + path_style, } } @@ -2774,12 +2931,19 @@ impl RepositorySnapshot { .iter() .map(|repo_path| repo_path.to_proto()) .collect(), + merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, id: self.id.to_proto(), - abs_path: self.work_directory_abs_path.to_proto(), + abs_path: self.work_directory_abs_path.to_string_lossy().into_owned(), entry_ids: vec![self.id.to_proto()], scan_id: self.scan_id, is_last_update: true, + stash_entries: self + .stash_entries + .entries + .iter() + .map(stash_to_proto) + .collect(), } } @@ -2808,13 +2972,13 @@ impl RepositorySnapshot { current_new_entry = new_statuses.next(); } Ordering::Greater => { - removed_statuses.push(old_entry.repo_path.as_ref().to_proto()); + removed_statuses.push(old_entry.repo_path.to_proto()); current_old_entry = old_statuses.next(); } } } (None, Some(old_entry)) => { - removed_statuses.push(old_entry.repo_path.as_ref().to_proto()); + removed_statuses.push(old_entry.repo_path.to_proto()); current_old_entry = old_statuses.next(); } (Some(new_entry), None) => { @@ -2834,14 +2998,21 @@ impl RepositorySnapshot { .merge .conflicted_paths .iter() - .map(|path| path.as_ref().to_proto()) + .map(|path| path.to_proto()) .collect(), + merge_message: self.merge.message.as_ref().map(|msg| msg.to_string()), project_id, id: self.id.to_proto(), - abs_path: self.work_directory_abs_path.to_proto(), + abs_path: self.work_directory_abs_path.to_string_lossy().into_owned(), entry_ids: vec![], scan_id: self.scan_id, is_last_update: true, + stash_entries: self + .stash_entries + .entries + .iter() + .map(stash_to_proto) + .collect(), } } @@ -2855,35 +3026,43 @@ impl RepositorySnapshot { pub fn status_for_path(&self, path: &RepoPath) -> Option { self.statuses_by_path - .get(&PathKey(path.0.clone()), &()) + .get(&PathKey(path.0.clone()), ()) .cloned() } pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option { - Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path) + Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style) + } + + fn repo_path_to_abs_path(&self, repo_path: &RepoPath) -> PathBuf { + self.path_style + .join(&self.work_directory_abs_path, repo_path.as_std_path()) + .unwrap() + .into() } #[inline] fn abs_path_to_repo_path_inner( work_directory_abs_path: &Path, abs_path: &Path, + path_style: PathStyle, ) -> Option { abs_path .strip_prefix(&work_directory_abs_path) - .map(RepoPath::from) .ok() + .and_then(|path| RepoPath::from_std_path(path, path_style).ok()) } pub fn had_conflict_on_last_merge_head_change(&self, repo_path: &RepoPath) -> bool { - self.merge.conflicted_paths.contains(&repo_path) + self.merge.conflicted_paths.contains(repo_path) } pub fn has_conflict(&self, repo_path: &RepoPath) -> bool { let had_conflict_on_last_merge_head_change = - self.merge.conflicted_paths.contains(&repo_path); + self.merge.conflicted_paths.contains(repo_path); let has_conflict_currently = self - .status_for_path(&repo_path) - .map_or(false, |entry| entry.status.is_conflicted()); + .status_for_path(repo_path) + .is_some_and(|entry| entry.status.is_conflicted()); had_conflict_on_last_merge_head_change || has_conflict_currently } @@ -2898,6 +3077,26 @@ impl RepositorySnapshot { } } +pub fn stash_to_proto(entry: &StashEntry) -> proto::StashEntry { + proto::StashEntry { + oid: entry.oid.as_bytes().to_vec(), + message: entry.message.clone(), + branch: entry.branch.clone(), + index: entry.index as u64, + timestamp: entry.timestamp, + } +} + +pub fn proto_to_stash(entry: &proto::StashEntry) -> Result { + Ok(StashEntry { + oid: Oid::from_bytes(&entry.oid)?, + message: entry.message.clone(), + index: entry.index as usize, + branch: entry.branch.clone(), + timestamp: entry.timestamp, + }) +} + impl MergeDetails { async fn load( backend: &Arc, @@ -2977,7 +3176,8 @@ impl Repository { git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path.clone()); + let snapshot = + RepositorySnapshot::empty(id, work_directory_abs_path.clone(), PathStyle::local()); Repository { this: cx.weak_entity(), git_store, @@ -3003,12 +3203,13 @@ impl Repository { fn remote( id: RepositoryId, work_directory_abs_path: Arc, + path_style: PathStyle, project_id: ProjectId, client: AnyProtoClient, git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path); + let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path, path_style); Self { this: cx.weak_entity(), snapshot, @@ -3052,12 +3253,11 @@ impl Repository { let buffer_store = git_store.buffer_store.read(cx); let buffer = buffer_store.get(*buffer_id)?; let file = File::from_dyn(buffer.read(cx).file())?; - let abs_path = - file.worktree.read(cx).absolutize(&file.path).ok()?; + let abs_path = file.worktree.read(cx).absolutize(&file.path); let repo_path = this.abs_path_to_repo_path(&abs_path)?; log::debug!( "start reload diff bases for repo path {}", - repo_path.0.display() + repo_path.as_unix_str() ); diff_state.update(cx, |diff_state, _| { let has_unstaged_diff = diff_state @@ -3273,14 +3473,19 @@ impl Repository { self.snapshot.status() } + pub fn cached_stash(&self) -> GitStash { + self.snapshot.stash_entries.clone() + } + pub fn repo_path_to_project_path(&self, path: &RepoPath, cx: &App) -> Option { let git_store = self.git_store.upgrade()?; let worktree_store = git_store.read(cx).worktree_store.read(cx); - let abs_path = self.snapshot.work_directory_abs_path.join(&path.0); + let abs_path = self.snapshot.repo_path_to_abs_path(path); + let abs_path = SanitizedPath::new(&abs_path); let (worktree, relative_path) = worktree_store.find_worktree(abs_path, cx)?; Some(ProjectPath { worktree_id: worktree.read(cx).id(), - path: relative_path.into(), + path: relative_path, }) } @@ -3359,7 +3564,7 @@ impl Repository { ) -> Task>> { cx.spawn(async move |repository, cx| { let buffer = buffer_store - .update(cx, |buffer_store, cx| buffer_store.create_buffer(cx))? + .update(cx, |buffer_store, cx| buffer_store.create_buffer(false, cx))? .await?; if let Some(language_registry) = language_registry { @@ -3404,10 +3609,7 @@ impl Repository { project_id: project_id.0, repository_id: id.to_proto(), commit, - paths: paths - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(), + paths: paths.into_iter().map(|p| p.to_proto()).collect(), }) .await?; @@ -3424,7 +3626,6 @@ impl Repository { reset_mode: ResetMode, _cx: &mut App, ) -> oneshot::Receiver> { - let commit = commit.to_string(); let id = self.id; self.send_job(None, move |git_repo, _| async move { @@ -3498,12 +3699,14 @@ impl Repository { files: response .files .into_iter() - .map(|file| CommitFile { - path: Path::new(&file.path).into(), - old_text: file.old_text, - new_text: file.new_text, + .map(|file| { + Ok(CommitFile { + path: RepoPath::from_proto(&file.path)?, + old_text: file.old_text, + new_text: file.new_text, + }) }) - .collect(), + .collect::>>()?, }) } } @@ -3531,14 +3734,14 @@ impl Repository { let Some(project_path) = self.repo_path_to_project_path(path, cx) else { continue; }; - if let Some(buffer) = buffer_store.get_by_path(&project_path) { - if buffer + if let Some(buffer) = buffer_store.get_by_path(&project_path) + && buffer .read(cx) .file() - .map_or(false, |file| file.disk_state().exists()) - { - save_futures.push(buffer_store.save_buffer(buffer, cx)); - } + .is_some_and(|file| file.disk_state().exists()) + && buffer.read(cx).has_unsaved_edits() + { + save_futures.push(buffer_store.save_buffer(buffer, cx)); } } }) @@ -3564,7 +3767,7 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.as_ref().to_proto()) + .map(|repo_path| repo_path.to_proto()) .collect(), }) .await @@ -3598,14 +3801,14 @@ impl Repository { let Some(project_path) = self.repo_path_to_project_path(path, cx) else { continue; }; - if let Some(buffer) = buffer_store.get_by_path(&project_path) { - if buffer + if let Some(buffer) = buffer_store.get_by_path(&project_path) + && buffer .read(cx) .file() - .map_or(false, |file| file.disk_state().exists()) - { - save_futures.push(buffer_store.save_buffer(buffer, cx)); - } + .is_some_and(|file| file.disk_state().exists()) + && buffer.read(cx).has_unsaved_edits() + { + save_futures.push(buffer_store.save_buffer(buffer, cx)); } } }) @@ -3631,7 +3834,7 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.as_ref().to_proto()) + .map(|repo_path| repo_path.to_proto()) .collect(), }) .await @@ -3652,7 +3855,7 @@ impl Repository { let to_stage = self .cached_status() .filter(|entry| !entry.status.staging().is_fully_staged()) - .map(|entry| entry.repo_path.clone()) + .map(|entry| entry.repo_path) .collect(); self.stage_entries(to_stage, cx) } @@ -3661,16 +3864,13 @@ impl Repository { let to_unstage = self .cached_status() .filter(|entry| entry.status.staging().has_staged()) - .map(|entry| entry.repo_path.clone()) + .map(|entry| entry.repo_path) .collect(); self.unstage_entries(to_unstage, cx) } pub fn stash_all(&mut self, cx: &mut Context) -> Task> { - let to_stash = self - .cached_status() - .map(|entry| entry.repo_path.clone()) - .collect(); + let to_stash = self.cached_status().map(|entry| entry.repo_path).collect(); self.stash_entries(to_stash, cx) } @@ -3698,7 +3898,7 @@ impl Repository { repository_id: id.to_proto(), paths: entries .into_iter() - .map(|repo_path| repo_path.as_ref().to_proto()) + .map(|repo_path| repo_path.to_proto()) .collect(), }) .await @@ -3713,7 +3913,11 @@ impl Repository { }) } - pub fn stash_pop(&mut self, cx: &mut Context) -> Task> { + pub fn stash_pop( + &mut self, + index: Option, + cx: &mut Context, + ) -> Task> { let id = self.id; cx.spawn(async move |this, cx| { this.update(cx, |this, _| { @@ -3723,12 +3927,13 @@ impl Repository { backend, environment, .. - } => backend.stash_pop(environment).await, + } => backend.stash_pop(index, environment).await, RepositoryState::Remote { project_id, client } => { client .request(proto::StashPop { project_id: project_id.0, repository_id: id.to_proto(), + stash_index: index.map(|i| i as u64), }) .await .context("sending stash pop request")?; @@ -3742,6 +3947,96 @@ impl Repository { }) } + pub fn stash_apply( + &mut self, + index: Option, + cx: &mut Context, + ) -> Task> { + let id = self.id; + cx.spawn(async move |this, cx| { + this.update(cx, |this, _| { + this.send_job(None, move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local { + backend, + environment, + .. + } => backend.stash_apply(index, environment).await, + RepositoryState::Remote { project_id, client } => { + client + .request(proto::StashApply { + project_id: project_id.0, + repository_id: id.to_proto(), + stash_index: index.map(|i| i as u64), + }) + .await + .context("sending stash apply request")?; + Ok(()) + } + } + }) + })? + .await??; + Ok(()) + }) + } + + pub fn stash_drop( + &mut self, + index: Option, + cx: &mut Context, + ) -> oneshot::Receiver> { + let id = self.id; + let updates_tx = self + .git_store() + .and_then(|git_store| match &git_store.read(cx).state { + GitStoreState::Local { downstream, .. } => downstream + .as_ref() + .map(|downstream| downstream.updates_tx.clone()), + _ => None, + }); + let this = cx.weak_entity(); + self.send_job(None, move |git_repo, mut cx| async move { + match git_repo { + RepositoryState::Local { + backend, + environment, + .. + } => { + // TODO would be nice to not have to do this manually + let result = backend.stash_drop(index, environment).await; + if result.is_ok() + && let Ok(stash_entries) = backend.stash_entries().await + { + let snapshot = this.update(&mut cx, |this, cx| { + this.snapshot.stash_entries = stash_entries; + cx.emit(RepositoryEvent::StashEntriesChanged); + this.snapshot.clone() + })?; + if let Some(updates_tx) = updates_tx { + updates_tx + .unbounded_send(DownstreamUpdate::UpdateRepository(snapshot)) + .ok(); + } + } + + result + } + RepositoryState::Remote { project_id, client } => { + client + .request(proto::StashDrop { + project_id: project_id.0, + repository_id: id.to_proto(), + stash_index: index.map(|i| i as u64), + }) + .await + .context("sending stash pop request")?; + Ok(()) + } + } + }) + } + pub fn commit( &mut self, message: SharedString, @@ -3858,7 +4153,7 @@ impl Repository { let this = cx.weak_entity(); self.send_job( - Some(format!("git push {} {} {}", args, branch, remote).into()), + Some(format!("git push {} {} {}", args, remote, branch).into()), move |git_repo, mut cx| async move { match git_repo { RepositoryState::Local { @@ -3876,18 +4171,15 @@ impl Repository { cx.clone(), ) .await; + // TODO would be nice to not have to do this manually if result.is_ok() { let branches = backend.branches().await?; let branch = branches.into_iter().find(|branch| branch.is_head); log::info!("head branch after scan is {branch:?}"); let snapshot = this.update(&mut cx, |this, cx| { this.snapshot.branch = branch; - let snapshot = this.snapshot.clone(); - cx.emit(RepositoryEvent::Updated { - full_scan: false, - new_instance: false, - }); - snapshot + cx.emit(RepositoryEvent::BranchChanged); + this.snapshot.clone() })?; if let Some(updates_tx) = updates_tx { updates_tx @@ -4002,7 +4294,10 @@ impl Repository { Some(GitJobKey::WriteIndex(path.clone())), None, move |git_repo, mut cx| async move { - log::debug!("start updating index text for buffer {}", path.display()); + log::debug!( + "start updating index text for buffer {}", + path.as_unix_str() + ); match git_repo { RepositoryState::Local { backend, @@ -4018,13 +4313,16 @@ impl Repository { .request(proto::SetIndexText { project_id: project_id.0, repository_id: id.to_proto(), - path: path.as_ref().to_proto(), + path: path.to_proto(), text: content, }) .await?; } } - log::debug!("finish updating index text for buffer {}", path.display()); + log::debug!( + "finish updating index text for buffer {}", + path.as_unix_str() + ); if let Some(hunk_staging_operation_count) = hunk_staging_operation_count { let project_path = this @@ -4126,6 +4424,62 @@ impl Repository { }) } + pub fn diff_tree( + &mut self, + diff_type: DiffTreeType, + _cx: &App, + ) -> oneshot::Receiver> { + let repository_id = self.snapshot.id; + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local { backend, .. } => backend.diff_tree(diff_type).await, + RepositoryState::Remote { client, project_id } => { + let response = client + .request(proto::GetTreeDiff { + project_id: project_id.0, + repository_id: repository_id.0, + is_merge: matches!(diff_type, DiffTreeType::MergeBase { .. }), + base: diff_type.base().to_string(), + head: diff_type.head().to_string(), + }) + .await?; + + let entries = response + .entries + .into_iter() + .filter_map(|entry| { + let status = match entry.status() { + proto::tree_diff_status::Status::Added => TreeDiffStatus::Added, + proto::tree_diff_status::Status::Modified => { + TreeDiffStatus::Modified { + old: git::Oid::from_str( + &entry.oid.context("missing oid").log_err()?, + ) + .log_err()?, + } + } + proto::tree_diff_status::Status::Deleted => { + TreeDiffStatus::Deleted { + old: git::Oid::from_str( + &entry.oid.context("missing oid").log_err()?, + ) + .log_err()?, + } + } + }; + Some(( + RepoPath(RelPath::from_proto(&entry.path).log_err()?), + status, + )) + }) + .collect(); + + Ok(TreeDiff { entries }) + } + } + }) + } + pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver> { let id = self.id; self.send_job(None, move |repo, _cx| async move { @@ -4203,6 +4557,36 @@ impl Repository { ) } + pub fn rename_branch( + &mut self, + branch: String, + new_name: String, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git branch -m {branch} {new_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local { backend, .. } => { + backend.rename_branch(branch, new_name).await + } + RepositoryState::Remote { project_id, client } => { + client + .request(proto::GitRenameBranch { + project_id: project_id.0, + repository_id: id.to_proto(), + branch, + new_name, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver>> { let id = self.id; self.send_job(None, move |repo, _cx| async move { @@ -4250,27 +4634,47 @@ impl Repository { pub(crate) fn apply_remote_update( &mut self, update: proto::UpdateRepository, - is_new: bool, cx: &mut Context, ) -> Result<()> { let conflicted_paths = TreeSet::from_ordered_entries( update .current_merge_conflicts .into_iter() - .map(|path| RepoPath(Path::new(&path).into())), + .filter_map(|path| RepoPath::from_proto(&path).log_err()), ); - self.snapshot.branch = update.branch_summary.as_ref().map(proto_to_branch); - self.snapshot.head_commit = update + let new_branch = update.branch_summary.as_ref().map(proto_to_branch); + let new_head_commit = update .head_commit_details .as_ref() .map(proto_to_commit_details); + if self.snapshot.branch != new_branch || self.snapshot.head_commit != new_head_commit { + cx.emit(RepositoryEvent::BranchChanged) + } + self.snapshot.branch = new_branch; + self.snapshot.head_commit = new_head_commit; self.snapshot.merge.conflicted_paths = conflicted_paths; + self.snapshot.merge.message = update.merge_message.map(SharedString::from); + let new_stash_entries = GitStash { + entries: update + .stash_entries + .iter() + .filter_map(|entry| proto_to_stash(entry).ok()) + .collect(), + }; + if self.snapshot.stash_entries != new_stash_entries { + cx.emit(RepositoryEvent::StashEntriesChanged) + } + self.snapshot.stash_entries = new_stash_entries; let edits = update .removed_statuses .into_iter() - .map(|path| sum_tree::Edit::Remove(PathKey(FromProto::from_proto(path)))) + .filter_map(|path| { + Some(sum_tree::Edit::Remove(PathKey( + RelPath::from_proto(&path).log_err()?, + ))) + }) .chain( update .updated_statuses @@ -4280,14 +4684,13 @@ impl Repository { }), ) .collect::>(); - self.snapshot.statuses_by_path.edit(edits, &()); + if !edits.is_empty() { + cx.emit(RepositoryEvent::StatusesChanged { full_scan: true }); + } + self.snapshot.statuses_by_path.edit(edits, ()); if update.is_last_update { self.snapshot.scan_id = update.scan_id; } - cx.emit(RepositoryEvent::Updated { - full_scan: true, - new_instance: is_new, - }); Ok(()) } @@ -4384,16 +4787,19 @@ impl Repository { .upgrade() .context("missing project environment")? .update(cx, |project_environment, cx| { - project_environment.get_directory_environment(work_directory_abs_path.clone(), cx) + project_environment.get_local_directory_environment(&Shell::System, work_directory_abs_path.clone(), cx) })? .await .unwrap_or_else(|| { log::error!("failed to get working directory environment for repository {work_directory_abs_path:?}"); HashMap::default() }); + let search_paths = environment.get("PATH").map(|val| val.to_owned()); let backend = cx .background_spawn(async move { - fs.open_repo(&dot_git_abs_path) + let system_git_binary_path = search_paths.and_then(|search_paths| which::which_in("git", Some(search_paths), &work_directory_abs_path).ok()) + .or_else(|| which::which("git").ok()); + fs.open_repo(&dot_git_abs_path, system_git_binary_path.as_deref()) .with_context(|| format!("opening repository at {dot_git_abs_path:?}")) }) .await?; @@ -4418,14 +4824,13 @@ impl Repository { } if let Some(job) = jobs.pop_front() { - if let Some(current_key) = &job.key { - if jobs + if let Some(current_key) = &job.key + && jobs .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) { continue; } - } (job.job)(state.clone(), cx).await; } else if let Some(job) = job_rx.next().await { jobs.push_back(job); @@ -4456,13 +4861,12 @@ impl Repository { } if let Some(job) = jobs.pop_front() { - if let Some(current_key) = &job.key { - if jobs + if let Some(current_key) = &job.key + && jobs .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) - { - continue; - } + { + continue; } (job.job)(state.clone(), cx).await; } else if let Some(job) = job_rx.next().await { @@ -4548,6 +4952,25 @@ impl Repository { cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) } + fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task> { + let repository_id = self.snapshot.id; + let rx = self.send_job(None, move |state, _| async move { + match state { + RepositoryState::Local { backend, .. } => backend.load_blob_content(oid).await, + RepositoryState::Remote { client, project_id } => { + let response = client + .request(proto::GetBlobContent { + project_id: project_id.to_proto(), + repository_id: repository_id.0, + oid: oid.to_string(), + }) + .await?; + Ok(response.content) + } + } + }); + cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) + } fn paths_changed( &mut self, @@ -4577,19 +5000,20 @@ impl Repository { return Ok(()); } let statuses = backend.status(&paths).await?; + let stash_entries = backend.stash_entries().await?; let changed_path_statuses = cx .background_spawn(async move { let mut changed_path_statuses = Vec::new(); let prev_statuses = prev_snapshot.statuses_by_path.clone(); - let mut cursor = prev_statuses.cursor::(&()); + let mut cursor = prev_statuses.cursor::(()); for (repo_path, status) in &*statuses.entries { changed_paths.remove(repo_path); - if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) { - if cursor.item().is_some_and(|entry| entry.status == *status) { - continue; - } + if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) + && cursor.item().is_some_and(|entry| entry.status == *status) + { + continue; } changed_path_statuses.push(Edit::Insert(StatusEntry { @@ -4597,7 +5021,7 @@ impl Repository { status: *status, })); } - let mut cursor = prev_statuses.cursor::(&()); + let mut cursor = prev_statuses.cursor::(()); for path in changed_paths.into_iter() { if cursor.seek_forward(&PathTarget::Path(&path), Bias::Left) { changed_path_statuses.push(Edit::Remove(PathKey(path.0))); @@ -4608,23 +5032,26 @@ impl Repository { .await; this.update(&mut cx, |this, cx| { + if this.snapshot.stash_entries != stash_entries { + cx.emit(RepositoryEvent::StashEntriesChanged); + this.snapshot.stash_entries = stash_entries; + } + if !changed_path_statuses.is_empty() { + cx.emit(RepositoryEvent::StatusesChanged { full_scan: false }); this.snapshot .statuses_by_path - .edit(changed_path_statuses, &()); + .edit(changed_path_statuses, ()); this.snapshot.scan_id += 1; - if let Some(updates_tx) = updates_tx { - updates_tx - .unbounded_send(DownstreamUpdate::UpdateRepository( - this.snapshot.clone(), - )) - .ok(); - } } - cx.emit(RepositoryEvent::Updated { - full_scan: false, - new_instance: false, - }); + + if let Some(updates_tx) = updates_tx { + updates_tx + .unbounded_send(DownstreamUpdate::UpdateRepository( + this.snapshot.clone(), + )) + .ok(); + } }) }, ); @@ -4680,11 +5107,15 @@ fn get_permalink_in_rust_registry_src( let path = PathBuf::from(cargo_vcs_info.path_in_vcs).join(path.strip_prefix(dir).unwrap()); let permalink = provider.build_permalink( remote, - BuildPermalinkParams { - sha: &cargo_vcs_info.git.sha1, - path: &path.to_string_lossy(), - selection: Some(selection), - }, + BuildPermalinkParams::new( + &cargo_vcs_info.git.sha1, + &RepoPath( + RelPath::new(&path, PathStyle::local()) + .context("invalid path")? + .into_arc(), + ), + Some(selection), + ), ); Ok(permalink) } @@ -4801,6 +5232,7 @@ fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch { sha: commit.sha.to_string(), subject: commit.subject.to_string(), commit_timestamp: commit.commit_timestamp, + author_name: commit.author_name.to_string(), }), } } @@ -4830,6 +5262,7 @@ fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch { sha: commit.sha.to_string().into(), subject: commit.subject.to_string().into(), commit_timestamp: commit.commit_timestamp, + author_name: commit.author_name.to_string().into(), has_parent: true, } }), @@ -4865,9 +5298,8 @@ async fn compute_snapshot( let mut events = Vec::new(); let branches = backend.branches().await?; let branch = branches.into_iter().find(|branch| branch.is_head); - let statuses = backend - .status(std::slice::from_ref(&WORK_DIRECTORY_REPO_PATH)) - .await?; + let statuses = backend.status(&[RelPath::empty().into()]).await?; + let stash_entries = backend.stash_entries().await?; let statuses_by_path = SumTree::from_iter( statuses .entries @@ -4876,34 +5308,30 @@ async fn compute_snapshot( repo_path: repo_path.clone(), status: *status, }), - &(), + (), ); let (merge_details, merge_heads_changed) = MergeDetails::load(&backend, &statuses_by_path, &prev_snapshot).await?; log::debug!("new merge details (changed={merge_heads_changed:?}): {merge_details:?}"); - if merge_heads_changed - || branch != prev_snapshot.branch - || statuses_by_path != prev_snapshot.statuses_by_path - { - events.push(RepositoryEvent::Updated { - full_scan: true, - new_instance: false, - }); - } - - // Cache merge conflict paths so they don't change from staging/unstaging, - // until the merge heads change (at commit time, etc.). if merge_heads_changed { events.push(RepositoryEvent::MergeHeadsChanged); } + if statuses_by_path != prev_snapshot.statuses_by_path { + events.push(RepositoryEvent::StatusesChanged { full_scan: true }) + } + // Useful when branch is None in detached head state let head_commit = match backend.head_sha().await { Some(head_sha) => backend.show(head_sha).await.log_err(), None => None, }; + if branch != prev_snapshot.branch || head_commit != prev_snapshot.head_commit { + events.push(RepositoryEvent::BranchChanged); + } + // Used by edit prediction data collection let remote_origin_url = backend.remote_url("origin"); let remote_upstream_url = backend.remote_url("upstream"); @@ -4912,12 +5340,14 @@ async fn compute_snapshot( id, statuses_by_path, work_directory_abs_path, + path_style: prev_snapshot.path_style, scan_id: prev_snapshot.scan_id + 1, branch, head_commit, merge: merge_details, remote_origin_url, remote_upstream_url, + stash_entries, }; Ok((snapshot, events)) diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/branch_diff.rs new file mode 100644 index 0000000000000000000000000000000000000000..08dbd77a541f01a52dbb9b0d10c5af3a377170f9 --- /dev/null +++ b/crates/project/src/git_store/branch_diff.rs @@ -0,0 +1,387 @@ +use anyhow::Result; +use buffer_diff::BufferDiff; +use collections::HashSet; +use futures::StreamExt; +use git::{ + repository::RepoPath, + status::{DiffTreeType, FileStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus}, +}; +use gpui::{ + App, AsyncWindowContext, Context, Entity, EventEmitter, SharedString, Subscription, Task, + WeakEntity, Window, +}; + +use language::Buffer; +use text::BufferId; +use util::ResultExt; + +use crate::{ + Project, + git_store::{GitStoreEvent, Repository, RepositoryEvent}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DiffBase { + Head, + Merge { base_ref: SharedString }, +} + +impl DiffBase { + pub fn is_merge_base(&self) -> bool { + matches!(self, DiffBase::Merge { .. }) + } +} + +pub struct BranchDiff { + diff_base: DiffBase, + repo: Option>, + project: Entity, + base_commit: Option, + head_commit: Option, + tree_diff: Option, + _subscription: Subscription, + update_needed: postage::watch::Sender<()>, + _task: Task<()>, +} + +pub enum BranchDiffEvent { + FileListChanged, +} + +impl EventEmitter for BranchDiff {} + +impl BranchDiff { + pub fn new( + source: DiffBase, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let git_store = project.read(cx).git_store().clone(); + let git_store_subscription = cx.subscribe_in( + &git_store, + window, + move |this, _git_store, event, _window, cx| match event { + GitStoreEvent::ActiveRepositoryChanged(_) + | GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::StatusesChanged { full_scan: _ }, + true, + ) + | GitStoreEvent::ConflictsUpdated => { + cx.emit(BranchDiffEvent::FileListChanged); + *this.update_needed.borrow_mut() = (); + } + _ => {} + }, + ); + + let (send, recv) = postage::watch::channel::<()>(); + let worker = window.spawn(cx, { + let this = cx.weak_entity(); + async |cx| Self::handle_status_updates(this, recv, cx).await + }); + let repo = git_store.read(cx).active_repository(); + + Self { + diff_base: source, + repo, + project, + tree_diff: None, + base_commit: None, + head_commit: None, + _subscription: git_store_subscription, + _task: worker, + update_needed: send, + } + } + + pub fn diff_base(&self) -> &DiffBase { + &self.diff_base + } + + pub async fn handle_status_updates( + this: WeakEntity, + mut recv: postage::watch::Receiver<()>, + cx: &mut AsyncWindowContext, + ) { + Self::reload_tree_diff(this.clone(), cx).await.log_err(); + while recv.next().await.is_some() { + let Ok(needs_update) = this.update(cx, |this, cx| { + let mut needs_update = false; + let active_repo = this + .project + .read(cx) + .git_store() + .read(cx) + .active_repository(); + if active_repo != this.repo { + needs_update = true; + this.repo = active_repo; + } else if let Some(repo) = this.repo.as_ref() { + repo.update(cx, |repo, _| { + if let Some(branch) = &repo.branch + && let DiffBase::Merge { base_ref } = &this.diff_base + && let Some(commit) = branch.most_recent_commit.as_ref() + && &branch.ref_name == base_ref + && this.base_commit.as_ref() != Some(&commit.sha) + { + this.base_commit = Some(commit.sha.clone()); + needs_update = true; + } + + if repo.head_commit.as_ref().map(|c| &c.sha) != this.head_commit.as_ref() { + this.head_commit = repo.head_commit.as_ref().map(|c| c.sha.clone()); + needs_update = true; + } + }) + } + needs_update + }) else { + return; + }; + + if needs_update { + Self::reload_tree_diff(this.clone(), cx).await.log_err(); + } + } + } + + pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option { + let (repo, path) = self + .project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx)?; + if self.repo() == Some(&repo) { + return self.merge_statuses( + repo.read(cx) + .status_for_path(&path) + .map(|status| status.status), + self.tree_diff + .as_ref() + .and_then(|diff| diff.entries.get(&path)), + ); + } + None + } + + pub fn merge_statuses( + &self, + diff_from_head: Option, + diff_from_merge_base: Option<&TreeDiffStatus>, + ) -> Option { + match (diff_from_head, diff_from_merge_base) { + (None, None) => None, + (Some(diff_from_head), None) => Some(diff_from_head), + (Some(diff_from_head @ FileStatus::Unmerged(_)), _) => Some(diff_from_head), + + // file does not exist in HEAD + // but *does* exist in work-tree + // and *does* exist in merge-base + ( + Some(FileStatus::Untracked) + | Some(FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Added, + worktree_status: _, + })), + Some(_), + ) => Some(FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + })), + + // file exists in HEAD + // but *does not* exist in work-tree + (Some(diff_from_head), Some(diff_from_merge_base)) if diff_from_head.is_deleted() => { + match diff_from_merge_base { + TreeDiffStatus::Added => None, // unchanged, didn't exist in merge base or worktree + _ => Some(diff_from_head), + } + } + + // file exists in HEAD + // and *does* exist in work-tree + (Some(FileStatus::Tracked(_)), Some(tree_status)) => { + Some(FileStatus::Tracked(TrackedStatus { + index_status: match tree_status { + TreeDiffStatus::Added { .. } => StatusCode::Added, + _ => StatusCode::Modified, + }, + worktree_status: match tree_status { + TreeDiffStatus::Added => StatusCode::Added, + _ => StatusCode::Modified, + }, + })) + } + + (_, Some(diff_from_merge_base)) => { + Some(diff_status_to_file_status(diff_from_merge_base)) + } + } + } + + pub async fn reload_tree_diff( + this: WeakEntity, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let task = this.update(cx, |this, cx| { + let DiffBase::Merge { base_ref } = this.diff_base.clone() else { + return None; + }; + let Some(repo) = this.repo.as_ref() else { + this.tree_diff.take(); + return None; + }; + repo.update(cx, |repo, cx| { + Some(repo.diff_tree( + DiffTreeType::MergeBase { + base: base_ref, + head: "HEAD".into(), + }, + cx, + )) + }) + })?; + let Some(task) = task else { return Ok(()) }; + + let diff = task.await??; + this.update(cx, |this, cx| { + this.tree_diff = Some(diff); + cx.emit(BranchDiffEvent::FileListChanged); + cx.notify(); + }) + } + + pub fn repo(&self) -> Option<&Entity> { + self.repo.as_ref() + } + + pub fn load_buffers(&mut self, cx: &mut Context) -> Vec { + let mut output = Vec::default(); + let Some(repo) = self.repo.clone() else { + return output; + }; + + self.project.update(cx, |_project, cx| { + let mut seen = HashSet::default(); + + for item in repo.read(cx).cached_status() { + seen.insert(item.repo_path.clone()); + let branch_diff = self + .tree_diff + .as_ref() + .and_then(|t| t.entries.get(&item.repo_path)) + .cloned(); + let Some(status) = self.merge_statuses(Some(item.status), branch_diff.as_ref()) + else { + continue; + }; + if !status.has_changes() { + continue; + } + + let Some(project_path) = + repo.read(cx).repo_path_to_project_path(&item.repo_path, cx) + else { + continue; + }; + let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx); + + output.push(DiffBuffer { + repo_path: item.repo_path.clone(), + load: task, + file_status: item.status, + }); + } + let Some(tree_diff) = self.tree_diff.as_ref() else { + return; + }; + + for (path, branch_diff) in tree_diff.entries.iter() { + if seen.contains(&path) { + continue; + } + + let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { + continue; + }; + let task = + Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx); + + let file_status = diff_status_to_file_status(branch_diff); + + output.push(DiffBuffer { + repo_path: path.clone(), + load: task, + file_status, + }); + } + }); + output + } + + fn load_buffer( + branch_diff: Option, + project_path: crate::ProjectPath, + repo: Entity, + cx: &Context<'_, Project>, + ) -> Task, Entity)>> { + let task = cx.spawn(async move |project, cx| { + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx))? + .await?; + + let languages = project.update(cx, |project, _cx| project.languages().clone())?; + + let changes = if let Some(entry) = branch_diff { + let oid = match entry { + git::status::TreeDiffStatus::Added { .. } => None, + git::status::TreeDiffStatus::Modified { old, .. } + | git::status::TreeDiffStatus::Deleted { old } => Some(old), + }; + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, languages, cx) + }) + })? + .await? + } else { + project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await? + }; + Ok((buffer, changes)) + }); + task + } +} + +fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> FileStatus { + let file_status = match branch_diff { + git::status::TreeDiffStatus::Added { .. } => FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Added, + }), + git::status::TreeDiffStatus::Modified { .. } => FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Modified, + worktree_status: StatusCode::Modified, + }), + git::status::TreeDiffStatus::Deleted { .. } => FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Deleted, + worktree_status: StatusCode::Deleted, + }), + }; + file_status +} + +#[derive(Debug)] +pub struct DiffBuffer { + pub repo_path: RepoPath, + pub file_status: FileStatus, + pub load: Task, Entity)>>, +} diff --git a/crates/project/src/git_store/conflict_set.rs b/crates/project/src/git_store/conflict_set.rs index 27b191f65f896e6488a4d9c52f37e9426cac1c46..160a384a4a0ff4481c97b6eda75faded28f01624 100644 --- a/crates/project/src/git_store/conflict_set.rs +++ b/crates/project/src/git_store/conflict_set.rs @@ -72,13 +72,15 @@ impl ConflictSetSnapshot { (None, None) => None, (None, Some(conflict)) => Some(conflict.range.start), (Some(conflict), None) => Some(conflict.range.start), - (Some(first), Some(second)) => Some(first.range.start.min(&second.range.start, buffer)), + (Some(first), Some(second)) => { + Some(*first.range.start.min(&second.range.start, buffer)) + } }; let end = match (old_conflicts.last(), new_conflicts.last()) { (None, None) => None, (None, Some(conflict)) => Some(conflict.range.end), (Some(first), None) => Some(first.range.end), - (Some(first), Some(second)) => Some(first.range.end.max(&second.range.end, buffer)), + (Some(first), Some(second)) => Some(*first.range.end.max(&second.range.end, buffer)), }; ConflictSetUpdate { buffer_range: start.zip(end).map(|(start, end)| start..end), @@ -255,20 +257,23 @@ impl EventEmitter for ConflictSet {} #[cfg(test)] mod tests { - use std::{path::Path, sync::mpsc}; + use std::sync::mpsc; - use crate::{Project, project_settings::ProjectSettings}; + use crate::Project; use super::*; use fs::FakeFs; - use git::status::{UnmergedStatus, UnmergedStatusCode}; + use git::{ + repository::repo_path, + status::{UnmergedStatus, UnmergedStatusCode}, + }; use gpui::{BackgroundExecutor, TestAppContext}; use language::language_settings::AllLanguageSettings; use serde_json::json; use settings::Settings as _; - use text::{Buffer, BufferId, Point, ToOffset as _}; + use text::{Buffer, BufferId, Point, ReplicaId, ToOffset as _}; use unindent::Unindent as _; - use util::path; + use util::{path, rel_path::rel_path}; use worktree::WorktreeSettings; #[test] @@ -294,7 +299,7 @@ mod tests { .unindent(); let buffer_id = BufferId::new(1).unwrap(); - let buffer = Buffer::new(0, buffer_id, test_content); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); @@ -344,8 +349,8 @@ mod tests { assert_eq!(conflicts_in_range.len(), 1); // Test with a range that doesn't include any conflicts - let range = buffer.anchor_after(first_conflict_end.to_offset(&buffer) + 1) - ..buffer.anchor_before(second_conflict_start.to_offset(&buffer) - 1); + let range = buffer.anchor_after(first_conflict_end.to_next_offset(&buffer)) + ..buffer.anchor_before(second_conflict_start.to_previous_offset(&buffer)); let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); assert_eq!(conflicts_in_range.len(), 0); } @@ -369,7 +374,7 @@ mod tests { .unindent(); let buffer_id = BufferId::new(1).unwrap(); - let buffer = Buffer::new(0, buffer_id, test_content.to_string()); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); @@ -400,7 +405,7 @@ mod tests { >>>>>>> "# .unindent(); let buffer_id = BufferId::new(1).unwrap(); - let buffer = Buffer::new(0, buffer_id, test_content.to_string()); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); @@ -442,7 +447,7 @@ mod tests { .unindent(); let buffer_id = BufferId::new(1).unwrap(); - let buffer = Buffer::new(0, buffer_id, test_content.clone()); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content.clone()); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); @@ -484,7 +489,7 @@ mod tests { cx.update(|cx| { settings::init(cx); WorktreeSettings::register(cx); - ProjectSettings::register(cx); + Project::init_settings(cx); AllLanguageSettings::register(cx); }); let initial_text = " @@ -543,7 +548,7 @@ mod tests { fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( - "a.txt".into(), + repo_path("a.txt"), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, @@ -585,7 +590,7 @@ mod tests { cx.update(|cx| { settings::init(cx); WorktreeSettings::register(cx); - ProjectSettings::register(cx); + Project::init_settings(cx); AllLanguageSettings::register(cx); }); @@ -621,7 +626,7 @@ mod tests { cx.run_until_parked(); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( - "a.txt".into(), + rel_path("a.txt").into(), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, @@ -647,20 +652,20 @@ mod tests { // Simulate the conflict being removed by e.g. staging the file. fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { - state.unmerged_paths.remove(Path::new("a.txt")) + state.unmerged_paths.remove(&repo_path("a.txt")) }) .unwrap(); cx.run_until_parked(); conflict_set.update(cx, |conflict_set, _| { - assert_eq!(conflict_set.has_conflict, false); + assert!(!conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); // Simulate the conflict being re-added. fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( - "a.txt".into(), + repo_path("a.txt"), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, diff --git a/crates/project/src/git_store/git_traversal.rs b/crates/project/src/git_store/git_traversal.rs index bbcffe046debd8ab4529cf2b661abbebefd13f47..ca4a22b14d3682790282744b4834980d669b8d93 100644 --- a/crates/project/src/git_store/git_traversal.rs +++ b/crates/project/src/git_store/git_traversal.rs @@ -3,6 +3,7 @@ use git::{repository::RepoPath, status::GitSummary}; use std::{collections::BTreeMap, ops::Deref, path::Path}; use sum_tree::Cursor; use text::Bias; +use util::rel_path::RelPath; use worktree::{Entry, PathProgress, PathTarget, Traversal}; use super::{RepositoryId, RepositorySnapshot, StatusEntry}; @@ -12,7 +13,10 @@ pub struct GitTraversal<'a> { traversal: Traversal<'a>, current_entry_summary: Option, repo_root_to_snapshot: BTreeMap<&'a Path, &'a RepositorySnapshot>, - repo_location: Option<(RepositoryId, Cursor<'a, StatusEntry, PathProgress<'a>>)>, + repo_location: Option<( + RepositoryId, + Cursor<'a, 'static, StatusEntry, PathProgress<'a>>, + )>, } impl<'a> GitTraversal<'a> { @@ -42,8 +46,8 @@ impl<'a> GitTraversal<'a> { // other_repo/ // .git/ // our_query.txt - let mut query = path.ancestors(); - while let Some(query) = query.next() { + let query = path.ancestors(); + for query in query { let (_, snapshot) = self .repo_root_to_snapshot .range(Path::new("")..=query) @@ -67,10 +71,7 @@ impl<'a> GitTraversal<'a> { return; }; - let Ok(abs_path) = self.traversal.snapshot().absolutize(&entry.path) else { - self.repo_location = None; - return; - }; + let abs_path = self.traversal.snapshot().absolutize(&entry.path); let Some((repo, repo_path)) = self.repo_root_for_path(&abs_path) else { self.repo_location = None; @@ -85,7 +86,7 @@ impl<'a> GitTraversal<'a> { .map(|(prev_repo_id, _)| *prev_repo_id) != Some(repo.id) { - self.repo_location = Some((repo.id, repo.statuses_by_path.cursor::(&()))); + self.repo_location = Some((repo.id, repo.statuses_by_path.cursor::(()))); } let Some((_, statuses)) = &mut self.repo_location else { @@ -94,13 +95,13 @@ impl<'a> GitTraversal<'a> { if entry.is_dir() { let mut statuses = statuses.clone(); - statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left); - let summary = statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left); + statuses.seek_forward(&PathTarget::Path(&repo_path), Bias::Left); + let summary = statuses.summary(&PathTarget::Successor(&repo_path), Bias::Left); self.current_entry_summary = Some(summary); } else if entry.is_file() { // For a file entry, park the cursor on the corresponding status - if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left) { + if statuses.seek_forward(&PathTarget::Path(&repo_path), Bias::Left) { // TODO: Investigate statuses.item() being None here. self.current_entry_summary = statuses.item().map(|item| item.status.into()); } else { @@ -156,7 +157,7 @@ impl<'a> Iterator for GitTraversal<'a> { } pub struct ChildEntriesGitIter<'a> { - parent_path: &'a Path, + parent_path: &'a RelPath, traversal: GitTraversal<'a>, } @@ -164,7 +165,7 @@ impl<'a> ChildEntriesGitIter<'a> { pub fn new( repo_snapshots: &'a HashMap, worktree_snapshot: &'a worktree::Snapshot, - parent_path: &'a Path, + parent_path: &'a RelPath, ) -> Self { let mut traversal = GitTraversal::new( repo_snapshots, @@ -182,11 +183,11 @@ impl<'a> Iterator for ChildEntriesGitIter<'a> { type Item = GitEntryRef<'a>; fn next(&mut self) -> Option { - if let Some(item) = self.traversal.entry() { - if item.path.starts_with(self.parent_path) { - self.traversal.advance_to_sibling(); - return Some(item); - } + if let Some(item) = self.traversal.entry() + && item.path.starts_with(self.parent_path) + { + self.traversal.advance_to_sibling(); + return Some(item); } None } @@ -199,7 +200,7 @@ pub struct GitEntryRef<'a> { } impl GitEntryRef<'_> { - pub fn to_owned(&self) -> GitEntry { + pub fn to_owned(self) -> GitEntry { GitEntry { entry: self.entry.clone(), git_summary: self.git_summary, @@ -211,7 +212,7 @@ impl Deref for GitEntryRef<'_> { type Target = Entry; fn deref(&self) -> &Self::Target { - &self.entry + self.entry } } @@ -262,7 +263,7 @@ mod tests { use gpui::TestAppContext; use serde_json::json; use settings::SettingsStore; - use util::path; + use util::{path, rel_path::rel_path}; const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus { first_head: UnmergedStatusCode::Updated, @@ -309,17 +310,14 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/x/.git")), &[ - (Path::new("x2.txt"), StatusCode::Modified.index()), - (Path::new("z.txt"), StatusCode::Added.index()), + ("x2.txt", StatusCode::Modified.index()), + ("z.txt", StatusCode::Added.index()), ], ); - fs.set_status_for_repo( - Path::new(path!("/root/x/y/.git")), - &[(Path::new("y1.txt"), CONFLICT)], - ); + fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Added.index())], + &[("z2.txt", StatusCode::Added.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; @@ -334,7 +332,7 @@ mod tests { let traversal = GitTraversal::new( &repo_snapshots, - worktree_snapshot.traverse_from_path(true, false, true, Path::new("x")), + worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()), ); let entries = traversal .map(|entry| (entry.path.clone(), entry.git_summary)) @@ -342,13 +340,13 @@ mod tests { pretty_assertions::assert_eq!( entries, [ - (Path::new("x/x1.txt").into(), GitSummary::UNCHANGED), - (Path::new("x/x2.txt").into(), MODIFIED), - (Path::new("x/y/y1.txt").into(), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt").into(), GitSummary::UNCHANGED), - (Path::new("x/z.txt").into(), ADDED), - (Path::new("z/z1.txt").into(), GitSummary::UNCHANGED), - (Path::new("z/z2.txt").into(), ADDED), + (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED), + (rel_path("x/x2.txt").into(), MODIFIED), + (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT), + (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED), + (rel_path("x/z.txt").into(), ADDED), + (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED), + (rel_path("z/z2.txt").into(), ADDED), ] ) } @@ -383,18 +381,15 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/x/.git")), &[ - (Path::new("x2.txt"), StatusCode::Modified.index()), - (Path::new("z.txt"), StatusCode::Added.index()), + ("x2.txt", StatusCode::Modified.index()), + ("z.txt", StatusCode::Added.index()), ], ); - fs.set_status_for_repo( - Path::new(path!("/root/x/y/.git")), - &[(Path::new("y1.txt"), CONFLICT)], - ); + fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Added.index())], + &[("z2.txt", StatusCode::Added.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; @@ -412,18 +407,18 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + ("x/y", GitSummary::CONFLICT), + ("x/y/y1.txt", GitSummary::CONFLICT), + ("x/y/y2.txt", GitSummary::UNCHANGED), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ - (Path::new("z"), ADDED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), ADDED), + ("z", ADDED), + ("z/z1.txt", GitSummary::UNCHANGED), + ("z/z2.txt", ADDED), ], ); @@ -432,9 +427,9 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + ("x", MODIFIED + ADDED), + ("x/y", GitSummary::CONFLICT), + ("x/y/y1.txt", GitSummary::CONFLICT), ], ); @@ -443,13 +438,13 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), - (Path::new("x/x2.txt"), MODIFIED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), - (Path::new("x/z.txt"), ADDED), + ("x", MODIFIED + ADDED), + ("x/x1.txt", GitSummary::UNCHANGED), + ("x/x2.txt", MODIFIED), + ("x/y", GitSummary::CONFLICT), + ("x/y/y1.txt", GitSummary::CONFLICT), + ("x/y/y2.txt", GitSummary::UNCHANGED), + ("x/z.txt", ADDED), ], ); @@ -458,9 +453,9 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new(""), GitSummary::UNCHANGED), - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + ("", GitSummary::UNCHANGED), + ("x", MODIFIED + ADDED), + ("x/x1.txt", GitSummary::UNCHANGED), ], ); @@ -469,17 +464,17 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new(""), GitSummary::UNCHANGED), - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), - (Path::new("x/x2.txt"), MODIFIED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), - (Path::new("x/z.txt"), ADDED), - (Path::new("z"), ADDED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), ADDED), + ("", GitSummary::UNCHANGED), + ("x", MODIFIED + ADDED), + ("x/x1.txt", GitSummary::UNCHANGED), + ("x/x2.txt", MODIFIED), + ("x/y", GitSummary::CONFLICT), + ("x/y/y1.txt", GitSummary::CONFLICT), + ("x/y/y2.txt", GitSummary::UNCHANGED), + ("x/z.txt", ADDED), + ("z", ADDED), + ("z/z1.txt", GitSummary::UNCHANGED), + ("z/z2.txt", ADDED), ], ); } @@ -517,9 +512,9 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/.git")), &[ - (Path::new("a/b/c1.txt"), StatusCode::Added.index()), - (Path::new("a/d/e2.txt"), StatusCode::Modified.index()), - (Path::new("g/h2.txt"), CONFLICT), + ("a/b/c1.txt", StatusCode::Added.index()), + ("a/d/e2.txt", StatusCode::Modified.index()), + ("g/h2.txt", CONFLICT), ], ); @@ -537,9 +532,9 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED), - (Path::new("g"), GitSummary::CONFLICT), - (Path::new("g/h2.txt"), GitSummary::CONFLICT), + ("", GitSummary::CONFLICT + MODIFIED + ADDED), + ("g", GitSummary::CONFLICT), + ("g/h2.txt", GitSummary::CONFLICT), ], ); @@ -547,17 +542,17 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED), - (Path::new("a"), ADDED + MODIFIED), - (Path::new("a/b"), ADDED), - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d"), MODIFIED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f"), GitSummary::UNCHANGED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), - (Path::new("g"), GitSummary::CONFLICT), - (Path::new("g/h2.txt"), GitSummary::CONFLICT), + ("", GitSummary::CONFLICT + ADDED + MODIFIED), + ("a", ADDED + MODIFIED), + ("a/b", ADDED), + ("a/b/c1.txt", ADDED), + ("a/b/c2.txt", GitSummary::UNCHANGED), + ("a/d", MODIFIED), + ("a/d/e2.txt", MODIFIED), + ("f", GitSummary::UNCHANGED), + ("f/no-status.txt", GitSummary::UNCHANGED), + ("g", GitSummary::CONFLICT), + ("g/h2.txt", GitSummary::CONFLICT), ], ); @@ -565,15 +560,15 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new("a/b"), ADDED), - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d"), MODIFIED), - (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f"), GitSummary::UNCHANGED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), - (Path::new("g"), GitSummary::CONFLICT), + ("a/b", ADDED), + ("a/b/c1.txt", ADDED), + ("a/b/c2.txt", GitSummary::UNCHANGED), + ("a/d", MODIFIED), + ("a/d/e1.txt", GitSummary::UNCHANGED), + ("a/d/e2.txt", MODIFIED), + ("f", GitSummary::UNCHANGED), + ("f/no-status.txt", GitSummary::UNCHANGED), + ("g", GitSummary::CONFLICT), ], ); @@ -581,11 +576,11 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + ("a/b/c1.txt", ADDED), + ("a/b/c2.txt", GitSummary::UNCHANGED), + ("a/d/e1.txt", GitSummary::UNCHANGED), + ("a/d/e2.txt", MODIFIED), + ("f/no-status.txt", GitSummary::UNCHANGED), ], ); } @@ -618,18 +613,18 @@ mod tests { fs.set_status_for_repo( Path::new(path!("/root/x/.git")), - &[(Path::new("x1.txt"), StatusCode::Added.index())], + &[("x1.txt", StatusCode::Added.index())], ); fs.set_status_for_repo( Path::new(path!("/root/y/.git")), &[ - (Path::new("y1.txt"), CONFLICT), - (Path::new("y2.txt"), StatusCode::Modified.index()), + ("y1.txt", CONFLICT), + ("y2.txt", StatusCode::Modified.index()), ], ); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Modified.index())], + &[("z2.txt", StatusCode::Modified.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; @@ -645,47 +640,44 @@ mod tests { check_git_statuses( &repo_snapshots, &worktree_snapshot, - &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], + &[("x", ADDED), ("x/x1.txt", ADDED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ - (Path::new("y"), GitSummary::CONFLICT + MODIFIED), - (Path::new("y/y1.txt"), GitSummary::CONFLICT), - (Path::new("y/y2.txt"), MODIFIED), + ("y", GitSummary::CONFLICT + MODIFIED), + ("y/y1.txt", GitSummary::CONFLICT), + ("y/y2.txt", MODIFIED), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, - &[ - (Path::new("z"), MODIFIED), - (Path::new("z/z2.txt"), MODIFIED), - ], + &[("z", MODIFIED), ("z/z2.txt", MODIFIED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, - &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], + &[("x", ADDED), ("x/x1.txt", ADDED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ - (Path::new("x"), ADDED), - (Path::new("x/x1.txt"), ADDED), - (Path::new("x/x2.txt"), GitSummary::UNCHANGED), - (Path::new("y"), GitSummary::CONFLICT + MODIFIED), - (Path::new("y/y1.txt"), GitSummary::CONFLICT), - (Path::new("y/y2.txt"), MODIFIED), - (Path::new("z"), MODIFIED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), MODIFIED), + ("x", ADDED), + ("x/x1.txt", ADDED), + ("x/x2.txt", GitSummary::UNCHANGED), + ("y", GitSummary::CONFLICT + MODIFIED), + ("y/y1.txt", GitSummary::CONFLICT), + ("y/y2.txt", MODIFIED), + ("z", MODIFIED), + ("z/z1.txt", GitSummary::UNCHANGED), + ("z/z2.txt", MODIFIED), ], ); } @@ -719,7 +711,7 @@ mod tests { .await; fs.set_head_and_index_for_repo( path!("/root/.git").as_ref(), - &[("a.txt".into(), "".into()), ("b/c.txt".into(), "".into())], + &[("a.txt", "".into()), ("b/c.txt", "".into())], ); cx.run_until_parked(); @@ -754,10 +746,7 @@ mod tests { // detected. fs.set_head_for_repo( path!("/root/.git").as_ref(), - &[ - ("a.txt".into(), "".into()), - ("b/c.txt".into(), "something-else".into()), - ], + &[("a.txt", "".into()), ("b/c.txt", "something-else".into())], "deadbeef", ); cx.executor().run_until_parked(); @@ -774,9 +763,9 @@ mod tests { &repo_snapshots, &worktree_snapshot, &[ - (Path::new(""), MODIFIED), - (Path::new("a.txt"), GitSummary::UNCHANGED), - (Path::new("b/c.txt"), MODIFIED), + ("", MODIFIED), + ("a.txt", GitSummary::UNCHANGED), + ("b/c.txt", MODIFIED), ], ); } @@ -785,17 +774,17 @@ mod tests { fn check_git_statuses( repo_snapshots: &HashMap, worktree_snapshot: &worktree::Snapshot, - expected_statuses: &[(&Path, GitSummary)], + expected_statuses: &[(&str, GitSummary)], ) { let mut traversal = GitTraversal::new( repo_snapshots, - worktree_snapshot.traverse_from_path(true, true, false, "".as_ref()), + worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()), ); let found_statuses = expected_statuses .iter() .map(|&(path, _)| { let git_entry = traversal - .find(|git_entry| &*git_entry.path == path) + .find(|git_entry| git_entry.path.as_ref() == rel_path(path)) .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}")); (path, git_entry.git_summary) }) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 79f134b91a36a2f7d1f3f256506931b47ae8cf9c..8fcf9c8a6172f866d819e34cbf3b0b4810a8fc8d 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -13,10 +13,9 @@ use image::{ExtendedColorType, GenericImageView, ImageReader}; use language::{DiskState, File}; use rpc::{AnyProtoClient, ErrorExt as _}; use std::num::NonZeroU64; -use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; -use std::{ffi::OsStr, path::PathBuf}; -use util::ResultExt; +use util::{ResultExt, rel_path::RelPath}; use worktree::{LoadedBinaryFile, PathChange, Worktree}; #[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)] @@ -207,8 +206,7 @@ pub fn is_image_file(project: &Entity, path: &ProjectPath, cx: &App) -> .abs_path(); path.path .extension() - .or_else(|| worktree_abs_path.extension()) - .and_then(OsStr::to_str) + .or_else(|| worktree_abs_path.extension()?.to_str()) .map(str::to_lowercase) }); @@ -224,7 +222,7 @@ impl ProjectItem for ImageItem { path: &ProjectPath, cx: &mut App, ) -> Option>>> { - if is_image_file(&project, &path, cx) { + if is_image_file(project, path, cx) { Some(cx.spawn({ let path = path.clone(); let project = project.clone(); @@ -244,7 +242,7 @@ impl ProjectItem for ImageItem { } fn project_path(&self, cx: &App) -> Option { - Some(self.project_path(cx).clone()) + Some(self.project_path(cx)) } fn is_dirty(&self) -> bool { @@ -255,7 +253,7 @@ impl ProjectItem for ImageItem { trait ImageStoreImpl { fn open_image( &self, - path: Arc, + path: Arc, worktree: Entity, cx: &mut Context, ) -> Task>>; @@ -375,7 +373,6 @@ impl ImageStore { let (mut tx, rx) = postage::watch::channel(); entry.insert(rx.clone()); - let project_path = project_path.clone(); let load_image = self .state .open_image(project_path.path.clone(), worktree, cx); @@ -446,15 +443,12 @@ impl ImageStore { event: &ImageItemEvent, cx: &mut Context, ) { - match event { - ImageItemEvent::FileHandleChanged => { - if let Some(local) = self.state.as_local() { - local.update(cx, |local, cx| { - local.image_changed_file(image, cx); - }) - } - } - _ => {} + if let ImageItemEvent::FileHandleChanged = event + && let Some(local) = self.state.as_local() + { + local.update(cx, |local, cx| { + local.image_changed_file(image, cx); + }) } } } @@ -462,7 +456,7 @@ impl ImageStore { impl ImageStoreImpl for Entity { fn open_image( &self, - path: Arc, + path: Arc, worktree: Entity, cx: &mut Context, ) -> Task>> { @@ -531,13 +525,10 @@ impl ImageStoreImpl for Entity { impl LocalImageStore { fn subscribe_to_worktree(&mut self, worktree: &Entity, cx: &mut Context) { cx.subscribe(worktree, |this, worktree, event, cx| { - if worktree.read(cx).is_local() { - match event { - worktree::Event::UpdatedEntries(changes) => { - this.local_worktree_entries_changed(&worktree, changes, cx); - } - _ => {} - } + if worktree.read(cx).is_local() + && let worktree::Event::UpdatedEntries(changes) = event + { + this.local_worktree_entries_changed(&worktree, changes, cx); } }) .detach(); @@ -546,7 +537,7 @@ impl LocalImageStore { fn local_worktree_entries_changed( &mut self, worktree_handle: &Entity, - changes: &[(Arc, ProjectEntryId, PathChange)], + changes: &[(Arc, ProjectEntryId, PathChange)], cx: &mut Context, ) { let snapshot = worktree_handle.read(cx).snapshot(); @@ -558,7 +549,7 @@ impl LocalImageStore { fn local_worktree_entry_changed( &mut self, entry_id: ProjectEntryId, - path: &Arc, + path: &Arc, worktree: &Entity, snapshot: &worktree::Snapshot, cx: &mut Context, @@ -696,6 +687,7 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { image::ImageFormat::Gif => gpui::ImageFormat::Gif, image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, format => anyhow::bail!("Image format {format:?} not supported"), }, content, @@ -705,7 +697,7 @@ fn create_gpui_image(content: Vec) -> anyhow::Result> { impl ImageStoreImpl for Entity { fn open_image( &self, - _path: Arc, + _path: Arc, _worktree: Entity, _cx: &mut Context, ) -> Task>> { @@ -736,7 +728,7 @@ mod tests { use gpui::TestAppContext; use serde_json::json; use settings::SettingsStore; - use std::path::PathBuf; + use util::rel_path::rel_path; pub fn init_test(cx: &mut TestAppContext) { zlog::init_test(); @@ -775,7 +767,7 @@ mod tests { let project_path = ProjectPath { worktree_id, - path: PathBuf::from("image_1.png").into(), + path: rel_path("image_1.png").into(), }; let (task1, task2) = project.update(cx, |project, cx| { diff --git a/crates/project/src/invalid_item_view.rs b/crates/project/src/invalid_item_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..fdcdd16a69ce73d8471f8387d55cf91576f114af --- /dev/null +++ b/crates/project/src/invalid_item_view.rs @@ -0,0 +1,118 @@ +use std::{path::Path, sync::Arc}; + +use gpui::{EventEmitter, FocusHandle, Focusable}; +use ui::{ + App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement, + KeyBinding, Label, LabelCommon, LabelSize, ParentElement, Render, SharedString, Styled as _, + Window, h_flex, v_flex, +}; +use zed_actions::workspace::OpenWithSystem; + +use crate::Item; + +/// A view to display when a certain buffer fails to open. +#[derive(Debug)] +pub struct InvalidItemView { + /// Which path was attempted to open. + pub abs_path: Arc, + /// An error message, happened when opening the buffer. + pub error: SharedString, + is_local: bool, + focus_handle: FocusHandle, +} + +impl InvalidItemView { + pub fn new( + abs_path: &Path, + is_local: bool, + e: &anyhow::Error, + _: &mut Window, + cx: &mut App, + ) -> Self { + Self { + is_local, + abs_path: Arc::from(abs_path), + error: format!("{}", e.root_cause()).into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Item for InvalidItemView { + type Event = (); + + fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString { + // Ensure we always render at least the filename. + detail += 1; + + let path = self.abs_path.as_ref(); + + let mut prefix = path; + while detail > 0 { + if let Some(parent) = prefix.parent() { + prefix = parent; + detail -= 1; + } else { + break; + } + } + + let path = if detail > 0 { + path + } else { + path.strip_prefix(prefix).unwrap_or(path) + }; + + SharedString::new(path.to_string_lossy()) + } +} + +impl EventEmitter<()> for InvalidItemView {} + +impl Focusable for InvalidItemView { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InvalidItemView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { + let abs_path = self.abs_path.clone(); + v_flex() + .size_full() + .track_focus(&self.focus_handle(cx)) + .flex_none() + .justify_center() + .overflow_hidden() + .key_context("InvalidBuffer") + .child( + h_flex().size_full().justify_center().child( + v_flex() + .justify_center() + .gap_2() + .child(h_flex().justify_center().child("Could not open file")) + .child( + h_flex() + .justify_center() + .child(Label::new(self.error.clone()).size(LabelSize::Small)), + ) + .when(self.is_local, |contents| { + contents.child( + h_flex().justify_center().child( + Button::new("open-with-system", "Open in Default App") + .on_click(move |_, _, cx| { + cx.open_with_system(&abs_path); + }) + .style(ButtonStyle::Outlined) + .key_binding(KeyBinding::for_action( + &OpenWithSystem, + window, + cx, + )), + ), + ) + }), + ), + ) + } +} diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index c458b6b300c34ec03d144cf297277faf4a94f5db..89b3315272b137e507a65df19f98ac28aa194d6a 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -16,8 +16,8 @@ use collections::{HashMap, HashSet}; use futures::future; use gpui::{App, AsyncApp, Entity, Task}; use language::{ - Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, OffsetRangeExt, PointUtf16, - ToOffset, ToPointUtf16, Transaction, Unclipped, + Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, + OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, language_settings::{InlayHintKind, LanguageSettings, language_settings}, point_from_lsp, point_to_lsp, proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version}, @@ -26,8 +26,8 @@ use language::{ use lsp::{ AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription, CompletionContext, CompletionListItemDefaultsEditRange, CompletionTriggerKind, - DocumentHighlightKind, LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities, - OneOf, RenameOptions, ServerCapabilities, + DiagnosticServerCapabilities, DocumentHighlightKind, LanguageServer, LanguageServerId, + LinkedEditingRangeServerCapabilities, OneOf, RenameOptions, ServerCapabilities, }; use serde_json::Value; use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; @@ -50,8 +50,8 @@ pub fn lsp_formatting_options(settings: &LanguageSettings) -> lsp::FormattingOpt } } -pub fn file_path_to_lsp_url(path: &Path) -> Result { - match lsp::Url::from_file_path(path) { +pub fn file_path_to_lsp_url(path: &Path) -> Result { + match lsp::Uri::from_file_path(path) { Ok(url) => Ok(url), Err(()) => anyhow::bail!("Invalid file path provided to LSP request: {path:?}"), } @@ -234,7 +234,7 @@ pub(crate) struct OnTypeFormatting { pub push_to_history: bool, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct InlayHints { pub range: Range, } @@ -262,6 +262,9 @@ pub(crate) struct LinkedEditingRange { #[derive(Clone, Debug)] pub(crate) struct GetDocumentDiagnostics { + /// We cannot blindly rely on server's capabilities.diagnostic_provider, as they're a singular field, whereas + /// a server can register multiple diagnostic providers post-mortem. + pub dynamic_caps: DiagnosticServerCapabilities, pub previous_result_id: Option, } @@ -332,9 +335,9 @@ impl LspCommand for PrepareRename { _: Entity, buffer: Entity, _: LanguageServerId, - mut cx: AsyncApp, + cx: AsyncApp, ) -> Result { - buffer.read_with(&mut cx, |buffer, _| match message { + buffer.read_with(&cx, |buffer, _| match message { Some(lsp::PrepareRenameResponse::Range(range)) | Some(lsp::PrepareRenameResponse::RangeWithPlaceholder { range, .. }) => { let Range { start, end } = range_from_lsp(range); @@ -350,7 +353,7 @@ impl LspCommand for PrepareRename { } Some(lsp::PrepareRenameResponse::DefaultBehavior { .. }) => { let snapshot = buffer.snapshot(); - let (range, _) = snapshot.surrounding_word(self.position, false); + let (range, _) = snapshot.surrounding_word(self.position, None); let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end); Ok(PrepareRenameResponse::Success(range)) } @@ -386,7 +389,7 @@ impl LspCommand for PrepareRename { .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -500,13 +503,12 @@ impl LspCommand for PerformRename { mut cx: AsyncApp, ) -> Result { if let Some(edit) = message { - let (lsp_adapter, lsp_server) = + let (_, lsp_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; LocalLspStore::deserialize_workspace_edit( lsp_store, edit, self.push_to_history, - lsp_adapter, lsp_server, &mut cx, ) @@ -544,7 +546,7 @@ impl LspCommand for PerformRename { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, new_name: message.new_name, push_to_history: false, }) @@ -659,7 +661,7 @@ impl LspCommand for GetDefinitions { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -762,7 +764,7 @@ impl LspCommand for GetDeclarations { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -864,7 +866,7 @@ impl LspCommand for GetImplementations { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -963,7 +965,7 @@ impl LspCommand for GetTypeDefinitions { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -1116,18 +1118,12 @@ pub async fn location_links_from_lsp( } } - let (lsp_adapter, language_server) = - language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; + let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { - this.open_local_buffer_via_lsp( - target_uri, - language_server.server_id(), - lsp_adapter.name.clone(), - cx, - ) + this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) })? .await?; @@ -1172,8 +1168,7 @@ pub async fn location_link_from_lsp( server_id: LanguageServerId, cx: &mut AsyncApp, ) -> Result { - let (lsp_adapter, language_server) = - language_server_for_buffer(&lsp_store, &buffer, server_id, cx)?; + let (_, language_server) = language_server_for_buffer(lsp_store, buffer, server_id, cx)?; let (origin_range, target_uri, target_range) = ( link.origin_selection_range, @@ -1183,12 +1178,7 @@ pub async fn location_link_from_lsp( let target_buffer_handle = lsp_store .update(cx, |lsp_store, cx| { - lsp_store.open_local_buffer_via_lsp( - target_uri, - language_server.server_id(), - lsp_adapter.name.clone(), - cx, - ) + lsp_store.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) })? .await?; @@ -1326,7 +1316,7 @@ impl LspCommand for GetReferences { mut cx: AsyncApp, ) -> Result> { let mut references = Vec::new(); - let (lsp_adapter, language_server) = + let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; if let Some(locations) = locations { @@ -1336,7 +1326,6 @@ impl LspCommand for GetReferences { lsp_store.open_local_buffer_via_lsp( lsp_location.uri, language_server.server_id(), - lsp_adapter.name.clone(), cx, ) })? @@ -1344,7 +1333,7 @@ impl LspCommand for GetReferences { target_buffer_handle .clone() - .read_with(&mut cx, |target_buffer, _| { + .read_with(&cx, |target_buffer, _| { let target_start = target_buffer .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); let target_end = target_buffer @@ -1388,7 +1377,7 @@ impl LspCommand for GetReferences { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -1498,9 +1487,9 @@ impl LspCommand for GetDocumentHighlights { _: Entity, buffer: Entity, _: LanguageServerId, - mut cx: AsyncApp, + cx: AsyncApp, ) -> Result> { - buffer.read_with(&mut cx, |buffer, _| { + buffer.read_with(&cx, |buffer, _| { let mut lsp_highlights = lsp_highlights.unwrap_or_default(); lsp_highlights.sort_unstable_by_key(|h| (h.range.start, Reverse(h.range.end))); lsp_highlights @@ -1548,7 +1537,7 @@ impl LspCommand for GetDocumentHighlights { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -1848,13 +1837,20 @@ impl LspCommand for GetSignatureHelp { message: Option, lsp_store: Entity, _: Entity, - _: LanguageServerId, + id: LanguageServerId, cx: AsyncApp, ) -> Result { let Some(message) = message else { return Ok(None); }; - cx.update(|cx| SignatureHelp::new(message, Some(lsp_store.read(cx).languages.clone()), cx)) + cx.update(|cx| { + SignatureHelp::new( + message, + Some(lsp_store.read(cx).languages.clone()), + Some(id), + cx, + ) + }) } fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest { @@ -1879,7 +1875,7 @@ impl LspCommand for GetSignatureHelp { })? .await .with_context(|| format!("waiting for version for buffer {}", buffer.entity_id()))?; - let buffer_snapshot = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot())?; + let buffer_snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; Ok(Self { position: payload .position @@ -1914,7 +1910,12 @@ impl LspCommand for GetSignatureHelp { .signature_help .map(proto_to_lsp_signature) .and_then(|signature| { - SignatureHelp::new(signature, Some(lsp_store.read(cx).languages.clone()), cx) + SignatureHelp::new( + signature, + Some(lsp_store.read(cx).languages.clone()), + None, + cx, + ) }) }) } @@ -1961,13 +1962,13 @@ impl LspCommand for GetHover { _: Entity, buffer: Entity, _: LanguageServerId, - mut cx: AsyncApp, + cx: AsyncApp, ) -> Result { let Some(hover) = message else { return Ok(None); }; - let (language, range) = buffer.read_with(&mut cx, |buffer, _| { + let (language, range) = buffer.read_with(&cx, |buffer, _| { ( buffer.language().cloned(), hover.range.map(|range| { @@ -2053,7 +2054,7 @@ impl LspCommand for GetHover { })? .await?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -2127,7 +2128,7 @@ impl LspCommand for GetHover { return Ok(None); } - let language = buffer.read_with(&mut cx, |buffer, _| buffer.language().cloned())?; + let language = buffer.read_with(&cx, |buffer, _| buffer.language().cloned())?; let range = if let (Some(start), Some(end)) = (message.start, message.end) { language::proto::deserialize_anchor(start) .and_then(|start| language::proto::deserialize_anchor(end).map(|end| start..end)) @@ -2222,7 +2223,7 @@ impl LspCommand for GetCompletions { let unfiltered_completions_count = completions.len(); let language_server_adapter = lsp_store - .read_with(&mut cx, |lsp_store, _| { + .read_with(&cx, |lsp_store, _| { lsp_store.language_server_adapter_for_id(server_id) })? .with_context(|| format!("no language server with id {server_id}"))?; @@ -2242,7 +2243,7 @@ impl LspCommand for GetCompletions { let lsp_edit = lsp_completion.text_edit.clone().or_else(|| { let default_text_edit = lsp_defaults.as_deref()?.edit_range.as_ref()?; let new_text = lsp_completion - .insert_text + .text_edit_text .as_ref() .unwrap_or(&lsp_completion.label) .clone(); @@ -2307,7 +2308,10 @@ impl LspCommand for GetCompletions { range_for_token .get_or_insert_with(|| { let offset = self.position.to_offset(&snapshot); - let (range, kind) = snapshot.surrounding_word(offset, true); + let (range, kind) = snapshot.surrounding_word( + offset, + Some(CharScopeContext::Completion), + ); let range = if kind == Some(CharKind::Word) { range } else { @@ -2355,15 +2359,14 @@ impl LspCommand for GetCompletions { .zip(completion_edits) .map(|(mut lsp_completion, mut edit)| { LineEnding::normalize(&mut edit.new_text); - if lsp_completion.data.is_none() { - if let Some(default_data) = lsp_defaults + if lsp_completion.data.is_none() + && let Some(default_data) = lsp_defaults .as_ref() .and_then(|item_defaults| item_defaults.data.clone()) - { - // Servers (e.g. JDTLS) prefer unchanged completions, when resolving the items later, - // so we do not insert the defaults here, but `data` is needed for resolving, so this is an exception. - lsp_completion.data = Some(default_data); - } + { + // Servers (e.g. JDTLS) prefer unchanged completions, when resolving the items later, + // so we do not insert the defaults here, but `data` is needed for resolving, so this is an exception. + lsp_completion.data = Some(default_data); } CoreCompletion { replace_range: edit.replace_range, @@ -2409,7 +2412,7 @@ impl LspCommand for GetCompletions { .position .and_then(language::proto::deserialize_anchor) .map(|p| { - buffer.read_with(&mut cx, |buffer, _| { + buffer.read_with(&cx, |buffer, _| { buffer.clip_point_utf16(Unclipped(p.to_point_utf16(buffer)), Bias::Left) }) }) @@ -2516,8 +2519,8 @@ pub(crate) fn parse_completion_text_edit( }; Some(ParsedCompletionEdit { - insert_range: insert_range, - replace_range: replace_range, + insert_range, + replace_range, new_text: new_text.clone(), }) } @@ -2610,11 +2613,9 @@ impl LspCommand for GetCodeActions { server_id: LanguageServerId, cx: AsyncApp, ) -> Result> { - let requested_kinds_set = if let Some(kinds) = self.kinds { - Some(kinds.into_iter().collect::>()) - } else { - None - }; + let requested_kinds_set = self + .kinds + .map(|kinds| kinds.into_iter().collect::>()); let language_server = cx.update(|cx| { lsp_store @@ -2637,10 +2638,10 @@ impl LspCommand for GetCodeActions { .filter_map(|entry| { let (lsp_action, resolved) = match entry { lsp::CodeActionOrCommand::CodeAction(lsp_action) => { - if let Some(command) = lsp_action.command.as_ref() { - if !available_commands.contains(&command.command) { - return None; - } + if let Some(command) = lsp_action.command.as_ref() + && !available_commands.contains(&command.command) + { + return None; } (LspAction::Action(Box::new(lsp_action)), false) } @@ -2655,10 +2656,9 @@ impl LspCommand for GetCodeActions { if let Some((requested_kinds, kind)) = requested_kinds_set.as_ref().zip(lsp_action.action_kind()) + && !requested_kinds.contains(&kind) { - if !requested_kinds.contains(&kind) { - return None; - } + return None; } Some(CodeAction { @@ -2755,7 +2755,7 @@ impl GetCodeActions { Some(lsp::CodeActionProviderCapability::Options(CodeActionOptions { code_action_kinds: Some(supported_action_kinds), .. - })) => Some(supported_action_kinds.clone()), + })) => Some(supported_action_kinds), _ => capabilities.code_action_kinds, } } @@ -2878,7 +2878,7 @@ impl LspCommand for OnTypeFormatting { })?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, trigger: message.trigger.clone(), options, push_to_history: false, @@ -3153,7 +3153,7 @@ impl InlayHints { Some(((uri, range), server_id)) => Some(( LanguageServerId(server_id as usize), lsp::Location { - uri: lsp::Url::parse(&uri) + uri: lsp::Uri::from_str(&uri) .context("invalid uri in hint part {part:?}")?, range: lsp::Range::new( point_to_lsp(PointUtf16::new( @@ -3462,10 +3462,7 @@ impl LspCommand for GetCodeLens { capabilities .server_capabilities .code_lens_provider - .as_ref() - .map_or(false, |code_lens_options| { - code_lens_options.resolve_provider.unwrap_or(false) - }) + .is_some() } fn to_lsp( @@ -3490,9 +3487,9 @@ impl LspCommand for GetCodeLens { lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, - mut cx: AsyncApp, + cx: AsyncApp, ) -> anyhow::Result> { - let snapshot = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot())?; + let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; let language_server = cx.update(|cx| { lsp_store .read(cx) @@ -3754,7 +3751,7 @@ impl GetDocumentDiagnostics { .filter_map(|diagnostics| { Some(LspPullDiagnostics::Response { server_id: LanguageServerId::from_proto(diagnostics.server_id), - uri: lsp::Url::from_str(diagnostics.uri.as_str()).log_err()?, + uri: lsp::Uri::from_str(diagnostics.uri.as_str()).log_err()?, diagnostics: if diagnostics.changed { PulledDiagnostics::Unchanged { result_id: diagnostics.result_id?, @@ -3809,9 +3806,9 @@ impl GetDocumentDiagnostics { start: point_to_lsp(PointUtf16::new(start.row, start.column)), end: point_to_lsp(PointUtf16::new(end.row, end.column)), }, - uri: lsp::Url::parse(&info.location_url.unwrap()).unwrap(), + uri: lsp::Uri::from_str(&info.location_url.unwrap()).unwrap(), }, - message: info.message.clone(), + message: info.message, } }) .collect::>(); @@ -3839,12 +3836,11 @@ impl GetDocumentDiagnostics { _ => None, }, code, - code_description: match diagnostic.code_description { - Some(code_description) => Some(CodeDescription { - href: Some(lsp::Url::parse(&code_description).unwrap()), + code_description: diagnostic + .code_description + .map(|code_description| CodeDescription { + href: Some(lsp::Uri::from_str(&code_description).unwrap()), }), - None => None, - }, related_information: Some(related_information), tags: Some(tags), source: diagnostic.source.clone(), @@ -3983,7 +3979,7 @@ pub struct WorkspaceLspPullDiagnostics { } fn process_full_workspace_diagnostics_report( - diagnostics: &mut HashMap, + diagnostics: &mut HashMap, server_id: LanguageServerId, report: lsp::WorkspaceFullDocumentDiagnosticReport, ) { @@ -4006,7 +4002,7 @@ fn process_full_workspace_diagnostics_report( } fn process_unchanged_workspace_diagnostics_report( - diagnostics: &mut HashMap, + diagnostics: &mut HashMap, server_id: LanguageServerId, report: lsp::WorkspaceUnchangedDocumentDiagnosticReport, ) { @@ -4038,26 +4034,22 @@ impl LspCommand for GetDocumentDiagnostics { "Get diagnostics" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { - server_capabilities - .server_capabilities - .diagnostic_provider - .is_some() + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true } fn to_lsp( &self, path: &Path, _: &Buffer, - language_server: &Arc, + _: &Arc, _: &App, ) -> Result { - let identifier = match language_server.capabilities().diagnostic_provider { - Some(lsp::DiagnosticServerCapabilities::Options(options)) => options.identifier, - Some(lsp::DiagnosticServerCapabilities::RegistrationOptions(options)) => { - options.diagnostic_options.identifier + let identifier = match &self.dynamic_caps { + lsp::DiagnosticServerCapabilities::Options(options) => options.identifier.clone(), + lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => { + options.diagnostic_options.identifier.clone() } - None => None, }; Ok(lsp::DocumentDiagnosticParams { @@ -4365,9 +4357,9 @@ impl LspCommand for GetDocumentColor { } fn process_related_documents( - diagnostics: &mut HashMap, + diagnostics: &mut HashMap, server_id: LanguageServerId, - documents: impl IntoIterator, + documents: impl IntoIterator, ) { for (url, report_kind) in documents { match report_kind { @@ -4382,9 +4374,9 @@ fn process_related_documents( } fn process_unchanged_diagnostics_report( - diagnostics: &mut HashMap, + diagnostics: &mut HashMap, server_id: LanguageServerId, - uri: lsp::Url, + uri: lsp::Uri, report: lsp::UnchangedDocumentDiagnosticReport, ) { let result_id = report.result_id; @@ -4426,9 +4418,9 @@ fn process_unchanged_diagnostics_report( } fn process_full_diagnostics_report( - diagnostics: &mut HashMap, + diagnostics: &mut HashMap, server_id: LanguageServerId, - uri: lsp::Url, + uri: lsp::Uri, report: lsp::FullDocumentDiagnosticReport, ) { let result_id = report.result_id; @@ -4509,9 +4501,8 @@ mod tests { data: Some(json!({"detail": "test detail"})), }; - let proto_diagnostic = - GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic.clone()) - .expect("Failed to serialize diagnostic"); + let proto_diagnostic = GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic) + .expect("Failed to serialize diagnostic"); let start = proto_diagnostic.start.unwrap(); let end = proto_diagnostic.end.unwrap(); @@ -4563,7 +4554,7 @@ mod tests { fn test_related_information() { let related_info = lsp::DiagnosticRelatedInformation { location: lsp::Location { - uri: lsp::Url::parse("file:///test.rs").unwrap(), + uri: lsp::Uri::from_str("file:///test.rs").unwrap(), range: lsp::Range { start: lsp::Position::new(1, 1), end: lsp::Position::new(1, 5), diff --git a/crates/project/src/lsp_command/signature_help.rs b/crates/project/src/lsp_command/signature_help.rs index 8adb69ac7726becada3f6123f9f350237e2aa22e..6a499311837b8ebd70874c89d9fac223b3c8ede1 100644 --- a/crates/project/src/lsp_command/signature_help.rs +++ b/crates/project/src/lsp_command/signature_help.rs @@ -2,8 +2,10 @@ use std::{ops::Range, sync::Arc}; use gpui::{App, AppContext, Entity, FontWeight, HighlightStyle, SharedString}; use language::LanguageRegistry; +use lsp::LanguageServerId; use markdown::Markdown; use rpc::proto::{self, documentation}; +use util::maybe; #[derive(Debug)] pub struct SignatureHelp { @@ -31,6 +33,7 @@ impl SignatureHelp { pub fn new( help: lsp::SignatureHelp, language_registry: Option>, + lang_server_id: Option, cx: &mut App, ) -> Option { if help.signatures.is_empty() { @@ -39,6 +42,7 @@ impl SignatureHelp { let active_signature = help.active_signature.unwrap_or(0) as usize; let mut signatures = Vec::::with_capacity(help.signatures.capacity()); for signature in &help.signatures { + let label = SharedString::from(signature.label.clone()); let active_parameter = signature .active_parameter .unwrap_or_else(|| help.active_parameter.unwrap_or(0)) @@ -49,39 +53,53 @@ impl SignatureHelp { if let Some(parameters) = &signature.parameters { for (index, parameter) in parameters.iter().enumerate() { let label_range = match ¶meter.label { - lsp::ParameterLabel::LabelOffsets(parameter_label_offsets) => { - let range = *parameter_label_offsets.get(0)? as usize - ..*parameter_label_offsets.get(1)? as usize; - if index == active_parameter { - highlights.push(( - range.clone(), - HighlightStyle { - font_weight: Some(FontWeight::EXTRA_BOLD), - ..HighlightStyle::default() - }, - )); - } - Some(range) + &lsp::ParameterLabel::LabelOffsets([offset1, offset2]) => { + maybe!({ + let offset1 = offset1 as usize; + let offset2 = offset2 as usize; + if offset1 < offset2 { + let mut indices = label.char_indices().scan( + 0, + |utf16_offset_acc, (offset, c)| { + let utf16_offset = *utf16_offset_acc; + *utf16_offset_acc += c.len_utf16(); + Some((utf16_offset, offset)) + }, + ); + let (_, offset1) = indices + .find(|(utf16_offset, _)| *utf16_offset == offset1)?; + let (_, offset2) = indices + .find(|(utf16_offset, _)| *utf16_offset == offset2)?; + Some(offset1..offset2) + } else { + log::warn!( + "language server {lang_server_id:?} produced invalid parameter label range: {offset1:?}..{offset2:?}", + ); + None + } + }) } lsp::ParameterLabel::Simple(parameter_label) => { if let Some(start) = signature.label.find(parameter_label) { - let range = start..start + parameter_label.len(); - if index == active_parameter { - highlights.push(( - range.clone(), - HighlightStyle { - font_weight: Some(FontWeight::EXTRA_BOLD), - ..HighlightStyle::default() - }, - )); - } - Some(range) + Some(start..start + parameter_label.len()) } else { None } } }; + if let Some(label_range) = &label_range + && index == active_parameter + { + highlights.push(( + label_range.clone(), + HighlightStyle { + font_weight: Some(FontWeight::EXTRA_BOLD), + ..HighlightStyle::default() + }, + )); + } + let documentation = parameter .documentation .as_ref() @@ -94,7 +112,6 @@ impl SignatureHelp { } } - let label = SharedString::from(signature.label.clone()); let documentation = signature .documentation .as_ref() @@ -290,7 +307,7 @@ mod tests { active_signature: Some(0), active_parameter: Some(0), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -336,7 +353,7 @@ mod tests { active_signature: Some(0), active_parameter: Some(1), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -396,7 +413,7 @@ mod tests { active_signature: Some(0), active_parameter: Some(0), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -449,7 +466,7 @@ mod tests { active_signature: Some(1), active_parameter: Some(0), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -502,7 +519,7 @@ mod tests { active_signature: Some(1), active_parameter: Some(1), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -555,7 +572,7 @@ mod tests { active_signature: Some(1), active_parameter: None, }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -623,7 +640,7 @@ mod tests { active_signature: Some(2), active_parameter: Some(1), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -645,7 +662,7 @@ mod tests { active_signature: None, active_parameter: None, }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_none()); } @@ -670,7 +687,7 @@ mod tests { active_signature: Some(0), active_parameter: Some(0), }; - let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_markdown = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_markdown.is_some()); let markdown = maybe_markdown.unwrap(); @@ -708,7 +725,8 @@ mod tests { active_signature: Some(0), active_parameter: Some(0), }; - let maybe_signature_help = cx.update(|cx| SignatureHelp::new(signature_help, None, cx)); + let maybe_signature_help = + cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); assert!(maybe_signature_help.is_some()); let signature_help = maybe_signature_help.unwrap(); @@ -736,4 +754,40 @@ mod tests { // Check that the active parameter is correct assert_eq!(signature.active_parameter, Some(0)); } + + #[gpui::test] + fn test_create_signature_help_implements_utf16_spec(cx: &mut TestAppContext) { + let signature_help = lsp::SignatureHelp { + signatures: vec![lsp::SignatureInformation { + label: "fn test(🦀: u8, 🦀: &str)".to_string(), + documentation: None, + parameters: Some(vec![ + lsp::ParameterInformation { + label: lsp::ParameterLabel::LabelOffsets([8, 10]), + documentation: None, + }, + lsp::ParameterInformation { + label: lsp::ParameterLabel::LabelOffsets([16, 18]), + documentation: None, + }, + ]), + active_parameter: None, + }], + active_signature: Some(0), + active_parameter: Some(0), + }; + let signature_help = cx.update(|cx| SignatureHelp::new(signature_help, None, None, cx)); + assert!(signature_help.is_some()); + + let markdown = signature_help.unwrap(); + let signature = markdown.signatures[markdown.active_signature].clone(); + let markdown = (signature.label, signature.highlights); + assert_eq!( + markdown, + ( + SharedString::new("fn test(🦀: u8, 🦀: &str)"), + vec![(8..12, current_parameter())] + ) + ); + } } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 196f55171a5949866222164e221686bbeb3598f8..762070796f068fb01b19522b4a506eb693b9bd63 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1,25 +1,44 @@ +//! LSP store provides unified access to the language server protocol. +//! The consumers of LSP store can interact with language servers without knowing exactly which language server they're interacting with. +//! +//! # Local/Remote LSP Stores +//! This module is split up into three distinct parts: +//! - [`LocalLspStore`], which is ran on the host machine (either project host or SSH host), that manages the lifecycle of language servers. +//! - [`RemoteLspStore`], which is ran on the remote machine (project guests) which is mostly about passing through the requests via RPC. +//! The remote stores don't really care about which language server they're running against - they don't usually get to decide which language server is going to responsible for handling their request. +//! - [`LspStore`], which unifies the two under one consistent interface for interacting with language servers. +//! +//! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate. pub mod clangd_ext; pub mod json_language_server_ext; +pub mod log_store; pub mod lsp_ext_command; pub mod rust_analyzer_ext; +pub mod vue_language_server_ext; +mod inlay_hint_cache; + +use self::inlay_hint_cache::BufferInlayHints; use crate::{ - CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource, - CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics, - ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, Symbol, - ToolchainStore, + CodeAction, ColorPresentation, Completion, CompletionDisplayOptions, CompletionResponse, + CompletionSource, CoreCompletion, DocumentColor, Hover, InlayHint, InlayId, LocationLink, + LspAction, LspPullDiagnostics, ManifestProvidersStore, Project, ProjectItem, ProjectPath, + ProjectTransaction, PulledDiagnostics, ResolveState, Symbol, buffer_store::{BufferStore, BufferStoreEvent}, environment::ProjectEnvironment, lsp_command::{self, *}, - lsp_store, + lsp_store::{ + self, + inlay_hint_cache::BufferChunk, + log_store::{GlobalLogStore, LanguageServerKind}, + }, manifest_tree::{ - AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, - ManifestQueryDelegate, ManifestTree, + LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate, + ManifestTree, }, prettier_store::{self, PrettierStore, PrettierStoreEvent}, project_settings::{LspSettings, ProjectSettings}, - relativize_path, resolve_path, - toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, + toolchain_store::{LocalToolchainStore, ToolchainStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, yarn::YarnPathStore, }; @@ -42,14 +61,12 @@ use gpui::{ use http_client::HttpClient; use itertools::Itertools as _; use language::{ - Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic, + Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName, - LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, - PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, - WorkspaceFoldersContent, - language_settings::{ - FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, language_settings, - }, + LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller, ManifestDelegate, + ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, + Transaction, Unclipped, + language_settings::{FormatOnSave, Formatter, LanguageSettings, language_settings}, point_to_lsp, proto::{ deserialize_anchor, deserialize_lsp_edit, deserialize_version, serialize_anchor, @@ -58,22 +75,22 @@ use language::{ range_from_lsp, range_to_lsp, }; use lsp::{ - AdapterServerCapabilities, CodeActionKind, CompletionContext, DiagnosticSeverity, - DiagnosticTag, DidChangeWatchedFilesRegistrationOptions, Edit, FileOperationFilter, - FileOperationPatternKind, FileOperationRegistrationOptions, FileRename, FileSystemWatcher, - LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId, - LanguageServerName, LanguageServerSelector, LspRequestFuture, MessageActionItem, MessageType, - OneOf, RenameFilesParams, SymbolKind, TextEdit, WillRenameFiles, WorkDoneProgressCancelParams, + AdapterServerCapabilities, CodeActionKind, CompletionContext, DiagnosticServerCapabilities, + DiagnosticSeverity, DiagnosticTag, DidChangeWatchedFilesRegistrationOptions, Edit, + FileOperationFilter, FileOperationPatternKind, FileOperationRegistrationOptions, FileRename, + FileSystemWatcher, LSP_REQUEST_TIMEOUT, LanguageServer, LanguageServerBinary, + LanguageServerBinaryOptions, LanguageServerId, LanguageServerName, LanguageServerSelector, + LspRequestFuture, MessageActionItem, MessageType, OneOf, RenameFilesParams, SymbolKind, + TextDocumentSyncSaveOptions, TextEdit, Uri, WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder, notification::DidRenameFiles, }; use node_runtime::read_package_installed_version; use parking_lot::Mutex; use postage::{mpsc, sink::Sink, stream::Stream, watch}; use rand::prelude::*; - use rpc::{ - AnyProtoClient, - proto::{FromProto, ToProto}, + AnyProtoClient, ErrorCode, ErrorExt as _, + proto::{LspRequestId, LspRequestMessage as _}, }; use serde::Serialize; use settings::{Settings, SettingsLocation, SettingsStore}; @@ -81,7 +98,7 @@ use sha2::{Digest, Sha256}; use smol::channel::Sender; use snippet::Snippet; use std::{ - any::Any, + any::TypeId, borrow::Cow, cell::RefCell, cmp::{Ordering, Reverse}, @@ -93,20 +110,25 @@ use std::{ path::{self, Path, PathBuf}, pin::pin, rc::Rc, - sync::Arc, + sync::{ + Arc, + atomic::{self, AtomicUsize}, + }, time::{Duration, Instant}, }; use sum_tree::Dimensions; -use text::{Anchor, BufferId, LineEnding, OffsetRangeExt}; -use url::Url; +use text::{Anchor, BufferId, LineEnding, OffsetRangeExt, Point, ToPoint as _}; + use util::{ ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into, - paths::{PathExt, SanitizedPath}, + paths::{PathStyle, SanitizedPath}, post_inc, + rel_path::RelPath, }; pub use fs::*; pub use language::Location; +pub use lsp_store::inlay_hint_cache::{CacheInlayHints, InvalidationStrategy}; #[cfg(any(test, feature = "test-support"))] pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX; pub use worktree::{ @@ -116,6 +138,54 @@ pub use worktree::{ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100); +const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:"; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub enum ProgressToken { + Number(i32), + String(SharedString), +} + +impl std::fmt::Display for ProgressToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Number(number) => write!(f, "{number}"), + Self::String(string) => write!(f, "{string}"), + } + } +} + +impl ProgressToken { + fn from_lsp(value: lsp::NumberOrString) -> Self { + match value { + lsp::NumberOrString::Number(number) => Self::Number(number), + lsp::NumberOrString::String(string) => Self::String(SharedString::new(string)), + } + } + + fn to_lsp(&self) -> lsp::NumberOrString { + match self { + Self::Number(number) => lsp::NumberOrString::Number(*number), + Self::String(string) => lsp::NumberOrString::String(string.to_string()), + } + } + + fn from_proto(value: proto::ProgressToken) -> Option { + Some(match value.value? { + proto::progress_token::Value::Number(number) => Self::Number(number), + proto::progress_token::Value::String(string) => Self::String(SharedString::new(string)), + }) + } + + fn to_proto(&self) -> proto::ProgressToken { + proto::ProgressToken { + value: Some(match self { + Self::Number(number) => proto::progress_token::Value::Number(*number), + Self::String(string) => proto::progress_token::Value::String(string.to_string()), + }), + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FormatTrigger { @@ -140,6 +210,20 @@ impl FormatTrigger { } } +#[derive(Clone)] +struct UnifiedLanguageServer { + id: LanguageServerId, + project_roots: HashSet>, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +struct LanguageServerSeed { + worktree_id: WorktreeId, + name: LanguageServerName, + toolchain: Option, + settings: Arc, +} + #[derive(Debug)] pub struct DocumentDiagnosticsUpdate<'a, D> { pub diagnostics: D, @@ -154,24 +238,30 @@ pub struct DocumentDiagnostics { version: Option, } +#[derive(Default)] +struct DynamicRegistrations { + did_change_watched_files: HashMap>, + diagnostics: HashMap, DiagnosticServerCapabilities>, +} + pub struct LocalLspStore { weak: WeakEntity, worktree_store: Entity, - toolchain_store: Entity, + toolchain_store: Entity, http_client: Arc, environment: Entity, fs: Arc, languages: Arc, - language_server_ids: HashMap<(WorktreeId, LanguageServerName), BTreeSet>, + language_server_ids: HashMap, yarn: Entity, pub language_servers: HashMap, buffers_being_formatted: HashSet, last_workspace_edits_by_language_server: HashMap, language_server_watched_paths: HashMap, + watched_manifest_filenames: HashSet, language_server_paths_watched_for_rename: HashMap, - language_server_watcher_registrations: - HashMap>>, + language_server_dynamic_registrations: HashMap, supplementary_language_servers: HashMap)>, prettier_store: Entity, @@ -179,7 +269,7 @@ pub struct LocalLspStore { diagnostics: HashMap< WorktreeId, HashMap< - Arc, + Arc, Vec<( LanguageServerId, Vec>>, @@ -188,7 +278,7 @@ pub struct LocalLspStore { >, buffer_snapshots: HashMap>>, // buffer_id -> server_id -> vec of snapshots _subscription: gpui::Subscription, - lsp_tree: Entity, + lsp_tree: LanguageServerTree, registered_buffers: HashMap, buffers_opened_in_servers: HashMap>, buffer_pull_diagnostics_result_ids: HashMap>>, @@ -208,31 +298,82 @@ impl LocalLspStore { } } + fn get_or_insert_language_server( + &mut self, + worktree_handle: &Entity, + delegate: Arc, + disposition: &Arc, + language_name: &LanguageName, + cx: &mut App, + ) -> LanguageServerId { + let key = LanguageServerSeed { + worktree_id: worktree_handle.read(cx).id(), + name: disposition.server_name.clone(), + settings: disposition.settings.clone(), + toolchain: disposition.toolchain.clone(), + }; + if let Some(state) = self.language_server_ids.get_mut(&key) { + state.project_roots.insert(disposition.path.path.clone()); + state.id + } else { + let adapter = self + .languages + .lsp_adapters(language_name) + .into_iter() + .find(|adapter| adapter.name() == disposition.server_name) + .expect("To find LSP adapter"); + let new_language_server_id = self.start_language_server( + worktree_handle, + delegate, + adapter, + disposition.settings.clone(), + key.clone(), + cx, + ); + if let Some(state) = self.language_server_ids.get_mut(&key) { + state.project_roots.insert(disposition.path.path.clone()); + } else { + debug_assert!( + false, + "Expected `start_language_server` to ensure that `key` exists in a map" + ); + } + new_language_server_id + } + } + fn start_language_server( &mut self, worktree_handle: &Entity, delegate: Arc, adapter: Arc, settings: Arc, + key: LanguageServerSeed, cx: &mut App, ) -> LanguageServerId { let worktree = worktree_handle.read(cx); - let worktree_id = worktree.id(); - let root_path = worktree.abs_path(); - let key = (worktree_id, adapter.name.clone()); + let root_path = worktree.abs_path(); + let toolchain = key.toolchain.clone(); let override_options = settings.initialization_options.clone(); let stderr_capture = Arc::new(Mutex::new(Some(String::new()))); let server_id = self.languages.next_language_server_id(); - log::info!( + log::trace!( "attempting to start language server {:?}, path: {root_path:?}, id: {server_id}", adapter.name.0 ); - let binary = self.get_language_server_binary(adapter.clone(), delegate.clone(), true, cx); - let pending_workspace_folders: Arc>> = Default::default(); + let binary = self.get_language_server_binary( + adapter.clone(), + settings, + toolchain.clone(), + delegate.clone(), + true, + cx, + ); + let pending_workspace_folders: Arc>> = Default::default(); let pending_server = cx.spawn({ let adapter = adapter.clone(); @@ -267,10 +408,7 @@ impl LocalLspStore { binary, &root_path, code_action_kinds, - Some(pending_workspace_folders).filter(|_| { - adapter.adapter.workspace_folders_content() - == WorkspaceFoldersContent::SubprojectRoots - }), + Some(pending_workspace_folders), cx, ) } @@ -283,29 +421,25 @@ impl LocalLspStore { let adapter = adapter.clone(); let lsp_store = self.weak.clone(); let pending_workspace_folders = pending_workspace_folders.clone(); - let fs = self.fs.clone(); + let pull_diagnostics = ProjectSettings::get_global(cx) .diagnostics .lsp_pull_diagnostics .enabled; cx.spawn(async move |cx| { let result = async { - let toolchains = - lsp_store.update(cx, |lsp_store, cx| lsp_store.toolchain_store(cx))?; let language_server = pending_server.await?; let workspace_config = Self::workspace_configuration_for_adapter( adapter.adapter.clone(), - fs.as_ref(), &delegate, - toolchains.clone(), + toolchain, cx, ) .await?; let mut initialization_options = Self::initialization_options_for_adapter( adapter.adapter.clone(), - fs.as_ref(), &delegate, ) .await?; @@ -327,21 +461,19 @@ impl LocalLspStore { Self::setup_lsp_messages( lsp_store.clone(), - fs, &language_server, delegate.clone(), adapter.clone(), ); - let did_change_configuration_params = - Arc::new(lsp::DidChangeConfigurationParams { - settings: workspace_config, - }); + let did_change_configuration_params = lsp::DidChangeConfigurationParams { + settings: workspace_config, + }; let language_server = cx .update(|cx| { language_server.initialize( initialization_params, - did_change_configuration_params.clone(), + Arc::new(did_change_configuration_params.clone()), cx, ) })? @@ -357,11 +489,9 @@ impl LocalLspStore { } })?; - language_server - .notify::( - &did_change_configuration_params, - ) - .ok(); + language_server.notify::( + did_change_configuration_params, + )?; anyhow::Ok(language_server) } @@ -370,14 +500,14 @@ impl LocalLspStore { match result { Ok(server) => { lsp_store - .update(cx, |lsp_store, mut cx| { + .update(cx, |lsp_store, cx| { lsp_store.insert_newly_running_language_server( adapter, server.clone(), server_id, key, pending_workspace_folders, - &mut cx, + cx, ); }) .ok(); @@ -417,31 +547,26 @@ impl LocalLspStore { self.language_servers.insert(server_id, state); self.language_server_ids .entry(key) - .or_default() - .insert(server_id); + .or_insert(UnifiedLanguageServer { + id: server_id, + project_roots: Default::default(), + }); server_id } fn get_language_server_binary( &self, adapter: Arc, + settings: Arc, + toolchain: Option, delegate: Arc, allow_binary_download: bool, cx: &mut App, ) -> Task> { - let settings = ProjectSettings::get( - Some(SettingsLocation { - worktree_id: delegate.worktree_id(), - path: Path::new(""), - }), - cx, - ) - .lsp - .get(&adapter.name) - .and_then(|s| s.binary.clone()); - - if settings.as_ref().is_some_and(|b| b.path.is_some()) { - let settings = settings.unwrap(); + if let Some(settings) = settings.binary.as_ref() + && settings.path.is_some() + { + let settings = settings.clone(); return cx.background_spawn(async move { let mut env = delegate.shell_env().await; @@ -461,16 +586,22 @@ impl LocalLspStore { } let lsp_binary_options = LanguageServerBinaryOptions { allow_path_lookup: !settings + .binary .as_ref() .and_then(|b| b.ignore_system_version) .unwrap_or_default(), allow_binary_download, + pre_release: settings + .fetch + .as_ref() + .and_then(|f| f.pre_release) + .unwrap_or(false), }; - let toolchains = self.toolchain_store.read(cx).as_language_toolchain_store(); + cx.spawn(async move |cx| { let binary_result = adapter .clone() - .get_language_server_command(delegate.clone(), toolchains, lsp_binary_options, cx) + .get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx) .await; delegate.update_status(adapter.name.clone(), BinaryStatus::None); @@ -480,12 +611,12 @@ impl LocalLspStore { shell_env.extend(binary.env.unwrap_or_default()); - if let Some(settings) = settings { - if let Some(arguments) = settings.arguments { - binary.arguments = arguments.into_iter().map(Into::into).collect(); + if let Some(settings) = settings.binary.as_ref() { + if let Some(arguments) = &settings.arguments { + binary.arguments = arguments.iter().map(Into::into).collect(); } - if let Some(env) = settings.env { - shell_env.extend(env); + if let Some(env) = &settings.env { + shell_env.extend(env.iter().map(|(k, v)| (k.clone(), v.clone()))); } } @@ -495,8 +626,7 @@ impl LocalLspStore { } fn setup_lsp_messages( - this: WeakEntity, - fs: Arc, + lsp_store: WeakEntity, language_server: &LanguageServer, delegate: Arc, adapter: Arc, @@ -506,7 +636,7 @@ impl LocalLspStore { language_server .on_notification::({ let adapter = adapter.clone(); - let this = this.clone(); + let this = lsp_store.clone(); move |mut params, cx| { let adapter = adapter.clone(); if let Some(this) = this.upgrade() { @@ -550,23 +680,26 @@ impl LocalLspStore { .on_request::({ let adapter = adapter.adapter.clone(); let delegate = delegate.clone(); - let this = this.clone(); - let fs = fs.clone(); + let this = lsp_store.clone(); move |params, cx| { let adapter = adapter.clone(); let delegate = delegate.clone(); let this = this.clone(); - let fs = fs.clone(); let mut cx = cx.clone(); async move { - let toolchains = - this.update(&mut cx, |this, cx| this.toolchain_store(cx))?; - + let toolchain_for_id = this + .update(&mut cx, |this, _| { + this.as_local()?.language_server_ids.iter().find_map( + |(seed, value)| { + (value.id == server_id).then(|| seed.toolchain.clone()) + }, + ) + })? + .context("Expected the LSP store to be in a local mode")?; let workspace_config = Self::workspace_configuration_for_adapter( adapter.clone(), - fs.as_ref(), &delegate, - toolchains.clone(), + toolchain_for_id, &mut cx, ) .await?; @@ -592,13 +725,13 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let this = lsp_store.clone(); move |_, cx| { let this = this.clone(); - let mut cx = cx.clone(); + let cx = cx.clone(); async move { - let Some(server) = this - .read_with(&mut cx, |this, _| this.language_server_for_id(server_id))? + let Some(server) = + this.read_with(&cx, |this, _| this.language_server_for_id(server_id))? else { return Ok(None); }; @@ -620,7 +753,7 @@ impl LocalLspStore { // to these requests when initializing. language_server .on_request::({ - let this = this.clone(); + let this = lsp_store.clone(); move |params, cx| { let this = this.clone(); let mut cx = cx.clone(); @@ -628,9 +761,9 @@ impl LocalLspStore { this.update(&mut cx, |this, _| { if let Some(status) = this.language_server_statuses.get_mut(&server_id) { - if let lsp::NumberOrString::String(token) = params.token { - status.progress_tokens.insert(token); - } + status + .progress_tokens + .insert(ProgressToken::from_lsp(params.token)); } })?; @@ -642,7 +775,7 @@ impl LocalLspStore { language_server .on_request::({ - let lsp_store = this.clone(); + let lsp_store = lsp_store.clone(); move |params, cx| { let lsp_store = lsp_store.clone(); let mut cx = cx.clone(); @@ -671,7 +804,7 @@ impl LocalLspStore { language_server .on_request::({ - let lsp_store = this.clone(); + let lsp_store = lsp_store.clone(); move |params, cx| { let lsp_store = lsp_store.clone(); let mut cx = cx.clone(); @@ -700,18 +833,15 @@ impl LocalLspStore { language_server .on_request::({ - let adapter = adapter.clone(); - let this = this.clone(); + let this = lsp_store.clone(); move |params, cx| { let mut cx = cx.clone(); let this = this.clone(); - let adapter = adapter.clone(); async move { LocalLspStore::on_lsp_workspace_edit( this.clone(), params, server_id, - adapter.clone(), &mut cx, ) .await @@ -722,18 +852,22 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let lsp_store = lsp_store.clone(); move |(), cx| { - let this = this.clone(); + let this = lsp_store.clone(); let mut cx = cx.clone(); async move { - this.update(&mut cx, |this, cx| { - cx.emit(LspStoreEvent::RefreshInlayHints); - this.downstream_client.as_ref().map(|(client, project_id)| { - client.send(proto::RefreshInlayHints { - project_id: *project_id, + this.update(&mut cx, |lsp_store, cx| { + cx.emit(LspStoreEvent::RefreshInlayHints(server_id)); + lsp_store + .downstream_client + .as_ref() + .map(|(client, project_id)| { + client.send(proto::RefreshInlayHints { + project_id: *project_id, + server_id: server_id.to_proto(), + }) }) - }) })? .transpose()?; Ok(()) @@ -744,7 +878,7 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let this = lsp_store.clone(); move |(), cx| { let this = this.clone(); let mut cx = cx.clone(); @@ -766,7 +900,7 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let this = lsp_store.clone(); move |(), cx| { let this = this.clone(); let mut cx = cx.clone(); @@ -792,7 +926,7 @@ impl LocalLspStore { language_server .on_request::({ - let this = this.clone(); + let this = lsp_store.clone(); let name = name.to_string(); move |params, cx| { let this = this.clone(); @@ -830,7 +964,7 @@ impl LocalLspStore { .detach(); language_server .on_notification::({ - let this = this.clone(); + let this = lsp_store.clone(); let name = name.to_string(); move |params, cx| { let this = this.clone(); @@ -847,7 +981,7 @@ impl LocalLspStore { message: params.message, actions: vec![], response_channel: tx, - lsp_name: name.clone(), + lsp_name: name, }; let _ = this.update(&mut cx, |_, cx| { @@ -862,7 +996,7 @@ impl LocalLspStore { language_server .on_notification::({ - let this = this.clone(); + let this = lsp_store.clone(); move |params, cx| { if let Some(this) = this.upgrade() { this.update(cx, |this, cx| { @@ -881,7 +1015,7 @@ impl LocalLspStore { language_server .on_notification::({ - let this = this.clone(); + let this = lsp_store.clone(); move |params, cx| { if let Some(this) = this.upgrade() { this.update(cx, |_, cx| { @@ -899,14 +1033,16 @@ impl LocalLspStore { language_server .on_notification::({ - let this = this.clone(); + let this = lsp_store.clone(); move |params, cx| { let mut cx = cx.clone(); if let Some(this) = this.upgrade() { this.update(&mut cx, |_, cx| { cx.emit(LspStoreEvent::LanguageServerLog( server_id, - LanguageServerLogType::Trace(params.verbose), + LanguageServerLogType::Trace { + verbose_info: params.verbose, + }, params.message, )); }) @@ -916,9 +1052,10 @@ impl LocalLspStore { }) .detach(); - json_language_server_ext::register_requests(this.clone(), language_server); - rust_analyzer_ext::register_notifications(this.clone(), language_server); - clangd_ext::register_notifications(this, language_server, adapter); + vue_language_server_ext::register_requests(lsp_store.clone(), language_server); + json_language_server_ext::register_requests(lsp_store.clone(), language_server); + rust_analyzer_ext::register_notifications(lsp_store.clone(), language_server); + clangd_ext::register_notifications(lsp_store, language_server, adapter); } fn shutdown_language_servers_on_quit( @@ -944,10 +1081,10 @@ impl LocalLspStore { } } LanguageServerState::Starting { startup, .. } => { - if let Some(server) = startup.await { - if let Some(shutdown) = server.shutdown() { - shutdown.await; - } + if let Some(server) = startup.await + && let Some(shutdown) = server.shutdown() + { + shutdown.await; } } } @@ -960,19 +1097,18 @@ impl LocalLspStore { ) -> impl Iterator> { self.language_server_ids .iter() - .flat_map(move |((language_server_path, _), ids)| { - ids.iter().filter_map(move |id| { - if *language_server_path != worktree_id { - return None; - } - if let Some(LanguageServerState::Running { server, .. }) = - self.language_servers.get(id) - { - return Some(server); - } else { - None - } - }) + .filter_map(move |(seed, state)| { + if seed.worktree_id != worktree_id { + return None; + } + + if let Some(LanguageServerState::Running { server, .. }) = + self.language_servers.get(&state.id) + { + Some(server) + } else { + None + } }) } @@ -989,19 +1125,18 @@ impl LocalLspStore { else { return Vec::new(); }; - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); - let root = self.lsp_tree.update(cx, |this, cx| { - this.get( + let delegate: Arc = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + + self.lsp_tree + .get( project_path, - AdapterQuery::Language(&language.name()), - delegate, + language.name(), + language.manifest(), + &delegate, cx, ) - .filter_map(|node| node.server_id()) .collect::>() - }); - - root } fn language_server_ids_for_buffer( @@ -1012,7 +1147,7 @@ impl LocalLspStore { if let Some((file, language)) = File::from_dyn(buffer.file()).zip(buffer.language()) { let worktree_id = file.worktree_id(cx); - let path: Arc = file + let path: Arc = file .path() .parent() .map(Arc::from) @@ -1083,7 +1218,7 @@ impl LocalLspStore { .collect::>() }) })?; - for (lsp_adapter, language_server) in adapters_and_servers.iter() { + for (_, language_server) in adapters_and_servers.iter() { let actions = Self::get_server_code_actions_from_action_kinds( &lsp_store, language_server.server_id(), @@ -1095,7 +1230,6 @@ impl LocalLspStore { Self::execute_code_actions_on_server( &lsp_store, language_server, - lsp_adapter, actions, push_to_history, &mut project_transaction, @@ -1267,7 +1401,7 @@ impl LocalLspStore { // Formatter for `code_actions_on_format` that runs before // the rest of the formatters - let mut code_actions_on_format_formatter = None; + let mut code_actions_on_format_formatters = None; let should_run_code_actions_on_format = !matches!( (trigger, &settings.format_on_save), (FormatTrigger::Save, &FormatOnSave::Off) @@ -1279,35 +1413,44 @@ impl LocalLspStore { .any(|enabled| *enabled); if have_code_actions_to_run_on_format { zlog::trace!(logger => "going to run code actions on format"); - code_actions_on_format_formatter = Some(Formatter::CodeActions( - settings.code_actions_on_format.clone(), - )); + code_actions_on_format_formatters = Some( + settings + .code_actions_on_format + .iter() + .filter_map(|(action, enabled)| enabled.then_some(action)) + .cloned() + .map(Formatter::CodeAction) + .collect::>(), + ); } } let formatters = match (trigger, &settings.format_on_save) { (FormatTrigger::Save, FormatOnSave::Off) => &[], - (FormatTrigger::Save, FormatOnSave::List(formatters)) => formatters.as_ref(), (FormatTrigger::Manual, _) | (FormatTrigger::Save, FormatOnSave::On) => { - match &settings.formatter { - SelectedFormatter::Auto => { - if settings.prettier.allowed { - zlog::trace!(logger => "Formatter set to auto: defaulting to prettier"); - std::slice::from_ref(&Formatter::Prettier) - } else { - zlog::trace!(logger => "Formatter set to auto: defaulting to primary language server"); - std::slice::from_ref(&Formatter::LanguageServer { name: None }) - } - } - SelectedFormatter::List(formatter_list) => formatter_list.as_ref(), - } + settings.formatter.as_ref() } }; - let formatters = code_actions_on_format_formatter.iter().chain(formatters); + let formatters = code_actions_on_format_formatters + .iter() + .flatten() + .chain(formatters); for formatter in formatters { + let formatter = if formatter == &Formatter::Auto { + if settings.prettier.allowed { + zlog::trace!(logger => "Formatter set to auto: defaulting to prettier"); + &Formatter::Prettier + } else { + zlog::trace!(logger => "Formatter set to auto: defaulting to primary language server"); + &Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current) + } + } else { + formatter + }; match formatter { + Formatter::Auto => unreachable!("Auto resolved above"), Formatter::Prettier => { let logger = zlog::scoped!(logger => "prettier"); zlog::trace!(logger => "formatting"); @@ -1362,7 +1505,7 @@ impl LocalLspStore { }, )?; } - Formatter::LanguageServer { name } => { + Formatter::LanguageServer(specifier) => { let logger = zlog::scoped!(logger => "language-server"); zlog::trace!(logger => "formatting"); let _timer = zlog::time!(logger => "Formatting buffer using language server"); @@ -1372,16 +1515,19 @@ impl LocalLspStore { continue; }; - let language_server = if let Some(name) = name.as_deref() { - adapters_and_servers.iter().find_map(|(adapter, server)| { - if adapter.name.0.as_ref() == name { - Some(server.clone()) - } else { - None - } - }) - } else { - adapters_and_servers.first().map(|e| e.1.clone()) + let language_server = match specifier { + settings::LanguageServerFormatterSpecifier::Specific { name } => { + adapters_and_servers.iter().find_map(|(adapter, server)| { + if adapter.name.0.as_ref() == name { + Some(server.clone()) + } else { + None + } + }) + } + settings::LanguageServerFormatterSpecifier::Current => { + adapters_and_servers.first().map(|e| e.1.clone()) + } }; let Some(language_server) = language_server else { @@ -1439,7 +1585,7 @@ impl LocalLspStore { }, )?; } - Formatter::CodeActions(code_actions) => { + Formatter::CodeAction(code_action_name) => { let logger = zlog::scoped!(logger => "code-actions"); zlog::trace!(logger => "formatting"); let _timer = zlog::time!(logger => "Formatting buffer using code actions"); @@ -1448,17 +1594,9 @@ impl LocalLspStore { zlog::warn!(logger => "Cannot format buffer that is not backed by a file on disk using code actions. Skipping"); continue; }; - let code_action_kinds = code_actions - .iter() - .filter_map(|(action_kind, enabled)| { - enabled.then_some(action_kind.clone().into()) - }) - .collect::>(); - if code_action_kinds.is_empty() { - zlog::trace!(logger => "No code action kinds enabled, skipping"); - continue; - } - zlog::trace!(logger => "Attempting to resolve code actions {:?}", &code_action_kinds); + + let code_action_kind: CodeActionKind = code_action_name.clone().into(); + zlog::trace!(logger => "Attempting to resolve code actions {:?}", &code_action_kind); let mut actions_and_servers = Vec::new(); @@ -1466,23 +1604,25 @@ impl LocalLspStore { let actions_result = Self::get_server_code_actions_from_action_kinds( &lsp_store, language_server.server_id(), - code_action_kinds.clone(), + vec![code_action_kind.clone()], &buffer.handle, cx, ) .await - .with_context( - || format!("Failed to resolve code actions with kinds {:?} for language server {}", - code_action_kinds.iter().map(|kind| kind.as_str()).join(", "), - language_server.name()) - ); + .with_context(|| { + format!( + "Failed to resolve code action {:?} with language server {}", + code_action_kind, + language_server.name() + ) + }); let Ok(actions) = actions_result else { // note: it may be better to set result to the error and break formatters here // but for now we try to execute the actions that we can resolve and skip the rest zlog::error!( logger => - "Failed to resolve code actions with kinds {:?} with language server {}", - code_action_kinds.iter().map(|kind| kind.as_str()).join(", "), + "Failed to resolve code action {:?} with language server {}", + code_action_kind, language_server.name() ); continue; @@ -1527,7 +1667,7 @@ impl LocalLspStore { if let Some(edit) = action.lsp_action.edit().cloned() { // NOTE: code below duplicated from `Self::deserialize_workspace_edit` - // but filters out and logs warnings for code actions that cause unreasonably + // but filters out and logs warnings for code actions that require unreasonably // difficult handling on our part, such as: // - applying edits that call commands // which can result in arbitrary workspace edits being sent from the server that @@ -1667,7 +1807,12 @@ impl LocalLspStore { formatting_transaction_id, cx, |buffer, cx| { + zlog::info!( + "Applying edits {edits:?}. Content: {:?}", + buffer.text() + ); buffer.edit(edits, None, cx); + zlog::info!("Applied edits. New Content: {:?}", buffer.text()); }, )?; } @@ -1769,17 +1914,19 @@ impl LocalLspStore { } if !project_transaction_command.0.is_empty() { - let extra_buffers = project_transaction_command - .0 - .keys() - .filter_map(|buffer_handle| { - buffer_handle - .read_with(cx, |b, cx| b.project_path(cx)) - .ok() - .flatten() - }) - .map(|p| p.path.to_sanitized_string()) - .join(", "); + let mut extra_buffers = String::new(); + for buffer in project_transaction_command.0.keys() { + buffer + .read_with(cx, |b, cx| { + if let Some(path) = b.project_path(cx) { + if !extra_buffers.is_empty() { + extra_buffers.push_str(", "); + } + extra_buffers.push_str(path.path.as_unix_str()); + } + }) + .ok(); + } zlog::warn!( logger => "Unexpected edits to buffers other than the buffer actively being formatted due to command {}. Impacted buffers: [{}].", @@ -1810,7 +1957,7 @@ impl LocalLspStore { ) -> Result, Arc)>> { let capabilities = &language_server.capabilities(); let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); - if range_formatting_provider.map_or(false, |provider| provider == &OneOf::Left(false)) { + if range_formatting_provider == Some(&OneOf::Left(false)) { anyhow::bail!( "{} language server does not support range formatting", language_server.name() @@ -1857,7 +2004,7 @@ impl LocalLspStore { if let Some(lsp_edits) = lsp_edits { this.update(cx, |this, cx| { this.as_local_mut().unwrap().edits_from_lsp( - &buffer_handle, + buffer_handle, lsp_edits, language_server.server_id(), None, @@ -2038,13 +2185,14 @@ impl LocalLspStore { let buffer = buffer_handle.read(cx); let file = buffer.file().cloned(); + let Some(file) = File::from_dyn(file.as_ref()) else { return; }; if !file.is_local() { return; } - + let path = ProjectPath::from_file(file, cx); let worktree_id = file.worktree_id(cx); let language = buffer.language().cloned(); @@ -2067,46 +2215,52 @@ impl LocalLspStore { let Some(language) = language else { return; }; - for adapter in self.languages.lsp_adapters(&language.name()) { - let servers = self - .language_server_ids - .get(&(worktree_id, adapter.name.clone())); - if let Some(server_ids) = servers { - for server_id in server_ids { - let server = self - .language_servers - .get(server_id) - .and_then(|server_state| { - if let LanguageServerState::Running { server, .. } = server_state { - Some(server.clone()) - } else { - None - } - }); - let server = match server { - Some(server) => server, - None => continue, - }; + let Some(snapshot) = self + .worktree_store + .read(cx) + .worktree_for_id(worktree_id, cx) + .map(|worktree| worktree.read(cx).snapshot()) + else { + return; + }; + let delegate: Arc = Arc::new(ManifestQueryDelegate::new(snapshot)); - buffer_handle.update(cx, |buffer, cx| { - buffer.set_completion_triggers( - server.server_id(), - server - .capabilities() - .completion_provider + for server_id in + self.lsp_tree + .get(path, language.name(), language.manifest(), &delegate, cx) + { + let server = self + .language_servers + .get(&server_id) + .and_then(|server_state| { + if let LanguageServerState::Running { server, .. } = server_state { + Some(server.clone()) + } else { + None + } + }); + let server = match server { + Some(server) => server, + None => continue, + }; + + buffer_handle.update(cx, |buffer, cx| { + buffer.set_completion_triggers( + server.server_id(), + server + .capabilities() + .completion_provider + .as_ref() + .and_then(|provider| { + provider + .trigger_characters .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), - cx, - ); - }); - } - } + .map(|characters| characters.iter().cloned().collect()) + }) + .unwrap_or_default(), + cx, + ); + }); } } @@ -2216,6 +2370,31 @@ impl LocalLspStore { Ok(()) } + fn register_language_server_for_invisible_worktree( + &mut self, + worktree: &Entity, + language_server_id: LanguageServerId, + cx: &mut App, + ) { + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); + debug_assert!(!worktree.is_visible()); + let Some(mut origin_seed) = self + .language_server_ids + .iter() + .find_map(|(seed, state)| (state.id == language_server_id).then(|| seed.clone())) + else { + return; + }; + origin_seed.worktree_id = worktree_id; + self.language_server_ids + .entry(origin_seed) + .or_insert_with(|| UnifiedLanguageServer { + id: language_server_id, + project_roots: Default::default(), + }); + } + fn register_buffer_with_language_servers( &mut self, buffer_handle: &Entity, @@ -2242,7 +2421,7 @@ impl LocalLspStore { let Some(language) = buffer.language().cloned() else { return; }; - let path: Arc = file + let path: Arc = file .path() .parent() .map(Arc::from) @@ -2256,27 +2435,23 @@ impl LocalLspStore { }; let language_name = language.name(); let (reused, delegate, servers) = self - .lsp_tree - .update(cx, |lsp_tree, cx| { - self.reuse_existing_language_server(lsp_tree, &worktree, &language_name, cx) - }) - .map(|(delegate, servers)| (true, delegate, servers)) + .reuse_existing_language_server(&self.lsp_tree, &worktree, &language_name, cx) + .map(|(delegate, apply)| (true, delegate, apply(&mut self.lsp_tree))) .unwrap_or_else(|| { let lsp_delegate = LocalLspAdapterDelegate::from_local_lsp(self, &worktree, cx); - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let delegate: Arc = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let servers = self .lsp_tree - .clone() - .update(cx, |language_server_tree, cx| { - language_server_tree - .get( - ProjectPath { worktree_id, path }, - AdapterQuery::Language(&language.name()), - delegate.clone(), - cx, - ) - .collect::>() - }); + .walk( + ProjectPath { worktree_id, path }, + language.name(), + language.manifest(), + &delegate, + cx, + ) + .collect::>(); (false, lsp_delegate, servers) }); let servers_and_adapters = servers @@ -2286,67 +2461,45 @@ impl LocalLspStore { return None; } if !only_register_servers.is_empty() { - if let Some(server_id) = server_node.server_id() { - if !only_register_servers.contains(&LanguageServerSelector::Id(server_id)) { - return None; - } + if let Some(server_id) = server_node.server_id() + && !only_register_servers.contains(&LanguageServerSelector::Id(server_id)) + { + return None; } - if let Some(name) = server_node.name() { - if !only_register_servers.contains(&LanguageServerSelector::Name(name)) { - return None; - } + if let Some(name) = server_node.name() + && !only_register_servers.contains(&LanguageServerSelector::Name(name)) + { + return None; } } - let server_id = server_node.server_id_or_init( - |LaunchDisposition { - server_name, - path, - settings, - }| { - let server_id = - { - let uri = Url::from_file_path( - worktree.read(cx).abs_path().join(&path.path), - ); - let key = (worktree_id, server_name.clone()); - if !self.language_server_ids.contains_key(&key) { - let language_name = language.name(); - let adapter = self.languages - .lsp_adapters(&language_name) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - self.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - } - if let Some(server_ids) = self - .language_server_ids - .get(&key) - { - debug_assert_eq!(server_ids.len(), 1); - let server_id = server_ids.iter().cloned().next().unwrap(); - if let Some(state) = self.language_servers.get(&server_id) { - if let Ok(uri) = uri { - state.add_workspace_folder(uri); - }; - } - server_id - } else { - unreachable!("Language server ID should be available, as it's registered on demand") - } + let server_id = server_node.server_id_or_init(|disposition| { + let path = &disposition.path; - }; - server_id - }, - )?; + { + let uri = Uri::from_file_path(worktree.read(cx).absolutize(&path.path)); + + let server_id = self.get_or_insert_language_server( + &worktree, + delegate.clone(), + disposition, + &language_name, + cx, + ); + + if let Some(state) = self.language_servers.get(&server_id) + && let Ok(uri) = uri + { + state.add_workspace_folder(uri); + }; + server_id + } + })?; let server_state = self.language_servers.get(&server_id)?; - if let LanguageServerState::Running { server, adapter, .. } = server_state { + if let LanguageServerState::Running { + server, adapter, .. + } = server_state + { Some((server.clone(), adapter.clone())) } else { None @@ -2404,7 +2557,7 @@ impl LocalLspStore { name: None, message: proto::update_language_server::Variant::RegisteredForBuffer( proto::RegisteredForBuffer { - buffer_abs_path: abs_path.to_string_lossy().to_string(), + buffer_abs_path: abs_path.to_string_lossy().into_owned(), buffer_id: buffer_id.to_proto(), }, ), @@ -2413,13 +2566,16 @@ impl LocalLspStore { } } - fn reuse_existing_language_server( + fn reuse_existing_language_server<'lang_name>( &self, - server_tree: &mut LanguageServerTree, + server_tree: &LanguageServerTree, worktree: &Entity, - language_name: &LanguageName, + language_name: &'lang_name LanguageName, cx: &mut App, - ) -> Option<(Arc, Vec)> { + ) -> Option<( + Arc, + impl FnOnce(&mut LanguageServerTree) -> Vec + use<'lang_name>, + )> { if worktree.read(cx).is_visible() { return None; } @@ -2458,16 +2614,16 @@ impl LocalLspStore { .into_values() .max_by_key(|servers| servers.len())?; - for server_node in &servers { - server_tree.register_reused( - worktree.read(cx).id(), - language_name.clone(), - server_node.clone(), - ); - } + let worktree_id = worktree.read(cx).id(); + let apply = move |tree: &mut LanguageServerTree| { + for server_node in &servers { + tree.register_reused(worktree_id, language_name.clone(), server_node.clone()); + } + servers + }; let delegate = LocalLspAdapterDelegate::from_local_lsp(self, worktree, cx); - Some((delegate, servers)) + Some((delegate, apply)) } pub(crate) fn unregister_old_buffer_from_language_servers( @@ -2481,11 +2637,8 @@ impl LocalLspStore { None => return, }; - let Ok(file_url) = lsp::Url::from_file_path(old_path.as_path()) else { - debug_panic!( - "`{}` is not parseable as an URI", - old_path.to_string_lossy() - ); + let Ok(file_url) = lsp::Uri::from_file_path(old_path.as_path()) else { + debug_panic!("{old_path:?} is not parseable as an URI"); return; }; self.unregister_buffer_from_language_servers(buffer, &file_url, cx); @@ -2494,7 +2647,7 @@ impl LocalLspStore { pub(crate) fn unregister_buffer_from_language_servers( &mut self, buffer: &Entity, - file_url: &lsp::Url, + file_url: &lsp::Uri, cx: &mut App, ) { buffer.update(cx, |buffer, cx| { @@ -2562,13 +2715,13 @@ impl LocalLspStore { this.request_lsp(buffer.clone(), server, request, cx) })? .await?; - return Ok(actions); + Ok(actions) } pub async fn execute_code_actions_on_server( lsp_store: &WeakEntity, language_server: &Arc, - lsp_adapter: &Arc, + actions: Vec, push_to_history: bool, project_transaction: &mut ProjectTransaction, @@ -2588,7 +2741,6 @@ impl LocalLspStore { lsp_store.upgrade().context("project dropped")?, edit.clone(), push_to_history, - lsp_adapter.clone(), language_server.clone(), cx, ) @@ -2639,7 +2791,7 @@ impl LocalLspStore { } } } - return Ok(()); + Ok(()) } pub async fn deserialize_text_edits( @@ -2769,7 +2921,6 @@ impl LocalLspStore { this: Entity, edit: lsp::WorkspaceEdit, push_to_history: bool, - lsp_adapter: Arc, language_server: Arc, cx: &mut AsyncApp, ) -> Result { @@ -2870,7 +3021,6 @@ impl LocalLspStore { this.open_local_buffer_via_lsp( op.text_document.uri.clone(), language_server.server_id(), - lsp_adapter.name.clone(), cx, ) })? @@ -2880,11 +3030,11 @@ impl LocalLspStore { .update(cx, |this, cx| { let path = buffer_to_edit.read(cx).project_path(cx); let active_entry = this.active_entry; - let is_active_entry = path.clone().map_or(false, |project_path| { + let is_active_entry = path.is_some_and(|project_path| { this.worktree_store .read(cx) .entry_for_path(&project_path, cx) - .map_or(false, |entry| Some(entry.id) == active_entry) + .is_some_and(|entry| Some(entry.id) == active_entry) }); let local = this.as_local_mut().unwrap(); @@ -2939,9 +3089,8 @@ impl LocalLspStore { Some(buffer_to_edit.read(cx).saved_version().clone()) }; - let most_recent_edit = version.and_then(|version| { - version.iter().max_by_key(|timestamp| timestamp.value) - }); + let most_recent_edit = + version.and_then(|version| version.most_recent()); // Check if the edit that triggered that edit has been made by this participant. if let Some(most_recent_edit) = most_recent_edit { @@ -2970,16 +3119,14 @@ impl LocalLspStore { buffer.edit([(range, text)], None, cx); } - let transaction = buffer.end_transaction(cx).and_then(|transaction_id| { + buffer.end_transaction(cx).and_then(|transaction_id| { if push_to_history { buffer.finalize_last_transaction(); buffer.get_transaction(transaction_id).cloned() } else { buffer.forget_transaction(transaction_id) } - }); - - transaction + }) })?; if let Some(transaction) = transaction { project_transaction.0.insert(buffer_to_edit, transaction); @@ -2995,7 +3142,6 @@ impl LocalLspStore { this: WeakEntity, params: lsp::ApplyWorkspaceEditParams, server_id: LanguageServerId, - adapter: Arc, cx: &mut AsyncApp, ) -> Result { let this = this.upgrade().context("project project closed")?; @@ -3006,7 +3152,6 @@ impl LocalLspStore { this.clone(), params.edit, true, - adapter.clone(), language_server.clone(), cx, ) @@ -3037,23 +3182,19 @@ impl LocalLspStore { prettier_store.remove_worktree(id_to_remove, cx); }); - let mut servers_to_remove = BTreeMap::default(); + let mut servers_to_remove = BTreeSet::default(); let mut servers_to_preserve = HashSet::default(); - for ((path, server_name), ref server_ids) in &self.language_server_ids { - if *path == id_to_remove { - servers_to_remove.extend(server_ids.iter().map(|id| (*id, server_name.clone()))); + for (seed, state) in &self.language_server_ids { + if seed.worktree_id == id_to_remove { + servers_to_remove.insert(state.id); } else { - servers_to_preserve.extend(server_ids.iter().cloned()); + servers_to_preserve.insert(state.id); } } - servers_to_remove.retain(|server_id, _| !servers_to_preserve.contains(server_id)); - - for (server_id_to_remove, _) in &servers_to_remove { - self.language_server_ids - .values_mut() - .for_each(|server_ids| { - server_ids.remove(server_id_to_remove); - }); + servers_to_remove.retain(|server_id| !servers_to_preserve.contains(server_id)); + self.language_server_ids + .retain(|_, state| !servers_to_remove.contains(&state.id)); + for server_id_to_remove in &servers_to_remove { self.language_server_watched_paths .remove(server_id_to_remove); self.language_server_paths_watched_for_rename @@ -3068,7 +3209,7 @@ impl LocalLspStore { } cx.emit(LspStoreEvent::LanguageServerRemoved(*server_id_to_remove)); } - servers_to_remove.into_keys().collect() + servers_to_remove.into_iter().collect() } fn rebuild_watched_paths_inner<'a>( @@ -3097,13 +3238,13 @@ impl LocalLspStore { for watcher in watchers { if let Some((worktree, literal_prefix, pattern)) = - self.worktree_and_path_for_file_watcher(&worktrees, &watcher, cx) + Self::worktree_and_path_for_file_watcher(&worktrees, watcher, cx) { worktree.update(cx, |worktree, _| { if let Some((tree, glob)) = worktree.as_local_mut().zip(Glob::new(&pattern).log_err()) { - tree.add_path_prefix_to_scan(literal_prefix.into()); + tree.add_path_prefix_to_scan(literal_prefix); worktree_globs .entry(tree.id()) .or_insert_with(GlobSetBuilder::new) @@ -3113,12 +3254,12 @@ impl LocalLspStore { } else { let (path, pattern) = match &watcher.glob_pattern { lsp::GlobPattern::String(s) => { - let watcher_path = SanitizedPath::from(s); + let watcher_path = SanitizedPath::new(s); let path = glob_literal_prefix(watcher_path.as_path()); let pattern = watcher_path .as_path() .strip_prefix(&path) - .map(|p| p.to_string_lossy().to_string()) + .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|e| { debug_panic!( "Failed to strip prefix for string pattern: {}, with prefix: {}, with error: {}", @@ -3126,7 +3267,7 @@ impl LocalLspStore { path.display(), e ); - watcher_path.as_path().to_string_lossy().to_string() + watcher_path.as_path().to_string_lossy().into_owned() }); (path, pattern) } @@ -3142,7 +3283,7 @@ impl LocalLspStore { let path = glob_literal_prefix(Path::new(&rp.pattern)); let pattern = Path::new(&rp.pattern) .strip_prefix(&path) - .map(|p| p.to_string_lossy().to_string()) + .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|e| { debug_panic!( "Failed to strip prefix for relative pattern: {}, with prefix: {}, with error: {}", @@ -3195,17 +3336,17 @@ impl LocalLspStore { } fn worktree_and_path_for_file_watcher( - &self, worktrees: &[Entity], watcher: &FileSystemWatcher, cx: &App, - ) -> Option<(Entity, PathBuf, String)> { + ) -> Option<(Entity, Arc, String)> { worktrees.iter().find_map(|worktree| { let tree = worktree.read(cx); let worktree_root_path = tree.abs_path(); + let path_style = tree.path_style(); match &watcher.glob_pattern { lsp::GlobPattern::String(s) => { - let watcher_path = SanitizedPath::from(s); + let watcher_path = SanitizedPath::new(s); let relative = watcher_path .as_path() .strip_prefix(&worktree_root_path) @@ -3213,8 +3354,8 @@ impl LocalLspStore { let literal_prefix = glob_literal_prefix(relative); Some(( worktree.clone(), - literal_prefix, - relative.to_string_lossy().to_string(), + RelPath::new(&literal_prefix, path_style).ok()?.into_arc(), + relative.to_string_lossy().into_owned(), )) } lsp::GlobPattern::Relative(rp) => { @@ -3227,7 +3368,11 @@ impl LocalLspStore { let relative = base_uri.strip_prefix(&worktree_root_path).ok()?; let mut literal_prefix = relative.to_owned(); literal_prefix.push(glob_literal_prefix(Path::new(&rp.pattern))); - Some((worktree.clone(), literal_prefix, rp.pattern.clone())) + Some(( + worktree.clone(), + RelPath::new(&literal_prefix, path_style).ok()?.into_arc(), + rp.pattern.clone(), + )) } } }) @@ -3238,15 +3383,18 @@ impl LocalLspStore { language_server_id: LanguageServerId, cx: &mut Context, ) { - let Some(watchers) = self - .language_server_watcher_registrations + let Some(registrations) = self + .language_server_dynamic_registrations .get(&language_server_id) else { return; }; - let watch_builder = - self.rebuild_watched_paths_inner(language_server_id, watchers.values().flatten(), cx); + let watch_builder = self.rebuild_watched_paths_inner( + language_server_id, + registrations.did_change_watched_files.values().flatten(), + cx, + ); let watcher = watch_builder.build(self.fs.clone(), language_server_id, cx); self.language_server_watched_paths .insert(language_server_id, watcher); @@ -3262,11 +3410,13 @@ impl LocalLspStore { cx: &mut Context, ) { let registrations = self - .language_server_watcher_registrations + .language_server_dynamic_registrations .entry(language_server_id) .or_default(); - registrations.insert(registration_id.to_string(), params.watchers); + registrations + .did_change_watched_files + .insert(registration_id.to_string(), params.watchers); self.rebuild_watched_paths(language_server_id, cx); } @@ -3278,11 +3428,15 @@ impl LocalLspStore { cx: &mut Context, ) { let registrations = self - .language_server_watcher_registrations + .language_server_dynamic_registrations .entry(language_server_id) .or_default(); - if registrations.remove(registration_id).is_some() { + if registrations + .did_change_watched_files + .remove(registration_id) + .is_some() + { log::info!( "language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}", language_server_id, @@ -3301,11 +3455,10 @@ impl LocalLspStore { async fn initialization_options_for_adapter( adapter: Arc, - fs: &dyn Fs, delegate: &Arc, ) -> Result> { let Some(mut initialization_config) = - adapter.clone().initialization_options(fs, delegate).await? + adapter.clone().initialization_options(delegate).await? else { return Ok(None); }; @@ -3316,7 +3469,7 @@ impl LocalLspStore { } if let Ok(Some(target_config)) = other_adapter .clone() - .additional_initialization_options(adapter.name(), fs, delegate) + .additional_initialization_options(adapter.name(), delegate) .await { merge_json_value_into(target_config.clone(), &mut initialization_config); @@ -3328,14 +3481,13 @@ impl LocalLspStore { async fn workspace_configuration_for_adapter( adapter: Arc, - fs: &dyn Fs, delegate: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { let mut workspace_config = adapter .clone() - .workspace_configuration(fs, delegate, toolchains.clone(), cx) + .workspace_configuration(delegate, toolchain, cx) .await?; for other_adapter in delegate.registered_lsp_adapters() { @@ -3344,13 +3496,7 @@ impl LocalLspStore { } if let Ok(Some(target_config)) = other_adapter .clone() - .additional_workspace_configuration( - adapter.name(), - fs, - delegate, - toolchains.clone(), - cx, - ) + .additional_workspace_configuration(adapter.name(), delegate, cx) .await { merge_json_value_into(target_config.clone(), &mut workspace_config); @@ -3416,17 +3562,63 @@ pub struct LspStore { nonce: u128, buffer_store: Entity, worktree_store: Entity, - toolchain_store: Option>, pub languages: Arc, - language_server_statuses: BTreeMap, + pub language_server_statuses: BTreeMap, active_entry: Option, _maintain_workspace_config: (Task>, watch::Sender<()>), _maintain_buffer_languages: Task<()>, diagnostic_summaries: - HashMap, HashMap>>, - pub(super) lsp_server_capabilities: HashMap, - lsp_document_colors: HashMap, - lsp_code_lens: HashMap, + HashMap, HashMap>>, + pub lsp_server_capabilities: HashMap, + lsp_data: HashMap, + next_hint_id: Arc, +} + +#[derive(Debug)] +pub struct BufferLspData { + buffer_version: Global, + document_colors: Option, + code_lens: Option, + inlay_hints: BufferInlayHints, + lsp_requests: HashMap>>, + chunk_lsp_requests: HashMap>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct LspKey { + request_type: TypeId, + server_queried: Option, +} + +impl BufferLspData { + fn new(buffer: &Entity, cx: &mut App) -> Self { + Self { + buffer_version: buffer.read(cx).version(), + document_colors: None, + code_lens: None, + inlay_hints: BufferInlayHints::new(buffer, cx), + lsp_requests: HashMap::default(), + chunk_lsp_requests: HashMap::default(), + } + } + + fn remove_server_data(&mut self, for_server: LanguageServerId) { + if let Some(document_colors) = &mut self.document_colors { + document_colors.colors.remove(&for_server); + document_colors.cache_version += 1; + } + + if let Some(code_lens) = &mut self.code_lens { + code_lens.lens.remove(&for_server); + } + + self.inlay_hints.remove_server_data(for_server); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn inlay_hints(&self) -> &BufferInlayHints { + &self.inlay_hints + } } #[derive(Debug, Default, Clone)] @@ -3436,11 +3628,10 @@ pub struct DocumentColors { } type DocumentColorTask = Shared>>>; -type CodeLensTask = Shared, Arc>>>; +type CodeLensTask = Shared>, Arc>>>; #[derive(Debug, Default)] struct DocumentColorData { - colors_for_version: Global, colors: HashMap>, cache_version: usize, colors_update: Option<(Global, DocumentColorTask)>, @@ -3448,17 +3639,10 @@ struct DocumentColorData { #[derive(Debug, Default)] struct CodeLensData { - lens_for_version: Global, lens: HashMap>, update: Option<(Global, CodeLensTask)>, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum LspFetchStrategy { - IgnoreCache, - UseCache { known_cache_version: Option }, -} - #[derive(Debug)] pub enum LspStoreEvent { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), @@ -3475,7 +3659,7 @@ pub enum LspStoreEvent { new_language: Option>, }, Notification(String), - RefreshInlayHints, + RefreshInlayHints(LanguageServerId), RefreshCodeLens, DiagnosticsUpdated { server_id: LanguageServerId, @@ -3497,9 +3681,10 @@ pub enum LspStoreEvent { #[derive(Clone, Debug, Serialize)] pub struct LanguageServerStatus { pub name: LanguageServerName, - pub pending_work: BTreeMap, + pub pending_work: BTreeMap, pub has_pending_diagnostic_updates: bool, - progress_tokens: HashSet, + progress_tokens: HashSet, + pub worktree: Option, } #[derive(Clone, Debug)] @@ -3507,16 +3692,34 @@ struct CoreSymbol { pub language_server_name: LanguageServerName, pub source_worktree_id: WorktreeId, pub source_language_server_id: LanguageServerId, - pub path: ProjectPath, + pub path: SymbolLocation, pub name: String, pub kind: lsp::SymbolKind, pub range: Range>, - pub signature: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SymbolLocation { + InProject(ProjectPath), + OutsideProject { + abs_path: Arc, + signature: [u8; 32], + }, +} + +impl SymbolLocation { + fn file_name(&self) -> Option<&str> { + match self { + Self::InProject(path) => path.path.file_name(), + Self::OutsideProject { abs_path, .. } => abs_path.file_name()?.to_str(), + } + } } impl LspStore { pub fn init(client: &AnyProtoClient) { - client.add_entity_request_handler(Self::handle_multi_lsp_query); + client.add_entity_request_handler(Self::handle_lsp_query); + client.add_entity_message_handler(Self::handle_lsp_query_response); client.add_entity_request_handler(Self::handle_restart_language_servers); client.add_entity_request_handler(Self::handle_stop_language_servers); client.add_entity_request_handler(Self::handle_cancel_language_server_work); @@ -3528,7 +3731,6 @@ impl LspStore { client.add_entity_request_handler(Self::handle_apply_code_action_kind); client.add_entity_request_handler(Self::handle_resolve_completion_documentation); client.add_entity_request_handler(Self::handle_apply_code_action); - client.add_entity_request_handler(Self::handle_inlay_hints); client.add_entity_request_handler(Self::handle_get_project_symbols); client.add_entity_request_handler(Self::handle_resolve_inlay_hint); client.add_entity_request_handler(Self::handle_get_color_presentation); @@ -3540,9 +3742,7 @@ impl LspStore { client.add_entity_request_handler(Self::handle_register_buffer_with_language_servers); client.add_entity_request_handler(Self::handle_rename_project_entry); client.add_entity_request_handler(Self::handle_pull_workspace_diagnostics); - client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); - client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_command::); @@ -3563,7 +3763,6 @@ impl LspStore { client.add_entity_request_handler( Self::handle_lsp_command::, ); - client.add_entity_request_handler(Self::handle_lsp_command::); } pub fn as_remote(&self) -> Option<&RemoteLspStore> { @@ -3607,7 +3806,7 @@ impl LspStore { buffer_store: Entity, worktree_store: Entity, prettier_store: Entity, - toolchain_store: Entity, + toolchain_store: Entity, environment: Entity, manifest_tree: Entity, languages: Arc, @@ -3624,32 +3823,20 @@ impl LspStore { .detach(); cx.subscribe(&toolchain_store, Self::on_toolchain_store_event) .detach(); - if let Some(extension_events) = extension::ExtensionEvents::try_global(cx).as_ref() { - cx.subscribe( - extension_events, - Self::reload_zed_json_schemas_on_extensions_changed, - ) - .detach(); - } else { - log::debug!("No extension events global found. Skipping JSON schema auto-reload setup"); - } cx.observe_global::(Self::on_settings_changed) .detach(); subscribe_to_binary_statuses(&languages, cx).detach(); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); - ( - Self::maintain_workspace_config(fs.clone(), receiver, cx), - sender, - ) + (Self::maintain_workspace_config(receiver, cx), sender) }; Self { mode: LspStoreMode::Local(LocalLspStore { weak: cx.weak_entity(), worktree_store: worktree_store.clone(), - toolchain_store: toolchain_store.clone(), + supplementary_language_servers: Default::default(), languages: languages.clone(), language_server_ids: Default::default(), @@ -3657,7 +3844,7 @@ impl LspStore { last_workspace_edits_by_language_server: Default::default(), language_server_watched_paths: Default::default(), language_server_paths_watched_for_rename: Default::default(), - language_server_watcher_registrations: Default::default(), + language_server_dynamic_registrations: Default::default(), buffers_being_formatted: Default::default(), buffer_snapshots: Default::default(), prettier_store, @@ -3672,23 +3859,29 @@ impl LspStore { .unwrap() .shutdown_language_servers_on_quit(cx) }), - lsp_tree: LanguageServerTree::new(manifest_tree, languages.clone(), cx), + lsp_tree: LanguageServerTree::new( + manifest_tree, + languages.clone(), + toolchain_store.clone(), + ), + toolchain_store, registered_buffers: HashMap::default(), buffers_opened_in_servers: HashMap::default(), buffer_pull_diagnostics_result_ids: HashMap::default(), + watched_manifest_filenames: ManifestProvidersStore::global(cx) + .manifest_file_names(), }), last_formatting_failure: None, downstream_client: None, buffer_store, worktree_store, - toolchain_store: Some(toolchain_store), languages: languages.clone(), language_server_statuses: Default::default(), - nonce: StdRng::from_entropy().r#gen(), + nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), - lsp_document_colors: HashMap::default(), - lsp_code_lens: HashMap::default(), + lsp_data: HashMap::default(), + next_hint_id: Arc::default(), active_entry: None, _maintain_workspace_config, _maintain_buffer_languages: Self::maintain_buffer_languages(languages, cx), @@ -3719,11 +3912,9 @@ impl LspStore { pub(super) fn new_remote( buffer_store: Entity, worktree_store: Entity, - toolchain_store: Option>, languages: Arc, upstream_client: AnyProtoClient, project_id: u64, - fs: Arc, cx: &mut Context, ) -> Self { cx.subscribe(&buffer_store, Self::on_buffer_store_event) @@ -3733,7 +3924,7 @@ impl LspStore { subscribe_to_binary_statuses(&languages, cx).detach(); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); - (Self::maintain_workspace_config(fs, receiver, cx), sender) + (Self::maintain_workspace_config(receiver, cx), sender) }; Self { mode: LspStoreMode::Remote(RemoteLspStore { @@ -3746,13 +3937,13 @@ impl LspStore { worktree_store, languages: languages.clone(), language_server_statuses: Default::default(), - nonce: StdRng::from_entropy().r#gen(), + nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), - lsp_document_colors: HashMap::default(), - lsp_code_lens: HashMap::default(), + next_hint_id: Arc::default(), + lsp_data: HashMap::default(), active_entry: None, - toolchain_store, + _maintain_workspace_config, _maintain_buffer_languages: Self::maintain_buffer_languages(languages.clone(), cx), } @@ -3770,13 +3961,13 @@ impl LspStore { } BufferStoreEvent::BufferChangedFilePath { buffer, old_file } => { let buffer_id = buffer.read(cx).remote_id(); - if let Some(local) = self.as_local_mut() { - if let Some(old_file) = File::from_dyn(old_file.as_ref()) { - local.reset_buffer(buffer, old_file, cx); + if let Some(local) = self.as_local_mut() + && let Some(old_file) = File::from_dyn(old_file.as_ref()) + { + local.reset_buffer(buffer, old_file, cx); - if local.registered_buffers.contains_key(&buffer_id) { - local.unregister_old_buffer_from_language_servers(buffer, old_file, cx); - } + if local.registered_buffers.contains_key(&buffer_id) { + local.unregister_old_buffer_from_language_servers(buffer, old_file, cx); } } @@ -3851,14 +4042,12 @@ impl LspStore { fn on_toolchain_store_event( &mut self, - _: Entity, + _: Entity, event: &ToolchainStoreEvent, _: &mut Context, ) { - match event { - ToolchainStoreEvent::ToolchainActivated { .. } => { - self.request_workspace_config_refresh() - } + if let ToolchainStoreEvent::ToolchainActivated = event { + self.request_workspace_config_refresh() } } @@ -3907,108 +4096,6 @@ impl LspStore { Ok(()) } - pub fn reload_zed_json_schemas_on_extensions_changed( - &mut self, - _: Entity, - evt: &extension::Event, - cx: &mut Context, - ) { - match evt { - extension::Event::ExtensionInstalled(_) - | extension::Event::ExtensionUninstalled(_) - | extension::Event::ConfigureExtensionRequested(_) => return, - extension::Event::ExtensionsInstalledChanged => {} - } - if self.as_local().is_none() { - return; - } - cx.spawn(async move |this, cx| { - let weak_ref = this.clone(); - - let servers = this - .update(cx, |this, cx| { - let local = this.as_local()?; - - let mut servers = Vec::new(); - for ((worktree_id, _), server_ids) in &local.language_server_ids { - for server_id in server_ids { - let Some(states) = local.language_servers.get(server_id) else { - continue; - }; - let (json_adapter, json_server) = match states { - LanguageServerState::Running { - adapter, server, .. - } if adapter.adapter.is_primary_zed_json_schema_adapter() => { - (adapter.adapter.clone(), server.clone()) - } - _ => continue, - }; - - let Some(worktree) = this - .worktree_store - .read(cx) - .worktree_for_id(*worktree_id, cx) - else { - continue; - }; - let json_delegate: Arc = - LocalLspAdapterDelegate::new( - local.languages.clone(), - &local.environment, - weak_ref.clone(), - &worktree, - local.http_client.clone(), - local.fs.clone(), - cx, - ); - - servers.push((json_adapter, json_server, json_delegate)); - } - } - return Some(servers); - }) - .ok() - .flatten(); - - let Some(servers) = servers else { - return; - }; - - let Ok(Some((fs, toolchain_store))) = this.read_with(cx, |this, cx| { - let local = this.as_local()?; - let toolchain_store = this.toolchain_store(cx); - return Some((local.fs.clone(), toolchain_store)); - }) else { - return; - }; - for (adapter, server, delegate) in servers { - adapter.clear_zed_json_schema_cache().await; - - let Some(json_workspace_config) = LocalLspStore::workspace_configuration_for_adapter( - adapter, - fs.as_ref(), - &delegate, - toolchain_store.clone(), - cx, - ) - .await - .context("generate new workspace configuration for JSON language server while trying to refresh JSON Schemas") - .ok() - else { - continue; - }; - server - .notify::( - &lsp::DidChangeConfigurationParams { - settings: json_workspace_config, - }, - ) - .ok(); - } - }) - .detach(); - } - pub(crate) fn register_buffer_with_language_servers( &mut self, buffer: &Entity, @@ -4051,13 +4138,12 @@ impl LspStore { *refcount }; if refcount == 0 { - lsp_store.lsp_document_colors.remove(&buffer_id); - lsp_store.lsp_code_lens.remove(&buffer_id); + lsp_store.lsp_data.remove(&buffer_id); let local = lsp_store.as_local_mut().unwrap(); local.registered_buffers.remove(&buffer_id); local.buffers_opened_in_servers.remove(&buffer_id); if let Some(file) = File::from_dyn(buffer.read(cx).file()).cloned() { - local.unregister_old_buffer_from_language_servers(&buffer, &file, cx); + local.unregister_old_buffer_from_language_servers(buffer, &file, cx); } } }) @@ -4127,14 +4213,12 @@ impl LspStore { if local .registered_buffers .contains_key(&buffer.read(cx).remote_id()) - { - if let Some(file_url) = + && let Some(file_url) = file_path_to_lsp_url(&f.abs_path(cx)).log_err() - { - local.unregister_buffer_from_language_servers( - &buffer, &file_url, cx, - ); - } + { + local.unregister_buffer_from_language_servers( + &buffer, &file_url, cx, + ); } } } @@ -4232,25 +4316,19 @@ impl LspStore { let buffer = buffer_entity.read(cx); let buffer_file = buffer.file().cloned(); let buffer_id = buffer.remote_id(); - if let Some(local_store) = self.as_local_mut() { - if local_store.registered_buffers.contains_key(&buffer_id) { - if let Some(abs_path) = - File::from_dyn(buffer_file.as_ref()).map(|file| file.abs_path(cx)) - { - if let Some(file_url) = file_path_to_lsp_url(&abs_path).log_err() { - local_store.unregister_buffer_from_language_servers( - buffer_entity, - &file_url, - cx, - ); - } - } - } + if let Some(local_store) = self.as_local_mut() + && local_store.registered_buffers.contains_key(&buffer_id) + && let Some(abs_path) = + File::from_dyn(buffer_file.as_ref()).map(|file| file.abs_path(cx)) + && let Some(file_url) = file_path_to_lsp_url(&abs_path).log_err() + { + local_store.unregister_buffer_from_language_servers(buffer_entity, &file_url, cx); } buffer_entity.update(cx, |buffer, cx| { - if buffer.language().map_or(true, |old_language| { - !Arc::ptr_eq(old_language, &new_language) - }) { + if buffer + .language() + .is_none_or(|old_language| !Arc::ptr_eq(old_language, &new_language)) + { buffer.set_language(Some(new_language.clone()), cx); } }); @@ -4262,33 +4340,28 @@ impl LspStore { let worktree_id = if let Some(file) = buffer_file { let worktree = file.worktree.clone(); - if let Some(local) = self.as_local_mut() { - if local.registered_buffers.contains_key(&buffer_id) { - local.register_buffer_with_language_servers( - buffer_entity, - HashSet::default(), - cx, - ); - } + if let Some(local) = self.as_local_mut() + && local.registered_buffers.contains_key(&buffer_id) + { + local.register_buffer_with_language_servers(buffer_entity, HashSet::default(), cx); } Some(worktree.read(cx).id()) } else { None }; - if settings.prettier.allowed { - if let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) - { - let prettier_store = self.as_local().map(|s| s.prettier_store.clone()); - if let Some(prettier_store) = prettier_store { - prettier_store.update(cx, |prettier_store, cx| { - prettier_store.install_default_prettier( - worktree_id, - prettier_plugins.iter().map(|s| Arc::from(s.as_str())), - cx, - ) - }) - } + if settings.prettier.allowed + && let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) + { + let prettier_store = self.as_local().map(|s| s.prettier_store.clone()); + if let Some(prettier_store) = prettier_store { + prettier_store.update(cx, |prettier_store, cx| { + prettier_store.install_default_prettier( + worktree_id, + prettier_plugins.iter().map(|s| Arc::from(s.as_str())), + cx, + ) + }) } } @@ -4307,37 +4380,32 @@ impl LspStore { } pub(crate) fn send_diagnostic_summaries(&self, worktree: &mut Worktree) { - if let Some((client, downstream_project_id)) = self.downstream_client.clone() { - if let Some(diangostic_summaries) = self.diagnostic_summaries.get(&worktree.id()) { - let mut summaries = - diangostic_summaries - .into_iter() - .flat_map(|(path, summaries)| { - summaries - .into_iter() - .map(|(server_id, summary)| summary.to_proto(*server_id, path)) - }); - if let Some(summary) = summaries.next() { - client - .send(proto::UpdateDiagnosticSummary { - project_id: downstream_project_id, - worktree_id: worktree.id().to_proto(), - summary: Some(summary), - more_summaries: summaries.collect(), - }) - .log_err(); - } + if let Some((client, downstream_project_id)) = self.downstream_client.clone() + && let Some(diangostic_summaries) = self.diagnostic_summaries.get(&worktree.id()) + { + let mut summaries = diangostic_summaries.iter().flat_map(|(path, summaries)| { + summaries + .iter() + .map(|(server_id, summary)| summary.to_proto(*server_id, path.as_ref())) + }); + if let Some(summary) = summaries.next() { + client + .send(proto::UpdateDiagnosticSummary { + project_id: downstream_project_id, + worktree_id: worktree.id().to_proto(), + summary: Some(summary), + more_summaries: summaries.collect(), + }) + .log_err(); } } } - // TODO: remove MultiLspQuery: instead, the proto handler should pick appropriate server(s) - // Then, use `send_lsp_proto_request` or analogue for most of the LSP proto requests and inline this check inside fn is_capable_for_proto_request( &self, buffer: &Entity, request: &R, - cx: &Context, + cx: &App, ) -> bool where R: LspCommand, @@ -4358,10 +4426,10 @@ impl LspStore { &self, buffer: &Entity, check: F, - cx: &Context, + cx: &App, ) -> bool where - F: Fn(&lsp::ServerCapabilities) -> bool, + F: FnMut(&lsp::ServerCapabilities) -> bool, { let Some(language) = buffer.read(cx).language().cloned() else { return false; @@ -4379,7 +4447,7 @@ impl LspStore { .contains(&server_status.name) .then_some(server_id) }) - .filter_map(|server_id| self.lsp_server_capabilities.get(&server_id)) + .filter_map(|server_id| self.lsp_server_capabilities.get(server_id)) .any(check) } @@ -4456,7 +4524,7 @@ impl LspStore { if !request.check_capabilities(language_server.adapter_server_capabilities()) { return Task::ready(Ok(Default::default())); } - return cx.spawn(async move |this, cx| { + cx.spawn(async move |this, cx| { let lsp_request = language_server.request::(lsp_params); let id = lsp_request.id(); @@ -4465,7 +4533,7 @@ impl LspStore { this.update(cx, |this, cx| { this.on_lsp_work_start( language_server.server_id(), - id.to_string(), + ProgressToken::Number(id), LanguageServerProgress { is_disk_based_diagnostics_progress: false, is_cancellable: false, @@ -4483,7 +4551,11 @@ impl LspStore { Some(defer(|| { cx.update(|cx| { this.update(cx, |this, cx| { - this.on_lsp_work_end(language_server.server_id(), id.to_string(), cx); + this.on_lsp_work_end( + language_server.server_id(), + ProgressToken::Number(id), + cx, + ); }) }) .log_err(); @@ -4505,7 +4577,7 @@ impl LspStore { anyhow::anyhow!(message) })?; - let response = request + request .response_from_lsp( response, this.upgrade().context("no app context")?, @@ -4513,9 +4585,8 @@ impl LspStore { language_server.server_id(), cx.clone(), ) - .await; - response - }); + .await + }) } fn on_settings_changed(&mut self, cx: &mut Context) { @@ -4533,7 +4604,7 @@ impl LspStore { } } - self.refresh_server_tree(cx); + self.request_workspace_config_refresh(); if let Some(prettier_store) = self.as_local().map(|s| s.prettier_store.clone()) { prettier_store.update(cx, |prettier_store, cx| { @@ -4546,158 +4617,149 @@ impl LspStore { fn refresh_server_tree(&mut self, cx: &mut Context) { let buffer_store = self.buffer_store.clone(); - if let Some(local) = self.as_local_mut() { - let mut adapters = BTreeMap::default(); - let get_adapter = { - let languages = local.languages.clone(); - let environment = local.environment.clone(); - let weak = local.weak.clone(); - let worktree_store = local.worktree_store.clone(); - let http_client = local.http_client.clone(); - let fs = local.fs.clone(); - move |worktree_id, cx: &mut App| { - let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; - Some(LocalLspAdapterDelegate::new( - languages.clone(), - &environment, - weak.clone(), - &worktree, - http_client.clone(), - fs.clone(), - cx, - )) - } - }; + let Some(local) = self.as_local_mut() else { + return; + }; + let mut adapters = BTreeMap::default(); + let get_adapter = { + let languages = local.languages.clone(); + let environment = local.environment.clone(); + let weak = local.weak.clone(); + let worktree_store = local.worktree_store.clone(); + let http_client = local.http_client.clone(); + let fs = local.fs.clone(); + move |worktree_id, cx: &mut App| { + let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; + Some(LocalLspAdapterDelegate::new( + languages.clone(), + &environment, + weak.clone(), + &worktree, + http_client.clone(), + fs.clone(), + cx, + )) + } + }; - let mut messages_to_report = Vec::new(); - let to_stop = local.lsp_tree.clone().update(cx, |lsp_tree, cx| { - let mut rebase = lsp_tree.rebase(); - for buffer_handle in buffer_store.read(cx).buffers().sorted_by_key(|buffer| { - Reverse( - File::from_dyn(buffer.read(cx).file()) - .map(|file| file.worktree.read(cx).is_visible()), - ) - }) { - let buffer = buffer_handle.read(cx); - let buffer_id = buffer.remote_id(); - if !local.registered_buffers.contains_key(&buffer_id) { - continue; - } - if let Some((file, language)) = File::from_dyn(buffer.file()) - .cloned() - .zip(buffer.language().map(|l| l.name())) + let mut messages_to_report = Vec::new(); + let (new_tree, to_stop) = { + let mut rebase = local.lsp_tree.rebase(); + let buffers = buffer_store + .read(cx) + .buffers() + .filter_map(|buffer| { + let raw_buffer = buffer.read(cx); + if !local + .registered_buffers + .contains_key(&raw_buffer.remote_id()) { - let worktree_id = file.worktree_id(cx); - let Some(worktree) = local - .worktree_store - .read(cx) - .worktree_for_id(worktree_id, cx) - else { - continue; - }; + return None; + } + let file = File::from_dyn(raw_buffer.file()).cloned()?; + let language = raw_buffer.language().cloned()?; + Some((file, language, raw_buffer.remote_id())) + }) + .sorted_by_key(|(file, _, _)| Reverse(file.worktree.read(cx).is_visible())); + for (file, language, buffer_id) in buffers { + let worktree_id = file.worktree_id(cx); + let Some(worktree) = local + .worktree_store + .read(cx) + .worktree_for_id(worktree_id, cx) + else { + continue; + }; + + if let Some((_, apply)) = local.reuse_existing_language_server( + rebase.server_tree(), + &worktree, + &language.name(), + cx, + ) { + (apply)(rebase.server_tree()); + } else if let Some(lsp_delegate) = adapters + .entry(worktree_id) + .or_insert_with(|| get_adapter(worktree_id, cx)) + .clone() + { + let delegate = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let path = file + .path() + .parent() + .map(Arc::from) + .unwrap_or_else(|| file.path().clone()); + let worktree_path = ProjectPath { worktree_id, path }; + let abs_path = file.abs_path(cx); + let nodes = rebase + .walk( + worktree_path, + language.name(), + language.manifest(), + delegate.clone(), + cx, + ) + .collect::>(); + for node in nodes { + let server_id = node.server_id_or_init(|disposition| { + let path = &disposition.path; + let uri = Uri::from_file_path(worktree.read(cx).absolutize(&path.path)); + let key = LanguageServerSeed { + worktree_id, + name: disposition.server_name.clone(), + settings: disposition.settings.clone(), + toolchain: local.toolchain_store.read(cx).active_toolchain( + path.worktree_id, + &path.path, + language.name(), + ), + }; + local.language_server_ids.remove(&key); - let Some((reused, delegate, nodes)) = local - .reuse_existing_language_server( - rebase.server_tree(), + let server_id = local.get_or_insert_language_server( &worktree, - &language, + lsp_delegate.clone(), + disposition, + &language.name(), cx, - ) - .map(|(delegate, servers)| (true, delegate, servers)) - .or_else(|| { - let lsp_delegate = adapters - .entry(worktree_id) - .or_insert_with(|| get_adapter(worktree_id, cx)) - .clone()?; - let delegate = Arc::new(ManifestQueryDelegate::new( - worktree.read(cx).snapshot(), - )); - let path = file - .path() - .parent() - .map(Arc::from) - .unwrap_or_else(|| file.path().clone()); - let worktree_path = ProjectPath { worktree_id, path }; - - let nodes = rebase.get( - worktree_path, - AdapterQuery::Language(&language), - delegate.clone(), - cx, - ); - - Some((false, lsp_delegate, nodes.collect())) - }) - else { - continue; - }; - - let abs_path = file.abs_path(cx); - for node in nodes { - if !reused { - let server_id = node.server_id_or_init( - |LaunchDisposition { - server_name, - - path, - settings, - }| - { - let uri = Url::from_file_path( - worktree.read(cx).abs_path().join(&path.path), - ); - let key = (worktree_id, server_name.clone()); - local.language_server_ids.remove(&key); - - let adapter = local - .languages - .lsp_adapters(&language) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - let server_id = local.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - if let Some(state) = - local.language_servers.get(&server_id) - { - if let Ok(uri) = uri { - state.add_workspace_folder(uri); - }; - } - server_id - } - ); + ); + if let Some(state) = local.language_servers.get(&server_id) + && let Ok(uri) = uri + { + state.add_workspace_folder(uri); + }; + server_id + }); - if let Some(language_server_id) = server_id { - messages_to_report.push(LspStoreEvent::LanguageServerUpdate { - language_server_id, - name: node.name(), - message: - proto::update_language_server::Variant::RegisteredForBuffer( - proto::RegisteredForBuffer { - buffer_abs_path: abs_path.to_string_lossy().to_string(), - buffer_id: buffer_id.to_proto(), - }, - ), - }); - } - } + if let Some(language_server_id) = server_id { + messages_to_report.push(LspStoreEvent::LanguageServerUpdate { + language_server_id, + name: node.name(), + message: + proto::update_language_server::Variant::RegisteredForBuffer( + proto::RegisteredForBuffer { + buffer_abs_path: abs_path + .to_string_lossy() + .into_owned(), + buffer_id: buffer_id.to_proto(), + }, + ), + }); } } + } else { + continue; } - rebase.finish() - }); - for message in messages_to_report { - cx.emit(message); - } - for (id, _) in to_stop { - self.stop_local_language_server(id, cx).detach(); } + rebase.finish() + }; + for message in messages_to_report { + cx.emit(message); + } + local.lsp_tree = new_tree; + for (id, _) in to_stop { + self.stop_local_language_server(id, cx).detach(); } } @@ -4729,7 +4791,7 @@ impl LspStore { .await }) } else if self.mode.is_local() { - let Some((lsp_adapter, lang_server)) = buffer_handle.update(cx, |buffer, cx| { + let Some((_, lang_server)) = buffer_handle.update(cx, |buffer, cx| { self.language_server_for_local_buffer(buffer, action.server_id, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) }) else { @@ -4739,19 +4801,18 @@ impl LspStore { LocalLspStore::try_resolve_code_action(&lang_server, &mut action) .await .context("resolving a code action")?; - if let Some(edit) = action.lsp_action.edit() { - if edit.changes.is_some() || edit.document_changes.is_some() { + if let Some(edit) = action.lsp_action.edit() + && (edit.changes.is_some() || edit.document_changes.is_some()) { return LocalLspStore::deserialize_workspace_edit( this.upgrade().context("no app present")?, edit.clone(), push_to_history, - lsp_adapter.clone(), + lang_server.clone(), cx, ) .await; } - } if let Some(command) = action.lsp_action.command() { let server_capabilities = lang_server.capabilities(); @@ -4803,7 +4864,7 @@ impl LspStore { push_to_history: bool, cx: &mut Context, ) -> Task> { - if let Some(_) = self.as_local() { + if self.as_local().is_some() { cx.spawn(async move |lsp_store, cx| { let buffers = buffers.into_iter().collect::>(); let result = LocalLspStore::execute_code_action_kind_locally( @@ -4855,7 +4916,65 @@ impl LspStore { } } - pub fn resolve_inlay_hint( + pub fn resolved_hint( + &mut self, + buffer_id: BufferId, + id: InlayId, + cx: &mut Context, + ) -> Option { + let buffer = self.buffer_store.read(cx).get(buffer_id)?; + + let lsp_data = self.lsp_data.get_mut(&buffer_id)?; + let buffer_lsp_hints = &mut lsp_data.inlay_hints; + let hint = buffer_lsp_hints.hint_for_id(id)?.clone(); + let (server_id, resolve_data) = match &hint.resolve_state { + ResolveState::Resolved => return Some(ResolvedHint::Resolved(hint)), + ResolveState::Resolving => { + return Some(ResolvedHint::Resolving( + buffer_lsp_hints.hint_resolves.get(&id)?.clone(), + )); + } + ResolveState::CanResolve(server_id, resolve_data) => (*server_id, resolve_data.clone()), + }; + + let resolve_task = self.resolve_inlay_hint(hint, buffer, server_id, cx); + let buffer_lsp_hints = &mut self.lsp_data.get_mut(&buffer_id)?.inlay_hints; + let previous_task = buffer_lsp_hints.hint_resolves.insert( + id, + cx.spawn(async move |lsp_store, cx| { + let resolved_hint = resolve_task.await; + lsp_store + .update(cx, |lsp_store, _| { + if let Some(old_inlay_hint) = lsp_store + .lsp_data + .get_mut(&buffer_id) + .and_then(|buffer_lsp_data| buffer_lsp_data.inlay_hints.hint_for_id(id)) + { + match resolved_hint { + Ok(resolved_hint) => { + *old_inlay_hint = resolved_hint; + } + Err(e) => { + old_inlay_hint.resolve_state = + ResolveState::CanResolve(server_id, resolve_data); + log::error!("Inlay hint resolve failed: {e:#}"); + } + } + } + }) + .ok(); + }) + .shared(), + ); + debug_assert!( + previous_task.is_none(), + "Did not change hint's resolve state after spawning its resolve" + ); + buffer_lsp_hints.hint_for_id(id)?.resolve_state = ResolveState::Resolving; + None + } + + fn resolve_inlay_hint( &self, mut hint: InlayHint, buffer: Entity, @@ -5193,154 +5312,132 @@ impl LspStore { pub fn definitions( &mut self, - buffer_handle: &Entity, + buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetDefinitions { position }; - if !self.is_capable_for_proto_request(buffer_handle, &request, cx) { - return Task::ready(Ok(Vec::new())); + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer_handle.read(cx).remote_id().into(), - version: serialize_version(&buffer_handle.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetDefinition( - request.to_proto(project_id, buffer_handle.read(cx)), - )), - }); - let buffer = buffer_handle.clone(); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetDefinitionResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|definitions_response| { - GetDefinitions { position }.response_from_proto( - definitions_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + GetDefinitions { position }.response_from_proto( + response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) .await; - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .dedup() - .collect()) + Ok(Some( + actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .dedup() + .collect(), + )) }) } else { let definitions_task = self.request_multiple_lsp_locally( - buffer_handle, + buffer, Some(position), GetDefinitions { position }, cx, ); cx.background_spawn(async move { - Ok(definitions_task - .await - .into_iter() - .flat_map(|(_, definitions)| definitions) - .dedup() - .collect()) + Ok(Some( + definitions_task + .await + .into_iter() + .flat_map(|(_, definitions)| definitions) + .dedup() + .collect(), + )) }) } } pub fn declarations( &mut self, - buffer_handle: &Entity, + buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetDeclarations { position }; - if !self.is_capable_for_proto_request(buffer_handle, &request, cx) { - return Task::ready(Ok(Vec::new())); + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer_handle.read(cx).remote_id().into(), - version: serialize_version(&buffer_handle.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetDeclaration( - request.to_proto(project_id, buffer_handle.read(cx)), - )), - }); - let buffer = buffer_handle.clone(); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetDeclarationResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|declarations_response| { - GetDeclarations { position }.response_from_proto( - declarations_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + GetDeclarations { position }.response_from_proto( + response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) .await; - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .dedup() - .collect()) + Ok(Some( + actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .dedup() + .collect(), + )) }) } else { let declarations_task = self.request_multiple_lsp_locally( - buffer_handle, + buffer, Some(position), GetDeclarations { position }, cx, ); cx.background_spawn(async move { - Ok(declarations_task - .await - .into_iter() - .flat_map(|(_, declarations)| declarations) - .dedup() - .collect()) + Ok(Some( + declarations_task + .await + .into_iter() + .flat_map(|(_, declarations)| declarations) + .dedup() + .collect(), + )) }) } } @@ -5350,59 +5447,46 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetTypeDefinitions { position }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(Vec::new())); + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetTypeDefinition( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetTypeDefinitionResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|type_definitions_response| { - GetTypeDefinitions { position }.response_from_proto( - type_definitions_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + GetTypeDefinitions { position }.response_from_proto( + response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) .await; - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .dedup() - .collect()) + Ok(Some( + actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .dedup() + .collect(), + )) }) } else { let type_definitions_task = self.request_multiple_lsp_locally( @@ -5412,12 +5496,14 @@ impl LspStore { cx, ); cx.background_spawn(async move { - Ok(type_definitions_task - .await - .into_iter() - .flat_map(|(_, type_definitions)| type_definitions) - .dedup() - .collect()) + Ok(Some( + type_definitions_task + .await + .into_iter() + .flat_map(|(_, type_definitions)| type_definitions) + .dedup() + .collect(), + )) }) } } @@ -5427,59 +5513,46 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetImplementations { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Ok(Vec::new())); + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetImplementation( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetImplementationResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|implementations_response| { - GetImplementations { position }.response_from_proto( - implementations_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) + let Some(responses) = request_task.await? else { + return Ok(None); + }; + let actions = join_all(responses.payload.into_iter().map(|response| { + GetImplementations { position }.response_from_proto( + response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) .await; - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .dedup() - .collect()) + Ok(Some( + actions + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .dedup() + .collect(), + )) }) } else { let implementations_task = self.request_multiple_lsp_locally( @@ -5489,12 +5562,14 @@ impl LspStore { cx, ); cx.background_spawn(async move { - Ok(implementations_task - .await - .into_iter() - .flat_map(|(_, implementations)| implementations) - .dedup() - .collect()) + Ok(Some( + implementations_task + .await + .into_iter() + .flat_map(|(_, implementations)| implementations) + .dedup() + .collect(), + )) }) } } @@ -5504,59 +5579,45 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetReferences { position }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(Vec::new())); + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), + + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetReferences( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetReferencesResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|references_response| { - GetReferences { position }.response_from_proto( - references_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) - .await; - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .dedup() - .collect()) + let locations = join_all(responses.payload.into_iter().map(|lsp_response| { + GetReferences { position }.response_from_proto( + lsp_response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await + .into_iter() + .collect::>>>()? + .into_iter() + .flatten() + .dedup() + .collect(); + Ok(Some(locations)) }) } else { let references_task = self.request_multiple_lsp_locally( @@ -5566,12 +5627,14 @@ impl LspStore { cx, ); cx.background_spawn(async move { - Ok(references_task - .await - .into_iter() - .flat_map(|(_, references)| references) - .dedup() - .collect()) + Ok(Some( + references_task + .await + .into_iter() + .flat_map(|(_, references)| references) + .dedup() + .collect(), + )) }) } } @@ -5582,82 +5645,68 @@ impl LspStore { range: Range, kinds: Option>, cx: &mut Context, - ) -> Task>> { + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetCodeActions { range: range.clone(), kinds: kinds.clone(), }; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Ok(Vec::new())); + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetCodeActions( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); }; - let responses = request_task.await?.responses; - let actions = join_all( - responses + let actions = join_all(responses.payload.into_iter().map(|response| { + GetCodeActions { + range: range.clone(), + kinds: kinds.clone(), + } + .response_from_proto( + response.response, + project.clone(), + buffer.clone(), + cx.clone(), + ) + })) + .await; + + Ok(Some( + actions .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetCodeActionsResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|code_actions_response| { - GetCodeActions { - range: range.clone(), - kinds: kinds.clone(), - } - .response_from_proto( - code_actions_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) - }), - ) - .await; - - Ok(actions - .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .collect()) + .collect::>>>()? + .into_iter() + .flatten() + .collect(), + )) }) } else { let all_actions_task = self.request_multiple_lsp_locally( buffer, Some(range.start), - GetCodeActions { - range: range.clone(), - kinds: kinds.clone(), - }, + GetCodeActions { range, kinds }, cx, ); cx.background_spawn(async move { - Ok(all_actions_task - .await - .into_iter() - .flat_map(|(_, actions)| actions) - .collect()) + Ok(Some( + all_actions_task + .await + .into_iter() + .flat_map(|(_, actions)| actions) + .collect(), + )) }) } } @@ -5669,30 +5718,38 @@ impl LspStore { ) -> CodeLensTask { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); + let existing_servers = self.as_local().map(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }); - if let Some(cached_data) = self.lsp_code_lens.get(&buffer_id) { - if !version_queried_for.changed_since(&cached_data.lens_for_version) { - let has_different_servers = self.as_local().is_some_and(|local| { - local - .buffers_opened_in_servers - .get(&buffer_id) - .cloned() - .unwrap_or_default() - != cached_data.lens.keys().copied().collect() - }); - if !has_different_servers { - return Task::ready(Ok(cached_data.lens.values().flatten().cloned().collect())) + if let Some(lsp_data) = self.current_lsp_data(buffer_id) { + if let Some(cached_lens) = &lsp_data.code_lens { + if !version_queried_for.changed_since(&lsp_data.buffer_version) { + let has_different_servers = existing_servers.is_some_and(|existing_servers| { + existing_servers != cached_lens.lens.keys().copied().collect() + }); + if !has_different_servers { + return Task::ready(Ok(Some( + cached_lens.lens.values().flatten().cloned().collect(), + ))) .shared(); + } + } else if let Some((updating_for, running_update)) = cached_lens.update.as_ref() { + if !version_queried_for.changed_since(updating_for) { + return running_update.clone(); + } } } } - let lsp_data = self.lsp_code_lens.entry(buffer_id).or_default(); - if let Some((updating_for, running_update)) = &lsp_data.update { - if !version_queried_for.changed_since(&updating_for) { - return running_update.clone(); - } - } + let lens_lsp_data = self + .latest_lsp_data(buffer, cx) + .code_lens + .get_or_insert_default(); let buffer = buffer.clone(); let query_version_queried_for = version_queried_for.clone(); let new_task = cx @@ -5711,7 +5768,13 @@ impl LspStore { Err(e) => { lsp_store .update(cx, |lsp_store, _| { - lsp_store.lsp_code_lens.entry(buffer_id).or_default().update = None; + if let Some(lens_lsp_data) = lsp_store + .lsp_data + .get_mut(&buffer_id) + .and_then(|lsp_data| lsp_data.code_lens.as_mut()) + { + lens_lsp_data.update = None; + } }) .ok(); return Err(e); @@ -5720,23 +5783,26 @@ impl LspStore { lsp_store .update(cx, |lsp_store, _| { - let lsp_data = lsp_store.lsp_code_lens.entry(buffer_id).or_default(); - if lsp_data.lens_for_version == query_version_queried_for { - lsp_data.lens.extend(fetched_lens.clone()); - } else if !lsp_data - .lens_for_version - .changed_since(&query_version_queried_for) - { - lsp_data.lens_for_version = query_version_queried_for; - lsp_data.lens = fetched_lens.clone(); + let lsp_data = lsp_store.current_lsp_data(buffer_id)?; + let code_lens = lsp_data.code_lens.as_mut()?; + if let Some(fetched_lens) = fetched_lens { + if lsp_data.buffer_version == query_version_queried_for { + code_lens.lens.extend(fetched_lens); + } else if !lsp_data + .buffer_version + .changed_since(&query_version_queried_for) + { + lsp_data.buffer_version = query_version_queried_for; + code_lens.lens = fetched_lens; + } } - lsp_data.update = None; - lsp_data.lens.values().flatten().cloned().collect() + code_lens.update = None; + Some(code_lens.lens.values().flatten().cloned().collect()) }) .map_err(Arc::new) }) .shared(); - lsp_data.update = Some((version_queried_for, new_task.clone())); + lens_lsp_data.update = Some((version_queried_for, new_task.clone())); new_task } @@ -5744,64 +5810,41 @@ impl LspStore { &mut self, buffer: &Entity, cx: &mut Context, - ) -> Task>>> { + ) -> Task>>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetCodeLens; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Ok(HashMap::default())); + return Task::ready(Ok(None)); } - let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), + let request_task = upstream_client.request_lsp( project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetCodeLens( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_lsp_store, cx| { let Some(lsp_store) = weak_lsp_store.upgrade() else { - return Ok(HashMap::default()); + return Ok(None); }; - let responses = request_task.await?.responses; - let code_lens_actions = join_all( - responses - .into_iter() - .filter_map(|lsp_response| { - let response = match lsp_response.response? { - proto::lsp_response::Response::GetCodeLensResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }?; - let server_id = LanguageServerId::from_proto(lsp_response.server_id); - Some((server_id, response)) - }) - .map(|(server_id, code_lens_response)| { - let lsp_store = lsp_store.clone(); - let buffer = buffer.clone(); - let cx = cx.clone(); - async move { - ( - server_id, - GetCodeLens - .response_from_proto( - code_lens_response, - lsp_store, - buffer, - cx, - ) - .await, - ) - } - }), - ) + let Some(responses) = request_task.await? else { + return Ok(None); + }; + + let code_lens_actions = join_all(responses.payload.into_iter().map(|response| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + async move { + ( + LanguageServerId::from_proto(response.server_id), + GetCodeLens + .response_from_proto(response.response, lsp_store, buffer, cx) + .await, + ) + } + })) .await; let mut has_errors = false; @@ -5820,14 +5863,14 @@ impl LspStore { !has_errors || !code_lens_actions.is_empty(), "Failed to fetch code lens" ); - Ok(code_lens_actions) + Ok(Some(code_lens_actions)) }) } else { let code_lens_actions_task = self.request_multiple_lsp_locally(buffer, None::, GetCodeLens, cx); - cx.background_spawn( - async move { Ok(code_lens_actions_task.await.into_iter().collect()) }, - ) + cx.background_spawn(async move { + Ok(Some(code_lens_actions_task.await.into_iter().collect())) + }) } } @@ -5875,6 +5918,7 @@ impl LspStore { .await; Ok(vec![CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), is_incomplete: completion_response.is_incomplete, }]) }) @@ -5888,7 +5932,8 @@ impl LspStore { buffer.read(cx).file(), cx, ) - .completions; + .completions + .clone(); if !completion_settings.lsp { return Task::ready(Ok(Vec::new())); } @@ -5967,6 +6012,7 @@ impl LspStore { .await; Some(CompletionResponse { completions, + display_options: CompletionDisplayOptions::default(), is_incomplete: completion_response.is_incomplete, }) }); @@ -6306,11 +6352,11 @@ impl LspStore { .old_replace_start .and_then(deserialize_anchor) .zip(response.old_replace_end.and_then(deserialize_anchor)); - if let Some((old_replace_start, old_replace_end)) = replace_range { - if !response.new_text.is_empty() { - completion.new_text = response.new_text; - completion.replace_range = old_replace_start..old_replace_end; - } + if let Some((old_replace_start, old_replace_end)) = replace_range + && !response.new_text.is_empty() + { + completion.new_text = response.new_text; + completion.replace_range = old_replace_start..old_replace_end; } Ok(()) @@ -6405,14 +6451,38 @@ impl LspStore { for (range, text) in edits { let primary = &completion.replace_range; - let start_within = primary.start.cmp(&range.start, buffer).is_le() - && primary.end.cmp(&range.start, buffer).is_ge(); - let end_within = range.start.cmp(&primary.end, buffer).is_le() - && range.end.cmp(&primary.end, buffer).is_ge(); + + // Special case: if both ranges start at the very beginning of the file (line 0, column 0), + // and the primary completion is just an insertion (empty range), then this is likely + // an auto-import scenario and should not be considered overlapping + // https://github.com/zed-industries/zed/issues/26136 + let is_file_start_auto_import = { + let snapshot = buffer.snapshot(); + let primary_start_point = primary.start.to_point(&snapshot); + let range_start_point = range.start.to_point(&snapshot); + + let result = primary_start_point.row == 0 + && primary_start_point.column == 0 + && range_start_point.row == 0 + && range_start_point.column == 0; + + result + }; + + let has_overlap = if is_file_start_auto_import { + false + } else { + let start_within = primary.start.cmp(&range.start, buffer).is_le() + && primary.end.cmp(&range.start, buffer).is_ge(); + let end_within = range.start.cmp(&primary.end, buffer).is_le() + && range.end.cmp(&primary.end, buffer).is_ge(); + let result = start_within || end_within; + result + }; //Skip additional edits which overlap with the primary completion edit //https://github.com/zed-industries/zed/pull/1871 - if !start_within && !end_within { + if !has_overlap { buffer.edit([(range, text)], None, cx); } } @@ -6443,68 +6513,83 @@ impl LspStore { let buffer_id = buffer.read(cx).remote_id(); if let Some((client, upstream_project_id)) = self.upstream_client() { - if !self.is_capable_for_proto_request( + let mut suitable_capabilities = None; + // Are we capable for proto request? + let any_server_has_diagnostics_provider = self.check_if_capable_for_proto_request( &buffer, - &GetDocumentDiagnostics { - previous_result_id: None, + |capabilities| { + if let Some(caps) = &capabilities.diagnostic_provider { + suitable_capabilities = Some(caps.clone()); + true + } else { + false + } }, cx, - ) { + ); + // We don't really care which caps are passed into the request, as they're ignored by RPC anyways. + let Some(dynamic_caps) = suitable_capabilities else { return Task::ready(Ok(None)); - } - let request_task = client.request(proto::MultiLspQuery { - buffer_id: buffer_id.to_proto(), - version: serialize_version(&buffer.read(cx).version()), - project_id: upstream_project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetDocumentDiagnostics( - proto::GetDocumentDiagnostics { - project_id: upstream_project_id, - buffer_id: buffer_id.to_proto(), - version: serialize_version(&buffer.read(cx).version()), - }, - )), - }); + }; + assert!(any_server_has_diagnostics_provider); + + let request = GetDocumentDiagnostics { + previous_result_id: None, + dynamic_caps, + }; + let request_task = client.request_lsp( + upstream_project_id, + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(upstream_project_id, buffer.read(cx)), + ); cx.background_spawn(async move { - let _proto_responses = request_task - .await? - .responses - .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetDocumentDiagnosticsResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .collect::>(); // Proto requests cause the diagnostics to be pulled from language server(s) on the local side // and then, buffer state updated with the diagnostics received, which will be later propagated to the client. // Do not attempt to further process the dummy responses here. + let _response = request_task.await?; Ok(None) }) } else { - let server_ids = buffer.update(cx, |buffer, cx| { + let servers = buffer.update(cx, |buffer, cx| { self.language_servers_for_local_buffer(buffer, cx) - .map(|(_, server)| server.server_id()) + .map(|(_, server)| server.clone()) .collect::>() }); - let pull_diagnostics = server_ids + + let pull_diagnostics = servers .into_iter() - .map(|server_id| { - let result_id = self.result_id(server_id, buffer_id, cx); - self.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - GetDocumentDiagnostics { - previous_result_id: result_id, - }, - cx, - ) + .flat_map(|server| { + let result = maybe!({ + let local = self.as_local()?; + let server_id = server.server_id(); + let providers_with_identifiers = local + .language_server_dynamic_registrations + .get(&server_id) + .into_iter() + .flat_map(|registrations| registrations.diagnostics.values().cloned()) + .collect::>(); + Some( + providers_with_identifiers + .into_iter() + .map(|dynamic_caps| { + let result_id = self.result_id(server_id, buffer_id, cx); + self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + GetDocumentDiagnostics { + previous_result_id: result_id, + dynamic_caps, + }, + cx, + ) + }) + .collect::>(), + ) + }); + + result.unwrap_or_default() }) .collect::>(); @@ -6518,58 +6603,305 @@ impl LspStore { } } + pub fn applicable_inlay_chunks( + &mut self, + buffer: &Entity, + ranges: &[Range], + cx: &mut Context, + ) -> Vec> { + self.latest_lsp_data(buffer, cx) + .inlay_hints + .applicable_chunks(ranges) + .map(|chunk| chunk.start..chunk.end) + .collect() + } + + pub fn invalidate_inlay_hints<'a>( + &'a mut self, + for_buffers: impl IntoIterator + 'a, + ) { + for buffer_id in for_buffers { + if let Some(lsp_data) = self.lsp_data.get_mut(buffer_id) { + lsp_data.inlay_hints.clear(); + } + } + } + pub fn inlay_hints( &mut self, + invalidate: InvalidationStrategy, buffer: Entity, - range: Range, + ranges: Vec>, + known_chunks: Option<(clock::Global, HashSet>)>, cx: &mut Context, - ) -> Task>> { - let range_start = range.start; - let range_end = range.end; - let buffer_id = buffer.read(cx).remote_id().into(); - let request = InlayHints { range }; + ) -> HashMap, Task>> { + let buffer_snapshot = buffer.read(cx).snapshot(); + let for_server = if let InvalidationStrategy::RefreshRequested(server_id) = invalidate { + Some(server_id) + } else { + None + }; + let invalidate_cache = invalidate.should_invalidate(); + let next_hint_id = self.next_hint_id.clone(); + let lsp_data = self.latest_lsp_data(&buffer, cx); + let existing_inlay_hints = &mut lsp_data.inlay_hints; + let known_chunks = known_chunks + .filter(|(known_version, _)| !lsp_data.buffer_version.changed_since(known_version)) + .map(|(_, known_chunks)| known_chunks) + .unwrap_or_default(); - if let Some((client, project_id)) = self.upstream_client() { - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(Vec::new())); + let mut hint_fetch_tasks = Vec::new(); + let mut cached_inlay_hints = HashMap::default(); + let mut ranges_to_query = Vec::new(); + let applicable_chunks = existing_inlay_hints + .applicable_chunks(ranges.as_slice()) + .filter(|chunk| !known_chunks.contains(&(chunk.start..chunk.end))) + .collect::>(); + if applicable_chunks.is_empty() { + return HashMap::default(); + } + + let last_chunk_number = existing_inlay_hints.buffer_chunks_len() - 1; + + for row_chunk in applicable_chunks { + match ( + existing_inlay_hints + .cached_hints(&row_chunk) + .filter(|_| !invalidate_cache) + .cloned(), + existing_inlay_hints + .fetched_hints(&row_chunk) + .as_ref() + .filter(|_| !invalidate_cache) + .cloned(), + ) { + (None, None) => { + let end = if last_chunk_number == row_chunk.id { + Point::new(row_chunk.end, buffer_snapshot.line_len(row_chunk.end)) + } else { + Point::new(row_chunk.end, 0) + }; + ranges_to_query.push(( + row_chunk, + buffer_snapshot.anchor_before(Point::new(row_chunk.start, 0)) + ..buffer_snapshot.anchor_after(end), + )); + } + (None, Some(fetched_hints)) => { + hint_fetch_tasks.push((row_chunk, fetched_hints.clone())) + } + (Some(cached_hints), None) => { + for (server_id, cached_hints) in cached_hints { + if for_server.is_none_or(|for_server| for_server == server_id) { + cached_inlay_hints + .entry(row_chunk.start..row_chunk.end) + .or_insert_with(HashMap::default) + .entry(server_id) + .or_insert_with(Vec::new) + .extend(cached_hints); + } + } + } + (Some(cached_hints), Some(fetched_hints)) => { + hint_fetch_tasks.push((row_chunk, fetched_hints.clone())); + for (server_id, cached_hints) in cached_hints { + if for_server.is_none_or(|for_server| for_server == server_id) { + cached_inlay_hints + .entry(row_chunk.start..row_chunk.end) + .or_insert_with(HashMap::default) + .entry(server_id) + .or_insert_with(Vec::new) + .extend(cached_hints); + } + } + } } - let proto_request = proto::InlayHints { - project_id, - buffer_id, - start: Some(serialize_anchor(&range_start)), - end: Some(serialize_anchor(&range_end)), - version: serialize_version(&buffer.read(cx).version()), - }; - cx.spawn(async move |project, cx| { - let response = client - .request(proto_request) - .await - .context("inlay hints proto request")?; - LspCommand::response_from_proto( - request, - response, - project.upgrade().context("No project")?, - buffer.clone(), - cx.clone(), + } + + let cached_chunk_data = cached_inlay_hints + .into_iter() + .map(|(row_chunk, hints)| (row_chunk, Task::ready(Ok(hints)))) + .collect(); + if hint_fetch_tasks.is_empty() && ranges_to_query.is_empty() { + cached_chunk_data + } else { + if invalidate_cache { + lsp_data.inlay_hints.clear(); + } + + for (chunk, range_to_query) in ranges_to_query { + let next_hint_id = next_hint_id.clone(); + let buffer = buffer.clone(); + let new_inlay_hints = cx + .spawn(async move |lsp_store, cx| { + let new_fetch_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.fetch_inlay_hints(for_server, &buffer, range_to_query, cx) + })?; + new_fetch_task + .await + .and_then(|new_hints_by_server| { + lsp_store.update(cx, |lsp_store, cx| { + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let update_cache = !lsp_data + .buffer_version + .changed_since(&buffer.read(cx).version()); + new_hints_by_server + .into_iter() + .map(|(server_id, new_hints)| { + let new_hints = new_hints + .into_iter() + .map(|new_hint| { + ( + InlayId::Hint(next_hint_id.fetch_add( + 1, + atomic::Ordering::AcqRel, + )), + new_hint, + ) + }) + .collect::>(); + if update_cache { + lsp_data.inlay_hints.insert_new_hints( + chunk, + server_id, + new_hints.clone(), + ); + } + (server_id, new_hints) + }) + .collect() + }) + }) + .map_err(Arc::new) + }) + .shared(); + + let fetch_task = lsp_data.inlay_hints.fetched_hints(&chunk); + *fetch_task = Some(new_inlay_hints.clone()); + hint_fetch_tasks.push((chunk, new_inlay_hints)); + } + + let mut combined_data = cached_chunk_data; + combined_data.extend(hint_fetch_tasks.into_iter().map(|(chunk, hints_fetch)| { + ( + chunk.start..chunk.end, + cx.spawn(async move |_, _| { + hints_fetch.await.map_err(|e| { + if e.error_code() != ErrorCode::Internal { + anyhow!(e.error_code()) + } else { + anyhow!("{e:#}") + } + }) + }), ) - .await - .context("inlay hints proto response conversion") + })); + combined_data + } + } + + fn fetch_inlay_hints( + &mut self, + for_server: Option, + buffer: &Entity, + range: Range, + cx: &mut Context, + ) -> Task>>> { + let request = InlayHints { + range: range.clone(), + }; + if let Some((upstream_client, project_id)) = self.upstream_client() { + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(HashMap::default())); + } + let request_task = upstream_client.request_lsp( + project_id, + for_server.map(|id| id.to_proto()), + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(HashMap::default()); + }; + let Some(responses) = request_task.await? else { + return Ok(HashMap::default()); + }; + + let inlay_hints = join_all(responses.payload.into_iter().map(|response| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + let request = request.clone(); + async move { + ( + LanguageServerId::from_proto(response.server_id), + request + .response_from_proto(response.response, lsp_store, buffer, cx) + .await, + ) + } + })) + .await; + + let mut has_errors = false; + let inlay_hints = inlay_hints + .into_iter() + .filter_map(|(server_id, inlay_hints)| match inlay_hints { + Ok(inlay_hints) => Some((server_id, inlay_hints)), + Err(e) => { + has_errors = true; + log::error!("{e:#}"); + None + } + }) + .collect::>(); + anyhow::ensure!( + !has_errors || !inlay_hints.is_empty(), + "Failed to fetch inlay hints" + ); + Ok(inlay_hints) }) } else { - let lsp_request_task = self.request_lsp( - buffer.clone(), - LanguageServerToQuery::FirstCapable, - request, - cx, - ); - cx.spawn(async move |_, cx| { - buffer - .update(cx, |buffer, _| { - buffer.wait_for_edits(vec![range_start.timestamp, range_end.timestamp]) - })? + let inlay_hints_task = match for_server { + Some(server_id) => { + let server_task = self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + request, + cx, + ); + cx.background_spawn(async move { + let mut responses = Vec::new(); + match server_task.await { + Ok(response) => responses.push((server_id, response)), + Err(e) => log::error!( + "Error handling response for inlay hints request: {e:#}" + ), + } + responses + }) + } + None => self.request_multiple_lsp_locally(buffer, None::, request, cx), + }; + let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + cx.background_spawn(async move { + Ok(inlay_hints_task .await - .context("waiting for inlay hint request range edits")?; - lsp_request_task.await.context("inlay hints LSP request") + .into_iter() + .map(|(server_id, mut new_hints)| { + new_hints.retain(|hint| { + hint.position.is_valid(&buffer_snapshot) + && range.start.is_valid(&buffer_snapshot) + && range.end.is_valid(&buffer_snapshot) + && hint.position.cmp(&range.start, &buffer_snapshot).is_ge() + && hint.position.cmp(&range.end, &buffer_snapshot).is_lt() + }); + (server_id, new_hints) + }) + .collect()) }) } } @@ -6650,7 +6982,7 @@ impl LspStore { File::from_dyn(buffer.file()) .and_then(|file| { let abs_path = file.as_local()?.abs_path(cx); - lsp::Url::from_file_path(abs_path).ok() + lsp::Uri::from_file_path(abs_path).ok() }) .is_none_or(|buffer_uri| { unchanged_buffers.contains(&buffer_uri) @@ -6673,58 +7005,62 @@ impl LspStore { pub fn document_colors( &mut self, - fetch_strategy: LspFetchStrategy, + known_cache_version: Option, buffer: Entity, cx: &mut Context, ) -> Option { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - match fetch_strategy { - LspFetchStrategy::IgnoreCache => {} - LspFetchStrategy::UseCache { - known_cache_version, - } => { - if let Some(cached_data) = self.lsp_document_colors.get(&buffer_id) { - if !version_queried_for.changed_since(&cached_data.colors_for_version) { - let has_different_servers = self.as_local().is_some_and(|local| { - local - .buffers_opened_in_servers - .get(&buffer_id) - .cloned() - .unwrap_or_default() - != cached_data.colors.keys().copied().collect() - }); - if !has_different_servers { - if Some(cached_data.cache_version) == known_cache_version { - return None; - } else { - return Some( - Task::ready(Ok(DocumentColors { - colors: cached_data - .colors - .values() - .flatten() - .cloned() - .collect(), - cache_version: Some(cached_data.cache_version), - })) - .shared(), - ); - } + let current_language_servers = self.as_local().map(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }); + + if let Some(lsp_data) = self.current_lsp_data(buffer_id) { + if let Some(cached_colors) = &lsp_data.document_colors { + if !version_queried_for.changed_since(&lsp_data.buffer_version) { + let has_different_servers = + current_language_servers.is_some_and(|current_language_servers| { + current_language_servers + != cached_colors.colors.keys().copied().collect() + }); + if !has_different_servers { + let cache_version = cached_colors.cache_version; + if Some(cache_version) == known_cache_version { + return None; + } else { + return Some( + Task::ready(Ok(DocumentColors { + colors: cached_colors + .colors + .values() + .flatten() + .cloned() + .collect(), + cache_version: Some(cache_version), + })) + .shared(), + ); } } } } } - let lsp_data = self.lsp_document_colors.entry(buffer_id).or_default(); - if let Some((updating_for, running_update)) = &lsp_data.colors_update { - if !version_queried_for.changed_since(&updating_for) { - return Some(running_update.clone()); - } + let color_lsp_data = self + .latest_lsp_data(&buffer, cx) + .document_colors + .get_or_insert_default(); + if let Some((updating_for, running_update)) = &color_lsp_data.colors_update + && !version_queried_for.changed_since(updating_for) + { + return Some(running_update.clone()); } - let query_version_queried_for = version_queried_for.clone(); + let buffer_version_queried_for = version_queried_for.clone(); let new_task = cx .spawn(async move |lsp_store, cx| { cx.background_executor() @@ -6739,13 +7075,12 @@ impl LspStore { .map_err(Arc::new); let fetched_colors = match fetched_colors { Ok(fetched_colors) => { - if fetch_strategy != LspFetchStrategy::IgnoreCache - && Some(true) - == buffer - .update(cx, |buffer, _| { - buffer.version() != query_version_queried_for - }) - .ok() + if Some(true) + == buffer + .update(cx, |buffer, _| { + buffer.version() != buffer_version_queried_for + }) + .ok() { return Ok(DocumentColors::default()); } @@ -6754,11 +7089,11 @@ impl LspStore { Err(e) => { lsp_store .update(cx, |lsp_store, _| { - lsp_store - .lsp_document_colors - .entry(buffer_id) - .or_default() - .colors_update = None; + if let Some(lsp_data) = lsp_store.lsp_data.get_mut(&buffer_id) { + if let Some(document_colors) = &mut lsp_data.document_colors { + document_colors.colors_update = None; + } + } }) .ok(); return Err(e); @@ -6766,22 +7101,25 @@ impl LspStore { }; lsp_store - .update(cx, |lsp_store, _| { - let lsp_data = lsp_store.lsp_document_colors.entry(buffer_id).or_default(); - - if lsp_data.colors_for_version == query_version_queried_for { - lsp_data.colors.extend(fetched_colors.clone()); - lsp_data.cache_version += 1; - } else if !lsp_data - .colors_for_version - .changed_since(&query_version_queried_for) - { - lsp_data.colors_for_version = query_version_queried_for; - lsp_data.colors = fetched_colors.clone(); - lsp_data.cache_version += 1; + .update(cx, |lsp_store, cx| { + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let lsp_colors = lsp_data.document_colors.get_or_insert_default(); + + if let Some(fetched_colors) = fetched_colors { + if lsp_data.buffer_version == buffer_version_queried_for { + lsp_colors.colors.extend(fetched_colors); + lsp_colors.cache_version += 1; + } else if !lsp_data + .buffer_version + .changed_since(&buffer_version_queried_for) + { + lsp_data.buffer_version = buffer_version_queried_for; + lsp_colors.colors = fetched_colors; + lsp_colors.cache_version += 1; + } } - lsp_data.colors_update = None; - let colors = lsp_data + lsp_colors.colors_update = None; + let colors = lsp_colors .colors .values() .flatten() @@ -6789,13 +7127,13 @@ impl LspStore { .collect::>(); DocumentColors { colors, - cache_version: Some(lsp_data.cache_version), + cache_version: Some(lsp_colors.cache_version), } }) .map_err(Arc::new) }) .shared(); - lsp_data.colors_update = Some((version_queried_for, new_task.clone())); + color_lsp_data.colors_update = Some((version_queried_for, new_task.clone())); Some(new_task) } @@ -6803,56 +7141,46 @@ impl LspStore { &mut self, buffer: &Entity, cx: &mut Context, - ) -> Task>>> { + ) -> Task>>>> { if let Some((client, project_id)) = self.upstream_client() { let request = GetDocumentColor {}; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Ok(HashMap::default())); + return Task::ready(Ok(None)); } - let request_task = client.request(proto::MultiLspQuery { + let request_task = client.request_lsp( project_id, - buffer_id: buffer.read(cx).remote_id().to_proto(), - version: serialize_version(&buffer.read(cx).version()), - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetDocumentColor( - request.to_proto(project_id, buffer.read(cx)), - )), - }); + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); - cx.spawn(async move |project, cx| { - let Some(project) = project.upgrade() else { - return Ok(HashMap::default()); + cx.spawn(async move |lsp_store, cx| { + let Some(project) = lsp_store.upgrade() else { + return Ok(None); }; let colors = join_all( request_task .await .log_err() - .map(|response| response.responses) + .flatten() + .map(|response| response.payload) .unwrap_or_default() .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetDocumentColorResponse(response) => { - Some(( - LanguageServerId::from_proto(lsp_response.server_id), - response, - )) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|(server_id, color_response)| { + .map(|color_response| { let response = request.response_from_proto( - color_response, + color_response.response, project.clone(), buffer.clone(), cx.clone(), ); - async move { (server_id, response.await.log_err().unwrap_or_default()) } + async move { + ( + LanguageServerId::from_proto(color_response.server_id), + response.await.log_err().unwrap_or_default(), + ) + } }), ) .await @@ -6863,23 +7191,25 @@ impl LspStore { .extend(colors); acc }); - Ok(colors) + Ok(Some(colors)) }) } else { let document_colors_task = self.request_multiple_lsp_locally(buffer, None::, GetDocumentColor, cx); cx.background_spawn(async move { - Ok(document_colors_task - .await - .into_iter() - .fold(HashMap::default(), |mut acc, (server_id, colors)| { - acc.entry(server_id) - .or_insert_with(HashSet::default) - .extend(colors); - acc - }) - .into_iter() - .collect()) + Ok(Some( + document_colors_task + .await + .into_iter() + .fold(HashMap::default(), |mut acc, (server_id, colors)| { + acc.entry(server_id) + .or_insert_with(HashSet::default) + .extend(colors); + acc + }) + .into_iter() + .collect(), + )) }) } } @@ -6889,49 +7219,35 @@ impl LspStore { buffer: &Entity, position: T, cx: &mut Context, - ) -> Task> { + ) -> Task>> { let position = position.to_point_utf16(buffer.read(cx)); if let Some((client, upstream_project_id)) = self.upstream_client() { let request = GetSignatureHelp { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Vec::new()); + return Task::ready(None); } - let request_task = client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), - project_id: upstream_project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetSignatureHelp( - request.to_proto(upstream_project_id, buffer.read(cx)), - )), - }); + let request_task = client.request_lsp( + upstream_project_id, + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(upstream_project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { - let Some(project) = weak_project.upgrade() else { - return Vec::new(); - }; - join_all( + let project = weak_project.upgrade()?; + let signatures = join_all( request_task .await .log_err() - .map(|response| response.responses) + .flatten() + .map(|response| response.payload) .unwrap_or_default() .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetSignatureHelpResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|signature_response| { + .map(|response| { let response = GetSignatureHelp { position }.response_from_proto( - signature_response, + response.response, project.clone(), buffer.clone(), cx.clone(), @@ -6942,7 +7258,8 @@ impl LspStore { .await .into_iter() .flatten() - .collect() + .collect(); + Some(signatures) }) } else { let all_actions_task = self.request_multiple_lsp_locally( @@ -6952,11 +7269,13 @@ impl LspStore { cx, ); cx.background_spawn(async move { - all_actions_task - .await - .into_iter() - .flat_map(|(_, actions)| actions) - .collect::>() + Some( + all_actions_task + .await + .into_iter() + .flat_map(|(_, actions)| actions) + .collect::>(), + ) }) } } @@ -6966,47 +7285,33 @@ impl LspStore { buffer: &Entity, position: PointUtf16, cx: &mut Context, - ) -> Task> { + ) -> Task>> { if let Some((client, upstream_project_id)) = self.upstream_client() { let request = GetHover { position }; if !self.is_capable_for_proto_request(buffer, &request, cx) { - return Task::ready(Vec::new()); + return Task::ready(None); } - let request_task = client.request(proto::MultiLspQuery { - buffer_id: buffer.read(cx).remote_id().into(), - version: serialize_version(&buffer.read(cx).version()), - project_id: upstream_project_id, - strategy: Some(proto::multi_lsp_query::Strategy::All( - proto::AllLanguageServers {}, - )), - request: Some(proto::multi_lsp_query::Request::GetHover( - request.to_proto(upstream_project_id, buffer.read(cx)), - )), - }); + let request_task = client.request_lsp( + upstream_project_id, + None, + LSP_REQUEST_TIMEOUT, + cx.background_executor().clone(), + request.to_proto(upstream_project_id, buffer.read(cx)), + ); let buffer = buffer.clone(); cx.spawn(async move |weak_project, cx| { - let Some(project) = weak_project.upgrade() else { - return Vec::new(); - }; - join_all( + let project = weak_project.upgrade()?; + let hovers = join_all( request_task .await .log_err() - .map(|response| response.responses) + .flatten() + .map(|response| response.payload) .unwrap_or_default() .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetHoverResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } - }) - .map(|hover_response| { + .map(|response| { let response = GetHover { position }.response_from_proto( - hover_response, + response.response, project.clone(), buffer.clone(), cx.clone(), @@ -7023,7 +7328,8 @@ impl LspStore { .await .into_iter() .flatten() - .collect() + .collect(); + Some(hovers) }) } else { let all_actions_task = self.request_multiple_lsp_locally( @@ -7033,11 +7339,13 @@ impl LspStore { cx, ); cx.background_spawn(async move { - all_actions_task - .await - .into_iter() - .filter_map(|(_, hover)| remove_empty_hover_blocks(hover?)) - .collect::>() + Some( + all_actions_task + .await + .into_iter() + .filter_map(|(_, hover)| remove_empty_hover_blocks(hover?)) + .collect::>(), + ) }) } } @@ -7067,17 +7375,16 @@ impl LspStore { server_id: LanguageServerId, lsp_adapter: Arc, worktree: WeakEntity, - worktree_abs_path: Arc, lsp_symbols: Vec<(String, SymbolKind, lsp::Location)>, } let mut requests = Vec::new(); let mut requested_servers = BTreeSet::new(); - 'next_server: for ((worktree_id, _), server_ids) in local.language_server_ids.iter() { + for (seed, state) in local.language_server_ids.iter() { let Some(worktree_handle) = self .worktree_store .read(cx) - .worktree_for_id(*worktree_id, cx) + .worktree_for_id(seed.worktree_id, cx) else { continue; }; @@ -7086,31 +7393,29 @@ impl LspStore { continue; } - let mut servers_to_query = server_ids - .difference(&requested_servers) - .cloned() - .collect::>(); - for server_id in &servers_to_query { - let (lsp_adapter, server) = match local.language_servers.get(server_id) { - Some(LanguageServerState::Running { - adapter, server, .. - }) => (adapter.clone(), server), - - _ => continue 'next_server, + if !requested_servers.insert(state.id) { + continue; + } + + let (lsp_adapter, server) = match local.language_servers.get(&state.id) { + Some(LanguageServerState::Running { + adapter, server, .. + }) => (adapter.clone(), server), + + _ => continue, + }; + let supports_workspace_symbol_request = + match server.capabilities().workspace_symbol_provider { + Some(OneOf::Left(supported)) => supported, + Some(OneOf::Right(_)) => true, + None => false, }; - let supports_workspace_symbol_request = - match server.capabilities().workspace_symbol_provider { - Some(OneOf::Left(supported)) => supported, - Some(OneOf::Right(_)) => true, - None => false, - }; - if !supports_workspace_symbol_request { - continue 'next_server; - } - let worktree_abs_path = worktree.abs_path().clone(); - let worktree_handle = worktree_handle.clone(); - let server_id = server.server_id(); - requests.push( + if !supports_workspace_symbol_request { + continue; + } + let worktree_handle = worktree_handle.clone(); + let server_id = server.server_id(); + requests.push( server .request::( lsp::WorkspaceSymbolParams { @@ -7147,13 +7452,10 @@ impl LspStore { server_id, lsp_adapter, worktree: worktree_handle.downgrade(), - worktree_abs_path, lsp_symbols, } }), ); - } - requested_servers.append(&mut servers_to_query); } cx.spawn(async move |this, cx| { @@ -7174,33 +7476,29 @@ impl LspStore { let source_worktree = result.worktree.upgrade()?; let source_worktree_id = source_worktree.read(cx).id(); - let path; - let worktree; - if let Some((tree, rel_path)) = + let path = if let Some((tree, rel_path)) = this.worktree_store.read(cx).find_worktree(&abs_path, cx) { - worktree = tree; - path = rel_path; + let worktree_id = tree.read(cx).id(); + SymbolLocation::InProject(ProjectPath { + worktree_id, + path: rel_path, + }) } else { - worktree = source_worktree.clone(); - path = relativize_path(&result.worktree_abs_path, &abs_path); - } - - let worktree_id = worktree.read(cx).id(); - let project_path = ProjectPath { - worktree_id, - path: path.into(), + SymbolLocation::OutsideProject { + signature: this.symbol_signature(&abs_path), + abs_path: abs_path.into(), + } }; - let signature = this.symbol_signature(&project_path); + Some(CoreSymbol { source_language_server_id: result.server_id, language_server_name: result.lsp_adapter.name.clone(), source_worktree_id, - path: project_path, + path, kind: symbol_kind, name: symbol_name, range: range_from_lsp(symbol_location.range), - signature, }) }) .collect() @@ -7231,6 +7529,36 @@ impl LspStore { summary } + /// Returns the diagnostic summary for a specific project path. + pub fn diagnostic_summary_for_path( + &self, + project_path: &ProjectPath, + _: &App, + ) -> DiagnosticSummary { + if let Some(summaries) = self + .diagnostic_summaries + .get(&project_path.worktree_id) + .and_then(|map| map.get(&project_path.path)) + { + let (error_count, warning_count) = summaries.iter().fold( + (0, 0), + |(error_count, warning_count), (_language_server_id, summary)| { + ( + error_count + summary.error_count, + warning_count + summary.warning_count, + ) + }, + ); + + DiagnosticSummary { + error_count, + warning_count, + } + } else { + DiagnosticSummary::default() + } + } + pub fn diagnostic_summaries<'a>( &'a self, include_ignored: bool, @@ -7251,7 +7579,7 @@ impl LspStore { include_ignored || worktree .entry_for_path(path.as_ref()) - .map_or(false, |entry| !entry.is_ignored) + .is_some_and(|entry| !entry.is_ignored) }) .flat_map(move |(path, summaries)| { summaries.iter().map(move |(server_id, summary)| { @@ -7285,7 +7613,7 @@ impl LspStore { let buffer = buffer.read(cx); let file = File::from_dyn(buffer.file())?; let abs_path = file.as_local()?.abs_path(cx); - let uri = lsp::Url::from_file_path(abs_path).unwrap(); + let uri = lsp::Uri::from_file_path(abs_path).unwrap(); let next_snapshot = buffer.text_snapshot(); for language_server in language_servers { let language_server = language_server.clone(); @@ -7360,7 +7688,7 @@ impl LspStore { language_server .notify::( - &lsp::DidChangeTextDocumentParams { + lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( uri.clone(), next_version, @@ -7397,7 +7725,7 @@ impl LspStore { }; server .notify::( - &lsp::DidSaveTextDocumentParams { + lsp::DidSaveTextDocumentParams { text_document: text_document.clone(), text, }, @@ -7416,101 +7744,86 @@ impl LspStore { None } - pub(crate) async fn refresh_workspace_configurations( - lsp_store: &WeakEntity, - fs: Arc, - cx: &mut AsyncApp, - ) { + async fn refresh_workspace_configurations(lsp_store: &WeakEntity, cx: &mut AsyncApp) { maybe!(async move { let mut refreshed_servers = HashSet::default(); let servers = lsp_store .update(cx, |lsp_store, cx| { - let toolchain_store = lsp_store.toolchain_store(cx); - let Some(local) = lsp_store.as_local() else { - return Vec::default(); - }; - local + let local = lsp_store.as_local()?; + + let servers = local .language_server_ids .iter() - .flat_map(|((worktree_id, _), server_ids)| { + .filter_map(|(seed, state)| { let worktree = lsp_store .worktree_store .read(cx) - .worktree_for_id(*worktree_id, cx); - let delegate = worktree.map(|worktree| { - LocalLspAdapterDelegate::new( - local.languages.clone(), - &local.environment, - cx.weak_entity(), - &worktree, - local.http_client.clone(), - local.fs.clone(), - cx, - ) - }); + .worktree_for_id(seed.worktree_id, cx); + let delegate: Arc = + worktree.map(|worktree| { + LocalLspAdapterDelegate::new( + local.languages.clone(), + &local.environment, + cx.weak_entity(), + &worktree, + local.http_client.clone(), + local.fs.clone(), + cx, + ) + })?; + let server_id = state.id; - let fs = fs.clone(); - let toolchain_store = toolchain_store.clone(); - server_ids.iter().filter_map(|server_id| { - let delegate = delegate.clone()? as Arc; - let states = local.language_servers.get(server_id)?; - - match states { - LanguageServerState::Starting { .. } => None, - LanguageServerState::Running { - adapter, server, .. - } => { - let fs = fs.clone(); - let toolchain_store = toolchain_store.clone(); - let adapter = adapter.clone(); - let server = server.clone(); - refreshed_servers.insert(server.name()); - Some(cx.spawn(async move |_, cx| { - let settings = - LocalLspStore::workspace_configuration_for_adapter( - adapter.adapter.clone(), - fs.as_ref(), - &delegate, - toolchain_store, - cx, - ) - .await - .ok()?; - server - .notify::( - &lsp::DidChangeConfigurationParams { settings }, - ) - .ok()?; - Some(()) - })) - } + let states = local.language_servers.get(&server_id)?; + + match states { + LanguageServerState::Starting { .. } => None, + LanguageServerState::Running { + adapter, server, .. + } => { + let adapter = adapter.clone(); + let server = server.clone(); + refreshed_servers.insert(server.name()); + let toolchain = seed.toolchain.clone(); + Some(cx.spawn(async move |_, cx| { + let settings = + LocalLspStore::workspace_configuration_for_adapter( + adapter.adapter.clone(), + &delegate, + toolchain, + cx, + ) + .await + .ok()?; + server + .notify::( + lsp::DidChangeConfigurationParams { settings }, + ) + .ok()?; + Some(()) + })) } - }).collect::>() + } }) - .collect::>() + .collect::>(); + + Some(servers) }) - .ok()?; + .ok() + .flatten()?; - log::info!("Refreshing workspace configurations for servers {refreshed_servers:?}"); + log::debug!("Refreshing workspace configurations for servers {refreshed_servers:?}"); // TODO this asynchronous job runs concurrently with extension (de)registration and may take enough time for a certain extension // to stop and unregister its language server wrapper. // This is racy : an extension might have already removed all `local.language_servers` state, but here we `.clone()` and hold onto it anyway. // This now causes errors in the logs, we should find a way to remove such servers from the processing everywhere. let _: Vec> = join_all(servers).await; + Some(()) }) .await; } - fn toolchain_store(&self, cx: &App) -> Arc { - if let Some(toolchain_store) = self.toolchain_store.as_ref() { - toolchain_store.read(cx).as_language_toolchain_store() - } else { - Arc::new(EmptyToolchainStore) - } - } fn maintain_workspace_config( - fs: Arc, external_refresh_requests: watch::Receiver<()>, cx: &mut Context, ) -> Task> { @@ -7523,9 +7836,20 @@ impl LspStore { let mut joint_future = futures::stream::select(settings_changed_rx, external_refresh_requests); + // Multiple things can happen when a workspace environment (selected toolchain + settings) change: + // - We might shut down a language server if it's no longer enabled for a given language (and there are no buffers using it otherwise). + // - We might also shut it down when the workspace configuration of all of the users of a given language server converges onto that of the other. + // - In the same vein, we might also decide to start a new language server if the workspace configuration *diverges* from the other. + // - In the easiest case (where we're not wrangling the lifetime of a language server anyhow), if none of the roots of a single language server diverge in their configuration, + // but it is still different to what we had before, we're gonna send out a workspace configuration update. cx.spawn(async move |this, cx| { while let Some(()) = joint_future.next().await { - Self::refresh_workspace_configurations(&this, fs.clone(), cx).await; + this.update(cx, |this, cx| { + this.refresh_server_tree(cx); + }) + .ok(); + + Self::refresh_workspace_configurations(&this, cx).await; } drop(settings_observation); @@ -7592,7 +7916,7 @@ impl LspStore { server: Some(proto::LanguageServer { id: server_id.to_proto(), name: status.name.to_string(), - worktree_id: None, + worktree_id: status.worktree.map(|id| id.to_proto()), }), capabilities: serde_json::to_string(&server.capabilities()) .expect("serializing server LSP capabilities"), @@ -7617,9 +7941,15 @@ impl LspStore { pub(crate) fn set_language_server_statuses_from_proto( &mut self, + project: WeakEntity, language_servers: Vec, server_capabilities: Vec, + cx: &mut Context, ) { + let lsp_logs = cx + .try_global::() + .map(|lsp_store| lsp_store.0.clone()); + self.language_server_statuses = language_servers .into_iter() .zip(server_capabilities) @@ -7629,60 +7959,40 @@ impl LspStore { self.lsp_server_capabilities .insert(server_id, server_capabilities); } + + let name = LanguageServerName::from_proto(server.name); + let worktree = server.worktree_id.map(WorktreeId::from_proto); + + if let Some(lsp_logs) = &lsp_logs { + lsp_logs.update(cx, |lsp_logs, cx| { + lsp_logs.add_language_server( + // Only remote clients get their language servers set from proto + LanguageServerKind::Remote { + project: project.clone(), + }, + server_id, + Some(name.clone()), + worktree, + None, + cx, + ); + }); + } + ( server_id, LanguageServerStatus { - name: LanguageServerName::from_proto(server.name), + name, pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree, }, ) }) .collect(); } - fn register_local_language_server( - &mut self, - worktree: Entity, - language_server_name: LanguageServerName, - language_server_id: LanguageServerId, - cx: &mut App, - ) { - let Some(local) = self.as_local_mut() else { - return; - }; - - let worktree_id = worktree.read(cx).id(); - if worktree.read(cx).is_visible() { - let path = ProjectPath { - worktree_id, - path: Arc::from("".as_ref()), - }; - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); - local.lsp_tree.update(cx, |language_server_tree, cx| { - for node in language_server_tree.get( - path, - AdapterQuery::Adapter(&language_server_name), - delegate, - cx, - ) { - node.server_id_or_init(|disposition| { - assert_eq!(disposition.server_name, &language_server_name); - - language_server_id - }); - } - }); - } - - local - .language_server_ids - .entry((worktree_id, language_server_name)) - .or_default() - .insert(language_server_id); - } - #[cfg(test)] pub fn update_diagnostic_entries( &mut self, @@ -7731,26 +8041,23 @@ impl LspStore { let worktree_id = worktree.read(cx).id(); let project_path = ProjectPath { worktree_id, - path: relative_path.into(), + path: relative_path, }; if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) { let snapshot = buffer_handle.read(cx).snapshot(); let buffer = buffer_handle.read(cx); let reused_diagnostics = buffer - .get_diagnostics(server_id) - .into_iter() - .flat_map(|diag| { - diag.iter() - .filter(|v| merge(buffer, &v.diagnostic, cx)) - .map(|v| { - let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); - let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); - DiagnosticEntry { - range: start..end, - diagnostic: v.diagnostic.clone(), - } - }) + .buffer_diagnostics(Some(server_id)) + .iter() + .filter(|v| merge(buffer, &v.diagnostic, cx)) + .map(|v| { + let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); + let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); + DiagnosticEntry { + range: start..end, + diagnostic: v.diagnostic.clone(), + } }) .collect::>(); @@ -7794,7 +8101,7 @@ impl LspStore { } None => { diagnostics_summary = Some(proto::UpdateDiagnosticSummary { - project_id: project_id, + project_id, worktree_id: worktree_id.to_proto(), summary: Some(proto::DiagnosticSummary { path: project_path.path.as_ref().to_proto(), @@ -7831,7 +8138,7 @@ impl LspStore { &mut self, worktree_id: WorktreeId, server_id: LanguageServerId, - path_in_worktree: Arc, + path_in_worktree: Arc, diagnostics: Vec>>, _: &mut Context, ) -> Result>> { @@ -7912,62 +8219,54 @@ impl LspStore { .await }) } else if let Some(local) = self.as_local() { - let Some(language_server_id) = local - .language_server_ids - .get(&( - symbol.source_worktree_id, - symbol.language_server_name.clone(), - )) - .and_then(|ids| { - ids.contains(&symbol.source_language_server_id) - .then_some(symbol.source_language_server_id) - }) - else { + let is_valid = local.language_server_ids.iter().any(|(seed, state)| { + seed.worktree_id == symbol.source_worktree_id + && state.id == symbol.source_language_server_id + && symbol.language_server_name == seed.name + }); + if !is_valid { return Task::ready(Err(anyhow!( "language server for worktree and language not found" ))); }; - let worktree_abs_path = if let Some(worktree_abs_path) = self - .worktree_store - .read(cx) - .worktree_for_id(symbol.path.worktree_id, cx) - .map(|worktree| worktree.read(cx).abs_path()) - { - worktree_abs_path - } else { - return Task::ready(Err(anyhow!("worktree not found for symbol"))); + let symbol_abs_path = match &symbol.path { + SymbolLocation::InProject(project_path) => self + .worktree_store + .read(cx) + .absolutize(&project_path, cx) + .context("no such worktree"), + SymbolLocation::OutsideProject { + abs_path, + signature: _, + } => Ok(abs_path.to_path_buf()), }; - - let symbol_abs_path = resolve_path(&worktree_abs_path, &symbol.path.path); - let symbol_uri = if let Ok(uri) = lsp::Url::from_file_path(symbol_abs_path) { + let symbol_abs_path = match symbol_abs_path { + Ok(abs_path) => abs_path, + Err(err) => return Task::ready(Err(err)), + }; + let symbol_uri = if let Ok(uri) = lsp::Uri::from_file_path(symbol_abs_path) { uri } else { return Task::ready(Err(anyhow!("invalid symbol path"))); }; - self.open_local_buffer_via_lsp( - symbol_uri, - language_server_id, - symbol.language_server_name.clone(), - cx, - ) + self.open_local_buffer_via_lsp(symbol_uri, symbol.source_language_server_id, cx) } else { Task::ready(Err(anyhow!("no upstream client or local store"))) } } - pub fn open_local_buffer_via_lsp( + pub(crate) fn open_local_buffer_via_lsp( &mut self, - mut abs_path: lsp::Url, + abs_path: lsp::Uri, language_server_id: LanguageServerId, - language_server_name: LanguageServerName, cx: &mut Context, ) -> Task>> { cx.spawn(async move |lsp_store, cx| { // Escape percent-encoded string. let current_scheme = abs_path.scheme().to_owned(); - let _ = abs_path.set_scheme("file"); + // Uri is immutable, so we can't modify the scheme let abs_path = abs_path .to_file_path() @@ -7998,8 +8297,7 @@ impl LspStore { worktree_store.find_worktree(&worktree_root_target, cx) }) })? { - let relative_path = - known_relative_path.unwrap_or_else(|| Arc::::from(result.1)); + let relative_path = known_relative_path.unwrap_or_else(|| result.1.clone()); (result.0, relative_path) } else { let worktree = lsp_store @@ -8012,12 +8310,13 @@ impl LspStore { if worktree.read_with(cx, |worktree, _| worktree.is_local())? { lsp_store .update(cx, |lsp_store, cx| { - lsp_store.register_local_language_server( - worktree.clone(), - language_server_name, - language_server_id, - cx, - ) + if let Some(local) = lsp_store.as_local_mut() { + local.register_language_server_for_invisible_worktree( + &worktree, + language_server_id, + cx, + ) + } }) .ok(); } @@ -8025,7 +8324,8 @@ impl LspStore { let relative_path = if let Some(known_path) = known_relative_path { known_path } else { - abs_path.strip_prefix(worktree_root)?.into() + RelPath::new(abs_path.strip_prefix(worktree_root)?, PathStyle::local())? + .into_arc() }; (worktree, relative_path) }; @@ -8100,8 +8400,9 @@ impl LspStore { cx.background_spawn(async move { let mut responses = Vec::with_capacity(response_results.len()); while let Some((server_id, response_result)) = response_results.next().await { - if let Some(response) = response_result.log_err() { - responses.push((server_id, response)); + match response_result { + Ok(response) => responses.push((server_id, response)), + Err(e) => log::error!("Error handling response for request {request:?}: {e:#}"), } } responses @@ -8150,470 +8451,247 @@ impl LspStore { })? } - async fn handle_multi_lsp_query( + async fn handle_lsp_query( lsp_store: Entity, - envelope: TypedEnvelope, + envelope: TypedEnvelope, mut cx: AsyncApp, - ) -> Result { - let response_from_ssh = lsp_store.read_with(&mut cx, |this, _| { - let (upstream_client, project_id) = this.upstream_client()?; - let mut payload = envelope.payload.clone(); - payload.project_id = project_id; - - Some(upstream_client.request(payload)) - })?; - if let Some(response_from_ssh) = response_from_ssh { - return response_from_ssh.await; - } - + ) -> Result { + use proto::lsp_query::Request; let sender_id = envelope.original_sender_id().unwrap_or_default(); - let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - let version = deserialize_version(&envelope.payload.version); - let buffer = lsp_store.update(&mut cx, |this, cx| { - this.buffer_store.read(cx).get_existing(buffer_id) - })??; - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(version.clone()) - })? - .await?; - let buffer_version = buffer.read_with(&mut cx, |buffer, _| buffer.version())?; - match envelope - .payload - .strategy - .context("invalid request without the strategy")? - { - proto::multi_lsp_query::Strategy::All(_) => { - // currently, there's only one multiple language servers query strategy, - // so just ensure it's specified correctly + let lsp_query = envelope.payload; + let lsp_request_id = LspRequestId(lsp_query.lsp_request_id); + let server_id = lsp_query.server_id.map(LanguageServerId::from_proto); + match lsp_query.request.context("invalid LSP query request")? { + Request::GetReferences(get_references) => { + let position = get_references.position.clone().and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_references, + position, + &mut cx, + ) + .await?; } - } - match envelope.payload.request { - Some(proto::multi_lsp_query::Request::GetHover(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - let get_hover = - GetHover::from_proto(message, lsp_store.clone(), buffer.clone(), cx.clone()) - .await?; - let all_hovers = lsp_store - .update(&mut cx, |this, cx| { - this.request_multiple_lsp_locally( - &buffer, - Some(get_hover.position), - get_hover, - cx, - ) - })? - .await - .into_iter() - .filter_map(|(server_id, hover)| { - Some((server_id, remove_empty_hover_blocks(hover?)?)) - }); - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: all_hovers - .map(|(server_id, hover)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetHoverResponse( - GetHover::response_to_proto( - Some(hover), - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) - } - Some(proto::multi_lsp_query::Request::GetCodeActions(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - let get_code_actions = GetCodeActions::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetDocumentColor(get_document_color) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_document_color, + None, + &mut cx, ) .await?; - - let all_actions = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_code_actions.range.start), - get_code_actions, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: all_actions - .map(|(server_id, code_actions)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetCodeActionsResponse( - GetCodeActions::response_to_proto( - code_actions, - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetSignatureHelp(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - let get_signature_help = GetSignatureHelp::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetHover(get_hover) => { + let position = get_hover.position.clone().and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_hover, + position, + &mut cx, ) .await?; - - let all_signatures = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_signature_help.position), - get_signature_help, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: all_signatures - .map(|(server_id, signature_help)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some( - proto::lsp_response::Response::GetSignatureHelpResponse( - GetSignatureHelp::response_to_proto( - signature_help, - project, - sender_id, - &buffer_version, - cx, - ), - ), - ), - }) - .collect(), - }) - } - Some(proto::multi_lsp_query::Request::GetCodeLens(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - let get_code_lens = - GetCodeLens::from_proto(message, lsp_store.clone(), buffer.clone(), cx.clone()) - .await?; - - let code_lens_actions = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - None::, - get_code_lens, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: code_lens_actions - .map(|(server_id, actions)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetCodeLensResponse( - GetCodeLens::response_to_proto( - actions, - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetDocumentDiagnostics(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - lsp_store - .update(&mut cx, |lsp_store, cx| { - lsp_store.pull_diagnostics_for_buffer(buffer, cx) - })? - .await?; - // `pull_diagnostics_for_buffer` will merge in the new diagnostics and send them to the client. - // The client cannot merge anything into its non-local LspStore, so we do not need to return anything. - Ok(proto::MultiLspQueryResponse { - responses: Vec::new(), - }) + Request::GetCodeActions(get_code_actions) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_code_actions, + None, + &mut cx, + ) + .await?; } - Some(proto::multi_lsp_query::Request::GetDocumentColor(message)) => { - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&message.version)) - })? - .await?; - let get_document_color = GetDocumentColor::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetSignatureHelp(get_signature_help) => { + let position = get_signature_help + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_signature_help, + position, + &mut cx, ) .await?; - - let all_colors = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - None::, - get_document_color, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: all_colors - .map(|(server_id, colors)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some( - proto::lsp_response::Response::GetDocumentColorResponse( - GetDocumentColor::response_to_proto( - colors, - project, - sender_id, - &buffer_version, - cx, - ), - ), - ), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetDefinition(message)) => { - let get_definitions = GetDefinitions::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetCodeLens(get_code_lens) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_code_lens, + None, + &mut cx, ) .await?; - - let definitions = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_definitions.position), - get_definitions, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: definitions - .map(|(server_id, definitions)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetDefinitionResponse( - GetDefinitions::response_to_proto( - definitions, - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetDeclaration(message)) => { - let get_declarations = GetDeclarations::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetDefinition(get_definition) => { + let position = get_definition.position.clone().and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_definition, + position, + &mut cx, ) .await?; - - let declarations = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_declarations.position), - get_declarations, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: declarations - .map(|(server_id, declarations)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetDeclarationResponse( - GetDeclarations::response_to_proto( - declarations, - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetTypeDefinition(message)) => { - let get_type_definitions = GetTypeDefinitions::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetDeclaration(get_declaration) => { + let position = get_declaration + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_declaration, + position, + &mut cx, ) .await?; - - let type_definitions = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_type_definitions.position), - get_type_definitions, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: type_definitions - .map(|(server_id, type_definitions)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some( - proto::lsp_response::Response::GetTypeDefinitionResponse( - GetTypeDefinitions::response_to_proto( - type_definitions, - project, - sender_id, - &buffer_version, - cx, - ), - ), - ), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetImplementation(message)) => { - let get_implementations = GetImplementations::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetTypeDefinition(get_type_definition) => { + let position = get_type_definition + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_type_definition, + position, + &mut cx, ) .await?; - - let implementations = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_implementations.position), - get_implementations, - cx, - ) - })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: implementations - .map(|(server_id, implementations)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some( - proto::lsp_response::Response::GetImplementationResponse( - GetImplementations::response_to_proto( - implementations, - project, - sender_id, - &buffer_version, - cx, - ), - ), - ), - }) - .collect(), - }) } - Some(proto::multi_lsp_query::Request::GetReferences(message)) => { - let get_references = GetReferences::from_proto( - message, - lsp_store.clone(), - buffer.clone(), - cx.clone(), + Request::GetImplementation(get_implementation) => { + let position = get_implementation + .position + .clone() + .and_then(deserialize_anchor); + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_implementation, + position, + &mut cx, ) .await?; - - let references = lsp_store - .update(&mut cx, |project, cx| { - project.request_multiple_lsp_locally( - &buffer, - Some(get_references.position), - get_references, - cx, - ) + } + Request::GetDocumentDiagnostics(get_document_diagnostics) => { + let buffer_id = BufferId::new(get_document_diagnostics.buffer_id())?; + let version = deserialize_version(get_document_diagnostics.buffer_version()); + let buffer = lsp_store.update(&mut cx, |this, cx| { + this.buffer_store.read(cx).get_existing(buffer_id) + })??; + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(version.clone()) })? - .await - .into_iter(); - - lsp_store.update(&mut cx, |project, cx| proto::MultiLspQueryResponse { - responses: references - .map(|(server_id, references)| proto::LspResponse { - server_id: server_id.to_proto(), - response: Some(proto::lsp_response::Response::GetReferencesResponse( - GetReferences::response_to_proto( - references, - project, - sender_id, - &buffer_version, - cx, - ), - )), - }) - .collect(), - }) + .await?; + lsp_store.update(&mut cx, |lsp_store, cx| { + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let key = LspKey { + request_type: TypeId::of::(), + server_queried: server_id, + }; + if ::ProtoRequest::stop_previous_requests( + ) { + if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { + lsp_requests.clear(); + }; + } + + let existing_queries = lsp_data.lsp_requests.entry(key).or_default(); + existing_queries.insert( + lsp_request_id, + cx.spawn(async move |lsp_store, cx| { + let diagnostics_pull = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }) + .ok(); + if let Some(diagnostics_pull) = diagnostics_pull { + match diagnostics_pull.await { + Ok(()) => {} + Err(e) => log::error!("Failed to pull diagnostics: {e:#}"), + }; + } + }), + ); + })?; + } + Request::InlayHints(inlay_hints) => { + let query_start = inlay_hints + .start + .clone() + .and_then(deserialize_anchor) + .context("invalid inlay hints range start")?; + let query_end = inlay_hints + .end + .clone() + .and_then(deserialize_anchor) + .context("invalid inlay hints range end")?; + Self::deduplicate_range_based_lsp_requests::( + &lsp_store, + server_id, + lsp_request_id, + &inlay_hints, + query_start..query_end, + &mut cx, + ) + .await + .context("preparing inlay hints request")?; + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + inlay_hints, + None, + &mut cx, + ) + .await + .context("querying for inlay hints")? } - None => anyhow::bail!("empty multi lsp query request"), } + Ok(proto::Ack {}) + } + + async fn handle_lsp_query_response( + lsp_store: Entity, + envelope: TypedEnvelope, + cx: AsyncApp, + ) -> Result<()> { + lsp_store.read_with(&cx, |lsp_store, _| { + if let Some((upstream_client, _)) = lsp_store.upstream_client() { + upstream_client.handle_lsp_response(envelope.clone()); + } + })?; + Ok(()) } async fn handle_apply_code_action( @@ -8702,39 +8780,56 @@ impl LspStore { mut cx: AsyncApp, ) -> Result { let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id); - let (worktree_id, worktree, old_path, is_dir) = this + let new_worktree_id = WorktreeId::from_proto(envelope.payload.new_worktree_id); + let new_path = + RelPath::from_proto(&envelope.payload.new_path).context("invalid relative path")?; + + let (worktree_store, old_worktree, new_worktree, old_entry) = this .update(&mut cx, |this, cx| { - this.worktree_store + let (worktree, entry) = this + .worktree_store .read(cx) - .worktree_and_entry_for_id(entry_id, cx) - .map(|(worktree, entry)| { - ( - worktree.read(cx).id(), - worktree, - entry.path.clone(), - entry.is_dir(), - ) - }) + .worktree_and_entry_for_id(entry_id, cx)?; + let new_worktree = this + .worktree_store + .read(cx) + .worktree_for_id(new_worktree_id, cx)?; + Some(( + this.worktree_store.clone(), + worktree, + new_worktree, + entry.clone(), + )) })? .context("worktree not found")?; - let (old_abs_path, new_abs_path) = { - let root_path = worktree.read_with(&mut cx, |this, _| this.abs_path())?; - let new_path = PathBuf::from_proto(envelope.payload.new_path.clone()); - (root_path.join(&old_path), root_path.join(&new_path)) - }; + let (old_abs_path, old_worktree_id) = old_worktree.read_with(&cx, |worktree, _| { + (worktree.absolutize(&old_entry.path), worktree.id()) + })?; + let new_abs_path = + new_worktree.read_with(&cx, |worktree, _| worktree.absolutize(&new_path))?; - Self::will_rename_entry( + let _transaction = Self::will_rename_entry( this.downgrade(), - worktree_id, + old_worktree_id, &old_abs_path, &new_abs_path, - is_dir, + old_entry.is_dir(), + cx.clone(), + ) + .await; + let response = WorktreeStore::handle_rename_project_entry( + worktree_store, + envelope.payload, cx.clone(), ) .await; - let response = Worktree::handle_rename_entry(worktree, envelope.payload, cx.clone()).await; - this.read_with(&mut cx, |this, _| { - this.did_rename_entry(worktree_id, &old_abs_path, &new_abs_path, is_dir); + this.read_with(&cx, |this, _| { + this.did_rename_entry( + old_worktree_id, + &old_abs_path, + &new_abs_path, + old_entry.is_dir(), + ); }) .ok(); response @@ -8757,7 +8852,7 @@ impl LspStore { { let project_path = ProjectPath { worktree_id, - path: Arc::::from_proto(message_summary.path), + path: RelPath::from_proto(&message_summary.path).context("invalid path")?, }; let path = project_path.path.clone(); let server_id = LanguageServerId(message_summary.language_server_id as usize); @@ -8769,12 +8864,11 @@ impl LspStore { if summary.is_empty() { if let Some(worktree_summaries) = lsp_store.diagnostic_summaries.get_mut(&worktree_id) + && let Some(summaries) = worktree_summaries.get_mut(&path) { - if let Some(summaries) = worktree_summaries.get_mut(&path) { - summaries.remove(&server_id); - if summaries.is_empty() { - worktree_summaries.remove(&path); - } + summaries.remove(&server_id); + if summaries.is_empty() { + worktree_summaries.remove(&path); } } } else { @@ -8859,6 +8953,7 @@ impl LspStore { pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree: server.worktree_id.map(WorktreeId::from_proto), }, ); cx.emit(LspStoreEvent::LanguageServerAdded( @@ -8883,7 +8978,8 @@ impl LspStore { proto::update_language_server::Variant::WorkStart(payload) => { lsp_store.on_lsp_work_start( language_server_id, - payload.token, + ProgressToken::from_proto(payload.token.context("missing progress token")?) + .context("invalid progress token value")?, LanguageServerProgress { title: payload.title, is_disk_based_diagnostics_progress: false, @@ -8898,7 +8994,8 @@ impl LspStore { proto::update_language_server::Variant::WorkProgress(payload) => { lsp_store.on_lsp_work_progress( language_server_id, - payload.token, + ProgressToken::from_proto(payload.token.context("missing progress token")?) + .context("invalid progress token value")?, LanguageServerProgress { title: None, is_disk_based_diagnostics_progress: false, @@ -8912,7 +9009,12 @@ impl LspStore { } proto::update_language_server::Variant::WorkEnd(payload) => { - lsp_store.on_lsp_work_end(language_server_id, payload.token, cx); + lsp_store.on_lsp_work_end( + language_server_id, + ProgressToken::from_proto(payload.token.context("missing progress token")?) + .context("invalid progress token value")?, + cx, + ); } proto::update_language_server::Variant::DiskBasedDiagnosticsUpdating(_) => { @@ -8968,18 +9070,19 @@ impl LspStore { async fn handle_lsp_ext_cancel_flycheck( lsp_store: Entity, envelope: TypedEnvelope, - mut cx: AsyncApp, + cx: AsyncApp, ) -> Result { let server_id = LanguageServerId(envelope.payload.language_server_id as usize); - lsp_store.read_with(&mut cx, |lsp_store, _| { + let task = lsp_store.read_with(&cx, |lsp_store, _| { if let Some(server) = lsp_store.language_server_for_id(server_id) { - server - .notify::(&()) - .context("handling lsp ext cancel flycheck") + Some(server.notify::(())) } else { - anyhow::Ok(()) + None } - })??; + })?; + if let Some(task) = task { + task.context("handling lsp ext cancel flycheck")?; + } Ok(proto::Ack {}) } @@ -8993,25 +9096,31 @@ impl LspStore { lsp_store.update(&mut cx, |lsp_store, cx| { if let Some(server) = lsp_store.language_server_for_id(server_id) { let text_document = if envelope.payload.current_file_only { - let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - lsp_store - .buffer_store() - .read(cx) - .get(buffer_id) - .and_then(|buffer| Some(buffer.read(cx).file()?.as_local()?.abs_path(cx))) - .map(|path| make_text_document_identifier(&path)) + let buffer_id = envelope + .payload + .buffer_id + .map(|id| BufferId::new(id)) + .transpose()?; + buffer_id + .and_then(|buffer_id| { + lsp_store + .buffer_store() + .read(cx) + .get(buffer_id) + .and_then(|buffer| { + Some(buffer.read(cx).file()?.as_local()?.abs_path(cx)) + }) + .map(|path| make_text_document_identifier(&path)) + }) .transpose()? } else { None }; - server - .notify::( - &lsp_store::lsp_ext_command::RunFlycheckParams { text_document }, - ) - .context("handling lsp ext run flycheck") - } else { - anyhow::Ok(()) + server.notify::( + lsp_store::lsp_ext_command::RunFlycheckParams { text_document }, + )?; } + anyhow::Ok(()) })??; Ok(proto::Ack {}) @@ -9020,18 +9129,18 @@ impl LspStore { async fn handle_lsp_ext_clear_flycheck( lsp_store: Entity, envelope: TypedEnvelope, - mut cx: AsyncApp, + cx: AsyncApp, ) -> Result { let server_id = LanguageServerId(envelope.payload.language_server_id as usize); - lsp_store.read_with(&mut cx, |lsp_store, _| { - if let Some(server) = lsp_store.language_server_for_id(server_id) { - server - .notify::(&()) - .context("handling lsp ext clear flycheck") - } else { - anyhow::Ok(()) - } - })??; + lsp_store + .read_with(&cx, |lsp_store, _| { + if let Some(server) = lsp_store.language_server_for_id(server_id) { + Some(server.notify::(())) + } else { + None + } + }) + .context("handling lsp ext clear flycheck")?; Ok(proto::Ack {}) } @@ -9153,8 +9262,12 @@ impl LspStore { maybe!({ let local_store = self.as_local()?; - let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from)?; - let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from)?; + let old_uri = lsp::Uri::from_file_path(old_path) + .ok() + .map(|uri| uri.to_string())?; + let new_uri = lsp::Uri::from_file_path(new_path) + .ok() + .map(|uri| uri.to_string())?; for language_server in local_store.language_servers_for_worktree(worktree_id) { let Some(filter) = local_store @@ -9166,7 +9279,7 @@ impl LspStore { if filter.should_send_did_rename(&old_uri, is_dir) { language_server - .notify::(&RenameFilesParams { + .notify::(RenameFilesParams { files: vec![FileRename { old_uri: old_uri.clone(), new_uri: new_uri.clone(), @@ -9186,9 +9299,13 @@ impl LspStore { new_path: &Path, is_dir: bool, cx: AsyncApp, - ) -> Task<()> { - let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from); - let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from); + ) -> Task { + let old_uri = lsp::Uri::from_file_path(old_path) + .ok() + .map(|uri| uri.to_string()); + let new_uri = lsp::Uri::from_file_path(new_path) + .ok() + .map(|uri| uri.to_string()); cx.spawn(async move |cx| { let mut tasks = vec![]; this.update(cx, |this, cx| { @@ -9202,11 +9319,7 @@ impl LspStore { else { continue; }; - let Some(adapter) = - this.language_server_adapter_for_id(language_server.server_id()) - else { - continue; - }; + if filter.should_send_will_rename(&old_uri, is_dir) { let apply_edit = cx.spawn({ let old_uri = old_uri.clone(); @@ -9223,17 +9336,16 @@ impl LspStore { .log_err() .flatten()?; - LocalLspStore::deserialize_workspace_edit( + let transaction = LocalLspStore::deserialize_workspace_edit( this.upgrade()?, edit, false, - adapter.clone(), language_server.clone(), cx, ) .await - .ok(); - Some(()) + .ok()?; + Some(transaction) } }); tasks.push(apply_edit); @@ -9243,11 +9355,17 @@ impl LspStore { }) .ok() .flatten(); + let mut merged_transaction = ProjectTransaction::default(); for task in tasks { // Await on tasks sequentially so that the order of application of edits is deterministic // (at least with regards to the order of registration of language servers) - task.await; + if let Some(transaction) = task.await { + for (buffer, buffer_transaction) in transaction.0 { + merged_transaction.0.insert(buffer, buffer_transaction); + } + } } + merged_transaction }) } @@ -9275,7 +9393,7 @@ impl LspStore { if !changes.is_empty() { server .notify::( - &lsp::DidChangeWatchedFilesParams { changes }, + lsp::DidChangeWatchedFilesParams { changes }, ) .ok(); } @@ -9289,38 +9407,38 @@ impl LspStore { fn on_lsp_progress( &mut self, - progress: lsp::ProgressParams, + progress_params: lsp::ProgressParams, language_server_id: LanguageServerId, disk_based_diagnostics_progress_token: Option, cx: &mut Context, ) { - let token = match progress.token { - lsp::NumberOrString::String(token) => token, - lsp::NumberOrString::Number(token) => { - log::info!("skipping numeric progress token {}", token); - return; - } - }; - - match progress.value { + match progress_params.value { lsp::ProgressParamsValue::WorkDone(progress) => { self.handle_work_done_progress( progress, language_server_id, disk_based_diagnostics_progress_token, - token, + ProgressToken::from_lsp(progress_params.token), cx, ); } lsp::ProgressParamsValue::WorkspaceDiagnostic(report) => { + let identifier = match progress_params.token { + lsp::NumberOrString::Number(_) => None, + lsp::NumberOrString::String(token) => token + .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) + .map(|(_, id)| id.to_owned()), + }; if let Some(LanguageServerState::Running { - workspace_refresh_task: Some(workspace_refresh_task), + workspace_diagnostics_refresh_tasks, .. }) = self .as_local_mut() .and_then(|local| local.language_servers.get_mut(&language_server_id)) + && let Some(workspace_diagnostics) = + workspace_diagnostics_refresh_tasks.get_mut(&identifier) { - workspace_refresh_task.progress_tx.try_send(()).ok(); + workspace_diagnostics.progress_tx.try_send(()).ok(); self.apply_workspace_diagnostic_report(language_server_id, report, cx) } } @@ -9332,7 +9450,7 @@ impl LspStore { progress: lsp::WorkDoneProgress, language_server_id: LanguageServerId, disk_based_diagnostics_progress_token: Option, - token: String, + token: ProgressToken, cx: &mut Context, ) { let language_server_status = @@ -9346,11 +9464,14 @@ impl LspStore { return; } - let is_disk_based_diagnostics_progress = disk_based_diagnostics_progress_token - .as_ref() - .map_or(false, |disk_based_token| { + let is_disk_based_diagnostics_progress = + if let (Some(disk_based_token), ProgressToken::String(token)) = + (&disk_based_diagnostics_progress_token, &token) + { token.starts_with(disk_based_token) - }); + } else { + false + }; match progress { lsp::WorkDoneProgress::Begin(report) => { @@ -9397,7 +9518,7 @@ impl LspStore { fn on_lsp_work_start( &mut self, language_server_id: LanguageServerId, - token: String, + token: ProgressToken, progress: LanguageServerProgress, cx: &mut Context, ) { @@ -9411,7 +9532,7 @@ impl LspStore { .language_server_adapter_for_id(language_server_id) .map(|adapter| adapter.name()), message: proto::update_language_server::Variant::WorkStart(proto::LspWorkStart { - token, + token: Some(token.to_proto()), title: progress.title, message: progress.message, percentage: progress.percentage.map(|p| p as u32), @@ -9423,7 +9544,7 @@ impl LspStore { fn on_lsp_work_progress( &mut self, language_server_id: LanguageServerId, - token: String, + token: ProgressToken, progress: LanguageServerProgress, cx: &mut Context, ) { @@ -9463,7 +9584,7 @@ impl LspStore { .map(|adapter| adapter.name()), message: proto::update_language_server::Variant::WorkProgress( proto::LspWorkProgress { - token, + token: Some(token.to_proto()), message: progress.message, percentage: progress.percentage.map(|p| p as u32), is_cancellable: Some(progress.is_cancellable), @@ -9476,14 +9597,14 @@ impl LspStore { fn on_lsp_work_end( &mut self, language_server_id: LanguageServerId, - token: String, + token: ProgressToken, cx: &mut Context, ) { if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) { - if let Some(work) = status.pending_work.remove(&token) { - if !work.is_disk_based_diagnostics_progress { - cx.emit(LspStoreEvent::RefreshInlayHints); - } + if let Some(work) = status.pending_work.remove(&token) + && !work.is_disk_based_diagnostics_progress + { + cx.emit(LspStoreEvent::RefreshInlayHints(language_server_id)); } cx.notify(); } @@ -9493,7 +9614,9 @@ impl LspStore { name: self .language_server_adapter_for_id(language_server_id) .map(|adapter| adapter.name()), - message: proto::update_language_server::Variant::WorkEnd(proto::LspWorkEnd { token }), + message: proto::update_language_server::Variant::WorkEnd(proto::LspWorkEnd { + token: Some(token.to_proto()), + }), }) } @@ -9615,12 +9738,14 @@ impl LspStore { } async fn handle_refresh_inlay_hints( - this: Entity, - _: TypedEnvelope, + lsp_store: Entity, + envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - this.update(&mut cx, |_, cx| { - cx.emit(LspStoreEvent::RefreshInlayHints); + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::RefreshInlayHints( + LanguageServerId::from_proto(envelope.payload.server_id), + )); })?; Ok(proto::Ack {}) } @@ -9637,51 +9762,6 @@ impl LspStore { Ok(proto::Ack {}) } - async fn handle_inlay_hints( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let sender_id = envelope.original_sender_id().unwrap_or_default(); - let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - let buffer = this.update(&mut cx, |this, cx| { - this.buffer_store.read(cx).get_existing(buffer_id) - })??; - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(deserialize_version(&envelope.payload.version)) - })? - .await - .with_context(|| format!("waiting for version for buffer {}", buffer.entity_id()))?; - - let start = envelope - .payload - .start - .and_then(deserialize_anchor) - .context("missing range start")?; - let end = envelope - .payload - .end - .and_then(deserialize_anchor) - .context("missing range end")?; - let buffer_hints = this - .update(&mut cx, |lsp_store, cx| { - lsp_store.inlay_hints(buffer.clone(), start..end, cx) - })? - .await - .context("inlay hints fetch")?; - - this.update(&mut cx, |project, cx| { - InlayHints::response_to_proto( - buffer_hints, - project, - sender_id, - &buffer.read(cx).version(), - cx, - ) - }) - } - async fn handle_get_color_presentation( lsp_store: Entity, envelope: TypedEnvelope, @@ -9747,7 +9827,7 @@ impl LspStore { } async fn handle_resolve_inlay_hint( - this: Entity, + lsp_store: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { @@ -9757,13 +9837,13 @@ impl LspStore { .expect("incorrect protobuf resolve inlay hint message: missing the inlay hint"); let hint = InlayHints::proto_to_project_hint(proto_hint) .context("resolved proto inlay hint conversion")?; - let buffer = this.update(&mut cx, |this, cx| { + let buffer = lsp_store.update(&mut cx, |lsp_store, cx| { let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - this.buffer_store.read(cx).get_existing(buffer_id) + lsp_store.buffer_store.read(cx).get_existing(buffer_id) })??; - let response_hint = this - .update(&mut cx, |this, cx| { - this.resolve_inlay_hint( + let response_hint = lsp_store + .update(&mut cx, |lsp_store, cx| { + lsp_store.resolve_inlay_hint( hint, buffer, LanguageServerId(envelope.payload.language_server_id as usize), @@ -9796,10 +9876,16 @@ impl LspStore { let peer_id = envelope.original_sender_id().unwrap_or_default(); let symbol = envelope.payload.symbol.context("invalid symbol")?; let symbol = Self::deserialize_symbol(symbol)?; - let symbol = this.read_with(&mut cx, |this, _| { - let signature = this.symbol_signature(&symbol.path); - anyhow::ensure!(signature == symbol.signature, "invalid symbol signature"); - Ok(symbol) + this.read_with(&cx, |this, _| { + if let SymbolLocation::OutsideProject { + abs_path, + signature, + } = &symbol.path + { + let new_signature = this.symbol_signature(&abs_path); + anyhow::ensure!(&new_signature == signature, "invalid symbol signature"); + } + Ok(()) })??; let buffer = this .update(&mut cx, |this, cx| { @@ -9812,12 +9898,7 @@ impl LspStore { name: symbol.name, kind: symbol.kind, range: symbol.range, - signature: symbol.signature, - label: CodeLabel { - text: Default::default(), - runs: Default::default(), - filter_range: Default::default(), - }, + label: CodeLabel::default(), }, cx, ) @@ -9844,10 +9925,9 @@ impl LspStore { })? } - fn symbol_signature(&self, project_path: &ProjectPath) -> [u8; 32] { + fn symbol_signature(&self, abs_path: &Path) -> [u8; 32] { let mut hasher = Sha256::new(); - hasher.update(project_path.worktree_id.to_proto().to_be_bytes()); - hasher.update(project_path.path.to_string_lossy().as_bytes()); + hasher.update(abs_path.to_string_lossy().as_bytes()); hasher.update(self.nonce.to_be_bytes()); hasher.finalize().as_slice().try_into().unwrap() } @@ -9948,25 +10028,33 @@ impl LspStore { } pub async fn handle_cancel_language_server_work( - this: Entity, + lsp_store: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - this.update(&mut cx, |this, cx| { + lsp_store.update(&mut cx, |lsp_store, cx| { if let Some(work) = envelope.payload.work { match work { proto::cancel_language_server_work::Work::Buffers(buffers) => { let buffers = - this.buffer_ids_to_buffers(buffers.buffer_ids.into_iter(), cx); - this.cancel_language_server_work_for_buffers(buffers, cx); + lsp_store.buffer_ids_to_buffers(buffers.buffer_ids.into_iter(), cx); + lsp_store.cancel_language_server_work_for_buffers(buffers, cx); } proto::cancel_language_server_work::Work::LanguageServerWork(work) => { let server_id = LanguageServerId::from_proto(work.language_server_id); - this.cancel_language_server_work(server_id, work.token, cx); + let token = work + .token + .map(|token| { + ProgressToken::from_proto(token) + .context("invalid work progress token") + }) + .transpose()?; + lsp_store.cancel_language_server_work(server_id, token, cx); } } } - })?; + anyhow::Ok(()) + })??; Ok(proto::Ack {}) } @@ -10008,11 +10096,7 @@ impl LspStore { new_text: completion.new_text, source: completion.source, documentation: None, - label: CodeLabel { - text: Default::default(), - runs: Default::default(), - filter_range: Default::default(), - }, + label: CodeLabel::default(), insert_text_mode: None, icon_path: None, confirm: None, @@ -10046,7 +10130,7 @@ impl LspStore { ) -> Shared>>> { if let Some(environment) = &self.as_local().map(|local| local.environment.clone()) { environment.update(cx, |env, cx| { - env.get_buffer_environment(&buffer, &self.worktree_store, cx) + env.get_buffer_environment(buffer, &self.worktree_store, cx) }) } else { Task::ready(None).shared() @@ -10062,7 +10146,7 @@ impl LspStore { cx: &mut Context, ) -> Task> { let logger = zlog::scoped!("format"); - if let Some(_) = self.as_local() { + if self.as_local().is_some() { zlog::trace!(logger => "Formatting locally"); let logger = zlog::scoped!(logger => "local"); let buffers = buffers @@ -10277,10 +10361,10 @@ impl LspStore { None => None, }; - if let Some(server) = server { - if let Some(shutdown) = server.shutdown() { - shutdown.await; - } + if let Some(server) = server + && let Some(shutdown) = server.shutdown() + { + shutdown.await; } } @@ -10290,28 +10374,18 @@ impl LspStore { &mut self, server_id: LanguageServerId, cx: &mut Context, - ) -> Task> { + ) -> Task<()> { let local = match &mut self.mode { LspStoreMode::Local(local) => local, _ => { - return Task::ready(Vec::new()); + return Task::ready(()); } }; - let mut orphaned_worktrees = Vec::new(); // Remove this server ID from all entries in the given worktree. - local.language_server_ids.retain(|(worktree, _), ids| { - if !ids.remove(&server_id) { - return true; - } - - if ids.is_empty() { - orphaned_worktrees.push(*worktree); - false - } else { - true - } - }); + local + .language_server_ids + .retain(|_, state| state.id != server_id); self.buffer_store.update(cx, |buffer_store, cx| { for buffer in buffer_store.buffers() { buffer.update(cx, |buffer, cx| { @@ -10364,7 +10438,7 @@ impl LspStore { let name = self .language_server_statuses .remove(&server_id) - .map(|status| status.name.clone()) + .map(|status| status.name) .or_else(|| { if let Some(LanguageServerState::Running { adapter, .. }) = server_state.as_ref() { Some(adapter.name()) @@ -10390,14 +10464,13 @@ impl LspStore { cx.notify(); }) .ok(); - orphaned_worktrees }); } if server_state.is_some() { cx.emit(LspStoreEvent::LanguageServerRemoved(server_id)); } - Task::ready(orphaned_worktrees) + Task::ready(()) } pub fn stop_all_language_servers(&mut self, cx: &mut Context) { @@ -10416,12 +10489,9 @@ impl LspStore { let language_servers_to_stop = local .language_server_ids .values() - .flatten() - .copied() + .map(|state| state.id) .collect(); - local.lsp_tree.update(cx, |this, _| { - this.remove_nodes(&language_servers_to_stop); - }); + local.lsp_tree.remove_nodes(&language_servers_to_stop); let tasks = language_servers_to_stop .into_iter() .map(|server| self.stop_local_language_server(server, cx)) @@ -10568,37 +10638,31 @@ impl LspStore { for buffer in buffers { buffer.update(cx, |buffer, cx| { language_servers_to_stop.extend(local.language_server_ids_for_buffer(buffer, cx)); - if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) { - if covered_worktrees.insert(worktree_id) { - language_server_names_to_stop.retain(|name| { - match local.language_server_ids.get(&(worktree_id, name.clone())) { - Some(server_ids) => { - language_servers_to_stop - .extend(server_ids.into_iter().copied()); - false - } - None => true, - } - }); - } + if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) + && covered_worktrees.insert(worktree_id) + { + language_server_names_to_stop.retain(|name| { + let old_ids_count = language_servers_to_stop.len(); + let all_language_servers_with_this_name = local + .language_server_ids + .iter() + .filter_map(|(seed, state)| seed.name.eq(name).then(|| state.id)); + language_servers_to_stop.extend(all_language_servers_with_this_name); + old_ids_count == language_servers_to_stop.len() + }); } }); } for name in language_server_names_to_stop { - if let Some(server_ids) = local - .language_server_ids - .iter() - .filter(|((_, server_name), _)| server_name == &name) - .map(|((_, _), server_ids)| server_ids) - .max_by_key(|server_ids| server_ids.len()) - { - language_servers_to_stop.extend(server_ids.into_iter().copied()); - } + language_servers_to_stop.extend( + local + .language_server_ids + .iter() + .filter_map(|(seed, v)| seed.name.eq(&name).then(|| v.id)), + ); } - local.lsp_tree.update(cx, |this, _| { - this.remove_nodes(&language_servers_to_stop); - }); + local.lsp_tree.remove_nodes(&language_servers_to_stop); let tasks = language_servers_to_stop .into_iter() .map(|server| self.stop_local_language_server(server, cx)) @@ -10613,7 +10677,7 @@ impl LspStore { let project_path = ProjectPath { worktree_id: worktree.read(cx).id(), - path: relative_path.into(), + path: relative_path, }; Some( @@ -10703,7 +10767,7 @@ impl LspStore { let is_supporting = diagnostic .related_information .as_ref() - .map_or(false, |infos| { + .is_some_and(|infos| { infos.iter().any(|info| { primary_diagnostic_group_ids.contains_key(&( source, @@ -10716,11 +10780,11 @@ impl LspStore { let is_unnecessary = diagnostic .tags .as_ref() - .map_or(false, |tags| tags.contains(&DiagnosticTag::UNNECESSARY)); + .is_some_and(|tags| tags.contains(&DiagnosticTag::UNNECESSARY)); let underline = self .language_server_adapter_for_id(server_id) - .map_or(true, |adapter| adapter.underline_diagnostic(diagnostic)); + .is_none_or(|adapter| adapter.underline_diagnostic(diagnostic)); if is_supporting { supporting_diagnostics.insert( @@ -10730,7 +10794,7 @@ impl LspStore { } else { let group_id = post_inc(&mut self.as_local_mut().unwrap().next_diagnostic_group_id); let is_disk_based = - source.map_or(false, |source| disk_based_sources.contains(source)); + source.is_some_and(|source| disk_based_sources.contains(source)); sources_by_group_id.insert(group_id, source); primary_diagnostic_group_ids @@ -10821,8 +10885,8 @@ impl LspStore { adapter: Arc, language_server: Arc, server_id: LanguageServerId, - key: (WorktreeId, LanguageServerName), - workspace_folders: Arc>>, + key: LanguageServerSeed, + workspace_folders: Arc>>, cx: &mut Context, ) { let Some(local) = self.as_local_mut() else { @@ -10833,7 +10897,7 @@ impl LspStore { if local .language_server_ids .get(&key) - .map(|ids| !ids.contains(&server_id)) + .map(|state| state.id != server_id) .unwrap_or(false) { return; @@ -10844,13 +10908,28 @@ impl LspStore { let workspace_folders = workspace_folders.lock().clone(); language_server.set_workspace_folders(workspace_folders); + let workspace_diagnostics_refresh_tasks = language_server + .capabilities() + .diagnostic_provider + .and_then(|provider| { + local + .language_server_dynamic_registrations + .entry(server_id) + .or_default() + .diagnostics + .entry(None) + .or_insert(provider.clone()); + let workspace_refresher = + lsp_workspace_diagnostics_refresh(None, provider, language_server.clone(), cx)?; + + Some((None, workspace_refresher)) + }) + .into_iter() + .collect(); local.language_servers.insert( server_id, LanguageServerState::Running { - workspace_refresh_task: lsp_workspace_diagnostics_refresh( - language_server.clone(), - cx, - ), + workspace_diagnostics_refresh_tasks, adapter: adapter.clone(), server: language_server.clone(), simulate_disk_based_diagnostics_completion: None, @@ -10884,15 +10963,16 @@ impl LspStore { pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), + worktree: Some(key.worktree_id), }, ); cx.emit(LspStoreEvent::LanguageServerAdded( server_id, language_server.name(), - Some(key.0), + Some(key.worktree_id), )); - cx.emit(LspStoreEvent::RefreshInlayHints); + cx.emit(LspStoreEvent::RefreshInlayHints(server_id)); let server_capabilities = language_server.capabilities(); if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { @@ -10902,7 +10982,7 @@ impl LspStore { server: Some(proto::LanguageServer { id: server_id.to_proto(), name: language_server.name().to_string(), - worktree_id: Some(key.0.to_proto()), + worktree_id: Some(key.worktree_id.to_proto()), }), capabilities: serde_json::to_string(&server_capabilities) .expect("serializing server LSP capabilities"), @@ -10914,13 +10994,16 @@ impl LspStore { // Tell the language server about every open buffer in the worktree that matches the language. // Also check for buffers in worktrees that reused this server - let mut worktrees_using_server = vec![key.0]; + let mut worktrees_using_server = vec![key.worktree_id]; if let Some(local) = self.as_local() { // Find all worktrees that have this server in their language server tree - for (worktree_id, servers) in &local.lsp_tree.read(cx).instances { - if *worktree_id != key.0 { - for (_, server_map) in &servers.roots { - if server_map.contains_key(&key.1) { + for (worktree_id, servers) in &local.lsp_tree.instances { + if *worktree_id != key.worktree_id { + for server_map in servers.roots.values() { + if server_map + .values() + .any(|(node, _)| node.id() == Some(server_id)) + { worktrees_using_server.push(*worktree_id); } } @@ -10930,6 +11013,7 @@ impl LspStore { let mut buffer_paths_registered = Vec::new(); self.buffer_store.clone().update(cx, |buffer_store, cx| { + let mut lsp_adapters = HashMap::default(); for buffer_handle in buffer_store.buffers() { let buffer = buffer_handle.read(cx); let file = match File::from_dyn(buffer.file()) { @@ -10942,11 +11026,11 @@ impl LspStore { }; if !worktrees_using_server.contains(&file.worktree.read(cx).id()) - || !self - .languages - .lsp_adapters(&language.name()) + || !lsp_adapters + .entry(language.name()) + .or_insert_with(|| self.languages.lsp_adapters(&language.name())) .iter() - .any(|a| a.name == key.1) + .any(|a| a.name == key.name) { continue; } @@ -10981,7 +11065,7 @@ impl LspStore { let snapshot = versions.last().unwrap(); let version = snapshot.version; let initial_snapshot = &snapshot.snapshot; - let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); + let uri = lsp::Uri::from_file_path(file.abs_path(cx)).unwrap(); language_server.register_buffer( uri, adapter.language_id(&language.name()), @@ -11021,7 +11105,7 @@ impl LspStore { name: Some(adapter.name()), message: proto::update_language_server::Variant::RegisteredForBuffer( proto::RegisteredForBuffer { - buffer_abs_path: abs_path.to_string_lossy().to_string(), + buffer_abs_path: abs_path.to_string_lossy().into_owned(), buffer_id: buffer_id.to_proto(), }, ), @@ -11081,7 +11165,7 @@ impl LspStore { pub(crate) fn cancel_language_server_work( &mut self, server_id: LanguageServerId, - token_to_cancel: Option, + token_to_cancel: Option, cx: &mut Context, ) { if let Some(local) = self.as_local() { @@ -11090,16 +11174,16 @@ impl LspStore { if let Some((LanguageServerState::Running { server, .. }, status)) = server.zip(status) { for (token, progress) in &status.pending_work { - if let Some(token_to_cancel) = token_to_cancel.as_ref() { - if token != token_to_cancel { - continue; - } + if let Some(token_to_cancel) = token_to_cancel.as_ref() + && token != token_to_cancel + { + continue; } if progress.is_cancellable { server .notify::( - &WorkDoneProgressCancelParams { - token: lsp::NumberOrString::String(token.clone()), + WorkDoneProgressCancelParams { + token: token.to_lsp(), }, ) .ok(); @@ -11113,7 +11197,7 @@ impl LspStore { proto::cancel_language_server_work::Work::LanguageServerWork( proto::cancel_language_server_work::LanguageServerWork { language_server_id: server_id.to_proto(), - token: token_to_cancel, + token: token_to_cancel.map(|token| token.to_proto()), }, ), ), @@ -11174,7 +11258,7 @@ impl LspStore { pub(super) fn update_local_worktree_language_servers( &mut self, worktree_handle: &Entity, - changes: &[(Arc, ProjectEntryId, PathChange)], + changes: &[(Arc, ProjectEntryId, PathChange)], cx: &mut Context, ) { if changes.is_empty() { @@ -11184,61 +11268,64 @@ impl LspStore { let Some(local) = self.as_local() else { return }; local.prettier_store.update(cx, |prettier_store, cx| { - prettier_store.update_prettier_settings(&worktree_handle, changes, cx) + prettier_store.update_prettier_settings(worktree_handle, changes, cx) }); let worktree_id = worktree_handle.read(cx).id(); let mut language_server_ids = local .language_server_ids .iter() - .flat_map(|((server_worktree, _), server_ids)| { - server_ids - .iter() - .filter_map(|server_id| server_worktree.eq(&worktree_id).then(|| *server_id)) - }) + .filter_map(|(seed, v)| seed.worktree_id.eq(&worktree_id).then(|| v.id)) .collect::>(); language_server_ids.sort(); language_server_ids.dedup(); - let abs_path = worktree_handle.read(cx).abs_path(); + // let abs_path = worktree_handle.read(cx).abs_path(); for server_id in &language_server_ids { if let Some(LanguageServerState::Running { server, .. }) = local.language_servers.get(server_id) - { - if let Some(watched_paths) = local + && let Some(watched_paths) = local .language_server_watched_paths .get(server_id) .and_then(|paths| paths.worktree_paths.get(&worktree_id)) - { - let params = lsp::DidChangeWatchedFilesParams { - changes: changes - .iter() - .filter_map(|(path, _, change)| { - if !watched_paths.is_match(path) { - return None; - } - let typ = match change { - PathChange::Loaded => return None, - PathChange::Added => lsp::FileChangeType::CREATED, - PathChange::Removed => lsp::FileChangeType::DELETED, - PathChange::Updated => lsp::FileChangeType::CHANGED, - PathChange::AddedOrUpdated => lsp::FileChangeType::CHANGED, - }; - Some(lsp::FileEvent { - uri: lsp::Url::from_file_path(abs_path.join(path)).unwrap(), - typ, - }) - }) - .collect(), - }; - if !params.changes.is_empty() { - server - .notify::(¶ms) - .ok(); - } + { + let params = lsp::DidChangeWatchedFilesParams { + changes: changes + .iter() + .filter_map(|(path, _, change)| { + if !watched_paths.is_match(path.as_std_path()) { + return None; + } + let typ = match change { + PathChange::Loaded => return None, + PathChange::Added => lsp::FileChangeType::CREATED, + PathChange::Removed => lsp::FileChangeType::DELETED, + PathChange::Updated => lsp::FileChangeType::CHANGED, + PathChange::AddedOrUpdated => lsp::FileChangeType::CHANGED, + }; + let uri = lsp::Uri::from_file_path( + worktree_handle.read(cx).absolutize(&path), + ) + .ok()?; + Some(lsp::FileEvent { uri, typ }) + }) + .collect(), + }; + if !params.changes.is_empty() { + server + .notify::(params) + .ok(); } } } + for (path, _, _) in changes { + if let Some(file_name) = path.file_name() + && local.watched_manifest_filenames.contains(file_name) + { + self.request_workspace_config_refresh(); + break; + } + } } pub fn wait_for_remote_buffer( @@ -11252,12 +11339,10 @@ impl LspStore { } fn serialize_symbol(symbol: &Symbol) -> proto::Symbol { - proto::Symbol { + let mut result = proto::Symbol { language_server_name: symbol.language_server_name.0.to_string(), source_worktree_id: symbol.source_worktree_id.to_proto(), language_server_id: symbol.source_language_server_id.to_proto(), - worktree_id: symbol.path.worktree_id.to_proto(), - path: symbol.path.path.as_ref().to_proto(), name: symbol.name.clone(), kind: unsafe { mem::transmute::(symbol.kind) }, start: Some(proto::PointUtf16 { @@ -11268,17 +11353,45 @@ impl LspStore { row: symbol.range.end.0.row, column: symbol.range.end.0.column, }), - signature: symbol.signature.to_vec(), + worktree_id: Default::default(), + path: Default::default(), + signature: Default::default(), + }; + match &symbol.path { + SymbolLocation::InProject(path) => { + result.worktree_id = path.worktree_id.to_proto(); + result.path = path.path.to_proto(); + } + SymbolLocation::OutsideProject { + abs_path, + signature, + } => { + result.path = abs_path.to_string_lossy().into_owned(); + result.signature = signature.to_vec(); + } } + result } fn deserialize_symbol(serialized_symbol: proto::Symbol) -> Result { let source_worktree_id = WorktreeId::from_proto(serialized_symbol.source_worktree_id); let worktree_id = WorktreeId::from_proto(serialized_symbol.worktree_id); let kind = unsafe { mem::transmute::(serialized_symbol.kind) }; - let path = ProjectPath { - worktree_id, - path: Arc::::from_proto(serialized_symbol.path), + + let path = if serialized_symbol.signature.is_empty() { + SymbolLocation::InProject(ProjectPath { + worktree_id, + path: RelPath::from_proto(&serialized_symbol.path) + .context("invalid symbol path")?, + }) + } else { + SymbolLocation::OutsideProject { + abs_path: Path::new(&serialized_symbol.path).into(), + signature: serialized_symbol + .signature + .try_into() + .map_err(|_| anyhow!("invalid signature"))?, + } }; let start = serialized_symbol.start.context("invalid start")?; @@ -11294,10 +11407,6 @@ impl LspStore { range: Unclipped(PointUtf16::new(start.row, start.column)) ..Unclipped(PointUtf16::new(end.row, end.column)), kind, - signature: serialized_symbol - .signature - .try_into() - .map_err(|_| anyhow!("invalid signature"))?, }) } @@ -11481,12 +11590,8 @@ impl LspStore { fn cleanup_lsp_data(&mut self, for_server: LanguageServerId) { self.lsp_server_capabilities.remove(&for_server); - for buffer_colors in self.lsp_document_colors.values_mut() { - buffer_colors.colors.remove(&for_server); - buffer_colors.cache_version += 1; - } - for buffer_lens in self.lsp_code_lens.values_mut() { - buffer_lens.lens.remove(&for_server); + for lsp_data in self.lsp_data.values_mut() { + lsp_data.remove_server_data(for_server); } if let Some(local) = self.as_local_mut() { local.buffer_pull_diagnostics_result_ids.remove(&for_server); @@ -11530,13 +11635,15 @@ impl LspStore { pub fn pull_workspace_diagnostics(&mut self, server_id: LanguageServerId) { if let Some(LanguageServerState::Running { - workspace_refresh_task: Some(workspace_refresh_task), + workspace_diagnostics_refresh_tasks, .. }) = self .as_local_mut() .and_then(|local| local.language_servers.get_mut(&server_id)) { - workspace_refresh_task.refresh_tx.try_send(()).ok(); + for diagnostics in workspace_diagnostics_refresh_tasks.values_mut() { + diagnostics.refresh_tx.try_send(()).ok(); + } } } @@ -11552,11 +11659,13 @@ impl LspStore { local.language_server_ids_for_buffer(buffer, cx) }) { if let Some(LanguageServerState::Running { - workspace_refresh_task: Some(workspace_refresh_task), + workspace_diagnostics_refresh_tasks, .. }) = local.language_servers.get_mut(&server_id) { - workspace_refresh_task.refresh_tx.try_send(()).ok(); + for diagnostics in workspace_diagnostics_refresh_tasks.values_mut() { + diagnostics.refresh_tx.try_send(()).ok(); + } } } } @@ -11630,7 +11739,7 @@ impl LspStore { File::from_dyn(buffer.file()) .and_then(|file| { let abs_path = file.as_local()?.abs_path(cx); - lsp::Url::from_file_path(abs_path).ok() + lsp::Uri::from_file_path(abs_path).ok() }) .is_none_or(|buffer_uri| { unchanged_buffers.contains(&buffer_uri) @@ -11679,13 +11788,26 @@ impl LspStore { "workspace/didChangeConfiguration" => { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } + "workspace/didChangeWorkspaceFolders" => { + // In this case register options is an empty object, we can ignore it + let caps = lsp::WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Right(reg.id)), + }; + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_default() + .workspace_folders = Some(caps); + }); + notify_server_capabilities_updated(&server, cx); + } "workspace/symbol" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.workspace_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.workspace_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "workspace/fileOperations" => { if let Some(options) = reg.register_options { @@ -11709,12 +11831,11 @@ impl LspStore { } } "textDocument/rangeFormatting" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_range_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_range_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/onTypeFormatting" => { if let Some(options) = reg @@ -11729,57 +11850,50 @@ impl LspStore { } } "textDocument/formatting" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/rename" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.rename_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.rename_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/inlayHint" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.inlay_hint_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.inlay_hint_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/documentSymbol" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/codeAction" => { - if let Some(options) = reg - .register_options - .map(serde_json::from_value) - .transpose()? - { - server.update_capabilities(|capabilities| { - capabilities.code_action_provider = - Some(lsp::CodeActionProviderCapability::Options(options)); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::CodeActionProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.code_action_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/definition" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.definition_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.definition_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/completion" => { if let Some(caps) = reg @@ -11794,16 +11908,15 @@ impl LspStore { } } "textDocument/hover" => { - if let Some(caps) = reg - .register_options - .map(serde_json::from_value) - .transpose()? - { - server.update_capabilities(|capabilities| { - capabilities.hover_provider = Some(caps); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::HoverProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.hover_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/signatureHelp" => { if let Some(caps) = reg @@ -11825,48 +11938,118 @@ impl LspStore { .transpose()? { server.update_capabilities(|capabilities| { + let mut sync_options = + Self::take_text_document_sync_options(capabilities); + sync_options.change = Some(sync_kind); capabilities.text_document_sync = - Some(lsp::TextDocumentSyncCapability::Kind(sync_kind)); + Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); notify_server_capabilities_updated(&server, cx); } } - "textDocument/codeLens" => { - if let Some(caps) = reg + "textDocument/didSave" => { + if let Some(include_text) = reg .register_options - .map(serde_json::from_value) + .map(|opts| { + let transpose = opts + .get("includeText") + .cloned() + .map(serde_json::from_value::>) + .transpose(); + match transpose { + Ok(value) => Ok(value.flatten()), + Err(e) => Err(e), + } + }) .transpose()? { server.update_capabilities(|capabilities| { - capabilities.code_lens_provider = Some(caps); + let mut sync_options = + Self::take_text_document_sync_options(capabilities); + sync_options.save = + Some(TextDocumentSyncSaveOptions::SaveOptions(lsp::SaveOptions { + include_text, + })); + capabilities.text_document_sync = + Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); notify_server_capabilities_updated(&server, cx); } } - "textDocument/diagnostic" => { + "textDocument/codeLens" => { if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? { server.update_capabilities(|capabilities| { - capabilities.diagnostic_provider = Some(caps); + capabilities.code_lens_provider = Some(caps); }); notify_server_capabilities_updated(&server, cx); } } - "textDocument/colorProvider" => { + "textDocument/diagnostic" => { if let Some(caps) = reg .register_options - .map(serde_json::from_value) + .map(serde_json::from_value::) .transpose()? { - server.update_capabilities(|capabilities| { - capabilities.color_provider = Some(caps); - }); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + let state = local + .language_servers + .get_mut(&server_id) + .context("Could not obtain Language Servers state")?; + local + .language_server_dynamic_registrations + .get_mut(&server_id) + .and_then(|registrations| { + registrations + .diagnostics + .insert(Some(reg.id.clone()), caps.clone()) + }); + + let mut can_now_provide_diagnostics = false; + if let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state + && let Some(task) = lsp_workspace_diagnostics_refresh( + Some(reg.id.clone()), + caps.clone(), + server.clone(), + cx, + ) + { + workspace_diagnostics_refresh_tasks.insert(Some(reg.id), task); + can_now_provide_diagnostics = true; + } + + // We don't actually care about capabilities.diagnostic_provider, but it IS relevant for the remote peer + // to know that there's at least one provider. Otherwise, it will never ask us to issue documentdiagnostic calls on their behalf, + // as it'll think that they're not supported. + if can_now_provide_diagnostics { + server.update_capabilities(|capabilities| { + debug_assert!(capabilities.diagnostic_provider.is_none()); + capabilities.diagnostic_provider = Some(caps); + }); + } + notify_server_capabilities_updated(&server, cx); } } + "textDocument/documentColor" => { + let options = parse_register_capabilities(reg)?; + let provider = match options { + OneOf::Left(value) => lsp::ColorProviderCapability::Simple(value), + OneOf::Right(caps) => caps, + }; + server.update_capabilities(|capabilities| { + capabilities.color_provider = Some(provider); + }); + notify_server_capabilities_updated(&server, cx); + } _ => log::warn!("unhandled capability registration: {reg:?}"), } } @@ -11900,6 +12083,18 @@ impl LspStore { "workspace/didChangeConfiguration" => { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } + "workspace/didChangeWorkspaceFolders" => { + server.update_capabilities(|capabilities| { + capabilities + .workspace + .get_or_insert_with(|| lsp::WorkspaceServerCapabilities { + workspace_folders: None, + file_operations: None, + }) + .workspace_folders = None; + }); + notify_server_capabilities_updated(&server, cx); + } "workspace/symbol" => { server.update_capabilities(|capabilities| { capabilities.workspace_symbol_provider = None @@ -11978,7 +12173,19 @@ impl LspStore { } "textDocument/didChange" => { server.update_capabilities(|capabilities| { - capabilities.text_document_sync = None; + let mut sync_options = Self::take_text_document_sync_options(capabilities); + sync_options.change = None; + capabilities.text_document_sync = + Some(lsp::TextDocumentSyncCapability::Options(sync_options)); + }); + notify_server_capabilities_updated(&server, cx); + } + "textDocument/didSave" => { + server.update_capabilities(|capabilities| { + let mut sync_options = Self::take_text_document_sync_options(capabilities); + sync_options.save = None; + capabilities.text_document_sync = + Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); notify_server_capabilities_updated(&server, cx); } @@ -11989,12 +12196,48 @@ impl LspStore { notify_server_capabilities_updated(&server, cx); } "textDocument/diagnostic" => { - server.update_capabilities(|capabilities| { - capabilities.diagnostic_provider = None; - }); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + + let state = local + .language_servers + .get_mut(&server_id) + .context("Could not obtain Language Servers state")?; + let options = local + .language_server_dynamic_registrations + .get_mut(&server_id) + .with_context(|| { + format!("Expected dynamic registration to exist for server {server_id}") + })?.diagnostics + .remove(&Some(unreg.id.clone())) + .with_context(|| format!( + "Attempted to unregister non-existent diagnostic registration with ID {}", + unreg.id) + )?; + + let mut has_any_diagnostic_providers_still = true; + if let Some(identifier) = diagnostic_identifier(&options) + && let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state + { + workspace_diagnostics_refresh_tasks.remove(&identifier); + has_any_diagnostic_providers_still = + !workspace_diagnostics_refresh_tasks.is_empty(); + } + + if !has_any_diagnostic_providers_still { + server.update_capabilities(|capabilities| { + debug_assert!(capabilities.diagnostic_provider.is_some()); + capabilities.diagnostic_provider = None; + }); + } + notify_server_capabilities_updated(&server, cx); } - "textDocument/colorProvider" => { + "textDocument/documentColor" => { server.update_capabilities(|capabilities| { capabilities.color_provider = None; }); @@ -12006,18 +12249,230 @@ impl LspStore { Ok(()) } + + async fn deduplicate_range_based_lsp_requests( + lsp_store: &Entity, + server_id: Option, + lsp_request_id: LspRequestId, + proto_request: &T::ProtoRequest, + range: Range, + cx: &mut AsyncApp, + ) -> Result<()> + where + T: LspCommand, + T::ProtoRequest: proto::LspRequestMessage, + { + let buffer_id = BufferId::new(proto_request.buffer_id())?; + let version = deserialize_version(proto_request.buffer_version()); + let buffer = lsp_store.update(cx, |this, cx| { + this.buffer_store.read(cx).get_existing(buffer_id) + })??; + buffer + .update(cx, |buffer, _| buffer.wait_for_version(version))? + .await?; + lsp_store.update(cx, |lsp_store, cx| { + let lsp_data = lsp_store + .lsp_data + .entry(buffer_id) + .or_insert_with(|| BufferLspData::new(&buffer, cx)); + let chunks_queried_for = lsp_data + .inlay_hints + .applicable_chunks(&[range]) + .collect::>(); + match chunks_queried_for.as_slice() { + &[chunk] => { + let key = LspKey { + request_type: TypeId::of::(), + server_queried: server_id, + }; + let previous_request = lsp_data + .chunk_lsp_requests + .entry(key) + .or_default() + .insert(chunk, lsp_request_id); + if let Some((previous_request, running_requests)) = + previous_request.zip(lsp_data.lsp_requests.get_mut(&key)) + { + running_requests.remove(&previous_request); + } + } + _ambiguous_chunks => { + // Have not found a unique chunk for the query range — be lenient and let the query to be spawned, + // there, a buffer version-based check will be performed and outdated requests discarded. + } + } + anyhow::Ok(()) + })??; + + Ok(()) + } + + async fn query_lsp_locally( + lsp_store: Entity, + for_server_id: Option, + sender_id: proto::PeerId, + lsp_request_id: LspRequestId, + proto_request: T::ProtoRequest, + position: Option, + cx: &mut AsyncApp, + ) -> Result<()> + where + T: LspCommand + Clone, + T::ProtoRequest: proto::LspRequestMessage, + ::Response: + Into<::Response>, + { + let buffer_id = BufferId::new(proto_request.buffer_id())?; + let version = deserialize_version(proto_request.buffer_version()); + let buffer = lsp_store.update(cx, |this, cx| { + this.buffer_store.read(cx).get_existing(buffer_id) + })??; + buffer + .update(cx, |buffer, _| buffer.wait_for_version(version.clone()))? + .await?; + let buffer_version = buffer.read_with(cx, |buffer, _| buffer.version())?; + let request = + T::from_proto(proto_request, lsp_store.clone(), buffer.clone(), cx.clone()).await?; + let key = LspKey { + request_type: TypeId::of::(), + server_queried: for_server_id, + }; + lsp_store.update(cx, |lsp_store, cx| { + let request_task = match for_server_id { + Some(server_id) => { + let server_task = lsp_store.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + request.clone(), + cx, + ); + cx.background_spawn(async move { + let mut responses = Vec::new(); + match server_task.await { + Ok(response) => responses.push((server_id, response)), + Err(e) => log::error!( + "Error handling response for request {request:?}: {e:#}" + ), + } + responses + }) + } + None => lsp_store.request_multiple_lsp_locally(&buffer, position, request, cx), + }; + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + if T::ProtoRequest::stop_previous_requests() { + if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { + lsp_requests.clear(); + } + } + lsp_data.lsp_requests.entry(key).or_default().insert( + lsp_request_id, + cx.spawn(async move |lsp_store, cx| { + let response = request_task.await; + lsp_store + .update(cx, |lsp_store, cx| { + if let Some((client, project_id)) = lsp_store.downstream_client.clone() + { + let response = response + .into_iter() + .map(|(server_id, response)| { + ( + server_id.to_proto(), + T::response_to_proto( + response, + lsp_store, + sender_id, + &buffer_version, + cx, + ) + .into(), + ) + }) + .collect::>(); + match client.send_lsp_response::( + project_id, + lsp_request_id, + response, + ) { + Ok(()) => {} + Err(e) => { + log::error!("Failed to send LSP response: {e:#}",) + } + } + } + }) + .ok(); + }), + ); + })?; + Ok(()) + } + + fn take_text_document_sync_options( + capabilities: &mut lsp::ServerCapabilities, + ) -> lsp::TextDocumentSyncOptions { + match capabilities.text_document_sync.take() { + Some(lsp::TextDocumentSyncCapability::Options(sync_options)) => sync_options, + Some(lsp::TextDocumentSyncCapability::Kind(sync_kind)) => { + let mut sync_options = lsp::TextDocumentSyncOptions::default(); + sync_options.change = Some(sync_kind); + sync_options + } + None => lsp::TextDocumentSyncOptions::default(), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn forget_code_lens_task(&mut self, buffer_id: BufferId) -> Option { + Some( + self.lsp_data + .get_mut(&buffer_id)? + .code_lens + .take()? + .update + .take()? + .1, + ) + } + + pub fn downstream_client(&self) -> Option<(AnyProtoClient, u64)> { + self.downstream_client.clone() + } + + pub fn worktree_store(&self) -> Entity { + self.worktree_store.clone() + } + + /// Gets what's stored in the LSP data for the given buffer. + pub fn current_lsp_data(&mut self, buffer_id: BufferId) -> Option<&mut BufferLspData> { + self.lsp_data.get_mut(&buffer_id) + } + + /// Gets the most recent LSP data for the given buffer: if the data is absent or out of date, + /// new [`BufferLspData`] will be created to replace the previous state. + pub fn latest_lsp_data(&mut self, buffer: &Entity, cx: &mut App) -> &mut BufferLspData { + let (buffer_id, buffer_version) = + buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.version())); + let lsp_data = self + .lsp_data + .entry(buffer_id) + .or_insert_with(|| BufferLspData::new(buffer, cx)); + if buffer_version.changed_since(&lsp_data.buffer_version) { + *lsp_data = BufferLspData::new(buffer, cx); + } + lsp_data + } } -// Registration with empty capabilities should be ignored. -// https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/formatting.ts#L67-L70 +// Registration with registerOptions as null, should fallback to true. +// https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133 fn parse_register_capabilities( reg: lsp::Registration, -) -> anyhow::Result>> { - Ok(reg - .register_options - .map(|options| serde_json::from_value::(options)) - .transpose()? - .map(OneOf::Right)) +) -> Result> { + Ok(match reg.register_options { + Some(options) => OneOf::Right(serde_json::from_value::(options)?), + None => OneOf::Left(true), + }) } fn subscribe_to_binary_statuses( @@ -12068,24 +12523,12 @@ fn subscribe_to_binary_statuses( } fn lsp_workspace_diagnostics_refresh( + registration_id: Option, + options: DiagnosticServerCapabilities, server: Arc, cx: &mut Context<'_, LspStore>, ) -> Option { - let identifier = match server.capabilities().diagnostic_provider? { - lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { - if !diagnostic_options.workspace_diagnostics { - return None; - } - diagnostic_options.identifier - } - lsp::DiagnosticServerCapabilities::RegistrationOptions(registration_options) => { - let diagnostic_options = registration_options.diagnostic_options; - if !diagnostic_options.workspace_diagnostics { - return None; - } - diagnostic_options.identifier - } - }; + let identifier = diagnostic_identifier(&options)?; let (progress_tx, mut progress_rx) = mpsc::channel(1); let (mut refresh_tx, mut refresh_rx) = mpsc::channel(1); @@ -12131,7 +12574,14 @@ fn lsp_workspace_diagnostics_refresh( return; }; - let token = format!("workspace/diagnostic-{}-{}", server.server_id(), requests); + let token = if let Some(identifier) = ®istration_id { + format!( + "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{identifier}", + server.server_id(), + ) + } else { + format!("workspace/diagnostic/{}/{requests}", server.server_id()) + }; progress_rx.try_recv().ok(); let timer = @@ -12197,6 +12647,24 @@ fn lsp_workspace_diagnostics_refresh( }) } +fn diagnostic_identifier(options: &DiagnosticServerCapabilities) -> Option> { + match &options { + lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { + if !diagnostic_options.workspace_diagnostics { + return None; + } + Some(diagnostic_options.identifier.clone()) + } + lsp::DiagnosticServerCapabilities::RegistrationOptions(registration_options) => { + let diagnostic_options = ®istration_options.diagnostic_options; + if !diagnostic_options.workspace_diagnostics { + return None; + } + Some(diagnostic_options.identifier.clone()) + } + } +} + fn resolve_word_completion(snapshot: &BufferSnapshot, completion: &mut Completion) { let CompletionSource::BufferWord { word_range, @@ -12252,11 +12720,10 @@ async fn populate_labels_for_completions( let lsp_completions = new_completions .iter() .filter_map(|new_completion| { - if let Some(lsp_completion) = new_completion.source.lsp_completion(true) { - Some(lsp_completion.into_owned()) - } else { - None - } + new_completion + .source + .lsp_completion(true) + .map(|lsp_completion| lsp_completion.into_owned()) }) .collect::>(); @@ -12276,11 +12743,7 @@ async fn populate_labels_for_completions( for completion in new_completions { match completion.source.lsp_completion(true) { Some(lsp_completion) => { - let documentation = if let Some(docs) = lsp_completion.documentation.clone() { - Some(docs.into()) - } else { - None - }; + let documentation = lsp_completion.documentation.clone().map(|docs| docs.into()); let mut label = labels.next().flatten().unwrap_or_else(|| { CodeLabel::fallback_for_completion(&lsp_completion, language.as_deref()) @@ -12376,7 +12839,7 @@ impl TryFrom<&FileOperationFilter> for RenameActionPredicate { ops.pattern .options .as_ref() - .map_or(false, |ops| ops.ignore_case.unwrap_or(false)), + .is_some_and(|ops| ops.ignore_case.unwrap_or(false)), ) .build()? .compile_matcher(), @@ -12391,7 +12854,7 @@ struct RenameActionPredicate { impl RenameActionPredicate { // Returns true if language server should be notified fn eval(&self, path: &str, is_dir: bool) -> bool { - self.kind.as_ref().map_or(true, |kind| { + self.kind.as_ref().is_none_or(|kind| { let expected_kind = if is_dir { FileOperationPatternKind::Folder } else { @@ -12521,45 +12984,69 @@ impl PartialEq for LanguageServerPromptRequest { #[derive(Clone, Debug, PartialEq)] pub enum LanguageServerLogType { Log(MessageType), - Trace(Option), + Trace { verbose_info: Option }, + Rpc { received: bool }, } impl LanguageServerLogType { pub fn to_proto(&self) -> proto::language_server_log::LogType { match self { Self::Log(log_type) => { - let message_type = match *log_type { - MessageType::ERROR => 1, - MessageType::WARNING => 2, - MessageType::INFO => 3, - MessageType::LOG => 4, + use proto::log_message::LogLevel; + let level = match *log_type { + MessageType::ERROR => LogLevel::Error, + MessageType::WARNING => LogLevel::Warning, + MessageType::INFO => LogLevel::Info, + MessageType::LOG => LogLevel::Log, other => { - log::warn!("Unknown lsp log message type: {:?}", other); - 4 + log::warn!("Unknown lsp log message type: {other:?}"); + LogLevel::Log } }; - proto::language_server_log::LogType::LogMessageType(message_type) + proto::language_server_log::LogType::Log(proto::LogMessage { + level: level as i32, + }) } - Self::Trace(message) => { - proto::language_server_log::LogType::LogTrace(proto::LspLogTrace { - message: message.clone(), + Self::Trace { verbose_info } => { + proto::language_server_log::LogType::Trace(proto::TraceMessage { + verbose_info: verbose_info.to_owned(), }) } + Self::Rpc { received } => { + let kind = if *received { + proto::rpc_message::Kind::Received + } else { + proto::rpc_message::Kind::Sent + }; + let kind = kind as i32; + proto::language_server_log::LogType::Rpc(proto::RpcMessage { kind }) + } } } pub fn from_proto(log_type: proto::language_server_log::LogType) -> Self { + use proto::log_message::LogLevel; + use proto::rpc_message; match log_type { - proto::language_server_log::LogType::LogMessageType(message_type) => { - Self::Log(match message_type { - 1 => MessageType::ERROR, - 2 => MessageType::WARNING, - 3 => MessageType::INFO, - 4 => MessageType::LOG, - _ => MessageType::LOG, - }) - } - proto::language_server_log::LogType::LogTrace(trace) => Self::Trace(trace.message), + proto::language_server_log::LogType::Log(message_type) => Self::Log( + match LogLevel::from_i32(message_type.level).unwrap_or(LogLevel::Log) { + LogLevel::Error => MessageType::ERROR, + LogLevel::Warning => MessageType::WARNING, + LogLevel::Info => MessageType::INFO, + LogLevel::Log => MessageType::LOG, + }, + ), + proto::language_server_log::LogType::Trace(trace_message) => Self::Trace { + verbose_info: trace_message.verbose_info, + }, + proto::language_server_log::LogType::Rpc(message) => Self::Rpc { + received: match rpc_message::Kind::from_i32(message.kind) + .unwrap_or(rpc_message::Kind::Received) + { + rpc_message::Kind::Received => true, + rpc_message::Kind::Sent => false, + }, + }, } } } @@ -12575,19 +13062,19 @@ pub enum LanguageServerState { Starting { startup: Task>>, /// List of language servers that will be added to the workspace once it's initialization completes. - pending_workspace_folders: Arc>>, + pending_workspace_folders: Arc>>, }, Running { adapter: Arc, server: Arc, simulate_disk_based_diagnostics_completion: Option>, - workspace_refresh_task: Option, + workspace_diagnostics_refresh_tasks: HashMap, WorkspaceRefreshTask>, }, } impl LanguageServerState { - fn add_workspace_folder(&self, uri: Url) { + fn add_workspace_folder(&self, uri: Uri) { match self { LanguageServerState::Starting { pending_workspace_folders, @@ -12600,7 +13087,7 @@ impl LanguageServerState { } } } - fn _remove_workspace_folder(&self, uri: Url) { + fn _remove_workspace_folder(&self, uri: Uri) { match self { LanguageServerState::Starting { pending_workspace_folders, @@ -12668,9 +13155,9 @@ impl DiagnosticSummary { } pub fn to_proto( - &self, + self, language_server_id: LanguageServerId, - path: &Path, + path: &RelPath, ) -> proto::DiagnosticSummary { proto::DiagnosticSummary { path: path.to_proto(), @@ -12698,6 +13185,21 @@ pub enum CompletionDocumentation { }, } +impl CompletionDocumentation { + #[cfg(any(test, feature = "test-support"))] + pub fn text(&self) -> SharedString { + match self { + CompletionDocumentation::Undocumented => "".into(), + CompletionDocumentation::SingleLine(s) => s.clone(), + CompletionDocumentation::MultiLinePlainText(s) => s.clone(), + CompletionDocumentation::MultiLineMarkdown(s) => s.clone(), + CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line, .. } => { + single_line.clone() + } + } + } +} + impl From for CompletionDocumentation { fn from(docs: lsp::Documentation) -> Self { match docs { @@ -12726,6 +13228,11 @@ impl From for CompletionDocumentation { } } +pub enum ResolvedHint { + Resolved(InlayHint), + Resolving(Shared>), +} + fn glob_literal_prefix(glob: &Path) -> PathBuf { glob.components() .take_while(|component| match component { @@ -12760,32 +13267,12 @@ impl SshLspAdapter { } } -#[async_trait(?Send)] -impl LspAdapter for SshLspAdapter { - fn name(&self) -> LanguageServerName { - self.name.clone() - } - - async fn initialization_options( - self: Arc, - _: &dyn Fs, - _: &Arc, - ) -> Result> { - let Some(options) = &self.initialization_options else { - return Ok(None); - }; - let result = serde_json::from_str(options)?; - Ok(result) - } - - fn code_action_kinds(&self) -> Option> { - self.code_action_kinds.clone() - } - +impl LspInstaller for SshLspAdapter { + type BinaryVersion = (); async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { Some(self.binary.clone()) @@ -12802,13 +13289,15 @@ impl LspAdapter for SshLspAdapter { async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, - ) -> Result> { + _: bool, + _: &mut AsyncApp, + ) -> Result<()> { anyhow::bail!("SshLspAdapter does not support fetch_latest_server_version") } async fn fetch_server_binary( &self, - _: Box, + _: (), _: PathBuf, _: &dyn LspAdapterDelegate, ) -> Result { @@ -12816,6 +13305,28 @@ impl LspAdapter for SshLspAdapter { } } +#[async_trait(?Send)] +impl LspAdapter for SshLspAdapter { + fn name(&self) -> LanguageServerName { + self.name.clone() + } + + async fn initialization_options( + self: Arc, + _: &Arc, + ) -> Result> { + let Some(options) = &self.initialization_options else { + return Ok(None); + }; + let result = serde_json::from_str(options)?; + Ok(result) + } + + fn code_action_kinds(&self) -> Option> { + self.code_action_kinds.clone() + } +} + pub fn language_server_settings<'a>( delegate: &'a dyn LspAdapterDelegate, language: &LanguageServerName, @@ -12824,7 +13335,7 @@ pub fn language_server_settings<'a>( language_server_settings_for( SettingsLocation { worktree_id: delegate.worktree_id(), - path: delegate.worktree_root_path(), + path: RelPath::empty(), }, language, cx, @@ -12954,23 +13465,24 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { return Ok(None); } - #[cfg(not(target_os = "windows"))] async fn which(&self, command: &OsStr) -> Option { - let worktree_abs_path = self.worktree.abs_path(); - let shell_path = self.shell_env().await.get("PATH").cloned(); - which::which_in(command, shell_path.as_ref(), worktree_abs_path).ok() - } + let mut worktree_abs_path = self.worktree_root_path().to_path_buf(); + if self.fs.is_file(&worktree_abs_path).await { + worktree_abs_path.pop(); + } - #[cfg(target_os = "windows")] - async fn which(&self, command: &OsStr) -> Option { - // todo(windows) Getting the shell env variables in a current directory on Windows is more complicated than other platforms - // there isn't a 'default shell' necessarily. The closest would be the default profile on the windows terminal - // SEE: https://learn.microsoft.com/en-us/windows/terminal/customize-settings/startup - which::which(command).ok() + let env = self.shell_env().await; + + let shell_path = env.get("PATH").cloned(); + + which::which_in(command, shell_path.as_ref(), worktree_abs_path).ok() } async fn try_exec(&self, command: LanguageServerBinary) -> Result<()> { - let working_dir = self.worktree_root_path(); + let mut working_dir = self.worktree_root_path().to_path_buf(); + if self.fs.is_file(&working_dir).await { + working_dir.pop(); + } let output = util::command::new_smol_command(&command.path) .args(command.arguments) .envs(command.env.clone().unwrap_or_default()) @@ -13014,16 +13526,12 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { Some(dir) } - async fn read_text_file(&self, path: PathBuf) -> Result { + async fn read_text_file(&self, path: &RelPath) -> Result { let entry = self .worktree - .entry_for_path(&path) + .entry_for_path(path) .with_context(|| format!("no worktree entry for path {path:?}"))?; - let abs_path = self - .worktree - .absolutize(&entry.path) - .with_context(|| format!("cannot absolutize path {path:?}"))?; - + let abs_path = self.worktree.absolutize(&entry.path); self.fs.load(&abs_path).await } } @@ -13037,14 +13545,17 @@ async fn populate_labels_for_symbols( #[allow(clippy::mutable_key_type)] let mut symbols_by_language = HashMap::>, Vec>::default(); - let mut unknown_paths = BTreeSet::new(); + let mut unknown_paths = BTreeSet::>::new(); for symbol in symbols { + let Some(file_name) = symbol.path.file_name() else { + continue; + }; let language = language_registry - .language_for_file_path(&symbol.path.path) + .load_language_for_file_path(Path::new(file_name)) .await .ok() .or_else(|| { - unknown_paths.insert(symbol.path.path.clone()); + unknown_paths.insert(file_name.into()); None }); symbols_by_language @@ -13054,10 +13565,7 @@ async fn populate_labels_for_symbols( } for unknown_path in unknown_paths { - log::info!( - "no language found for symbol path {}", - unknown_path.display() - ); + log::info!("no language found for symbol in file {unknown_path:?}"); } let mut label_params = Vec::new(); @@ -13100,7 +13608,6 @@ async fn populate_labels_for_symbols( name, kind: symbol.kind, range: symbol.range, - signature: symbol.signature, }); } } @@ -13108,24 +13615,18 @@ async fn populate_labels_for_symbols( fn include_text(server: &lsp::LanguageServer) -> Option { match server.capabilities().text_document_sync.as_ref()? { - lsp::TextDocumentSyncCapability::Kind(kind) => match *kind { - lsp::TextDocumentSyncKind::NONE => None, - lsp::TextDocumentSyncKind::FULL => Some(true), - lsp::TextDocumentSyncKind::INCREMENTAL => Some(false), - _ => None, - }, - lsp::TextDocumentSyncCapability::Options(options) => match options.save.as_ref()? { - lsp::TextDocumentSyncSaveOptions::Supported(supported) => { - if *supported { - Some(true) - } else { - None - } - } + lsp::TextDocumentSyncCapability::Options(opts) => match opts.save.as_ref()? { + // Server wants didSave but didn't specify includeText. + lsp::TextDocumentSyncSaveOptions::Supported(true) => Some(false), + // Server doesn't want didSave at all. + lsp::TextDocumentSyncSaveOptions::Supported(false) => None, + // Server provided SaveOptions. lsp::TextDocumentSyncSaveOptions::SaveOptions(save_options) => { Some(save_options.include_text.unwrap_or(false)) } }, + // We do not have any save info. Kind affects didChange only. + lsp::TextDocumentSyncCapability::Kind(_) => None, } } @@ -13142,10 +13643,10 @@ fn ensure_uniform_list_compatible_label(label: &mut CodeLabel) { let mut offset_map = vec![0; label.text.len() + 1]; let mut last_char_was_space = false; let mut new_idx = 0; - let mut chars = label.text.char_indices().fuse(); + let chars = label.text.char_indices().fuse(); let mut newlines_removed = false; - while let Some((idx, c)) = chars.next() { + for (idx, c) in chars { offset_map[idx] = new_idx; match c { @@ -13278,19 +13779,19 @@ mod tests { #[test] fn test_multi_len_chars_normalization() { - let mut label = CodeLabel { - text: "myElˇ (parameter) myElˇ: {\n foo: string;\n}".to_string(), - runs: vec![(0..6, HighlightId(1))], - filter_range: 0..6, - }; + let mut label = CodeLabel::new( + "myElˇ (parameter) myElˇ: {\n foo: string;\n}".to_string(), + 0..6, + vec![(0..6, HighlightId(1))], + ); ensure_uniform_list_compatible_label(&mut label); assert_eq!( label, - CodeLabel { - text: "myElˇ (parameter) myElˇ: { foo: string; }".to_string(), - runs: vec![(0..6, HighlightId(1))], - filter_range: 0..6, - } + CodeLabel::new( + "myElˇ (parameter) myElˇ: { foo: string; }".to_string(), + 0..6, + vec![(0..6, HighlightId(1))], + ) ); } } diff --git a/crates/project/src/lsp_store/clangd_ext.rs b/crates/project/src/lsp_store/clangd_ext.rs index 274b1b898086eeddf72710052397dd9963833663..b02f68dd4d1271ca9a8fa97e9ef41e03fdfe9763 100644 --- a/crates/project/src/lsp_store/clangd_ext.rs +++ b/crates/project/src/lsp_store/clangd_ext.rs @@ -58,7 +58,7 @@ pub fn register_notifications( language_server .on_notification::({ - let adapter = adapter.clone(); + let adapter = adapter; let this = lsp_store; move |params: InactiveRegionsParams, cx| { diff --git a/crates/project/src/lsp_store/inlay_hint_cache.rs b/crates/project/src/lsp_store/inlay_hint_cache.rs new file mode 100644 index 0000000000000000000000000000000000000000..7d3ec27e5af83c4d83b269c171943d90754bd1a6 --- /dev/null +++ b/crates/project/src/lsp_store/inlay_hint_cache.rs @@ -0,0 +1,225 @@ +use std::{collections::hash_map, ops::Range, sync::Arc}; + +use collections::HashMap; +use futures::future::Shared; +use gpui::{App, Entity, Task}; +use language::{Buffer, BufferRow, BufferSnapshot}; +use lsp::LanguageServerId; +use text::OffsetRangeExt; + +use crate::{InlayHint, InlayId}; + +pub type CacheInlayHints = HashMap>; +pub type CacheInlayHintsTask = Shared>>>; + +/// A logic to apply when querying for new inlay hints and deciding what to do with the old entries in the cache in case of conflicts. +#[derive(Debug, Clone, Copy)] +pub enum InvalidationStrategy { + /// Language servers reset hints via request. + /// Demands to re-query all inlay hints needed and invalidate all cached entries, but does not require instant update with invalidation. + /// + /// Despite nothing forbids language server from sending this request on every edit, it is expected to be sent only when certain internal server state update, invisible for the editor otherwise. + RefreshRequested(LanguageServerId), + /// Multibuffer excerpt(s) and/or singleton buffer(s) were edited at least on one place. + /// Neither editor nor LSP is able to tell which open file hints' are not affected, so all of them have to be invalidated, re-queried and do that fast enough to avoid being slow, but also debounce to avoid loading hints on every fast keystroke sequence. + BufferEdited, + /// A new file got opened/new excerpt was added to a multibuffer/a [multi]buffer was scrolled to a new position. + /// No invalidation should be done at all, all new hints are added to the cache. + /// + /// A special case is the editor toggles and settings change: + /// in addition to LSP capabilities, Zed allows omitting certain hint kinds (defined by the corresponding LSP part: type/parameter/other) and toggling hints. + /// This does not lead to cache invalidation, but would require cache usage for determining which hints are not displayed and issuing an update to inlays on the screen. + None, +} + +impl InvalidationStrategy { + pub fn should_invalidate(&self) -> bool { + matches!( + self, + InvalidationStrategy::RefreshRequested(_) | InvalidationStrategy::BufferEdited + ) + } +} + +pub struct BufferInlayHints { + snapshot: BufferSnapshot, + buffer_chunks: Vec, + hints_by_chunks: Vec>, + fetches_by_chunks: Vec>, + hints_by_id: HashMap, + pub(super) hint_resolves: HashMap>>, +} + +#[derive(Debug, Clone, Copy)] +struct HintForId { + chunk_id: usize, + server_id: LanguageServerId, + position: usize, +} + +/// An range of rows, exclusive as [`lsp::Range`] and +/// +/// denote. +/// +/// Represents an area in a text editor, adjacent to other ones. +/// Together, chunks form entire document at a particular version [`clock::Global`]. +/// Each chunk is queried for inlays as `(start_row, 0)..(end_exclusive, 0)` via +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BufferChunk { + pub id: usize, + pub start: BufferRow, + pub end: BufferRow, +} + +impl std::fmt::Debug for BufferInlayHints { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BufferInlayHints") + .field("buffer_chunks", &self.buffer_chunks) + .field("hints_by_chunks", &self.hints_by_chunks) + .field("fetches_by_chunks", &self.fetches_by_chunks) + .field("hints_by_id", &self.hints_by_id) + .finish_non_exhaustive() + } +} + +const MAX_ROWS_IN_A_CHUNK: u32 = 50; + +impl BufferInlayHints { + pub fn new(buffer: &Entity, cx: &mut App) -> Self { + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + let buffer_point_range = (0..buffer.len()).to_point(&snapshot); + let last_row = buffer_point_range.end.row; + let buffer_chunks = (buffer_point_range.start.row..=last_row) + .step_by(MAX_ROWS_IN_A_CHUNK as usize) + .enumerate() + .map(|(id, chunk_start)| BufferChunk { + id, + start: chunk_start, + end: (chunk_start + MAX_ROWS_IN_A_CHUNK).min(last_row), + }) + .collect::>(); + + Self { + hints_by_chunks: vec![None; buffer_chunks.len()], + fetches_by_chunks: vec![None; buffer_chunks.len()], + hints_by_id: HashMap::default(), + hint_resolves: HashMap::default(), + snapshot, + buffer_chunks, + } + } + + pub fn applicable_chunks( + &self, + ranges: &[Range], + ) -> impl Iterator { + let row_ranges = ranges + .iter() + .map(|range| range.to_point(&self.snapshot)) + .map(|point_range| point_range.start.row..=point_range.end.row) + .collect::>(); + self.buffer_chunks + .iter() + .filter(move |chunk| -> bool { + // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range. + // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around. + let chunk_range = chunk.start..=chunk.end; + row_ranges.iter().any(|row_range| { + chunk_range.contains(&row_range.start()) + || chunk_range.contains(&row_range.end()) + }) + }) + .copied() + } + + pub fn cached_hints(&mut self, chunk: &BufferChunk) -> Option<&CacheInlayHints> { + self.hints_by_chunks[chunk.id].as_ref() + } + + pub fn fetched_hints(&mut self, chunk: &BufferChunk) -> &mut Option { + &mut self.fetches_by_chunks[chunk.id] + } + + #[cfg(any(test, feature = "test-support"))] + pub fn all_cached_hints(&self) -> Vec { + self.hints_by_chunks + .iter() + .filter_map(|hints| hints.as_ref()) + .flat_map(|hints| hints.values().cloned()) + .flatten() + .map(|(_, hint)| hint) + .collect() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn all_fetched_hints(&self) -> Vec { + self.fetches_by_chunks + .iter() + .filter_map(|fetches| fetches.clone()) + .collect() + } + + pub fn remove_server_data(&mut self, for_server: LanguageServerId) { + for (chunk_index, hints) in self.hints_by_chunks.iter_mut().enumerate() { + if let Some(hints) = hints { + if hints.remove(&for_server).is_some() { + self.fetches_by_chunks[chunk_index] = None; + } + } + } + } + + pub fn clear(&mut self) { + self.hints_by_chunks = vec![None; self.buffer_chunks.len()]; + self.fetches_by_chunks = vec![None; self.buffer_chunks.len()]; + self.hints_by_id.clear(); + self.hint_resolves.clear(); + } + + pub fn insert_new_hints( + &mut self, + chunk: BufferChunk, + server_id: LanguageServerId, + new_hints: Vec<(InlayId, InlayHint)>, + ) { + let existing_hints = self.hints_by_chunks[chunk.id] + .get_or_insert_default() + .entry(server_id) + .or_insert_with(Vec::new); + let existing_count = existing_hints.len(); + existing_hints.extend(new_hints.into_iter().enumerate().filter_map( + |(i, (id, new_hint))| { + let new_hint_for_id = HintForId { + chunk_id: chunk.id, + server_id, + position: existing_count + i, + }; + if let hash_map::Entry::Vacant(vacant_entry) = self.hints_by_id.entry(id) { + vacant_entry.insert(new_hint_for_id); + Some((id, new_hint)) + } else { + None + } + }, + )); + *self.fetched_hints(&chunk) = None; + } + + pub fn hint_for_id(&mut self, id: InlayId) -> Option<&mut InlayHint> { + let hint_for_id = self.hints_by_id.get(&id)?; + let (hint_id, hint) = self + .hints_by_chunks + .get_mut(hint_for_id.chunk_id)? + .as_mut()? + .get_mut(&hint_for_id.server_id)? + .get_mut(hint_for_id.position)?; + debug_assert_eq!(*hint_id, id, "Invalid pointer {hint_for_id:?}"); + Some(hint) + } + + pub fn buffer_chunks_len(&self) -> usize { + self.buffer_chunks.len() + } +} diff --git a/crates/project/src/lsp_store/json_language_server_ext.rs b/crates/project/src/lsp_store/json_language_server_ext.rs index 3eb93386a99bf40dffc5f6de75d56248936b38e3..78df7132734e9bf71bac8df176f92e15eec21361 100644 --- a/crates/project/src/lsp_store/json_language_server_ext.rs +++ b/crates/project/src/lsp_store/json_language_server_ext.rs @@ -1,9 +1,11 @@ -use anyhow::Context as _; -use collections::HashMap; -use gpui::WeakEntity; +use anyhow::{Context, Result}; +use gpui::{App, AsyncApp, Entity, Global, WeakEntity}; use lsp::LanguageServer; use crate::LspStore; + +const LOGGER: zlog::Logger = zlog::scoped!("json-schema"); + /// https://github.com/Microsoft/vscode/blob/main/extensions/json-language-features/server/README.md#schema-content-request /// /// Represents a "JSON language server-specific, non-standardized, extension to the LSP" with which the vscode-json-language-server @@ -20,82 +22,77 @@ impl lsp::request::Request for SchemaContentRequest { const METHOD: &'static str = "vscode/content"; } -pub fn register_requests(_lsp_store: WeakEntity, language_server: &LanguageServer) { - language_server - .on_request::(|params, cx| { - // PERF: Use a cache (`OnceLock`?) to avoid recomputing the action schemas - let mut generator = settings::KeymapFile::action_schema_generator(); - let all_schemas = cx.update(|cx| HashMap::from_iter(cx.action_schemas(&mut generator))); - async move { - let all_schemas = all_schemas?; - let Some(uri) = params.get(0) else { - anyhow::bail!("No URI"); - }; - let normalized_action_name = uri - .strip_prefix("zed://schemas/action/") - .context("Invalid URI")?; - let action_name = denormalize_action_name(normalized_action_name); - let schema = root_schema_from_action_schema( - all_schemas - .get(action_name.as_str()) - .and_then(Option::as_ref), - &mut generator, - ) - .to_value(); +type SchemaRequestHandler = fn(Entity, String, &mut AsyncApp) -> Result; +pub struct SchemaHandlingImpl(SchemaRequestHandler); - serde_json::to_string(&schema).context("Failed to serialize schema") - } - }) - .detach(); -} +impl Global for SchemaHandlingImpl {} -pub fn normalize_action_name(action_name: &str) -> String { - action_name.replace("::", "__") +pub fn register_schema_handler(handler: SchemaRequestHandler, cx: &mut App) { + debug_assert!( + !cx.has_global::(), + "SchemaHandlingImpl already registered" + ); + cx.set_global(SchemaHandlingImpl(handler)); } -pub fn denormalize_action_name(action_name: &str) -> String { - action_name.replace("__", "::") -} +struct SchemaContentsChanged {} -pub fn normalized_action_file_name(action_name: &str) -> String { - normalized_action_name_to_file_name(normalize_action_name(action_name)) +impl lsp::notification::Notification for SchemaContentsChanged { + const METHOD: &'static str = "json/schemaContent"; + type Params = String; } -pub fn normalized_action_name_to_file_name(mut normalized_action_name: String) -> String { - normalized_action_name.push_str(".json"); - normalized_action_name -} +pub fn notify_schema_changed(lsp_store: Entity, uri: String, cx: &App) { + zlog::trace!(LOGGER => "Notifying schema changed for URI: {:?}", uri); + let servers = lsp_store.read_with(cx, |lsp_store, _| { + let mut servers = Vec::new(); + let Some(local) = lsp_store.as_local() else { + return servers; + }; -pub fn url_schema_for_action(action_name: &str) -> serde_json::Value { - let normalized_name = normalize_action_name(action_name); - let file_name = normalized_action_name_to_file_name(normalized_name.clone()); - serde_json::json!({ - "fileMatch": [file_name], - "url": format!("zed://schemas/action/{}", normalized_name) - }) -} + for states in local.language_servers.values() { + let json_server = match states { + super::LanguageServerState::Running { + adapter, server, .. + } if adapter.adapter.is_primary_zed_json_schema_adapter() => server.clone(), + _ => continue, + }; -fn root_schema_from_action_schema( - action_schema: Option<&schemars::Schema>, - generator: &mut schemars::SchemaGenerator, -) -> schemars::Schema { - let Some(action_schema) = action_schema else { - return schemars::json_schema!(false); - }; - let meta_schema = generator - .settings() - .meta_schema - .as_ref() - .expect("meta_schema should be present in schemars settings") - .to_string(); - let defs = generator.definitions(); - let mut schema = schemars::json_schema!({ - "$schema": meta_schema, - "allowTrailingCommas": true, - "$defs": defs, + servers.push(json_server); + } + servers }); - schema - .ensure_object() - .extend(std::mem::take(action_schema.clone().ensure_object())); - schema + for server in servers { + zlog::trace!(LOGGER => "Notifying server {:?} of schema change for URI: {:?}", server.server_id(), &uri); + // TODO: handle errors + server.notify::(uri.clone()).ok(); + } +} + +pub fn register_requests(lsp_store: WeakEntity, language_server: &LanguageServer) { + language_server + .on_request::(move |params, cx| { + let handler = cx.try_read_global::(|handler, _| { + handler.0 + }); + let mut cx = cx.clone(); + let uri = params.clone().pop(); + let lsp_store = lsp_store.clone(); + let resolution = async move { + let lsp_store = lsp_store.upgrade().context("LSP store has been dropped")?; + let uri = uri.context("No URI")?; + let handle_schema_request = handler.context("No schema handler registered")?; + handle_schema_request(lsp_store, uri, &mut cx) + }; + async move { + zlog::trace!(LOGGER => "Handling schema request for {:?}", ¶ms); + let result = resolution.await; + match &result { + Ok(content) => {zlog::trace!(LOGGER => "Schema request resolved with {}B schema", content.len());}, + Err(err) => {zlog::warn!(LOGGER => "Schema request failed: {}", err);}, + } + result + } + }) + .detach(); } diff --git a/crates/project/src/lsp_store/log_store.rs b/crates/project/src/lsp_store/log_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..00098712bf0092a6795de2ed48c7ccf15925c555 --- /dev/null +++ b/crates/project/src/lsp_store/log_store.rs @@ -0,0 +1,712 @@ +use std::{collections::VecDeque, sync::Arc}; + +use collections::HashMap; +use futures::{StreamExt, channel::mpsc}; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity}; +use lsp::{ + IoKind, LanguageServer, LanguageServerId, LanguageServerName, LanguageServerSelector, + MessageType, TraceValue, +}; +use rpc::proto; +use settings::WorktreeId; + +use crate::{LanguageServerLogType, LspStore, Project, ProjectItem as _}; + +const SEND_LINE: &str = "\n// Send:"; +const RECEIVE_LINE: &str = "\n// Receive:"; +const MAX_STORED_LOG_ENTRIES: usize = 2000; + +pub fn init(on_headless_host: bool, cx: &mut App) -> Entity { + let log_store = cx.new(|cx| LogStore::new(on_headless_host, cx)); + cx.set_global(GlobalLogStore(log_store.clone())); + log_store +} + +pub struct GlobalLogStore(pub Entity); + +impl Global for GlobalLogStore {} + +#[derive(Debug)] +pub enum Event { + NewServerLogEntry { + id: LanguageServerId, + kind: LanguageServerLogType, + text: String, + }, +} + +impl EventEmitter for LogStore {} + +pub struct LogStore { + on_headless_host: bool, + projects: HashMap, ProjectState>, + pub copilot_log_subscription: Option, + pub language_servers: HashMap, + io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>, +} + +struct ProjectState { + _subscriptions: [Subscription; 2], +} + +pub trait Message: AsRef { + type Level: Copy + std::fmt::Debug; + fn should_include(&self, _: Self::Level) -> bool { + true + } +} + +#[derive(Debug)] +pub struct LogMessage { + message: String, + typ: MessageType, +} + +impl AsRef for LogMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for LogMessage { + type Level = MessageType; + + fn should_include(&self, level: Self::Level) -> bool { + match (self.typ, level) { + (MessageType::ERROR, _) => true, + (_, MessageType::ERROR) => false, + (MessageType::WARNING, _) => true, + (_, MessageType::WARNING) => false, + (MessageType::INFO, _) => true, + (_, MessageType::INFO) => false, + _ => true, + } + } +} + +#[derive(Debug)] +pub struct TraceMessage { + message: String, + is_verbose: bool, +} + +impl AsRef for TraceMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for TraceMessage { + type Level = TraceValue; + + fn should_include(&self, level: Self::Level) -> bool { + match level { + TraceValue::Off => false, + TraceValue::Messages => !self.is_verbose, + TraceValue::Verbose => true, + } + } +} + +#[derive(Debug)] +pub struct RpcMessage { + message: String, +} + +impl AsRef for RpcMessage { + fn as_ref(&self) -> &str { + &self.message + } +} + +impl Message for RpcMessage { + type Level = (); +} + +pub struct LanguageServerState { + pub name: Option, + pub worktree_id: Option, + pub kind: LanguageServerKind, + log_messages: VecDeque, + trace_messages: VecDeque, + pub rpc_state: Option, + pub trace_level: TraceValue, + pub log_level: MessageType, + io_logs_subscription: Option, + pub toggled_log_kind: Option, +} + +impl std::fmt::Debug for LanguageServerState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LanguageServerState") + .field("name", &self.name) + .field("worktree_id", &self.worktree_id) + .field("kind", &self.kind) + .field("log_messages", &self.log_messages) + .field("trace_messages", &self.trace_messages) + .field("rpc_state", &self.rpc_state) + .field("trace_level", &self.trace_level) + .field("log_level", &self.log_level) + .field("toggled_log_kind", &self.toggled_log_kind) + .finish_non_exhaustive() + } +} + +#[derive(PartialEq, Clone)] +pub enum LanguageServerKind { + Local { project: WeakEntity }, + Remote { project: WeakEntity }, + LocalSsh { lsp_store: WeakEntity }, + Global, +} + +impl std::fmt::Debug for LanguageServerKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), + LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), + LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"), + LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), + } + } +} + +impl LanguageServerKind { + pub fn project(&self) -> Option<&WeakEntity> { + match self { + Self::Local { project } => Some(project), + Self::Remote { project } => Some(project), + Self::LocalSsh { .. } => None, + Self::Global { .. } => None, + } + } +} + +#[derive(Debug)] +pub struct LanguageServerRpcState { + pub rpc_messages: VecDeque, + last_message_kind: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MessageKind { + Send, + Receive, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum LogKind { + Rpc, + Trace, + #[default] + Logs, + ServerInfo, +} + +impl LogKind { + pub fn from_server_log_type(log_type: &LanguageServerLogType) -> Self { + match log_type { + LanguageServerLogType::Log(_) => Self::Logs, + LanguageServerLogType::Trace { .. } => Self::Trace, + LanguageServerLogType::Rpc { .. } => Self::Rpc, + } + } +} + +impl LogStore { + pub fn new(on_headless_host: bool, cx: &mut Context) -> Self { + let (io_tx, mut io_rx) = mpsc::unbounded(); + + let log_store = Self { + projects: HashMap::default(), + language_servers: HashMap::default(), + copilot_log_subscription: None, + on_headless_host, + io_tx, + }; + cx.spawn(async move |log_store, cx| { + while let Some((server_id, io_kind, message)) = io_rx.next().await { + if let Some(log_store) = log_store.upgrade() { + log_store.update(cx, |log_store, cx| { + log_store.on_io(server_id, io_kind, &message, cx); + })?; + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + log_store + } + + pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { + let weak_project = project.downgrade(); + self.projects.insert( + project.downgrade(), + ProjectState { + _subscriptions: [ + cx.observe_release(project, move |this, _, _| { + this.projects.remove(&weak_project); + this.language_servers + .retain(|_, state| state.kind.project() != Some(&weak_project)); + }), + cx.subscribe(project, move |log_store, project, event, cx| { + let server_kind = if project.read(cx).is_local() { + LanguageServerKind::Local { + project: project.downgrade(), + } + } else { + LanguageServerKind::Remote { + project: project.downgrade(), + } + }; + match event { + crate::Event::LanguageServerAdded(id, name, worktree_id) => { + log_store.add_language_server( + server_kind, + *id, + Some(name.clone()), + *worktree_id, + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(*id), + cx, + ); + } + crate::Event::LanguageServerBufferRegistered { + server_id, + buffer_id, + name, + .. + } => { + let worktree_id = project + .read(cx) + .buffer_for_id(*buffer_id, cx) + .and_then(|buffer| { + Some(buffer.read(cx).project_path(cx)?.worktree_id) + }); + let name = name.clone().or_else(|| { + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_statuses + .get(server_id) + .map(|status| status.name.clone()) + }); + log_store.add_language_server( + server_kind, + *server_id, + name, + worktree_id, + None, + cx, + ); + } + crate::Event::LanguageServerRemoved(id) => { + log_store.remove_language_server(*id, cx); + } + crate::Event::LanguageServerLog(id, typ, message) => { + log_store.add_language_server( + server_kind, + *id, + None, + None, + None, + cx, + ); + match typ { + crate::LanguageServerLogType::Log(typ) => { + log_store.add_language_server_log(*id, *typ, message, cx); + } + crate::LanguageServerLogType::Trace { verbose_info } => { + log_store.add_language_server_trace( + *id, + message, + verbose_info.clone(), + cx, + ); + } + crate::LanguageServerLogType::Rpc { received } => { + let kind = if *received { + MessageKind::Receive + } else { + MessageKind::Send + }; + log_store.add_language_server_rpc(*id, kind, message, cx); + } + } + } + crate::Event::ToggleLspLogs { + server_id, + enabled, + toggled_log_kind, + } => { + if let Some(server_state) = + log_store.get_language_server_state(*server_id) + { + if *enabled { + server_state.toggled_log_kind = Some(*toggled_log_kind); + } else { + server_state.toggled_log_kind = None; + } + } + if LogKind::Rpc == *toggled_log_kind { + if *enabled { + log_store.enable_rpc_trace_for_language_server(*server_id); + } else { + log_store.disable_rpc_trace_for_language_server(*server_id); + } + } + } + _ => {} + } + }), + ], + }, + ); + } + + pub fn get_language_server_state( + &mut self, + id: LanguageServerId, + ) -> Option<&mut LanguageServerState> { + self.language_servers.get_mut(&id) + } + + pub fn add_language_server( + &mut self, + kind: LanguageServerKind, + server_id: LanguageServerId, + name: Option, + worktree_id: Option, + server: Option>, + cx: &mut Context, + ) -> Option<&mut LanguageServerState> { + let server_state = self.language_servers.entry(server_id).or_insert_with(|| { + cx.notify(); + LanguageServerState { + name: None, + worktree_id: None, + kind, + rpc_state: None, + log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_level: TraceValue::Off, + log_level: MessageType::LOG, + io_logs_subscription: None, + toggled_log_kind: None, + } + }); + + if let Some(name) = name { + server_state.name = Some(name); + } + if let Some(worktree_id) = worktree_id { + server_state.worktree_id = Some(worktree_id); + } + + if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) { + let io_tx = self.io_tx.clone(); + let server_id = server.server_id(); + server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { + io_tx + .unbounded_send((server_id, io_kind, message.to_string())) + .ok(); + })); + } + + Some(server_state) + } + + pub fn add_language_server_log( + &mut self, + id: LanguageServerId, + typ: MessageType, + message: &str, + cx: &mut Context, + ) -> Option<()> { + let store_logs = !self.on_headless_host; + let language_server_state = self.get_language_server_state(id)?; + + let log_lines = &mut language_server_state.log_messages; + let message = message.trim_end().to_string(); + if !store_logs { + // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Log(typ), + text: message, + }, + cx, + ); + } else if let Some(new_message) = Self::push_new_message( + log_lines, + LogMessage { message, typ }, + language_server_state.log_level, + ) { + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Log(typ), + text: new_message, + }, + cx, + ); + } + Some(()) + } + + fn add_language_server_trace( + &mut self, + id: LanguageServerId, + message: &str, + verbose_info: Option, + cx: &mut Context, + ) -> Option<()> { + let store_logs = !self.on_headless_host; + let language_server_state = self.get_language_server_state(id)?; + + let log_lines = &mut language_server_state.trace_messages; + if !store_logs { + // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Trace { verbose_info }, + text: message.trim().to_string(), + }, + cx, + ); + } else if let Some(new_message) = Self::push_new_message( + log_lines, + TraceMessage { + message: message.trim().to_string(), + is_verbose: false, + }, + TraceValue::Messages, + ) { + if let Some(verbose_message) = verbose_info.as_ref() { + Self::push_new_message( + log_lines, + TraceMessage { + message: verbose_message.clone(), + is_verbose: true, + }, + TraceValue::Verbose, + ); + } + self.emit_event( + Event::NewServerLogEntry { + id, + kind: LanguageServerLogType::Trace { verbose_info }, + text: new_message, + }, + cx, + ); + } + Some(()) + } + + fn push_new_message( + log_lines: &mut VecDeque, + message: T, + current_severity: ::Level, + ) -> Option { + while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + log_lines.pop_front(); + } + let visible = message.should_include(current_severity); + + let visible_message = visible.then(|| message.as_ref().to_string()); + log_lines.push_back(message); + visible_message + } + + fn add_language_server_rpc( + &mut self, + language_server_id: LanguageServerId, + kind: MessageKind, + message: &str, + cx: &mut Context<'_, Self>, + ) { + let store_logs = !self.on_headless_host; + let Some(state) = self + .get_language_server_state(language_server_id) + .and_then(|state| state.rpc_state.as_mut()) + else { + return; + }; + + let received = kind == MessageKind::Receive; + let rpc_log_lines = &mut state.rpc_messages; + if state.last_message_kind != Some(kind) { + while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + rpc_log_lines.pop_front(); + } + let line_before_message = match kind { + MessageKind::Send => SEND_LINE, + MessageKind::Receive => RECEIVE_LINE, + }; + if store_logs { + rpc_log_lines.push_back(RpcMessage { + message: line_before_message.to_string(), + }); + } + // Do not send a synthetic message over the wire, it will be derived from the actual RPC message + cx.emit(Event::NewServerLogEntry { + id: language_server_id, + kind: LanguageServerLogType::Rpc { received }, + text: line_before_message.to_string(), + }); + } + + while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES { + rpc_log_lines.pop_front(); + } + + if store_logs { + rpc_log_lines.push_back(RpcMessage { + message: message.trim().to_owned(), + }); + } + + self.emit_event( + Event::NewServerLogEntry { + id: language_server_id, + kind: LanguageServerLogType::Rpc { received }, + text: message.to_owned(), + }, + cx, + ); + } + + pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { + self.language_servers.remove(&id); + cx.notify(); + } + + pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { + Some(&self.language_servers.get(&server_id)?.log_messages) + } + + pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque> { + Some(&self.language_servers.get(&server_id)?.trace_messages) + } + + pub fn server_ids_for_project<'a>( + &'a self, + lookup_project: &'a WeakEntity, + ) -> impl Iterator + 'a { + self.language_servers + .iter() + .filter_map(move |(id, state)| match &state.kind { + LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { + if project == lookup_project { + Some(*id) + } else { + None + } + } + LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id), + }) + } + + pub fn enable_rpc_trace_for_language_server( + &mut self, + server_id: LanguageServerId, + ) -> Option<&mut LanguageServerRpcState> { + let rpc_state = self + .language_servers + .get_mut(&server_id)? + .rpc_state + .get_or_insert_with(|| LanguageServerRpcState { + rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + last_message_kind: None, + }); + Some(rpc_state) + } + + pub fn disable_rpc_trace_for_language_server( + &mut self, + server_id: LanguageServerId, + ) -> Option<()> { + self.language_servers.get_mut(&server_id)?.rpc_state.take(); + Some(()) + } + + pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { + match server { + LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), + LanguageServerSelector::Name(name) => self + .language_servers + .iter() + .any(|(_, state)| state.name.as_ref() == Some(name)), + } + } + + fn on_io( + &mut self, + language_server_id: LanguageServerId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) -> Option<()> { + let is_received = match io_kind { + IoKind::StdOut => true, + IoKind::StdIn => false, + IoKind::StdErr => { + self.add_language_server_log(language_server_id, MessageType::LOG, message, cx); + return Some(()); + } + }; + + let kind = if is_received { + MessageKind::Receive + } else { + MessageKind::Send + }; + + self.add_language_server_rpc(language_server_id, kind, message, cx); + cx.notify(); + Some(()) + } + + fn emit_event(&mut self, e: Event, cx: &mut Context) { + let on_headless_host = self.on_headless_host; + match &e { + Event::NewServerLogEntry { id, kind, text } => { + if let Some(state) = self.get_language_server_state(*id) { + let downstream_client = match &state.kind { + LanguageServerKind::Remote { project } + | LanguageServerKind::Local { project } => project + .upgrade() + .map(|project| project.read(cx).lsp_store()), + LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(), + LanguageServerKind::Global => None, + } + .and_then(|lsp_store| lsp_store.read(cx).downstream_client()); + if let Some((client, project_id)) = downstream_client { + if on_headless_host + || Some(LogKind::from_server_log_type(kind)) == state.toggled_log_kind + { + client + .send(proto::LanguageServerLog { + project_id, + language_server_id: id.to_proto(), + message: text.clone(), + log_type: Some(kind.to_proto()), + }) + .ok(); + } + } + } + } + } + + cx.emit(e); + } +} diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index cb13fa5efcfd753e0ffb12fbcc0f3d84e09ff370..5066143244da890a63ead6650cb61fdb71d3964a 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -115,14 +115,14 @@ impl LspCommand for ExpandMacro { message: Self::ProtoRequest, _: Entity, buffer: Entity, - mut cx: AsyncApp, + cx: AsyncApp, ) -> anyhow::Result { let position = message .position .and_then(deserialize_anchor) .context("invalid position")?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -213,7 +213,7 @@ impl LspCommand for OpenDocs { ) -> Result { Ok(OpenDocsParams { text_document: lsp::TextDocumentIdentifier { - uri: lsp::Url::from_file_path(path).unwrap(), + uri: lsp::Uri::from_file_path(path).unwrap(), }, position: point_to_lsp(self.position), }) @@ -249,14 +249,14 @@ impl LspCommand for OpenDocs { message: Self::ProtoRequest, _: Entity, buffer: Entity, - mut cx: AsyncApp, + cx: AsyncApp, ) -> anyhow::Result { let position = message .position .and_then(deserialize_anchor) .context("invalid position")?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -462,14 +462,14 @@ impl LspCommand for GoToParentModule { request: Self::ProtoRequest, _: Entity, buffer: Entity, - mut cx: AsyncApp, + cx: AsyncApp, ) -> anyhow::Result { let position = request .position .and_then(deserialize_anchor) .context("bad request with bad position")?; Ok(Self { - position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?, + position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer))?, }) } @@ -657,6 +657,7 @@ impl LspCommand for GetLspRunnables { ); task_template.args.extend(cargo.cargo_args); if !cargo.executable_args.is_empty() { + let shell_kind = task_template.shell.shell_kind(cfg!(windows)); task_template.args.push("--".to_string()); task_template.args.extend( cargo @@ -682,7 +683,7 @@ impl LspCommand for GetLspRunnables { // That bit is not auto-expanded when using single quotes. // Escape extra cargo args unconditionally as those are unlikely to contain `~`. .flat_map(|extra_arg| { - shlex::try_quote(&extra_arg).ok().map(|s| s.to_string()) + shell_kind.try_quote(&extra_arg).map(|s| s.to_string()) }), ); } @@ -691,7 +692,7 @@ impl LspCommand for GetLspRunnables { task_template.command = shell.program; task_template.args = shell.args; task_template.env = shell.environment; - task_template.cwd = Some(shell.cwd.to_string_lossy().to_string()); + task_template.cwd = Some(shell.cwd.to_string_lossy().into_owned()); } } diff --git a/crates/project/src/lsp_store/rust_analyzer_ext.rs b/crates/project/src/lsp_store/rust_analyzer_ext.rs index 6c425717a82e94985c60db8d1034d470f1aeec35..4d5f134e5f1682d53df3a0ab3f55a4b3676518f8 100644 --- a/crates/project/src/lsp_store/rust_analyzer_ext.rs +++ b/crates/project/src/lsp_store/rust_analyzer_ext.rs @@ -1,8 +1,8 @@ use ::serde::{Deserialize, Serialize}; use anyhow::Context as _; -use gpui::{App, Entity, Task, WeakEntity}; -use language::ServerHealth; -use lsp::{LanguageServer, LanguageServerName}; +use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; +use language::{Buffer, ServerHealth}; +use lsp::{LanguageServer, LanguageServerId, LanguageServerName}; use rpc::proto; use crate::{LspStore, LspStoreEvent, Project, ProjectPath, lsp_store}; @@ -34,7 +34,6 @@ pub fn register_notifications(lsp_store: WeakEntity, language_server: language_server .on_notification::({ - let name = name.clone(); move |params, cx| { let message = params.message; let log_message = message.as_ref().map(|message| { @@ -84,31 +83,32 @@ pub fn register_notifications(lsp_store: WeakEntity, language_server: pub fn cancel_flycheck( project: Entity, - buffer_path: ProjectPath, + buffer_path: Option, cx: &mut App, ) -> Task> { let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client(); let lsp_store = project.read(cx).lsp_store(); - let buffer = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - buffer_store.open_buffer(buffer_path, cx) + let buffer = buffer_path.map(|buffer_path| { + project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + buffer_store.open_buffer(buffer_path, cx) + }) }) }); cx.spawn(async move |cx| { - let buffer = buffer.await?; - let Some(rust_analyzer_server) = project.read_with(cx, |project, cx| { - project.language_server_id_for_name(buffer.read(cx), &RUST_ANALYZER_NAME, cx) - })? + let buffer = match buffer { + Some(buffer) => Some(buffer.await?), + None => None, + }; + let Some(rust_analyzer_server) = find_rust_analyzer_server(&project, buffer.as_ref(), cx) else { return Ok(()); }; - let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?; if let Some((client, project_id)) = upstream_client { let request = proto::LspExtCancelFlycheck { project_id, - buffer_id, language_server_id: rust_analyzer_server.to_proto(), }; client @@ -119,11 +119,12 @@ pub fn cancel_flycheck( lsp_store .read_with(cx, |lsp_store, _| { if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) { - server.notify::(&())?; + server.notify::(()) + } else { + Ok(()) } - anyhow::Ok(()) - })? - .context("lsp ext cancel flycheck")?; + }) + .context("lsp ext cancel flycheck")??; }; anyhow::Ok(()) }) @@ -131,28 +132,33 @@ pub fn cancel_flycheck( pub fn run_flycheck( project: Entity, - buffer_path: ProjectPath, + buffer_path: Option, cx: &mut App, ) -> Task> { let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client(); let lsp_store = project.read(cx).lsp_store(); - let buffer = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - buffer_store.open_buffer(buffer_path, cx) + let buffer = buffer_path.map(|buffer_path| { + project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + buffer_store.open_buffer(buffer_path, cx) + }) }) }); cx.spawn(async move |cx| { - let buffer = buffer.await?; - let Some(rust_analyzer_server) = project.read_with(cx, |project, cx| { - project.language_server_id_for_name(buffer.read(cx), &RUST_ANALYZER_NAME, cx) - })? + let buffer = match buffer { + Some(buffer) => Some(buffer.await?), + None => None, + }; + let Some(rust_analyzer_server) = find_rust_analyzer_server(&project, buffer.as_ref(), cx) else { return Ok(()); }; - let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?; if let Some((client, project_id)) = upstream_client { + let buffer_id = buffer + .map(|buffer| buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())) + .transpose()?; let request = proto::LspExtRunFlycheck { project_id, buffer_id, @@ -168,14 +174,15 @@ pub fn run_flycheck( .read_with(cx, |lsp_store, _| { if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) { server.notify::( - &lsp_store::lsp_ext_command::RunFlycheckParams { + lsp_store::lsp_ext_command::RunFlycheckParams { text_document: None, }, - )?; + ) + } else { + Ok(()) } - anyhow::Ok(()) - })? - .context("lsp ext run flycheck")?; + }) + .context("lsp ext run flycheck")??; }; anyhow::Ok(()) }) @@ -183,31 +190,32 @@ pub fn run_flycheck( pub fn clear_flycheck( project: Entity, - buffer_path: ProjectPath, + buffer_path: Option, cx: &mut App, ) -> Task> { let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client(); let lsp_store = project.read(cx).lsp_store(); - let buffer = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - buffer_store.open_buffer(buffer_path, cx) + let buffer = buffer_path.map(|buffer_path| { + project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + buffer_store.open_buffer(buffer_path, cx) + }) }) }); cx.spawn(async move |cx| { - let buffer = buffer.await?; - let Some(rust_analyzer_server) = project.read_with(cx, |project, cx| { - project.language_server_id_for_name(buffer.read(cx), &RUST_ANALYZER_NAME, cx) - })? + let buffer = match buffer { + Some(buffer) => Some(buffer.await?), + None => None, + }; + let Some(rust_analyzer_server) = find_rust_analyzer_server(&project, buffer.as_ref(), cx) else { return Ok(()); }; - let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?; if let Some((client, project_id)) = upstream_client { let request = proto::LspExtClearFlycheck { project_id, - buffer_id, language_server_id: rust_analyzer_server.to_proto(), }; client @@ -218,12 +226,50 @@ pub fn clear_flycheck( lsp_store .read_with(cx, |lsp_store, _| { if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) { - server.notify::(&())?; + server.notify::(()) + } else { + Ok(()) } - anyhow::Ok(()) - })? - .context("lsp ext clear flycheck")?; + }) + .context("lsp ext clear flycheck")??; }; anyhow::Ok(()) }) } + +fn find_rust_analyzer_server( + project: &Entity, + buffer: Option<&Entity>, + cx: &mut AsyncApp, +) -> Option { + project + .read_with(cx, |project, cx| { + buffer + .and_then(|buffer| { + project.language_server_id_for_name(buffer.read(cx), &RUST_ANALYZER_NAME, cx) + }) + // If no rust-analyzer found for the current buffer (e.g. `settings.json`), fall back to the project lookup + // and use project's rust-analyzer if it's the only one. + .or_else(|| { + let rust_analyzer_servers = project + .lsp_store() + .read(cx) + .language_server_statuses + .iter() + .filter_map(|(server_id, server_status)| { + if server_status.name == RUST_ANALYZER_NAME { + Some(*server_id) + } else { + None + } + }) + .collect::>(); + if rust_analyzer_servers.len() == 1 { + rust_analyzer_servers.first().copied() + } else { + None + } + }) + }) + .ok()? +} diff --git a/crates/project/src/lsp_store/vue_language_server_ext.rs b/crates/project/src/lsp_store/vue_language_server_ext.rs new file mode 100644 index 0000000000000000000000000000000000000000..28249745403d2c6afe3532582ee92bb94de7dde7 --- /dev/null +++ b/crates/project/src/lsp_store/vue_language_server_ext.rs @@ -0,0 +1,124 @@ +use anyhow::Context as _; +use gpui::{AppContext, WeakEntity}; +use lsp::{LanguageServer, LanguageServerName}; +use serde_json::Value; + +use crate::LspStore; + +struct VueServerRequest; +struct TypescriptServerResponse; + +impl lsp::notification::Notification for VueServerRequest { + type Params = Vec<(u64, String, serde_json::Value)>; + + const METHOD: &'static str = "tsserver/request"; +} + +impl lsp::notification::Notification for TypescriptServerResponse { + type Params = Vec<(u64, serde_json::Value)>; + + const METHOD: &'static str = "tsserver/response"; +} + +const VUE_SERVER_NAME: LanguageServerName = LanguageServerName::new_static("vue-language-server"); +const VTSLS: LanguageServerName = LanguageServerName::new_static("vtsls"); +const TS_LS: LanguageServerName = LanguageServerName::new_static("typescript-language-server"); + +pub fn register_requests(lsp_store: WeakEntity, language_server: &LanguageServer) { + let language_server_name = language_server.name(); + if language_server_name == VUE_SERVER_NAME { + let vue_server_id = language_server.server_id(); + language_server + .on_notification::({ + move |params, cx| { + let lsp_store = lsp_store.clone(); + let Ok(Some(vue_server)) = lsp_store.read_with(cx, |this, _| { + this.language_server_for_id(vue_server_id) + }) else { + return; + }; + + let requests = params; + let target_server = match lsp_store.read_with(cx, |this, _| { + let language_server_id = this + .as_local() + .and_then(|local| { + local.language_server_ids.iter().find_map(|(seed, v)| { + [VTSLS, TS_LS].contains(&seed.name).then_some(v.id) + }) + }) + .context("Could not find language server")?; + + this.language_server_for_id(language_server_id) + .context("language server not found") + }) { + Ok(Ok(server)) => server, + other => { + log::warn!( + "vue-language-server forwarding skipped: {other:?}. \ + Returning null tsserver responses" + ); + if !requests.is_empty() { + let null_responses = requests + .into_iter() + .map(|(id, _, _)| (id, Value::Null)) + .collect::>(); + let _ = vue_server + .notify::(null_responses); + } + return; + } + }; + + let cx = cx.clone(); + for (request_id, command, payload) in requests.into_iter() { + let target_server = target_server.clone(); + let vue_server = vue_server.clone(); + cx.background_spawn(async move { + let response = target_server + .request::( + lsp::ExecuteCommandParams { + command: "typescript.tsserverRequest".to_owned(), + arguments: vec![Value::String(command), payload], + ..Default::default() + }, + ) + .await; + + let response_body = match response { + util::ConnectionResult::Result(Ok(result)) => match result { + Some(Value::Object(mut map)) => map + .remove("body") + .unwrap_or(Value::Object(map)), + Some(other) => other, + None => Value::Null, + }, + util::ConnectionResult::Result(Err(error)) => { + log::warn!( + "typescript.tsserverRequest failed: {error:?} for request {request_id}" + ); + Value::Null + } + other => { + log::warn!( + "typescript.tsserverRequest did not return a response: {other:?} for request {request_id}" + ); + Value::Null + } + }; + + if let Err(err) = vue_server + .notify::(vec![(request_id, response_body)]) + { + log::warn!( + "Failed to notify vue-language-server of tsserver response: {err:?}" + ); + } + }) + .detach(); + } + } + }) + .detach(); + } +} diff --git a/crates/project/src/manifest_tree.rs b/crates/project/src/manifest_tree.rs index 7266acb5b4a29b68d8863feb760334de46260424..ffa4872ca78e2295e18c515a03e81e4d7b63c07b 100644 --- a/crates/project/src/manifest_tree.rs +++ b/crates/project/src/manifest_tree.rs @@ -7,20 +7,15 @@ mod manifest_store; mod path_trie; mod server_tree; -use std::{ - borrow::Borrow, - collections::{BTreeMap, hash_map::Entry}, - ops::ControlFlow, - path::Path, - sync::Arc, -}; +use std::{borrow::Borrow, collections::hash_map::Entry, ops::ControlFlow, sync::Arc}; use collections::HashMap; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription}; +use gpui::{App, AppContext as _, Context, Entity, Subscription}; use language::{ManifestDelegate, ManifestName, ManifestQuery}; -pub use manifest_store::ManifestProviders; +pub use manifest_store::ManifestProvidersStore; use path_trie::{LabelPresence, RootPathTrie, TriePath}; use settings::{SettingsStore, WorktreeId}; +use util::rel_path::RelPath; use worktree::{Event as WorktreeEvent, Snapshot, Worktree}; use crate::{ @@ -28,9 +23,7 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; -pub(crate) use server_tree::{ - AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, -}; +pub(crate) use server_tree::{LanguageServerTree, LanguageServerTreeNode, LaunchDisposition}; struct WorktreeRoots { roots: RootPathTrie, @@ -51,12 +44,9 @@ impl WorktreeRoots { match event { WorktreeEvent::UpdatedEntries(changes) => { for (path, _, kind) in changes.iter() { - match kind { - worktree::PathChange::Removed => { - let path = TriePath::from(path.as_ref()); - this.roots.remove(&path); - } - _ => {} + if kind == &worktree::PathChange::Removed { + let path = TriePath::from(path.as_ref()); + this.roots.remove(&path); } } } @@ -81,14 +71,6 @@ pub struct ManifestTree { _subscriptions: [Subscription; 2], } -#[derive(PartialEq)] -pub(crate) enum ManifestTreeEvent { - WorktreeRemoved(WorktreeId), - Cleared, -} - -impl EventEmitter for ManifestTree {} - impl ManifestTree { pub fn new(worktree_store: Entity, cx: &mut App) -> Entity { cx.new(|cx| Self { @@ -96,35 +78,33 @@ impl ManifestTree { _subscriptions: [ cx.subscribe(&worktree_store, Self::on_worktree_store_event), cx.observe_global::(|this, cx| { - for (_, roots) in &mut this.root_points { + for roots in this.root_points.values_mut() { roots.update(cx, |worktree_roots, _| { worktree_roots.roots = RootPathTrie::new(); }) } - cx.emit(ManifestTreeEvent::Cleared); }), ], worktree_store, }) } + pub(crate) fn root_for_path( &mut self, - ProjectPath { worktree_id, path }: ProjectPath, - manifests: &mut dyn Iterator, - delegate: Arc, + ProjectPath { worktree_id, path }: &ProjectPath, + manifest_name: &ManifestName, + delegate: &Arc, cx: &mut App, - ) -> BTreeMap { - debug_assert_eq!(delegate.worktree_id(), worktree_id); - let mut roots = BTreeMap::from_iter( - manifests.map(|manifest| (manifest, (None, LabelPresence::KnownAbsent))), - ); - let worktree_roots = match self.root_points.entry(worktree_id) { + ) -> Option { + debug_assert_eq!(delegate.worktree_id(), *worktree_id); + let (mut marked_path, mut current_presence) = (None, LabelPresence::KnownAbsent); + let worktree_roots = match self.root_points.entry(*worktree_id) { Entry::Occupied(occupied_entry) => occupied_entry.get().clone(), Entry::Vacant(vacant_entry) => { let Some(worktree) = self .worktree_store .read(cx) - .worktree_for_id(worktree_id, cx) + .worktree_for_id(*worktree_id, cx) else { return Default::default(); }; @@ -133,16 +113,16 @@ impl ManifestTree { } }; - let key = TriePath::from(&*path); + let key = TriePath::from(&**path); worktree_roots.read_with(cx, |this, _| { this.roots.walk(&key, &mut |path, labels| { for (label, presence) in labels { - if let Some((marked_path, current_presence)) = roots.get_mut(label) { - if *current_presence > *presence { + if label == manifest_name { + if current_presence > *presence { debug_assert!(false, "RootPathTrie precondition violation; while walking the tree label presence is only allowed to increase"); } - *marked_path = Some(ProjectPath {worktree_id, path: path.clone()}); - *current_presence = *presence; + marked_path = Some(ProjectPath {worktree_id: *worktree_id, path: path.clone()}); + current_presence = *presence; } } @@ -150,12 +130,9 @@ impl ManifestTree { }); }); - for (manifest_name, (root_path, presence)) in &mut roots { - if *presence == LabelPresence::Present { - continue; - } - - let depth = root_path + if current_presence == LabelPresence::KnownAbsent { + // Some part of the path is unexplored. + let depth = marked_path .as_ref() .map(|root_path| { path.strip_prefix(&root_path.path) @@ -165,13 +142,10 @@ impl ManifestTree { }) .unwrap_or_else(|| path.components().count() + 1); - if depth > 0 { - let Some(provider) = ManifestProviders::global(cx).get(manifest_name.borrow()) - else { - log::warn!("Manifest provider `{}` not found", manifest_name.as_ref()); - continue; - }; - + if depth > 0 + && let Some(provider) = + ManifestProvidersStore::global(cx).get(manifest_name.borrow()) + { let root = provider.search(ManifestQuery { path: path.clone(), depth, @@ -182,9 +156,9 @@ impl ManifestTree { let root = TriePath::from(&*known_root); this.roots .insert(&root, manifest_name.clone(), LabelPresence::Present); - *presence = LabelPresence::Present; - *root_path = Some(ProjectPath { - worktree_id, + current_presence = LabelPresence::Present; + marked_path = Some(ProjectPath { + worktree_id: *worktree_id, path: known_root, }); }), @@ -195,27 +169,34 @@ impl ManifestTree { } } } + marked_path.filter(|_| current_presence.eq(&LabelPresence::Present)) + } - roots - .into_iter() - .filter_map(|(k, (path, presence))| { - let path = path?; - presence.eq(&LabelPresence::Present).then(|| (k, path)) + pub(crate) fn root_for_path_or_worktree_root( + &mut self, + project_path: &ProjectPath, + manifest_name: Option<&ManifestName>, + delegate: &Arc, + cx: &mut App, + ) -> ProjectPath { + let worktree_id = project_path.worktree_id; + // Backwards-compat: Fill in any adapters for which we did not detect the root as having the project root at the root of a worktree. + manifest_name + .and_then(|manifest_name| self.root_for_path(project_path, manifest_name, delegate, cx)) + .unwrap_or_else(|| ProjectPath { + worktree_id, + path: RelPath::empty().into(), }) - .collect() } + fn on_worktree_store_event( &mut self, _: Entity, evt: &WorktreeStoreEvent, - cx: &mut Context, + _: &mut Context, ) { - match evt { - WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) => { - self.root_points.remove(&worktree_id); - cx.emit(ManifestTreeEvent::WorktreeRemoved(*worktree_id)); - } - _ => {} + if let WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) = evt { + self.root_points.remove(worktree_id); } } } @@ -223,6 +204,7 @@ impl ManifestTree { pub(crate) struct ManifestQueryDelegate { worktree: Snapshot, } + impl ManifestQueryDelegate { pub fn new(worktree: Snapshot) -> Self { Self { worktree } @@ -230,11 +212,9 @@ impl ManifestQueryDelegate { } impl ManifestDelegate for ManifestQueryDelegate { - fn exists(&self, path: &Path, is_dir: Option) -> bool { - self.worktree.entry_for_path(path).map_or(false, |entry| { - is_dir.map_or(true, |is_required_to_be_dir| { - is_required_to_be_dir == entry.is_dir() - }) + fn exists(&self, path: &RelPath, is_dir: Option) -> bool { + self.worktree.entry_for_path(path).is_some_and(|entry| { + is_dir.is_none_or(|is_required_to_be_dir| is_required_to_be_dir == entry.is_dir()) }) } diff --git a/crates/project/src/manifest_tree/manifest_store.rs b/crates/project/src/manifest_tree/manifest_store.rs index 0462b257985c6ec554519c565f1e935853654e59..cf9f81aee470646d5800ca4a1a4ed7aff4cbd03d 100644 --- a/crates/project/src/manifest_tree/manifest_store.rs +++ b/crates/project/src/manifest_tree/manifest_store.rs @@ -1,4 +1,4 @@ -use collections::HashMap; +use collections::{HashMap, HashSet}; use gpui::{App, Global, SharedString}; use parking_lot::RwLock; use std::{ops::Deref, sync::Arc}; @@ -11,13 +11,13 @@ struct ManifestProvidersState { } #[derive(Clone, Default)] -pub struct ManifestProviders(Arc>); +pub struct ManifestProvidersStore(Arc>); #[derive(Default)] -struct GlobalManifestProvider(ManifestProviders); +struct GlobalManifestProvider(ManifestProvidersStore); impl Deref for GlobalManifestProvider { - type Target = ManifestProviders; + type Target = ManifestProvidersStore; fn deref(&self) -> &Self::Target { &self.0 @@ -26,7 +26,7 @@ impl Deref for GlobalManifestProvider { impl Global for GlobalManifestProvider {} -impl ManifestProviders { +impl ManifestProvidersStore { /// Returns the global [`ManifestStore`]. /// /// Inserts a default [`ManifestStore`] if one does not yet exist. @@ -45,4 +45,7 @@ impl ManifestProviders { pub(super) fn get(&self, name: &SharedString) -> Option> { self.0.read().providers.get(name).cloned() } + pub(crate) fn manifest_file_names(&self) -> HashSet { + self.0.read().providers.keys().cloned().collect() + } } diff --git a/crates/project/src/manifest_tree/path_trie.rs b/crates/project/src/manifest_tree/path_trie.rs index 1a0736765a43b9e1365334de95eacbe9dbf64382..9710bb46d022382819d1edf7e84b85b5f325cdb7 100644 --- a/crates/project/src/manifest_tree/path_trie.rs +++ b/crates/project/src/manifest_tree/path_trie.rs @@ -1,11 +1,11 @@ use std::{ collections::{BTreeMap, btree_map::Entry}, - ffi::OsStr, ops::ControlFlow, - path::{Path, PathBuf}, sync::Arc, }; +use util::rel_path::RelPath; + /// [RootPathTrie] is a workhorse of [super::ManifestTree]. It is responsible for determining the closest known entry for a given path. /// It also determines how much of a given path is unexplored, thus letting callers fill in that gap if needed. /// Conceptually, it allows one to annotate Worktree entries with arbitrary extra metadata and run closest-ancestor searches. @@ -14,17 +14,17 @@ use std::{ /// For example, if there's a project root at path `python/project` and we query for a path `python/project/subdir/another_subdir/file.py`, there is /// a known root at `python/project` and the unexplored part is `subdir/another_subdir` - we need to run a scan on these 2 directories. pub(super) struct RootPathTrie